+ {/* 侧边栏 - 桌面版 */}
+
+
+ {/* 移动端侧边栏遮罩 */}
+ {sidebarOpen && (
+
setSidebarOpen(false)}
+ />
+ )}
+
+ {/* 移动端侧边栏 */}
+
+
+ {/* 主内容区 */}
+
+ {/* 顶部导航栏 - 移动端 */}
+
+
+
+
+ 管理员后台
+
+ {/* 占位 */}
+
+
+ {/* 页面内容 */}
+
+
+
+
+
+ )
+}
+
diff --git a/src/src/app/src/components/Layout/MainLayout.tsx b/src/src/app/src/components/Layout/MainLayout.tsx
new file mode 100644
index 0000000..c22c310
--- /dev/null
+++ b/src/src/app/src/components/Layout/MainLayout.tsx
@@ -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 (
+
+ {/* 手机容器 - 严格9:20比例 (9/20 = 0.45, 所以宽度×20/9=高度) */}
+
+ {/* 主内容区域 - 可滚动 */}
+
+
+
+
+ {/* 底部导航栏 - 固定在手机容器内底部(5个导航项) */}
+
+
+
+ )
+}
+
+export default MainLayout
+
diff --git a/src/src/app/src/index.css b/src/src/app/src/index.css
new file mode 100644
index 0000000..f87ef19
--- /dev/null
+++ b/src/src/app/src/index.css
@@ -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;
+}
+
diff --git a/src/src/app/src/main.tsx b/src/src/app/src/main.tsx
new file mode 100644
index 0000000..6eedccb
--- /dev/null
+++ b/src/src/app/src/main.tsx
@@ -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(
+
+
+ ,
+)
+
diff --git a/src/src/app/src/pages/AddDish.tsx b/src/src/app/src/pages/AddDish.tsx
new file mode 100644
index 0000000..8cdccda
--- /dev/null
+++ b/src/src/app/src/pages/AddDish.tsx
@@ -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
([])
+ const [newIngredient, setNewIngredient] = useState('')
+ const [allergens, setAllergens] = useState([])
+ const [newAllergen, setNewAllergen] = useState('')
+
+ // 更新表单字段
+ const handleChange = (e: React.ChangeEvent) => {
+ 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 (
+
+ {/* 顶部导航栏 */}
+
+
+
+
+ 上传菜品
+
+
+
+ {/* 表单 */}
+
+
+ )
+}
+
diff --git a/src/src/app/src/pages/AdminDashboard.tsx b/src/src/app/src/pages/AdminDashboard.tsx
new file mode 100644
index 0000000..23008ca
--- /dev/null
+++ b/src/src/app/src/pages/AdminDashboard.tsx
@@ -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(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 (
+
+ )
+ }
+
+ return (
+
+
+ {/* 页面标题 */}
+
+
+
+
管理员仪表板
+
欢迎回来,查看系统运行概况
+
+
+ {/* 统计卡片 */}
+
+
+
+
+
用户总数
+
+ {stats?.userCount || 0}
+
+
+
+
+
+
+
+
+
+
菜品总数
+
+ {stats?.dishCount || 0}
+
+
+
+
+
+
+
+
+
+
今日新增用户
+
+ {stats?.todayNewUsers || 0}
+
+
+
+
+
+
+
+ {/* 快捷操作 */}
+
+
快捷操作
+
+
+
+
+
+
+
+
+ )
+}
+
diff --git a/src/src/app/src/pages/AdminDishes.tsx b/src/src/app/src/pages/AdminDishes.tsx
new file mode 100644
index 0000000..4f0690a
--- /dev/null
+++ b/src/src/app/src/pages/AdminDishes.tsx
@@ -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 = {
+ '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([])
+ 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(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) => {
+ 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 (
+
+
+ {/* 页面标题 */}
+
+
+ {/* 搜索和添加按钮 */}
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+
+
+
+ {/* 统计信息 */}
+
+
+ {/* 菜品列表 */}
+
+
+
菜品列表
+
+
+ {loading ? (
+
+ ) : !Array.isArray(dishes) || dishes.length === 0 ? (
+
+ {keyword ? '未找到匹配的菜品' : '暂无菜品'}
+
+ ) : (
+
+ {dishes.map((dish) => (
+
+
+ {/* 菜品图片 */}
+
+

{
+ const target = e.target as HTMLImageElement
+ target.src = 'https://picsum.photos/100'
+ }}
+ />
+
+
+ {/* 菜品信息 */}
+
+
+
+
{dish.name}
+
+ {/* 价格信息 */}
+
+ ¥{Number(dish.price).toFixed(2)}
+ {dish.original_price && Number(dish.original_price) > Number(dish.price) && (
+ ¥{Number(dish.original_price).toFixed(2)}
+ )}
+
+
+ {/* 评分和食堂信息 */}
+
+ ⭐ {dish.rating ? Number(dish.rating).toFixed(1) : '5.0'}
+ {dish.canteen_name && 📍 {dish.canteen_name}}
+
+
+ {/* 状态标签 */}
+
+
+ {dish.status === 'available' ? '在售' : '售罄'}
+
+
+
+
+ {/* 操作按钮 */}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* 分页 */}
+ {totalPages > 1 && (
+
+
+ 第 {page} / {totalPages} 页,共 {total} 条
+
+
+
+
+
+
+ )}
+
+
+
+ {/* 添加菜品模态框 */}
+ {showAddModal && (
+
+
+
+
添加菜品
+
+
+
+ {/* 基本信息 */}
+
+
基本信息
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 请输入数字添加标签:1(减脂)、2(增肌)、3(高蛋白)、4(低碳水)、5(低脂肪)、6(低热量)、7(均衡营养)、8(维生素丰富)、9(膳食纤维)、10(健身推荐)
+
+
+ {/* 数字标签选择 */}
+ {Object.entries(tagMap).map(([num, tag]) => {
+ // 获取当前已选的数字标签
+ const currentNumTags = addForm.tags?.split(',').filter(t => tagMap[t]) || [];
+ const isSelected = currentNumTags.includes(num);
+
+ return (
+
+ );
+ })}
+
+
{
+ // 只允许输入数字和逗号
+ 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 && (
+
+ 已选标签:
+ {addForm.tags.split(',').map(num => tagMap[num]).filter(Boolean).join('、')}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {/* 营养信息 */}
+
+
+ {/* 操作按钮 */}
+
+
+
+
+
+
+
+ )}
+
+ {/* 编辑菜品模态框 */}
+ {showEditModal && currentDish && (
+
+
+
+
编辑菜品
+
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
diff --git a/src/src/app/src/pages/AdminLogin.tsx b/src/src/app/src/pages/AdminLogin.tsx
new file mode 100644
index 0000000..a3cea92
--- /dev/null
+++ b/src/src/app/src/pages/AdminLogin.tsx
@@ -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 (
+
+
+
+ {/* Logo */}
+
+
+
+
+
管理员登录
+
请使用管理员账号登录
+
+
+ {/* 表单 */}
+
+
+ {/* 返回普通登录 */}
+
+
+
+
+ )
+}
+
diff --git a/src/src/app/src/pages/AdminPendingDishes.tsx b/src/src/app/src/pages/AdminPendingDishes.tsx
new file mode 100644
index 0000000..aa4fdb6
--- /dev/null
+++ b/src/src/app/src/pages/AdminPendingDishes.tsx
@@ -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([])
+ const [loading, setLoading] = useState(true)
+ const [filter, setFilter] = useState<'pending' | 'approved' | 'rejected'>('pending')
+ const [showDetailModal, setShowDetailModal] = useState(false)
+ const [currentDish, setCurrentDish] = useState(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 (
+
+
+ {/* 页面标题 */}
+
+ {/* 筛选器 */}
+
+
+
+
+
+
+
+
+ {/* 统计卡片 */}
+
+
+
+
+ {filter === 'pending' && `${dishes.length} 个菜品等待审核`}
+ {filter === 'approved' && `${dishes.length} 个菜品已通过审核`}
+ {filter === 'rejected' && `${dishes.length} 个菜品已被拒绝`}
+
+
+
+
+ {/* 菜品列表 */}
+
+ {loading ? (
+
+ ) : dishes.length === 0 ? (
+
+
+
+ {filter === 'pending' && '暂无待审核菜品'}
+ {filter === 'approved' && '暂无已通过的菜品'}
+ {filter === 'rejected' && '暂无已拒绝的菜品'}
+
+
+ ) : (
+ dishes.map((dish) => (
+
+
+
+ {/* 菜品图片 */}
+
+

{
+ e.currentTarget.src = 'https://picsum.photos/150'
+ }}
+ />
+
+
+ {/* 菜品信息 */}
+
+
+
+
{dish.name}
+
+ {dish.description || '暂无描述'}
+
+
+
+ {/* 状态标签 */}
+
+ {dish.status === 'pending' && '待审核'}
+ {dish.status === 'approved' && '已通过'}
+ {dish.status === 'rejected' && '已拒绝'}
+
+
+
+
+
👤 上传者: {dish.user_name || dish.username}
+
🏷️ 分类: {dish.category || '未分类'}
+ {dish.price &&
💰 价格: ¥{Number(dish.price).toFixed(2)}
}
+ {dish.window_number &&
🪟 窗口: {dish.window_number}
}
+ {dish.spicy_level !== undefined && (
+
🌶️ 辣度: {spicyLevelText[dish.spicy_level] || '未知'}
+ )}
+
+
+ {/* 营养信息 */}
+ {(dish.calories || dish.protein || dish.fat || dish.carbs) && (
+
+ {dish.calories && 🔥 {dish.calories}卡}
+ {dish.protein && 蛋白 {dish.protein}g}
+ {dish.fat && 脂肪 {dish.fat}g}
+ {dish.carbs && 碳水 {dish.carbs}g}
+
+ )}
+
+ {/* 拒绝原因 */}
+ {dish.status === 'rejected' && dish.reject_reason && (
+
+
+ 拒绝原因:
+ {dish.reject_reason}
+
+
+ )}
+
+
+
+ 提交于 {new Date(dish.created_at).toLocaleString('zh-CN')}
+
+
+ {/* 操作按钮 */}
+
+
+
+ {dish.status === 'pending' && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+ {/* 详情模态框 */}
+ {showDetailModal && currentDish && (
+
+
+
+
菜品详情
+
+
+
+
+ {/* 菜品图片 */}
+ {currentDish.image_url && (
+
+

{
+ e.currentTarget.src = 'https://picsum.photos/400'
+ }}
+ />
+
+ )}
+
+ {/* 基本信息 */}
+
+
{currentDish.name}
+
{currentDish.description || '暂无描述'}
+
+
+ {/* 详细信息 */}
+
+
+
上传者
+
{currentDish.user_name || currentDish.username}
+
+
+
分类
+
{currentDish.category || '未分类'}
+
+ {currentDish.price && (
+
+
价格
+
¥{Number(currentDish.price).toFixed(2)}
+
+ )}
+ {currentDish.window_number && (
+
+
窗口号
+
{currentDish.window_number}
+
+ )}
+
+
辣度
+
{spicyLevelText[currentDish.spicy_level] || '未知'}
+
+
+
提交时间
+
+ {new Date(currentDish.created_at).toLocaleString('zh-CN')}
+
+
+
+
+ {/* 营养信息 */}
+ {(currentDish.calories || currentDish.protein || currentDish.fat || currentDish.carbs) && (
+
+
营养信息
+
+ {currentDish.calories && (
+
+
热量
+
{currentDish.calories} 卡
+
+ )}
+ {currentDish.protein && (
+
+
蛋白质
+
{currentDish.protein} 克
+
+ )}
+ {currentDish.fat && (
+
+
脂肪
+
{currentDish.fat} 克
+
+ )}
+ {currentDish.carbs && (
+
+
碳水化合物
+
{currentDish.carbs} 克
+
+ )}
+
+
+ )}
+
+ {/* 原料成分 */}
+ {currentDish.ingredients && parseJsonField(currentDish.ingredients).length > 0 && (
+
+
原料成分
+
+ {parseJsonField(currentDish.ingredients).map((ingredient: string, index: number) => (
+
+ {ingredient}
+
+ ))}
+
+
+ )}
+
+ {/* 过敏原 */}
+ {currentDish.allergens && parseJsonField(currentDish.allergens).length > 0 && (
+
+
过敏原信息
+
+ {parseJsonField(currentDish.allergens).map((allergen: string, index: number) => (
+
+ ⚠️ {allergen}
+
+ ))}
+
+
+ )}
+
+ {/* 操作按钮 */}
+ {currentDish.status === 'pending' && (
+
+
+
+
+ )}
+
+
+
+ )}
+
+ )
+}
+
diff --git a/src/src/app/src/pages/AdminUsers.tsx b/src/src/app/src/pages/AdminUsers.tsx
new file mode 100644
index 0000000..35077d8
--- /dev/null
+++ b/src/src/app/src/pages/AdminUsers.tsx
@@ -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([])
+ 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(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 (
+
+
+ {/* 页面标题 */}
+
+ {/* 搜索栏 */}
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+ {/* 统计信息 */}
+
+
+ {/* 用户列表 */}
+
+
+
用户列表
+
+
+ {loading ? (
+
+ ) : users.length === 0 ? (
+
+ {keyword ? '未找到匹配的用户' : '暂无用户'}
+
+ ) : (
+
+ {users.map((user) => (
+
+
+
+
+
{user.name}
+ {user.role === 'admin' && (
+
+
+ 管理员
+
+ )}
+
+
@{user.username}
+
+ 📱 {user.phone}
+ {user.email && 📧 {user.email}}
+
+
+ 收藏: {user.favorites_count}
+ 上传: {user.uploaded_dishes_count}
+ 注册: {new Date(user.created_at).toLocaleDateString('zh-CN')}
+
+
+
+ {/* 操作按钮 */}
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* 分页 */}
+ {totalPages > 1 && (
+
+
+ 第 {page} / {totalPages} 页,共 {total} 条
+
+
+
+
+
+
+ )}
+
+
+
+ {/* 编辑用户模态框 */}
+ {showEditModal && currentUser && (
+
+
+
编辑用户
+
+
+
+
+ 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"
+ />
+
+
+
+
+ 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"
+ />
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* 重置密码模态框 */}
+ {showPasswordModal && currentUser && (
+
+
+
重置密码
+
+
+
+ 为用户 {currentUser.name} 重置密码
+
+
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"
+ />
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
diff --git a/src/src/app/src/pages/Favorites.tsx b/src/src/app/src/pages/Favorites.tsx
new file mode 100644
index 0000000..0e563e2
--- /dev/null
+++ b/src/src/app/src/pages/Favorites.tsx
@@ -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([])
+ 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 (
+
+ {/* 顶部导航栏 */}
+
+
+
+
+ 我的收藏
+
+
+
+ {/* 主内容区 */}
+
+ {loading ? (
+
+ ) : favorites.length === 0 ? (
+
+
+
还没有收藏任何菜品
+
+
+ ) : (
+
+ {favorites.map((dish) => (
+
+
+ {/* 菜品图片 */}
+
handleDishClick(dish.id)}
+ className="flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden cursor-pointer"
+ >
+

+
+
+ {/* 菜品信息 */}
+
+
handleDishClick(dish.id)}
+ className="font-bold text-base mb-1 cursor-pointer hover:text-primary truncate"
+ >
+ {dish.name}
+
+
+
+ ⭐ {dish.rating ? Number(dish.rating).toFixed(1) : '5.0'}
+ •
+ 已售 {dish.sales_count || 0}
+ {dish.canteen_name && (
+ <>
+ •
+ {dish.canteen_name}
+ >
+ )}
+
+
+ {/* 营养信息 */}
+ {dish.calories && (
+
+ {dish.calories}卡
+ {dish.protein && 蛋白 {dish.protein}g}
+
+ )}
+
+ {/* 价格和操作 */}
+
+
+
+ ¥{dish.price ? Number(dish.price).toFixed(2) : '0.00'}
+
+
+
+
+
+
+
+
+ {/* 收藏时间 */}
+
+ 收藏于 {new Date(dish.favorited_at).toLocaleString('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ })}
+
+
+ ))}
+
+ )}
+
+
+ {/* 统计信息 */}
+ {!loading && favorites.length > 0 && (
+
+
+ 共收藏 {favorites.length} 个菜品
+
+
+ )}
+
+ )
+}
diff --git a/src/src/app/src/pages/Home.tsx b/src/src/app/src/pages/Home.tsx
new file mode 100644
index 0000000..542e649
--- /dev/null
+++ b/src/src/app/src/pages/Home.tsx
@@ -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(false)
+ const [loading, setLoading] = useState(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([])
+ const [greeting, setGreeting] = useState('')
+ const [userName, setUserName] = useState('用户')
+ const [activeCategory, setActiveCategory] = useState('推荐')
+
+ interface NutritionData {
+ caloriesConsumed: number
+ caloriesGoal: number
+ proteinConsumed: number
+ proteinGoal: number
+ }
+
+ const [nutritionData, setNutritionData] = useState({
+ caloriesConsumed: 0,
+ caloriesGoal: 2000,
+ proteinConsumed: 0,
+ proteinGoal: 80
+ })
+
+ // 上传菜品相关状态
+ const [showUploadModal, setShowUploadModal] = useState(false)
+ const [uploading, setUploading] = useState(false)
+
+ interface Canteen {
+ id: string
+ name: string
+ }
+
+ const [canteens, setCanteens] = useState([])
+ 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 = {
+ '推荐': '',
+ '减脂': '减脂',
+ '增肌': '增肌',
+ '清淡': '清淡',
+ '川菜': '川菜',
+ '西餐': '西餐'
+ }
+ 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 (
+
+ {/* 顶部栏 */}
+
+
+
+
{greeting},{userName}
+
今天吃什么?
+
+
+
+
+
+
+
+ {/* 今日营养摘要 */}
+
+
+
+ 今日营养摘要
+
+
+ {/* 热量进度 */}
+
+
+ 热量
+ {caloriesConsumed}/{caloriesGoal}
+
+
+
还需 {caloriesGoal - caloriesConsumed} 千卡
+
+ {/* 蛋白质进度 */}
+
+
+ 蛋白质
+ {proteinConsumed}/{proteinGoal}g
+
+
+
还需 {proteinGoal - proteinConsumed}g
+
+
+
+
+
+ {/* 智能推荐区域 */}
+
+ {/* 调试信息 - 显示当前选中的分类 */}
+
+ 当前分类: {activeCategory}
+
+
+ {/* 快捷筛选标签 */}
+
+ {['推荐', '减脂', '增肌', '清淡', '川菜', '西餐'].map((tag) => {
+ const isActive = tag === activeCategory
+ console.log(`🎨 按钮 "${tag}": ${isActive ? '激活' : '未激活'}`)
+ return (
+
+ )
+ })}
+
+
+ {/* 标题和刷新按钮 */}
+
+
+
+
+
+ {/* 推荐理由 */}
+
+
+ 💡 推荐理由:
+ 根据您的减脂目标和清淡口味偏好,为您精选高蛋白低脂餐品
+
+
+
+ {/* 餐品列表 */}
+
+ {meals.map((meal) => (
+
+ ))}
+
+
+ {/* 食堂动态 */}
+
+
+ 📢
+ 食堂动态
+
+
+
+
第一食堂 · 10分钟前
+
今日特价:宫保鸡丁套餐 限量50份
+
+
+
第二食堂 · 30分钟前
+
新品上架:香煎三文鱼 营养美味
+
+
+
+
+
+ {/* 浮动上传按钮 */}
+
+
+ {/* 上传菜品模态框 */}
+ {showUploadModal && (
+
+
+
+
+
+ 上传菜品
+
+
+
+
+
+
+
📝 提示
+
您上传的菜品将提交给管理员审核,审核通过后会显示在菜品列表中。
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
+export default HomePage
+
diff --git a/src/src/app/src/pages/Login.tsx b/src/src/app/src/pages/Login.tsx
new file mode 100644
index 0000000..cfcaa99
--- /dev/null
+++ b/src/src/app/src/pages/Login.tsx
@@ -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 (
+
+
+
+ {/* Logo区域 */}
+
+
+
+
+
欢迎回来
+
登录享受个性化推荐
+
+
+ {/* 表单区域 */}
+
+
}
+ />
+
+
}
+ />
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ 还没有账号?
+
+ 立即注册
+
+
+
+ {/* 管理员登录入口 */}
+
+
+
+
+
+ )
+}
+
+export default LoginPage
+
diff --git a/src/src/app/src/pages/MealDetail.tsx b/src/src/app/src/pages/MealDetail.tsx
new file mode 100644
index 0000000..a852c8d
--- /dev/null
+++ b/src/src/app/src/pages/MealDetail.tsx
@@ -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(null)
+ const [similarMeals, setSimilarMeals] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [isFavorite, setIsFavorite] = useState(false)
+ const [currentImageIndex, setCurrentImageIndex] = useState(0)
+ const [hasEatenToday, setHasEatenToday] = useState(false)
+ const [recording, setRecording] = useState(false)
+ // 评价相关状态
+ const [selectedRating, setSelectedRating] = useState(0)
+ const [reviewContent, setReviewContent] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+ const [showReviewForm, setShowReviewForm] = useState(false)
+ const [userReviewed, setUserReviewed] = useState(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 (
+
+ {[1, 2, 3, 4, 5].map((star) => (
+ onChange && onChange(star)}
+ />
+ ))}
+
+ )
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (!meal) {
+ return (
+
+
菜品不存在
+
+
+ )
+ }
+
+ 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 (
+
+ {/* 图片轮播 */}
+
+

+
+ {/* 顶部按钮 */}
+
+
+
+ {/* 图片指示器 */}
+
+ {images.map((_, index) => (
+
+
+
+
+ {/* 基本信息 */}
+
+
+
+
{meal.name}
+
+
+ {meal.location}
+
+
+ {/* 健康标签 */}
+ {meal.healthTags && meal.healthTags.length > 0 && (
+
+ {meal.healthTags.map((tag, index) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ {meal.rating}
+ 评分
+
+
+
+ 已售 256+
+
+
+
+
+ {/* 推荐理由 */}
+ {meal.matchRate && (
+
+
+
+ {meal.matchRate}% 匹配
+
+
+
+
+ 推荐理由:
+ 高蛋白低脂,符合您的减脂目标,清淡口味适合您的偏好
+
+
+ )}
+
+ {/* 营养成分 */}
+
+
营养成分
+
+ {nutrients.map((nutrient) => (
+
+
{nutrient.value}
+
{nutrient.unit}
+
{nutrient.label}
+
+ ))}
+
+
+ {/* 标记今日已吃按钮 */}
+
+
+ {hasEatenToday && (
+
+ 已记录到今日营养摄入,可在首页和营养报告中查看
+
+ )}
+
+
+ {/* 详细说明 */}
+
+
详细说明
+
+
+
+
主要原料
+
+ {meal.ingredients.map((ingredient) => (
+
+ {ingredient}
+
+ ))}
+
+
+
+ {meal.healthTags && meal.healthTags.length > 0 && (
+
+
健康标签
+
+ {meal.healthTags.map((tag, index) => (
+
+ {tag}
+
+ ))}
+
+
+ )}
+
+
+
烹饪方式
+
{meal.cookingMethod}
+
+
+
+
适合人群
+
+ {meal.suitableFor.map((group) => (
+
+ ✓ {group}
+
+ ))}
+
+
+
+
+
菜品说明
+
{meal.description}
+
+
+
+
+ {/* 过敏提醒 */}
+
+
+ ⚠️ 温馨提示:
+ 本菜品含有{meal.ingredients.slice(0, 2).join('、')}等食材,请注意过敏史
+
+
+
+ {/* 用户评价 */}
+
+
用户评价 ({meal.reviews?.length || 0})
+
+ {/* 添加评价按钮 */}
+ {!userReviewed && !showReviewForm && (
+
+ )}
+
+ {/* 评价表单 */}
+ {showReviewForm && (
+
+
+
请选择评分:
+ {renderRatingStars(selectedRating, handleRatingChange)}
+
+
+
评价内容:
+
+
+
+
+
+
+ )}
+
+ {userReviewed && !showReviewForm && (
+
+ ✓ 您已评价过该菜品
+
+ )}
+
+ {meal.reviews && meal.reviews.length > 0 ? (
+
+ {meal.reviews.map((review, index) => (
+
+
+
+
+ {(review.user || review.userName)?.[0] || 'U'}
+
+
{review.user || review.userName}
+
+
+
+ {review.rating}
+
+
+
{review.comment || review.content}
+
{review.date}
+
+ ))}
+
+ ) : (
+
暂无评价
+ )}
+
+
+ {/* 相似推荐 */}
+
+
相似推荐
+
+ {similarMeals.map((similarMeal) => (
+
+ ))}
+
+
+
+
+ )
+}
+
+export default MealDetailPage
+
diff --git a/src/src/app/src/pages/MealRecords.tsx b/src/src/app/src/pages/MealRecords.tsx
new file mode 100644
index 0000000..af9fd81
--- /dev/null
+++ b/src/src/app/src/pages/MealRecords.tsx
@@ -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([
+ // 暂时保留一些示例数据以维持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 (
+
+ {/* 顶部标题栏 */}
+
+
+ {records.length > 0 ? (
+
+ {/* 统计卡片 */}
+
+
+
+
+ 用餐次数
+
+
{totalMeals}
+
近30天
+
+
+
+
+
+ 总消费
+
+
¥{totalSpent.toFixed(0)}
+
近30天
+
+
+
+ {/* 记录列表 */}
+
+ {records.map((record) => (
+
+
+
+
{formatDate(record.date)}
+
+ {record.meals.length} 餐
+
+
+
+
+ {record.meals.map((meal) => (
+
+
+

+
+
+
{meal.name}
+ ¥{meal.price}
+
+
{meal.location}
+
+ {meal.time}
+
+
+
+
+
+ {/* 营养信息 */}
+
+ 热量: {meal.calories}千卡
+ 蛋白质: {meal.protein}g
+ 脂肪: {meal.fat}g
+
+
+ ))}
+
+
+ ))}
+
+
+ {/* 日历视图切换 */}
+
+
+
+
+ {/* 导出按钮 */}
+
+
+ ) : (
+
+ }
+ title="暂无用餐记录"
+ description="开始记录你的美食之旅吧"
+ actionText="去首页看看"
+ onAction={() => window.history.back()}
+ />
+
+ )}
+
+ )
+}
+
+export default MealRecordsPage
+
diff --git a/src/src/app/src/pages/Menu.tsx b/src/src/app/src/pages/Menu.tsx
new file mode 100644
index 0000000..29a5414
--- /dev/null
+++ b/src/src/app/src/pages/Menu.tsx
@@ -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([])
+ const [sortBy, setSortBy] = useState('推荐')
+ const [meals, setMeals] = useState([])
+ 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 (
+
+ {/* 顶部搜索栏 */}
+
+
+
+
+
+
+
+
+
+ {/* 食堂标签 */}
+
+ {canteens.map((canteen) => (
+
+ ))}
+
+
+
+ {/* 筛选面板 */}
+ {showFilter && (
+
+ {/* 排序 */}
+
+
排序方式
+
+ {sortOptions.map((option) => (
+
+ ))}
+
+
+
+ {/* 价格范围 */}
+
+
价格范围
+
+ setPriceRange([0, parseInt(e.target.value)])}
+ className="flex-1"
+ />
+
+ ¥0 - ¥{priceRange[1]}
+
+
+
+
+ {/* 菜系分类 */}
+
+
菜系分类
+
+ {categories.map((category) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ )}
+
+ {/* 食堂信息卡片 */}
+ {selectedCanteen !== '全部' && (
+
+
{selectedCanteen}
+
+
+
+ 教学楼东侧200米
+
+
+
+ 营业中 06:30-20:00
+
+
+
+ 当前排队:约 8 人
+
+
+
+ )}
+
+ {/* 菜品列表 */}
+
+
+
+ 共 {meals.length} 道菜品
+
+ 按{sortBy}排序
+
+
+
+ {meals.map((meal) => (
+
+ ))}
+
+
+ {/* 加载更多 */}
+
+
+
+ )
+}
+
+export default MenuPage
+
diff --git a/src/src/app/src/pages/MyDishes.tsx b/src/src/app/src/pages/MyDishes.tsx
new file mode 100644
index 0000000..d660bf9
--- /dev/null
+++ b/src/src/app/src/pages/MyDishes.tsx
@@ -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([])
+ 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 (
+
+ {/* 顶部导航栏 */}
+
+
+
+
+ 我上传的菜品
+
+
+
+
+ {/* 上传按钮 */}
+
+
+ {/* 筛选器 */}
+
+
+
+
+
+
+
+
+
+ {/* 菜品列表 */}
+ {loading ? (
+
+ ) : filteredDishes.length === 0 ? (
+
+
+
+ {filter === 'all' && '还没有上传任何菜品'}
+ {filter === 'pending' && '没有待审核的菜品'}
+ {filter === 'approved' && '没有已通过的菜品'}
+ {filter === 'rejected' && '没有被拒绝的菜品'}
+
+ {filter === 'all' && (
+
+ )}
+
+ ) : (
+
+ {filteredDishes.map((dish) => {
+ const status = statusConfig[dish.status]
+ const StatusIcon = status.icon
+
+ return (
+
+
+
+ {/* 菜品图片 */}
+
+

{
+ e.currentTarget.src = 'https://picsum.photos/100'
+ }}
+ />
+
+
+ {/* 菜品信息 */}
+
+
+
{dish.name}
+
+
+ {status.text}
+
+
+
+ {dish.description && (
+
+ {dish.description}
+
+ )}
+
+
+ {dish.category && 🏷️ {dish.category}}
+ {dish.price && 💰 ¥{Number(dish.price).toFixed(2)}}
+ {dish.window_number && 🪟 {dish.window_number}}
+ {dish.spicy_level !== undefined && (
+ 🌶️ {spicyLevelText[dish.spicy_level]}
+ )}
+
+
+ {/* 营养信息 */}
+ {(dish.calories || dish.protein) && (
+
+ {dish.calories && 🔥 {dish.calories}卡}
+ {dish.protein && 蛋白 {dish.protein}g}
+ {dish.fat && 脂肪 {dish.fat}g}
+ {dish.carbs && 碳水 {dish.carbs}g}
+
+ )}
+
+ {/* 拒绝原因 */}
+ {dish.status === 'rejected' && dish.reject_reason && (
+
+
+ 拒绝原因:
+ {dish.reject_reason}
+
+
+ )}
+
+ {/* 时间和操作 */}
+
+
+ {new Date(dish.created_at).toLocaleDateString('zh-CN')}
+
+
+ {dish.status === 'pending' && (
+
+ )}
+
+
+
+
+
+ )
+ })}
+
+ )}
+
+ {/* 提示信息 */}
+ {dishes.length > 0 && (
+
+
💡 温馨提示
+
+ - • 待审核:菜品正在等待管理员审核
+ - • 已通过:菜品已添加到数据库,可在食堂查看
+ - • 已拒绝:菜品未通过审核,请查看拒绝原因
+ - • 只能删除待审核状态的菜品
+
+
+ )}
+
+
+ )
+}
+
diff --git a/src/src/app/src/pages/NotFound.tsx b/src/src/app/src/pages/NotFound.tsx
new file mode 100644
index 0000000..3e8fc5f
--- /dev/null
+++ b/src/src/app/src/pages/NotFound.tsx
@@ -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 (
+
+
+
+ {/* 404 图标 */}
+
404
+
+ {/* 插图 */}
+
🔍
+
+ {/* 文字说明 */}
+
+ 页面不存在
+
+
+ 抱歉,您访问的页面走丢了
+
+
+ {/* 按钮 */}
+
+
+
+
+
+
+
+
+ )
+}
+
+export default NotFoundPage
+
diff --git a/src/src/app/src/pages/PersonalFile.tsx b/src/src/app/src/pages/PersonalFile.tsx
new file mode 100644
index 0000000..fdce6f1
--- /dev/null
+++ b/src/src/app/src/pages/PersonalFile.tsx
@@ -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({
+ name: '',
+ studentId: '',
+ grade: '',
+ major: '',
+ avatar: '',
+ gender: '',
+ age: 0,
+ email: '',
+ phone: '',
+ joinDate: '',
+ college: '',
+ address: ''
+ })
+
+ const [healthData, setHealthData] = useState({
+ 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 (
+
+ {/* 顶部个人信息卡片 */}
+
+
+
+

+
+
+
{profile.name}
+
{profile.studentId}
+
+
+
+
+ {/* 标签页切换 */}
+
+
+
+ {[
+ { key: 'basic', label: '基本信息' },
+ { key: 'health', label: '健康档案' },
+ { key: 'preferences', label: '饮食偏好' },
+ ].map((tab) => (
+
+ ))}
+
+
+ {/* 编辑/保存/取消按钮 */}
+
+ {isEditing ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+ {/* 标签页内容 */}
+
+ {activeTab === 'basic' && (
+
+ {/* 基本信息卡片 */}
+
+
+
+ 个人信息
+
+
+ {isEditing ? (
+ <>
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+ 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"
+ />
+
+
+ >
+ ) : (
+ <>
+
} label="姓名" value={profile.name} />
+
} label="性别" value={profile.gender} />
+
} label="年龄" value={`${profile.age}岁`} />
+
} label="邮箱" value={profile.email} />
+
} label="手机" value={profile.phone} />
+ >
+ )}
+
+
+
+ )}
+
+ {activeTab === 'health' && (
+
+ {/* 身体数据 */}
+
+
+
+ 身体数据
+
+ {isEditing ? (
+
+
+
+ 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="请输入身高"
+ />
+
+
+
+ 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="请输入体重"
+ />
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
身高
+
{healthData.height || '--'}
+
cm
+
+
+
体重
+
{healthData.weight || '--'}
+
kg
+
+
+
BMI
+
+ {healthData.bmi || '--'}
+
+
{healthData.bmi > 0 ? (healthData.bmi < 18.5 ? '偏瘦' : healthData.bmi < 24 ? '标准' : healthData.bmi < 28 ? '偏胖' : '肥胖') : '--'}
+
+
+
年龄
+
{profile.age}
+
岁
+
+
+ )}
+
+
+ {/* 营养目标 */}
+
+
+
+ 营养目标
+
+
+
+
+ 健身目标
+ {healthData.healthGoal || '未设置'}
+
+
+
+
+ 每日热量目标
+ {healthData.targetCalories} 千卡
+
+
+
+
+
+ 蛋白质目标
+ {healthData.targetProtein}g
+
+
+
+
+
+ 脂肪目标
+ {healthData.targetFat}g
+
+
+
+
+
+ 碳水化合物目标
+ {healthData.targetCarbs}g
+
+
+
+
+
+
+ )}
+
+ {activeTab === 'preferences' && (
+
+ {/* 饮食类型 */}
+
+
+
+ 饮食偏好
+
+ {isEditing ? (
+
+
+
+
+ {['清淡', '重口味', '低脂', '高蛋白', '素食', '海鲜', '川菜', '粤菜', '西餐'].map((pref) => (
+
+ ))}
+
+
+
+ ) : (
+
+
+
+
+
+
+
饮食偏好
+
+ {healthData.dietaryPreferences && healthData.dietaryPreferences.length > 0 ? (
+ healthData.dietaryPreferences.map((pref: string) => (
+
+ {pref}
+
+ ))
+ ) : (
+ 未设置
+ )}
+
+
+
+
+ )}
+
+
+ {/* 饮食限制 */}
+
+
+
+ 饮食限制
+
+ {isEditing ? (
+
+
+
+
+ {['花生', '海鲜', '鸡蛋', '牛奶', '大豆', '坚果', '小麦', '芝麻'].map((allergy) => (
+
+ ))}
+
+
+
+ ) : (
+
+
+
过敏食物
+
+ {healthData.allergies && healthData.allergies.length > 0 ? (
+ healthData.allergies.map((allergy: string) => (
+
+ ⚠️ {allergy}
+
+ ))
+ ) : (
+ 无过敏食物
+ )}
+
+
+
+
饮食限制
+
+ {healthData.restrictions && healthData.restrictions.length > 0 ? (
+ healthData.restrictions.map((restriction: string) => (
+
+ {restriction}
+
+ ))
+ ) : (
+ 无特殊限制
+ )}
+
+
+
+ )}
+
+
+ {/* 预算设置 */}
+
+
+
+ 用餐预算
+
+
+
+
+
每餐预算范围
+
+ ¥10 - ¥30
+
+
+
💰
+
+
+
+
+ )}
+
+
+ {/* 退出登录按钮 */}
+
+
+
+
+ )
+}
+
+// 信息项组件
+const InfoItem = ({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) => (
+
+
{icon}
+
+ {label}
+ {value}
+
+
+)
+
+export default PersonalFilePage
+
diff --git a/src/src/app/src/pages/Profile.tsx b/src/src/app/src/pages/Profile.tsx
new file mode 100644
index 0000000..217ab89
--- /dev/null
+++ b/src/src/app/src/pages/Profile.tsx
@@ -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 (
+
+ {/* 顶部背景 */}
+
+
+ {/* 用户信息卡片 */}
+
+
+
+

+
+
+
{userInfo.name}
+
+ LV.{userInfo.level}
+
+
+
{userInfo.phone}
+
+
+
+
+ {/* 数据统计 */}
+
+ {stats.map((stat) => (
+
+
+
+
+
{stat.value}
+
{stat.label}
+
+ ))}
+
+
+
+ {/* BMI卡片 */}
+
+
+
+
+
{bmiInfo.value}
+
{bmiInfo.status}
+
+
+
+
+
{bmiInfo.height}
+
身高(cm)
+
+
+
{bmiInfo.weight}
+
体重(kg)
+
+
+
+
+ {/* 个人档案 */}
+
+
+
个人档案
+
+ {profileItems.map((item, index) => (
+
+ ))}
+
+
+ {/* 设置与帮助 */}
+
+
+
设置与帮助
+
+ {settingItems.map((item, index) => (
+
+ ))}
+
+
+ {/* 退出登录 */}
+
+
+
+ )
+}
+
+export default ProfilePage
+
diff --git a/src/src/app/src/pages/ProfileEdit.tsx b/src/src/app/src/pages/ProfileEdit.tsx
new file mode 100644
index 0000000..370ad14
--- /dev/null
+++ b/src/src/app/src/pages/ProfileEdit.tsx
@@ -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(['清淡'])
+ const [dislikedFoods, setDislikedFoods] = useState([])
+
+ // 营养目标
+ const [goal, setGoal] = useState('保持健康')
+ const [activityLevel, setActivityLevel] = useState('中等')
+
+ // 过敏史
+ const [allergies, setAllergies] = useState(['无'])
+
+ 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 (
+
+ {/* 顶部导航 */}
+
+
+
编辑档案
+
+
+
+
+ {/* 基本信息 */}
+
+
基本信息
+
+
+
+
+
+ {['男', '女'].map((g) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {/* BMI实时显示 */}
+ {height && weight && (
+
+
+
+
当前BMI指数
+
{calculateBMI()}
+
+
+
+
+ )}
+
+
+
+ {/* 健康状态 */}
+
+
健康状态
+
+ {['健康', '亚健康', '慢性病', '康复期'].map((status) => (
+
+ ))}
+
+
+
+ {/* 饮食偏好 */}
+
+
饮食偏好
+
+
+
+
+ {tasteOptions.map((t) => (
+
+ ))}
+
+
+
+
+
+ setDislikedFoods(val.split(',').map(v => v.trim()))}
+ placeholder="例如:香菜、芹菜(用逗号分隔)"
+ />
+
+
+
+ {/* 营养目标 */}
+
+
营养目标
+
+
+
+
+ {goalOptions.map((g) => (
+
+ ))}
+
+
+
+
+
+
+ {activityOptions.map((level) => (
+
+ ))}
+
+
+
+
+ {/* 过敏史 */}
+
+
过敏史
+
+ {allergyOptions.map((a) => (
+
+ ))}
+
+
+
+ {/* 营养目标预览 */}
+
+
+ {/* 保存按钮 */}
+
+
+
+ )
+}
+
+export default ProfileEditPage
+
diff --git a/src/src/app/src/pages/Register.tsx b/src/src/app/src/pages/Register.tsx
new file mode 100644
index 0000000..c4991d3
--- /dev/null
+++ b/src/src/app/src/pages/Register.tsx
@@ -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([])
+ const [goal, setGoal] = useState('保持')
+ const [allergies, setAllergies] = useState([])
+
+ 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) {
+ // 保存 token(API 已经自动保存了)
+ 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 (
+
+
+ {/* 顶部导航 */}
+
+
+
注册账号
+
+
+
+ {/* 进度条 */}
+
+
+ = 1 ? 'text-primary font-medium' : 'text-gray-400'}`}>基本信息
+ = 2 ? 'text-primary font-medium' : 'text-gray-400'}`}>健康档案
+
+
+
+
+
+ {/* 第一步:基本信息 */}
+ {step === 1 && (
+
+ )}
+
+ {/* 第二步:健康档案 */}
+ {step === 2 && (
+
+ {/* 性别 */}
+
+
+
+ {['男', '女'].map((g) => (
+
+ ))}
+
+
+
+ {/* 身高体重 */}
+
+
+
+
+
+ {/* BMI显示 */}
+ {height && weight && (
+
+
您的BMI指数
+
{calculateBMI()}
+
+ )}
+
+ {/* 口味偏好 */}
+
+
+
+ {tasteOptions.map((t) => (
+
+ ))}
+
+
+
+ {/* 营养目标 */}
+
+
+
+ {goalOptions.map((g) => (
+
+ ))}
+
+
+
+ {/* 过敏史 */}
+
+
+
+ {allergyOptions.map((a) => (
+
+ ))}
+
+
+
+
+
+ )}
+
+
+
+ )
+}
+
+export default RegisterPage
+
diff --git a/src/src/app/src/pages/Report.tsx b/src/src/app/src/pages/Report.tsx
new file mode 100644
index 0000000..54f43e7
--- /dev/null
+++ b/src/src/app/src/pages/Report.tsx
@@ -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(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 (
+
+ )
+ }
+
+ if (!reportData) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {/* 顶部标题栏 */}
+
+
+ {/* 时间切换 */}
+
+
+ {['本周', '本月'].map((period) => (
+
+ ))}
+
+
+
+
+ {/* 营养摄入趋势 */}
+
+
+ {/* 营养素分布 */}
+
+
+ {/* 每日平均摄入 */}
+
+
+
+
每日平均摄入
+
+
+ {avgNutrients.map((nutrient) => (
+
+
+ {nutrient.name}
+
+ {nutrient.value}/{nutrient.goal} {nutrient.unit}
+
+
+
+
+ 达成率:{Math.min(Math.round((nutrient.value / nutrient.goal) * 100), 999)}%
+
+
+ ))}
+
+
+
+ {/* 饮食建议 */}
+
+
+
+ {reportData?.suggestions && reportData.suggestions.length > 0 ? (
+ reportData.suggestions.map((suggestion: string, index: number) => (
+
{suggestion}
+ ))
+ ) : (
+
暂无建议
+ )}
+
+
+
+ {/* 成就卡片 */}
+
+
{timePeriod}成就
+
+
+
🏆
+
连续打卡
+
{reportData?.achievements?.consecutive_days || 0}天
+
+
+
💪
+
目标达成
+
{reportData?.achievements?.goal_achieved_days || 0}天
+
+
+
🎯
+
完美一天
+
{reportData?.achievements?.perfect_days || 0}次
+
+
+
+
+ {/* 数据导出 */}
+
+
+
+ )
+}
+
+export default ReportPage
+
diff --git a/src/src/app/src/pages/Reviews.tsx b/src/src/app/src/pages/Reviews.tsx
new file mode 100644
index 0000000..d93dbfe
--- /dev/null
+++ b/src/src/app/src/pages/Reviews.tsx
@@ -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 (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ )
+ }
+
+ return (
+
+ {/* 顶部标题栏 */}
+
+
我的评价
+
已评价 {reviews.length} 道美食
+
+
+ {reviews.length > 0 ? (
+
+ {/* 评价统计 */}
+
+
+
+
我的平均评分
+
+ {avgRating.toFixed(1)}
+ {renderStars(avgRating)}
+
+
+
+
总评价数
+
{reviews.length}
+
+
+
+
+ {/* 评价列表 */}
+
+ {reviews.map((review) => (
+
+ {/* 餐品信息 */}
+
+

+
+
{review.mealName}
+
{review.date}
+
+
+
+ {/* 评分 */}
+
+ {renderStars(review.rating)}
+ {review.rating}
+
+
+ {/* 评价内容 */}
+
{review.comment}
+
+ {/* 图片(如果有) */}
+ {review.images.length > 0 && (
+
+ {review.images.map((img, index) => (
+

+ ))}
+
+ )}
+
+ {/* 互动数据 */}
+
+
+
+ {review.likes} 赞
+
+
+
+ {review.replies} 回复
+
+
+
+ {/* 操作按钮 */}
+
+
+
+
+
+ {/* 商家回复(如果有) */}
+ {review.replies > 0 && (
+
+
+
+ 感谢您的评价!我们会继续努力提供更好的服务。
+
+
+ )}
+
+ ))}
+
+
+ ) : (
+
+ }
+ title="还没有评价"
+ description="去吃过的美食留下你的评价吧"
+ actionText="去用餐记录"
+ onAction={() => window.history.back()}
+ />
+
+ )}
+
+ )
+}
+
+export default ReviewsPage
+
diff --git a/src/src/app/src/pages/Search.tsx b/src/src/app/src/pages/Search.tsx
new file mode 100644
index 0000000..aaae58b
--- /dev/null
+++ b/src/src/app/src/pages/Search.tsx
@@ -0,0 +1,241 @@
+import { useState, useEffect, useRef } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { ArrowLeft, Search as SearchIcon, X, Clock, TrendingUp } from 'lucide-react'
+import MealCard from '../components/Common/MealCard'
+import { searchDishes } from '../api/dishes'
+
+const SearchPage = () => {
+ const navigate = useNavigate()
+ const inputRef = useRef(null)
+ const [keyword, setKeyword] = useState('')
+ const [searchHistory, setSearchHistory] = useState(() => {
+ const saved = localStorage.getItem('searchHistory')
+ return saved ? JSON.parse(saved) : []
+ })
+ const [searchResults, setSearchResults] = useState([])
+ const [suggestions, setSuggestions] = useState([])
+
+ const hotSearches = ['宫保鸡丁', '番茄鸡蛋面', '三文鱼', '麻辣香锅', '减脂餐', '高蛋白']
+
+ useEffect(() => {
+ inputRef.current?.focus()
+ }, [])
+
+ useEffect(() => {
+ if (keyword) {
+ const fetchSearchResults = async () => {
+ try {
+ const results = await searchDishes(keyword)
+ // 从响应中正确提取菜品数据
+ const dishes = results?.data?.dishes || []
+ setSearchResults(dishes)
+
+ // 从搜索结果中提取建议词
+ const allKeywords = dishes.map((item: any) => item.name)
+ const filtered = [...new Set(allKeywords.filter((k: string) => k.includes(keyword)))].slice(0, 6)
+ setSuggestions(filtered as string[])
+ } catch (error) {
+ console.error('搜索菜品失败:', error)
+ setSearchResults([])
+ setSuggestions([])
+ }
+ }
+
+ fetchSearchResults()
+ } else {
+ setSearchResults([])
+ setSuggestions([])
+ }
+ }, [keyword])
+
+ const handleSearch = (text: string) => {
+ if (!text.trim()) return
+
+ setKeyword(text)
+
+ // 添加到搜索历史
+ const newHistory = [text, ...searchHistory.filter(h => h !== text)].slice(0, 10)
+ setSearchHistory(newHistory)
+ localStorage.setItem('searchHistory', JSON.stringify(newHistory))
+ }
+
+ const clearHistory = () => {
+ setSearchHistory([])
+ localStorage.removeItem('searchHistory')
+ }
+
+ const deleteHistoryItem = (item: string) => {
+ const newHistory = searchHistory.filter(h => h !== item)
+ setSearchHistory(newHistory)
+ localStorage.setItem('searchHistory', JSON.stringify(newHistory))
+ }
+
+ return (
+
+ {/* 搜索栏 */}
+
+
+
+
+
+
+ setKeyword(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch(keyword)}
+ placeholder="搜索菜品、食堂..."
+ className="w-full pl-11 pr-10 py-3 bg-gray-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/50"
+ />
+ {keyword && (
+
+ )}
+
+
+
+
+
+
+
+ {/* 搜索建议 */}
+ {keyword && suggestions.length > 0 && (
+
+ {suggestions.map((suggestion) => (
+
+ ))}
+
+ )}
+
+ {/* 搜索结果 */}
+ {searchResults.length > 0 && (
+
+
+
+ 找到 {searchResults.length} 个结果
+
+
+ {searchResults.map((meal) => (
+
+ ))}
+
+ )}
+
+ {/* 无搜索结果时显示 */}
+ {!keyword && (
+ <>
+ {/* 搜索历史 */}
+ {searchHistory.length > 0 && (
+
+
+
+ {searchHistory.map((item) => (
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* 热门搜索 */}
+
+
+
+
热门搜索
+
+
+ {hotSearches.map((item, index) => (
+
+ ))}
+
+
+ >
+ )}
+
+ {/* 无结果提示 */}
+ {keyword && searchResults.length === 0 && (
+
+
🔍
+
未找到相关结果
+
换个关键词试试吧
+
+
+ )}
+
+
+ )
+}
+
+export default SearchPage
+
diff --git a/src/src/app/src/pages/Settings.tsx b/src/src/app/src/pages/Settings.tsx
new file mode 100644
index 0000000..75b3d69
--- /dev/null
+++ b/src/src/app/src/pages/Settings.tsx
@@ -0,0 +1,136 @@
+import { useNavigate } from 'react-router-dom'
+import {
+ User, Lock, Phone, Bell, Shield, HelpCircle,
+ FileText, Trash2, Info, ChevronRight, ArrowLeft
+} from 'lucide-react'
+
+const SettingsPage = () => {
+ const navigate = useNavigate()
+
+ const handleClearCache = () => {
+ if (confirm('确定要清除缓存吗?')) {
+ alert('缓存已清除!')
+ }
+ }
+
+ const settingSections = [
+ {
+ title: '账户信息',
+ items: [
+ { icon: User, label: '修改用户名', path: '/settings/username' },
+ { icon: Lock, label: '修改密码', path: '/settings/password' },
+ { icon: Phone, label: '绑定手机号', path: '/settings/phone', badge: '已绑定' },
+ ],
+ },
+ {
+ title: '通知设置',
+ items: [
+ { icon: Bell, label: '推送通知', path: '/settings/notifications', toggle: true },
+ { icon: Bell, label: '消息提醒', path: '/settings/messages', toggle: true },
+ ],
+ },
+ {
+ title: '隐私与安全',
+ items: [
+ { icon: Shield, label: '隐私设置', path: '/settings/privacy' },
+ { icon: Shield, label: '账户安全', path: '/settings/security' },
+ ],
+ },
+ {
+ title: '其他',
+ items: [
+ { icon: HelpCircle, label: '帮助中心', path: '/help' },
+ { icon: FileText, label: '用户协议', path: '/terms' },
+ { icon: Info, label: '关于我们', path: '/about' },
+ { icon: Trash2, label: '清除缓存', action: handleClearCache, badge: '12.5MB' },
+ ],
+ },
+ ]
+
+ return (
+
+ {/* 顶部导航 */}
+
+
+
账户设置
+
+
+
+
+ {/* 用户信息卡片 */}
+
+
+

+
+
+
+
+ {/* 设置列表 */}
+ {settingSections.map((section) => (
+
+
+ {section.title}
+
+
+ {section.items.map((item, index) => (
+
+ ))}
+
+
+ ))}
+
+ {/* 版本信息 */}
+
+
+ {/* 注销账户 */}
+
+
+
+ )
+}
+
+export default SettingsPage
+
diff --git a/src/src/app/src/pages/Splash.tsx b/src/src/app/src/pages/Splash.tsx
new file mode 100644
index 0000000..6bedff0
--- /dev/null
+++ b/src/src/app/src/pages/Splash.tsx
@@ -0,0 +1,25 @@
+import { UtensilsCrossed } from 'lucide-react'
+
+const SplashPage = () => {
+ return (
+
+
+
+
+
+
+
校园食堂
+
智能推荐 · 健康饮食
+
+
+
+
+ )
+}
+
+export default SplashPage
+
diff --git a/src/src/app/src/utils/mockData.ts b/src/src/app/src/utils/mockData.ts
new file mode 100644
index 0000000..53432e7
--- /dev/null
+++ b/src/src/app/src/utils/mockData.ts
@@ -0,0 +1,841 @@
+// 模拟餐品数据 - 扩展版(30道菜品)
+export const mockMeals = [
+ {
+ id: '1',
+ name: '宫保鸡丁套餐',
+ image: 'https://images.unsplash.com/photo-1603073203482-55d7e2e66325?w=500',
+ price: 15.8,
+ location: '第一食堂 2楼',
+ rating: 4.8,
+ calories: 650,
+ protein: 35,
+ fat: 25,
+ carbs: 70,
+ category: '川菜',
+ matchRate: 95,
+ description: '经典川菜,麻辣鲜香,配米饭和小菜',
+ ingredients: ['鸡胸肉', '花生', '干辣椒', '花椒', '葱姜蒜'],
+ cookingMethod: '爆炒',
+ suitableFor: ['减脂增肌', '喜欢辣味'],
+ reviews: [
+ { user: '用户xxx', rating: 5, comment: '非常好吃,辣度适中!', date: '2025-10-27' },
+ { user: '用户xxx', rating: 4.5, comment: '味道不错,就是有点贵', date: '2025-10-26' },
+ { user: '用户xxx', rating: 5, comment: '花生很香脆,鸡肉很嫩', date: '2025-10-25' },
+ ],
+ },
+ {
+ id: '2',
+ name: '番茄鸡蛋面',
+ image: 'https://images.unsplash.com/photo-1569718212165-3a8278d5f624?w=500',
+ price: 12.5,
+ location: '第二食堂 1楼',
+ rating: 4.6,
+ calories: 480,
+ protein: 18,
+ fat: 12,
+ carbs: 75,
+ category: '面食',
+ matchRate: 88,
+ description: '经典家常面,汤鲜味美',
+ ingredients: ['面条', '番茄', '鸡蛋', '葱花'],
+ cookingMethod: '煮制',
+ suitableFor: ['早餐', '清淡饮食'],
+ reviews: [
+ { user: '用户xxx', rating: 4.5, comment: '汤很鲜,面条劲道', date: '2025-10-27' },
+ ],
+ },
+ {
+ id: '3',
+ name: '香煎三文鱼',
+ image: 'https://images.unsplash.com/photo-1467003909585-2f8a72700288?w=500',
+ price: 28.8,
+ location: '第三食堂 3楼',
+ rating: 4.9,
+ calories: 420,
+ protein: 42,
+ fat: 20,
+ carbs: 15,
+ category: '西餐',
+ matchRate: 92,
+ description: '优质三文鱼,富含Omega-3',
+ ingredients: ['挪威三文鱼', '柠檬', '黑胡椒', '橄榄油'],
+ cookingMethod: '煎制',
+ suitableFor: ['健身增肌', '减脂'],
+ reviews: [
+ { user: '用户xxx', rating: 5, comment: '三文鱼很新鲜,值得推荐', date: '2025-10-28' },
+ { user: '用户xxx', rating: 4.8, comment: '分量足,口感好', date: '2025-10-27' },
+ ],
+ },
+ {
+ id: '4',
+ name: '麻辣香锅',
+ image: 'https://images.unsplash.com/photo-1552611052-33e04de081de?w=500',
+ price: 22.0,
+ location: '第一食堂 1楼',
+ rating: 4.7,
+ calories: 780,
+ protein: 28,
+ fat: 38,
+ carbs: 68,
+ category: '川菜',
+ matchRate: 85,
+ description: '多种食材自选,麻辣过瘾',
+ ingredients: ['多种蔬菜', '肉类可选', '麻辣底料'],
+ cookingMethod: '爆炒',
+ suitableFor: ['喜欢辣味', '食量大'],
+ reviews: [
+ { user: '用户xxx', rating: 4.5, comment: '很辣很过瘾!', date: '2025-10-26' },
+ ],
+ },
+ {
+ id: '5',
+ name: '清蒸鲈鱼',
+ image: 'https://images.unsplash.com/photo-1519708227418-c8fd9a32b7a2?w=500',
+ price: 32.0,
+ location: '第二食堂 3楼',
+ rating: 4.8,
+ calories: 280,
+ protein: 38,
+ fat: 8,
+ carbs: 5,
+ category: '粤菜',
+ matchRate: 90,
+ description: '鲜嫩多汁,原汁原味',
+ ingredients: ['鲈鱼', '姜葱', '蒸鱼豉油'],
+ cookingMethod: '清蒸',
+ suitableFor: ['清淡饮食', '减脂'],
+ reviews: [
+ { user: '用户xxx', rating: 5, comment: '鱼很新鲜,清淡健康', date: '2025-10-28' },
+ ],
+ },
+ {
+ id: '6',
+ name: '牛肉汉堡套餐',
+ image: 'https://images.unsplash.com/photo-1568901346375-23c9450c58cd?w=500',
+ price: 18.8,
+ location: '第三食堂 1楼',
+ rating: 4.5,
+ calories: 720,
+ protein: 32,
+ fat: 35,
+ carbs: 65,
+ category: '西餐',
+ matchRate: 82,
+ description: '纯牛肉饼,配薯条和可乐',
+ ingredients: ['牛肉饼', '生菜', '番茄', '奶酪', '面包'],
+ cookingMethod: '煎烤',
+ suitableFor: ['快餐', '增重'],
+ reviews: [],
+ },
+ {
+ id: '7',
+ name: '红烧肉套餐',
+ image: 'https://images.unsplash.com/photo-1529692236671-f1f6cf9683ba?w=500',
+ price: 19.8,
+ location: '第一食堂 3楼',
+ rating: 4.9,
+ calories: 850,
+ protein: 30,
+ fat: 45,
+ carbs: 75,
+ category: '本帮菜',
+ matchRate: 87,
+ description: '肥而不腻,入口即化,配米饭绝配',
+ ingredients: ['五花肉', '冰糖', '酱油', '料酒', '八角'],
+ cookingMethod: '红烧',
+ suitableFor: ['传统口味', '增重'],
+ reviews: [
+ { user: '用户xxx', rating: 5, comment: '太好吃了!肉质软烂', date: '2025-10-27' },
+ { user: '用户xxx', rating: 4.8, comment: '很正宗的红烧肉', date: '2025-10-26' },
+ ],
+ },
+ {
+ id: '8',
+ name: '酸菜鱼',
+ image: 'https://images.unsplash.com/photo-1534422298391-e4f8c172dddb?w=500',
+ price: 26.0,
+ location: '第二食堂 2楼',
+ rating: 4.7,
+ calories: 520,
+ protein: 35,
+ fat: 18,
+ carbs: 45,
+ category: '川菜',
+ matchRate: 89,
+ description: '鱼肉鲜嫩,汤汁酸辣开胃',
+ ingredients: ['草鱼', '酸菜', '泡椒', '姜蒜'],
+ cookingMethod: '煮制',
+ suitableFor: ['酸辣口味', '减脂'],
+ reviews: [
+ { user: '用户xxx', rating: 4.5, comment: '酸辣适中,很开胃', date: '2025-10-28' },
+ ],
+ },
+ {
+ id: '9',
+ name: '黑椒牛柳',
+ image: 'https://images.unsplash.com/photo-1544025162-d76694265947?w=500',
+ price: 23.8,
+ location: '第三食堂 2楼',
+ rating: 4.6,
+ calories: 580,
+ protein: 40,
+ fat: 25,
+ carbs: 35,
+ category: '西餐',
+ matchRate: 91,
+ description: '牛肉嫩滑,黑椒味浓郁',
+ ingredients: ['牛里脊', '黑胡椒', '洋葱', '青椒'],
+ cookingMethod: '炒制',
+ suitableFor: ['增肌', '高蛋白'],
+ reviews: [],
+ },
+ {
+ id: '10',
+ name: '扬州炒饭',
+ image: 'https://images.unsplash.com/photo-1603133872878-684f208fb84b?w=500',
+ price: 14.5,
+ location: '第一食堂 1楼',
+ rating: 4.5,
+ calories: 620,
+ protein: 22,
+ fat: 20,
+ carbs: 82,
+ category: '粤菜',
+ matchRate: 84,
+ description: '粒粒分明,配料丰富',
+ ingredients: ['米饭', '虾仁', '火腿', '鸡蛋', '豌豆'],
+ cookingMethod: '炒制',
+ suitableFor: ['快餐', '传统口味'],
+ reviews: [
+ { user: '用户xxx', rating: 4.5, comment: '炒饭很香,料很足', date: '2025-10-27' },
+ ],
+ },
+ {
+ id: '11',
+ name: '水煮牛肉',
+ image: 'https://images.unsplash.com/photo-1555939594-58d7cb561ad1?w=500',
+ price: 24.8,
+ location: '第二食堂 2楼',
+ rating: 4.8,
+ calories: 680,
+ protein: 38,
+ fat: 32,
+ carbs: 48,
+ category: '川菜',
+ matchRate: 86,
+ description: '麻辣鲜香,牛肉嫩滑',
+ ingredients: ['牛肉', '豆芽', '辣椒', '花椒', '豆瓣酱'],
+ cookingMethod: '水煮',
+ suitableFor: ['重口味', '喜欢辣味'],
+ reviews: [],
+ },
+ {
+ id: '12',
+ name: '广式烧鹅饭',
+ image: 'https://images.unsplash.com/photo-1598103442097-8b74394b95c6?w=500',
+ price: 22.8,
+ location: '第三食堂 3楼',
+ rating: 4.9,
+ calories: 720,
+ protein: 35,
+ fat: 30,
+ carbs: 70,
+ category: '粤菜',
+ matchRate: 88,
+ description: '皮脆肉嫩,汁水丰富',
+ ingredients: ['烧鹅', '米饭', '酱汁'],
+ cookingMethod: '烧烤',
+ suitableFor: ['传统口味', '增重'],
+ reviews: [
+ { user: '用户xxx', rating: 5, comment: '鹅肉很香,皮很脆', date: '2025-10-28' },
+ ],
+ },
+ {
+ id: '13',
+ name: '蒜蓉粉丝蒸虾',
+ image: 'https://images.unsplash.com/photo-1565680018434-b513d5e5fd47?w=500',
+ price: 28.0,
+ location: '第一食堂 2楼',
+ rating: 4.7,
+ calories: 380,
+ protein: 32,
+ fat: 12,
+ carbs: 35,
+ category: '粤菜',
+ matchRate: 93,
+ description: '虾肉鲜美,蒜香浓郁',
+ ingredients: ['大虾', '粉丝', '蒜蓉', '葱'],
+ cookingMethod: '清蒸',
+ suitableFor: ['海鲜爱好', '减脂'],
+ reviews: [],
+ },
+ {
+ id: '14',
+ name: '麻婆豆腐',
+ image: 'https://images.unsplash.com/photo-1580822184713-fc5400e7fe10?w=500',
+ price: 11.8,
+ location: '第二食堂 1楼',
+ rating: 4.4,
+ calories: 320,
+ protein: 15,
+ fat: 18,
+ carbs: 28,
+ category: '川菜',
+ matchRate: 79,
+ description: '麻辣鲜香,下饭神器',
+ ingredients: ['豆腐', '肉末', '豆瓣酱', '花椒'],
+ cookingMethod: '炒制',
+ suitableFor: ['素食', '清淡饮食'],
+ reviews: [
+ { user: '用户xxx', rating: 4.5, comment: '很下饭,麻辣适中', date: '2025-10-26' },
+ ],
+ },
+ {
+ id: '15',
+ name: '日式照烧鸡腿饭',
+ image: 'https://images.unsplash.com/photo-1604908815878-758735e59fc1?w=500',
+ price: 21.5,
+ location: '第三食堂 1楼',
+ rating: 4.8,
+ calories: 650,
+ protein: 35,
+ fat: 22,
+ carbs: 75,
+ category: '日料',
+ matchRate: 90,
+ description: '鸡腿肉嫩多汁,酱汁甜中带咸',
+ ingredients: ['鸡腿肉', '照烧酱', '米饭', '青菜'],
+ cookingMethod: '照烧',
+ suitableFor: ['日式风味', '增肌'],
+ reviews: [],
+ },
+ {
+ id: '16',
+ name: '糖醋里脊',
+ image: 'https://images.unsplash.com/photo-1613743983303-b3e89f8a7e8d?w=500',
+ price: 18.5,
+ location: '第一食堂 3楼',
+ rating: 4.6,
+ calories: 580,
+ protein: 28,
+ fat: 24,
+ carbs: 62,
+ category: '鲁菜',
+ matchRate: 83,
+ description: '外酥里嫩,酸甜可口',
+ ingredients: ['里脊肉', '番茄酱', '白醋', '白糖'],
+ cookingMethod: '炸+炒',
+ suitableFor: ['酸甜口味', '儿童'],
+ reviews: [
+ { user: '用户xxx', rating: 4.5, comment: '很好吃,酸甜适中', date: '2025-10-27' },
+ ],
+ },
+ {
+ id: '17',
+ name: '鱼香肉丝',
+ image: 'https://images.unsplash.com/photo-1582878826629-29b7ad1cdc43?w=500',
+ price: 16.8,
+ location: '第二食堂 2楼',
+ rating: 4.5,
+ calories: 520,
+ protein: 25,
+ fat: 22,
+ carbs: 55,
+ category: '川菜',
+ matchRate: 85,
+ description: '味道鲜美,酸甜微辣',
+ ingredients: ['猪肉丝', '木耳', '胡萝卜', '泡椒'],
+ cookingMethod: '炒制',
+ suitableFor: ['传统口味', '下饭'],
+ reviews: [],
+ },
+ {
+ id: '18',
+ name: '西红柿牛腩',
+ image: 'https://images.unsplash.com/photo-1574484284002-952d92456975?w=500',
+ price: 25.8,
+ location: '第三食堂 2楼',
+ rating: 4.7,
+ calories: 580,
+ protein: 32,
+ fat: 24,
+ carbs: 52,
+ category: '本帮菜',
+ matchRate: 87,
+ description: '牛肉软烂,番茄酸甜',
+ ingredients: ['牛腩', '番茄', '洋葱', '土豆'],
+ cookingMethod: '炖煮',
+ suitableFor: ['营养均衡', '家常'],
+ reviews: [
+ { user: '用户xxx', rating: 4.8, comment: '牛肉炖得很烂,好吃', date: '2025-10-28' },
+ ],
+ },
+ {
+ id: '19',
+ name: '香辣小龙虾',
+ image: 'https://images.unsplash.com/photo-1599487488170-d11ec9c172f0?w=500',
+ price: 38.0,
+ location: '第一食堂 2楼',
+ rating: 4.9,
+ calories: 420,
+ protein: 35,
+ fat: 15,
+ carbs: 30,
+ category: '川菜',
+ matchRate: 88,
+ description: '麻辣鲜香,越吃越上瘾',
+ ingredients: ['小龙虾', '辣椒', '花椒', '香料'],
+ cookingMethod: '爆炒',
+ suitableFor: ['夏季', '重口味'],
+ reviews: [
+ { user: '用户xxx', rating: 5, comment: '小龙虾很新鲜,超级好吃', date: '2025-10-27' },
+ ],
+ },
+ {
+ id: '20',
+ name: '铁板牛排',
+ image: 'https://images.unsplash.com/photo-1600891964092-4316c288032e?w=500',
+ price: 35.8,
+ location: '第三食堂 3楼',
+ rating: 4.8,
+ calories: 680,
+ protein: 45,
+ fat: 32,
+ carbs: 42,
+ category: '西餐',
+ matchRate: 91,
+ description: '肉质鲜嫩,配黑椒酱',
+ ingredients: ['牛排', '黑椒汁', '西兰花', '土豆'],
+ cookingMethod: '煎烤',
+ suitableFor: ['高蛋白', '增肌'],
+ reviews: [],
+ },
+ {
+ id: '21',
+ name: '担担面',
+ image: 'https://images.unsplash.com/photo-1617093727343-374698b1b08d?w=500',
+ price: 13.8,
+ location: '第二食堂 1楼',
+ rating: 4.6,
+ calories: 520,
+ protein: 20,
+ fat: 18,
+ carbs: 68,
+ category: '面食',
+ matchRate: 82,
+ description: '面条劲道,麻辣鲜香',
+ ingredients: ['面条', '肉末', '芝麻酱', '花椒'],
+ cookingMethod: '拌制',
+ suitableFor: ['快餐', '川味'],
+ reviews: [
+ { user: '用户xxx', rating: 4.5, comment: '很地道的担担面', date: '2025-10-26' },
+ ],
+ },
+ {
+ id: '22',
+ name: '小笼包套餐',
+ image: 'https://images.unsplash.com/photo-1563245372-f21724e3856d?w=500',
+ price: 15.0,
+ location: '第一食堂 1楼',
+ rating: 4.7,
+ calories: 480,
+ protein: 22,
+ fat: 15,
+ carbs: 62,
+ category: '点心',
+ matchRate: 86,
+ description: '皮薄馅大,汁水丰富',
+ ingredients: ['面粉', '猪肉馅', '高汤', '葱姜'],
+ cookingMethod: '蒸制',
+ suitableFor: ['早餐', '传统口味'],
+ reviews: [],
+ },
+ {
+ id: '23',
+ name: '椒盐排骨',
+ image: 'https://images.unsplash.com/photo-1529042410759-befb1204b468?w=500',
+ price: 20.8,
+ location: '第二食堂 3楼',
+ rating: 4.8,
+ calories: 720,
+ protein: 32,
+ fat: 38,
+ carbs: 55,
+ category: '本帮菜',
+ matchRate: 84,
+ description: '外酥里嫩,椒盐味浓',
+ ingredients: ['猪排骨', '椒盐', '蒜末', '辣椒'],
+ cookingMethod: '炸制',
+ suitableFor: ['重口味', '增重'],
+ reviews: [
+ { user: '用户xxx', rating: 5, comment: '排骨很酥脆,好吃', date: '2025-10-28' },
+ ],
+ },
+ {
+ id: '24',
+ name: '烤鸭饭',
+ image: 'https://images.unsplash.com/photo-1567188040759-fb8a883dc6d8?w=500',
+ price: 26.8,
+ location: '第三食堂 2楼',
+ rating: 4.9,
+ calories: 750,
+ protein: 38,
+ fat: 35,
+ carbs: 72,
+ category: '京菜',
+ matchRate: 89,
+ description: '皮脆肉嫩,配秘制酱料',
+ ingredients: ['烤鸭', '米饭', '甜面酱', '黄瓜'],
+ cookingMethod: '烤制',
+ suitableFor: ['传统口味', '特色'],
+ reviews: [],
+ },
+ {
+ id: '25',
+ name: '干锅花菜',
+ image: 'https://images.unsplash.com/photo-1543332143-4e8c27e3256a?w=500',
+ price: 14.8,
+ location: '第一食堂 3楼',
+ rating: 4.5,
+ calories: 380,
+ protein: 12,
+ fat: 20,
+ carbs: 38,
+ category: '川菜',
+ matchRate: 80,
+ description: '干香麻辣,素菜精品',
+ ingredients: ['花菜', '五花肉', '干辣椒', '蒜片'],
+ cookingMethod: '干锅',
+ suitableFor: ['素食', '清淡饮食'],
+ reviews: [
+ { user: '用户xxx', rating: 4.5, comment: '花菜很入味', date: '2025-10-27' },
+ ],
+ },
+ {
+ id: '26',
+ name: '咖喱鸡饭',
+ image: 'https://images.unsplash.com/photo-1588137378633-dea1336ce1e2?w=500',
+ price: 17.8,
+ location: '第二食堂 1楼',
+ rating: 4.6,
+ calories: 620,
+ protein: 28,
+ fat: 22,
+ carbs: 78,
+ category: '东南亚',
+ matchRate: 85,
+ description: '咖喱浓郁,鸡肉嫩滑',
+ ingredients: ['鸡肉', '土豆', '胡萝卜', '咖喱'],
+ cookingMethod: '炖煮',
+ suitableFor: ['异国风味', '下饭'],
+ reviews: [],
+ },
+ {
+ id: '27',
+ name: '烧烤拼盘',
+ image: 'https://images.unsplash.com/photo-1529692236671-f1f6cf9683ba?w=500',
+ price: 32.8,
+ location: '第三食堂 1楼',
+ rating: 4.8,
+ calories: 880,
+ protein: 42,
+ fat: 48,
+ carbs: 65,
+ category: '烧烤',
+ matchRate: 83,
+ description: '肉串、蔬菜、海鲜应有尽有',
+ ingredients: ['羊肉串', '鸡翅', '香菇', '玉米'],
+ cookingMethod: '烤制',
+ suitableFor: ['聚餐', '重口味'],
+ reviews: [
+ { user: '用户xxx', rating: 4.5, comment: '烤得很香,分量大', date: '2025-10-26' },
+ ],
+ },
+ {
+ id: '28',
+ name: '皮蛋瘦肉粥',
+ image: 'https://images.unsplash.com/photo-1573866974339-e9a93c0ddec1?w=500',
+ price: 8.8,
+ location: '第一食堂 1楼',
+ rating: 4.4,
+ calories: 280,
+ protein: 12,
+ fat: 8,
+ carbs: 42,
+ category: '粤菜',
+ matchRate: 78,
+ description: '粥绵滑,皮蛋香浓',
+ ingredients: ['大米', '皮蛋', '瘦肉', '葱花'],
+ cookingMethod: '熬煮',
+ suitableFor: ['早餐', '养胃'],
+ reviews: [],
+ },
+ {
+ id: '29',
+ name: '韩式石锅拌饭',
+ image: 'https://images.unsplash.com/photo-1553163147-622ab57be1c7?w=500',
+ price: 19.8,
+ location: '第二食堂 2楼',
+ rating: 4.7,
+ calories: 580,
+ protein: 24,
+ fat: 18,
+ carbs: 82,
+ category: '韩料',
+ matchRate: 86,
+ description: '营养丰富,口味独特',
+ ingredients: ['米饭', '牛肉', '蔬菜', '韩式辣酱', '煎蛋'],
+ cookingMethod: '拌制',
+ suitableFor: ['异国风味', '营养均衡'],
+ reviews: [
+ { user: '用户xxx', rating: 4.8, comment: '很正宗的韩式拌饭', date: '2025-10-28' },
+ ],
+ },
+ {
+ id: '30',
+ name: '海鲜炒饭',
+ image: 'https://images.unsplash.com/photo-1512058564366-18510be2db19?w=500',
+ price: 22.8,
+ location: '第三食堂 3楼',
+ rating: 4.8,
+ calories: 650,
+ protein: 32,
+ fat: 20,
+ carbs: 85,
+ category: '粤菜',
+ matchRate: 88,
+ description: '海鲜丰富,米饭香Q',
+ ingredients: ['米饭', '虾仁', '鱿鱼', '蟹肉', '鸡蛋'],
+ cookingMethod: '炒制',
+ suitableFor: ['海鲜爱好', '快餐'],
+ reviews: [],
+ },
+]
+
+// 获取所有餐品
+export const getAllMeals = () => mockMeals
+
+// 根据ID获取餐品
+export const getMealById = (id: string) => mockMeals.find(meal => meal.id === id)
+
+// 获取推荐餐品
+export const getRecommendedMeals = () => mockMeals.slice(0, 6)
+
+// 搜索餐品
+export const searchMeals = (keyword: string) => {
+ if (!keyword) return mockMeals
+ return mockMeals.filter(meal =>
+ meal.name.toLowerCase().includes(keyword.toLowerCase()) ||
+ meal.category.toLowerCase().includes(keyword.toLowerCase())
+ )
+}
+
+// 模拟用户信息
+export const mockUserProfile = {
+ id: 'user001',
+ name: 'xxx',
+ avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200',
+ email: 'xxx@university.edu.cn',
+ phone: '138****8888',
+ gender: '男',
+ age: 21,
+ height: 175,
+ weight: 70,
+ studentId: '2021001234',
+ major: '计算机科学与技术',
+ grade: '大三',
+ memberLevel: '黄金会员',
+ points: 2580,
+ joinDate: '2021-09-01',
+ preferences: {
+ dietType: '均衡饮食',
+ spicyLevel: '中辣',
+ allergies: ['花生', '海鲜'],
+ dislikedFoods: ['香菜', '芹菜'],
+ favoriteCuisines: ['川菜', '粤菜', '西餐'],
+ mealBudget: { min: 10, max: 30 },
+ },
+ nutritionGoal: {
+ dailyCalories: 2200,
+ protein: 100,
+ fat: 60,
+ carbs: 280,
+ goal: '增肌',
+ },
+}
+
+// 模拟用餐记录(30条)
+export const mockMealRecords = [
+ { id: 'r1', mealId: '1', mealName: '宫保鸡丁套餐', date: '2025-10-28', time: '12:30', location: '第一食堂 2楼', calories: 650, price: 15.8, rating: 5 },
+ { id: 'r2', mealId: '3', mealName: '香煎三文鱼', date: '2025-10-28', time: '18:45', location: '第三食堂 3楼', calories: 420, price: 28.8, rating: 5 },
+ { id: 'r3', mealId: '2', mealName: '番茄鸡蛋面', date: '2025-10-27', time: '07:30', location: '第二食堂 1楼', calories: 480, price: 12.5, rating: 4.5 },
+ { id: 'r4', mealId: '7', mealName: '红烧肉套餐', date: '2025-10-27', time: '12:15', location: '第一食堂 3楼', calories: 850, price: 19.8, rating: 5 },
+ { id: 'r5', mealId: '15', mealName: '日式照烧鸡腿饭', date: '2025-10-27', time: '18:30', location: '第三食堂 1楼', calories: 650, price: 21.5, rating: 4.5 },
+ { id: 'r6', mealId: '5', mealName: '清蒸鲈鱼', date: '2025-10-26', time: '12:00', location: '第二食堂 3楼', calories: 280, price: 32.0, rating: 5 },
+ { id: 'r7', mealId: '10', mealName: '扬州炒饭', date: '2025-10-26', time: '18:00', location: '第一食堂 1楼', calories: 620, price: 14.5, rating: 4 },
+ { id: 'r8', mealId: '8', mealName: '酸菜鱼', date: '2025-10-25', time: '12:30', location: '第二食堂 2楼', calories: 520, price: 26.0, rating: 4.5 },
+ { id: 'r9', mealId: '20', mealName: '铁板牛排', date: '2025-10-25', time: '19:00', location: '第三食堂 3楼', calories: 680, price: 35.8, rating: 5 },
+ { id: 'r10', mealId: '12', mealName: '广式烧鹅饭', date: '2025-10-24', time: '12:15', location: '第三食堂 3楼', calories: 720, price: 22.8, rating: 5 },
+ { id: 'r11', mealId: '4', mealName: '麻辣香锅', date: '2025-10-24', time: '18:30', location: '第一食堂 1楼', calories: 780, price: 22.0, rating: 4.5 },
+ { id: 'r12', mealId: '18', mealName: '西红柿牛腩', date: '2025-10-23', time: '12:00', location: '第三食堂 2楼', calories: 580, price: 25.8, rating: 4.8 },
+ { id: 'r13', mealId: '6', mealName: '牛肉汉堡套餐', date: '2025-10-23', time: '18:00', location: '第三食堂 1楼', calories: 720, price: 18.8, rating: 4 },
+ { id: 'r14', mealId: '11', mealName: '水煮牛肉', date: '2025-10-22', time: '12:30', location: '第二食堂 2楼', calories: 680, price: 24.8, rating: 4.8 },
+ { id: 'r15', mealId: '22', mealName: '小笼包套餐', date: '2025-10-22', time: '07:30', location: '第一食堂 1楼', calories: 480, price: 15.0, rating: 4.5 },
+ { id: 'r16', mealId: '16', mealName: '糖醋里脊', date: '2025-10-21', time: '12:15', location: '第一食堂 3楼', calories: 580, price: 18.5, rating: 4.5 },
+ { id: 'r17', mealId: '24', mealName: '烤鸭饭', date: '2025-10-21', time: '18:45', location: '第三食堂 2楼', calories: 750, price: 26.8, rating: 5 },
+ { id: 'r18', mealId: '13', mealName: '蒜蓉粉丝蒸虾', date: '2025-10-20', time: '12:00', location: '第一食堂 2楼', calories: 380, price: 28.0, rating: 4.5 },
+ { id: 'r19', mealId: '29', mealName: '韩式石锅拌饭', date: '2025-10-20', time: '18:30', location: '第二食堂 2楼', calories: 580, price: 19.8, rating: 4.8 },
+ { id: 'r20', mealId: '9', mealName: '黑椒牛柳', date: '2025-10-19', time: '12:30', location: '第三食堂 2楼', calories: 580, price: 23.8, rating: 4.5 },
+ { id: 'r21', mealId: '21', mealName: '担担面', date: '2025-10-19', time: '18:00', location: '第二食堂 1楼', calories: 520, price: 13.8, rating: 4.5 },
+ { id: 'r22', mealId: '23', mealName: '椒盐排骨', date: '2025-10-18', time: '12:15', location: '第二食堂 3楼', calories: 720, price: 20.8, rating: 5 },
+ { id: 'r23', mealId: '26', mealName: '咖喱鸡饭', date: '2025-10-18', time: '18:30', location: '第二食堂 1楼', calories: 620, price: 17.8, rating: 4 },
+ { id: 'r24', mealId: '14', mealName: '麻婆豆腐', date: '2025-10-17', time: '12:00', location: '第二食堂 1楼', calories: 320, price: 11.8, rating: 4.5 },
+ { id: 'r25', mealId: '30', mealName: '海鲜炒饭', date: '2025-10-17', time: '18:15', location: '第三食堂 3楼', calories: 650, price: 22.8, rating: 4.8 },
+ { id: 'r26', mealId: '17', mealName: '鱼香肉丝', date: '2025-10-16', time: '12:30', location: '第二食堂 2楼', calories: 520, price: 16.8, rating: 4 },
+ { id: 'r27', mealId: '19', mealName: '香辣小龙虾', date: '2025-10-16', time: '19:00', location: '第一食堂 2楼', calories: 420, price: 38.0, rating: 5 },
+ { id: 'r28', mealId: '25', mealName: '干锅花菜', date: '2025-10-15', time: '12:00', location: '第一食堂 3楼', calories: 380, price: 14.8, rating: 4.5 },
+ { id: 'r29', mealId: '27', mealName: '烧烤拼盘', date: '2025-10-15', time: '19:30', location: '第三食堂 1楼', calories: 880, price: 32.8, rating: 4.5 },
+ { id: 'r30', mealId: '28', mealName: '皮蛋瘦肉粥', date: '2025-10-14', time: '07:30', location: '第一食堂 1楼', calories: 280, price: 8.8, rating: 4 },
+]
+
+// 模拟收藏记录(20条)
+export const mockFavorites = [
+ { id: 'f1', mealId: '1', addedDate: '2025-10-20', note: '最爱的川菜!' },
+ { id: 'f2', mealId: '3', addedDate: '2025-10-18', note: '健身必备' },
+ { id: 'f3', mealId: '7', addedDate: '2025-10-15', note: '红烧肉超级棒' },
+ { id: 'f4', mealId: '12', addedDate: '2025-10-12', note: '烧鹅很香' },
+ { id: 'f5', mealId: '20', addedDate: '2025-10-10', note: '高蛋白牛排' },
+ { id: 'f6', mealId: '15', addedDate: '2025-10-08', note: '日式风味' },
+ { id: 'f7', mealId: '5', addedDate: '2025-10-05', note: '清淡健康' },
+ { id: 'f8', mealId: '8', addedDate: '2025-10-03', note: '酸辣开胃' },
+ { id: 'f9', mealId: '19', addedDate: '2025-10-01', note: '夏天必吃' },
+ { id: 'f10', mealId: '24', addedDate: '2025-09-28', note: '北京烤鸭' },
+ { id: 'f11', mealId: '13', addedDate: '2025-09-25', note: '海鲜美味' },
+ { id: 'f12', mealId: '9', addedDate: '2025-09-22', note: '黑椒牛柳好吃' },
+ { id: 'f13', mealId: '29', addedDate: '2025-09-20', note: '韩式拌饭' },
+ { id: 'f14', mealId: '4', addedDate: '2025-09-18', note: '麻辣过瘾' },
+ { id: 'f15', mealId: '11', addedDate: '2025-09-15', note: '水煮牛肉赞' },
+ { id: 'f16', mealId: '30', addedDate: '2025-09-12', note: '海鲜炒饭' },
+ { id: 'f17', mealId: '18', addedDate: '2025-09-10', note: '牛腩很烂' },
+ { id: 'f18', mealId: '23', addedDate: '2025-09-08', note: '排骨酥脆' },
+ { id: 'f19', mealId: '16', addedDate: '2025-09-05', note: '糖醋口味' },
+ { id: 'f20', mealId: '26', addedDate: '2025-09-03', note: '咖喱香浓' },
+]
+
+// 模拟食堂信息
+export const mockCanteens = [
+ {
+ id: 'c1',
+ name: '第一食堂',
+ image: 'https://images.unsplash.com/photo-1555939594-58d7cb561ad1?w=500',
+ location: '校园东区',
+ rating: 4.7,
+ openTime: '06:30-20:30',
+ floors: 3,
+ description: '学校最大的食堂,菜品丰富,价格实惠',
+ features: ['川菜', '本帮菜', '面食', '烧烤'],
+ phoneNumber: '0571-88888888',
+ },
+ {
+ id: 'c2',
+ name: '第二食堂',
+ image: 'https://images.unsplash.com/photo-1567620905732-2d1ec7ab7445?w=500',
+ location: '校园西区',
+ rating: 4.6,
+ openTime: '06:30-21:00',
+ floors: 3,
+ description: '环境优雅,特色菜品多',
+ features: ['粤菜', '川菜', '清真', '素食'],
+ phoneNumber: '0571-88888889',
+ },
+ {
+ id: 'c3',
+ name: '第三食堂',
+ image: 'https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=500',
+ location: '校园南区',
+ rating: 4.8,
+ openTime: '07:00-20:00',
+ floors: 3,
+ description: '新开业食堂,设施现代化',
+ features: ['西餐', '日料', '韩料', '东南亚'],
+ phoneNumber: '0571-88888890',
+ },
+]
+
+// 模拟营养周报数据
+export const mockWeeklyNutrition = {
+ week: '2025年第43周(10月21日-10月27日)',
+ totalCalories: 15200,
+ avgDailyCalories: 2171,
+ totalProtein: 665,
+ totalFat: 470,
+ totalCarbs: 1950,
+ mealsCount: 21,
+ dailyData: [
+ { date: '10-21', calories: 2150, protein: 95, fat: 68, carbs: 275 },
+ { date: '10-22', calories: 2300, protein: 105, fat: 72, carbs: 290 },
+ { date: '10-23', calories: 2050, protein: 90, fat: 65, carbs: 268 },
+ { date: '10-24', calories: 2250, protein: 100, fat: 70, carbs: 285 },
+ { date: '10-25', calories: 2100, protein: 92, fat: 66, carbs: 272 },
+ { date: '10-26', calories: 2200, protein: 98, fat: 69, carbs: 280 },
+ { date: '10-27', calories: 2150, protein: 85, fat: 60, carbs: 280 },
+ ],
+ categoryDistribution: {
+ '川菜': 35,
+ '粤菜': 25,
+ '西餐': 20,
+ '面食': 10,
+ '其他': 10,
+ },
+ healthScore: 85,
+ suggestions: [
+ '本周营养摄入均衡,继续保持',
+ '蛋白质摄入充足,有利于肌肉生长',
+ '建议增加蔬菜摄入量',
+ '控制油炸食品的频率',
+ ],
+}
+
+// 模拟通知消息(15条)
+export const mockNotifications = [
+ { id: 'n1', type: 'system', title: '系统通知', content: '第三食堂新增日料窗口,快来尝鲜!', time: '2025-10-28 10:30', read: false },
+ { id: 'n2', type: 'discount', title: '优惠活动', content: '本周三第一食堂全场8折优惠', time: '2025-10-28 09:00', read: false },
+ { id: 'n3', type: 'meal', title: '餐品推荐', content: '根据您的口味,为您推荐香煎三文鱼', time: '2025-10-27 18:00', read: true },
+ { id: 'n4', type: 'nutrition', title: '营养提醒', content: '今日蛋白质摄入不足,建议晚餐增加蛋白质', time: '2025-10-27 16:30', read: true },
+ { id: 'n5', type: 'system', title: '系统维护', content: '系统将于今晚23:00-01:00进行维护', time: '2025-10-26 20:00', read: true },
+ { id: 'n6', type: 'discount', title: '会员福利', content: '黄金会员专享:积分兑换菜品9折', time: '2025-10-26 12:00', read: true },
+ { id: 'n7', type: 'meal', title: '新品上线', content: '第二食堂推出韩式石锅拌饭', time: '2025-10-25 15:00', read: true },
+ { id: 'n8', type: 'nutrition', title: '健康建议', content: '本周卡路里摄入略高,建议适当调整', time: '2025-10-25 10:00', read: true },
+ { id: 'n9', type: 'system', title: '功能更新', content: 'App新增营养分析功能', time: '2025-10-24 14:00', read: true },
+ { id: 'n10', type: 'discount', title: '限时优惠', content: '小龙虾特惠:立减10元', time: '2025-10-24 11:00', read: true },
+ { id: 'n11', type: 'meal', title: '今日特价', content: '红烧肉套餐今日特价15.8元', time: '2025-10-23 10:30', read: true },
+ { id: 'n12', type: 'nutrition', title: '运动提醒', content: '您已连续7天达到运动目标,继续保持', time: '2025-10-23 08:00', read: true },
+ { id: 'n13', type: 'system', title: '评价奖励', content: '完成餐品评价可获得积分奖励', time: '2025-10-22 16:00', read: true },
+ { id: 'n14', type: 'discount', title: '生日福利', content: '生日当月享受全场9折优惠', time: '2025-10-22 09:00', read: true },
+ { id: 'n15', type: 'meal', title: '菜单更新', content: '本周菜单已更新,查看新菜品', time: '2025-10-21 12:00', read: true },
+]
+
+// 模拟设置选项
+export const mockSettings = {
+ notifications: {
+ mealRecommendation: true,
+ nutritionReminder: true,
+ discountAlert: true,
+ systemUpdate: true,
+ },
+ privacy: {
+ showProfile: true,
+ showMealRecords: false,
+ allowDataAnalysis: true,
+ },
+ display: {
+ theme: 'light',
+ fontSize: 'medium',
+ language: 'zh-CN',
+ },
+ account: {
+ autoLogin: true,
+ biometricAuth: false,
+ },
+}
+
diff --git a/src/src/app/tsconfig.json b/src/src/app/tsconfig.json
new file mode 100644
index 0000000..2f9e43d
--- /dev/null
+++ b/src/src/app/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": false,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
+
diff --git a/src/src/app/vite.config.ts b/src/src/app/vite.config.ts
new file mode 100644
index 0000000..6590606
--- /dev/null
+++ b/src/src/app/vite.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ host: true
+ }
+})
+
diff --git a/src/src/backend/.gitignore b/src/src/backend/.gitignore
new file mode 100644
index 0000000..3d8e1e0
--- /dev/null
+++ b/src/src/backend/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env
+.DS_Store
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
diff --git a/src/src/backend/config/database.js b/src/src/backend/config/database.js
new file mode 100644
index 0000000..9bc11db
--- /dev/null
+++ b/src/src/backend/config/database.js
@@ -0,0 +1,34 @@
+import mysql from 'mysql2/promise';
+import dotenv from 'dotenv';
+
+dotenv.config();
+
+// 创建数据库连接池
+const pool = mysql.createPool({
+ host: process.env.DB_HOST || 'localhost',
+ port: process.env.DB_PORT || 3306,
+ user: process.env.DB_USER || 'mariadb',
+ password: process.env.DB_PASSWORD || '12345678',
+ database: process.env.DB_NAME || 'campus_canteen',
+ waitForConnections: true,
+ connectionLimit: 10,
+ queueLimit: 0,
+ enableKeepAlive: true,
+ keepAliveInitialDelay: 0
+});
+
+// 测试数据库连接
+async function testConnection() {
+ try {
+ const connection = await pool.getConnection();
+ console.log('✅ 数据库连接成功!');
+ connection.release();
+ return true;
+ } catch (error) {
+ console.error('❌ 数据库连接失败:', error.message);
+ return false;
+ }
+}
+
+export { pool, testConnection };
+
diff --git a/src/src/backend/middleware/auth.js b/src/src/backend/middleware/auth.js
new file mode 100644
index 0000000..d7ef489
--- /dev/null
+++ b/src/src/backend/middleware/auth.js
@@ -0,0 +1,66 @@
+import jwt from 'jsonwebtoken';
+
+// 验证JWT令牌
+export const authenticateToken = (req, res, next) => {
+ const authHeader = req.headers['authorization'];
+ const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
+
+ if (!token) {
+ return res.status(401).json({
+ success: false,
+ message: '未提供认证令牌'
+ });
+ }
+
+ // 开发环境下接受测试token
+ if (process.env.NODE_ENV === 'development' && token.includes('test_user')) {
+ // 模拟用户信息,直接通过认证
+ req.user = {
+ id: 1,
+ userId: 1,
+ username: 'test_user',
+ role: 'user'
+ };
+ return next();
+ }
+
+ jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
+ if (err) {
+ return res.status(403).json({
+ success: false,
+ message: '令牌无效或已过期'
+ });
+ }
+
+ req.user = user;
+ next();
+ });
+};
+
+// 可选的认证中间件(不强制登录)
+export const optionalAuth = (req, res, next) => {
+ const authHeader = req.headers['authorization'];
+ const token = authHeader && authHeader.split(' ')[1];
+
+ if (token) {
+ // 开发环境下接受测试token
+ if (process.env.NODE_ENV === 'development' && token.includes('test_user')) {
+ req.user = {
+ id: 1,
+ userId: 1,
+ username: 'test_user',
+ role: 'user'
+ };
+ return next();
+ }
+
+ jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
+ if (!err) {
+ req.user = user;
+ }
+ });
+ }
+
+ next();
+};
+
diff --git a/src/src/backend/routes/admin.js b/src/src/backend/routes/admin.js
new file mode 100644
index 0000000..b8c68e3
--- /dev/null
+++ b/src/src/backend/routes/admin.js
@@ -0,0 +1,679 @@
+import express from 'express';
+import { pool } from '../config/database.js';
+import { authenticateToken } from '../middleware/auth.js';
+import bcrypt from 'bcryptjs';
+
+const router = express.Router();
+
+// 管理员权限中间件
+const requireAdmin = async (req, res, next) => {
+ try {
+ const userId = req.user.userId;
+ const [users] = await pool.query(
+ 'SELECT role FROM users WHERE id = ?',
+ [userId]
+ );
+
+ if (users.length === 0 || users[0].role !== 'admin') {
+ return res.status(403).json({
+ success: false,
+ message: '需要管理员权限'
+ });
+ }
+
+ next();
+ } catch (error) {
+ console.error('权限检查失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '权限检查失败'
+ });
+ }
+};
+
+// ============= 待审核菜品管理 =============
+
+// 获取待审核菜品列表
+router.get('/pending-dishes', authenticateToken, requireAdmin, async (req, res) => {
+ try {
+ const { status = 'pending' } = req.query;
+
+ const [dishes] = await pool.query(`
+ SELECT
+ ud.*,
+ u.username,
+ u.name as user_name
+ FROM user_dishes ud
+ JOIN users u ON ud.user_id = u.id
+ WHERE ud.status = ?
+ ORDER BY ud.created_at DESC
+ `, [status]);
+
+ res.json({
+ success: true,
+ data: dishes
+ });
+ } catch (error) {
+ console.error('获取待审核菜品失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取待审核菜品失败'
+ });
+ }
+});
+
+// 审核通过菜品
+router.put('/dishes/:id/approve', authenticateToken, requireAdmin, async (req, res) => {
+ const connection = await pool.getConnection();
+
+ try {
+ await connection.beginTransaction();
+
+ const { id } = req.params;
+ const adminId = req.user.userId;
+
+ // 获取待审核菜品信息
+ const [userDishes] = await connection.query(
+ 'SELECT * FROM user_dishes WHERE id = ?',
+ [id]
+ );
+
+ if (userDishes.length === 0) {
+ await connection.rollback();
+ return res.status(404).json({
+ success: false,
+ message: '菜品不存在'
+ });
+ }
+
+ const dish = userDishes[0];
+
+ if (dish.status !== 'pending') {
+ await connection.rollback();
+ return res.status(400).json({
+ success: false,
+ message: '该菜品已被审核'
+ });
+ }
+
+ // 将菜品添加到正式菜品表
+ const [result] = await connection.query(`
+ INSERT INTO dishes (
+ name, description, category_id, price, canteen_id,
+ image, calories, protein, fat, carbs, tags,
+ status, approval_status, created_by
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'available', 'approved', ?)
+ `, [
+ dish.name,
+ dish.description,
+ null, // category_id 可以根据需要映射
+ dish.price,
+ dish.canteen_id,
+ dish.image_url,
+ dish.calories,
+ dish.protein,
+ dish.fat,
+ dish.carbs,
+ dish.ingredients,
+ dish.user_id
+ ]);
+
+ // 更新用户提交的菜品状态
+ await connection.query(
+ "UPDATE user_dishes SET status = 'approved' WHERE id = ?",
+ [id]
+ );
+
+ // 记录管理员操作日志
+ await connection.query(
+ `INSERT INTO admin_logs (admin_id, action, target_type, target_id, details)
+ VALUES (?, 'approve_dish', 'user_dish', ?, ?)`,
+ [adminId, id, JSON.stringify({ dish_id: result.insertId, user_id: dish.user_id })]
+ );
+
+ await connection.commit();
+
+ res.json({
+ success: true,
+ message: '审核通过',
+ data: {
+ dishId: result.insertId
+ }
+ });
+ } catch (error) {
+ await connection.rollback();
+ console.error('审核通过失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '审核通过失败'
+ });
+ } finally {
+ connection.release();
+ }
+});
+
+// 拒绝菜品
+router.put('/dishes/:id/reject', authenticateToken, requireAdmin, async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { reason } = req.body;
+ const adminId = req.user.userId;
+
+ const [result] = await pool.query(
+ "UPDATE user_dishes SET status = 'rejected', reject_reason = ? WHERE id = ? AND status = 'pending'",
+ [reason || '不符合要求', id]
+ );
+
+ if (result.affectedRows === 0) {
+ return res.status(400).json({
+ success: false,
+ message: '菜品不存在或已被审核'
+ });
+ }
+
+ // 记录管理员操作日志
+ await pool.query(
+ `INSERT INTO admin_logs (admin_id, action, target_type, target_id, details)
+ VALUES (?, 'reject_dish', 'user_dish', ?, ?)`,
+ [adminId, id, JSON.stringify({ reason })]
+ );
+
+ res.json({
+ success: true,
+ message: '已拒绝该菜品'
+ });
+ } catch (error) {
+ console.error('拒绝菜品失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '拒绝菜品失败'
+ });
+ }
+});
+
+// ============= 用户管理 =============
+
+// 获取用户列表
+router.get('/users', authenticateToken, requireAdmin, async (req, res) => {
+ try {
+ const { page = 1, limit = 20, keyword } = req.query;
+ const offset = (page - 1) * limit;
+
+ let query = `
+ SELECT
+ u.id,
+ u.username,
+ u.name,
+ u.phone,
+ u.email,
+ u.role,
+ u.created_at,
+ 0 as favorites_count, -- 暂时硬编码为0,因为favorites表不存在
+ COUNT(DISTINCT ud.id) as uploaded_dishes_count
+ FROM users u
+ LEFT JOIN user_dishes ud ON u.id = ud.user_id
+ `;
+
+ let params = [];
+
+ if (keyword) {
+ query += ' WHERE u.username LIKE ? OR u.name LIKE ? OR u.phone LIKE ?';
+ const searchTerm = `%${keyword}%`;
+ params = [searchTerm, searchTerm, searchTerm];
+ }
+
+ query += ' GROUP BY u.id ORDER BY u.created_at DESC LIMIT ? OFFSET ?';
+ params.push(parseInt(limit), parseInt(offset));
+
+ const [users] = await pool.query(query, params);
+
+ // 获取总数
+ let countQuery = 'SELECT COUNT(*) as total FROM users';
+ let countParams = [];
+
+ if (keyword) {
+ countQuery += ' WHERE username LIKE ? OR name LIKE ? OR phone LIKE ?';
+ const searchTerm = `%${keyword}%`;
+ countParams = [searchTerm, searchTerm, searchTerm];
+ }
+
+ const [countResult] = await pool.query(countQuery, countParams);
+
+ res.json({
+ success: true,
+ data: {
+ users,
+ total: countResult[0].total,
+ page: parseInt(page),
+ limit: parseInt(limit)
+ }
+ });
+ } catch (error) {
+ console.error('获取用户列表失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取用户列表失败'
+ });
+ }
+});
+
+// 更新用户信息
+router.put('/users/:id', authenticateToken, requireAdmin, async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { role, name, phone, email } = req.body;
+ const adminId = req.user.userId;
+
+ // 不能修改自己的角色
+ if (parseInt(id) === adminId && role && role !== 'admin') {
+ return res.status(400).json({
+ success: false,
+ message: '不能修改自己的管理员角色'
+ });
+ }
+
+ const updates = [];
+ const params = [];
+
+ if (role) {
+ updates.push('role = ?');
+ params.push(role);
+ }
+ if (name) {
+ updates.push('name = ?');
+ params.push(name);
+ }
+ if (phone) {
+ updates.push('phone = ?');
+ params.push(phone);
+ }
+ if (email !== undefined) {
+ updates.push('email = ?');
+ params.push(email);
+ }
+
+ if (updates.length === 0) {
+ return res.status(400).json({
+ success: false,
+ message: '没有要更新的字段'
+ });
+ }
+
+ params.push(id);
+
+ await pool.query(
+ `UPDATE users SET ${updates.join(', ')} WHERE id = ?`,
+ params
+ );
+
+ // 记录管理员操作日志
+ await pool.query(
+ `INSERT INTO admin_logs (admin_id, action, target_type, target_id, details)
+ VALUES (?, 'update_user', 'user', ?, ?)`,
+ [adminId, id, JSON.stringify(req.body)]
+ );
+
+ res.json({
+ success: true,
+ message: '用户信息更新成功'
+ });
+ } catch (error) {
+ console.error('更新用户信息失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '更新用户信息失败'
+ });
+ }
+});
+
+// 重置用户密码
+router.put('/users/:id/reset-password', authenticateToken, requireAdmin, async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { newPassword } = req.body;
+ const adminId = req.user.userId;
+
+ if (!newPassword || newPassword.length < 6) {
+ return res.status(400).json({
+ success: false,
+ message: '密码长度至少为6位'
+ });
+ }
+
+ const hashedPassword = await bcrypt.hash(newPassword, 10);
+
+ await pool.query(
+ 'UPDATE users SET password = ? WHERE id = ?',
+ [hashedPassword, id]
+ );
+
+ // 记录管理员操作日志
+ await pool.query(
+ `INSERT INTO admin_logs (admin_id, action, target_type, target_id, details)
+ VALUES (?, 'reset_password', 'user', ?, ?)`,
+ [adminId, id, '{}']
+ );
+
+ res.json({
+ success: true,
+ message: '密码重置成功'
+ });
+ } catch (error) {
+ console.error('重置密码失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '重置密码失败'
+ });
+ }
+});
+
+// ============= 菜品管理 =============
+
+// 获取所有菜品列表
+router.get('/dishes', authenticateToken, requireAdmin, async (req, res) => {
+ try {
+ const { page = 1, limit = 20, keyword, approval_status } = req.query;
+ const offset = (page - 1) * limit;
+
+ let query = 'SELECT * FROM dishes WHERE 1=1';
+ let params = [];
+
+ if (keyword) {
+ query += ' AND name LIKE ?';
+ params.push(`%${keyword}%`);
+ }
+
+ if (approval_status) {
+ query += ' AND approval_status = ?';
+ params.push(approval_status);
+ }
+
+ query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
+ params.push(parseInt(limit), parseInt(offset));
+
+ const [dishes] = await pool.query(query, params);
+
+ // 获取总数
+ let countQuery = 'SELECT COUNT(*) as total FROM dishes WHERE 1=1';
+ let countParams = [];
+
+ if (keyword) {
+ countQuery += ' AND name LIKE ?';
+ countParams.push(`%${keyword}%`);
+ }
+
+ if (approval_status) {
+ countQuery += ' AND approval_status = ?';
+ countParams.push(approval_status);
+ }
+
+ const [countResult] = await pool.query(countQuery, countParams);
+
+ res.json({
+ success: true,
+ data: {
+ dishes,
+ total: countResult[0].total,
+ page: parseInt(page),
+ limit: parseInt(limit)
+ }
+ });
+ } catch (error) {
+ console.error('获取菜品列表失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取菜品列表失败'
+ });
+ }
+});
+
+// 更新菜品信息
+router.put('/dishes/:id', authenticateToken, requireAdmin, async (req, res) => {
+ try {
+ const { id } = req.params;
+ const adminId = req.user.userId;
+ const updates = [];
+ const params = [];
+
+ // 可更新的字段
+ const allowedFields = [
+ 'name', 'description', 'price', 'original_price', 'discount',
+ 'image', 'category_id', 'canteen_id', 'canteen_name',
+ 'tags', 'flavor', 'portion', 'shelf_life',
+ 'calories', 'protein', 'fat', 'carbs',
+ 'status', 'approval_status'
+ ];
+
+ allowedFields.forEach(field => {
+ if (req.body[field] !== undefined) {
+ updates.push(`${field} = ?`);
+ params.push(req.body[field]);
+ }
+ });
+
+ if (updates.length === 0) {
+ return res.status(400).json({
+ success: false,
+ message: '没有要更新的字段'
+ });
+ }
+
+ params.push(id);
+
+ await pool.query(
+ `UPDATE dishes SET ${updates.join(', ')} WHERE id = ?`,
+ params
+ );
+
+ // 记录管理员操作日志
+ await pool.query(
+ `INSERT INTO admin_logs (admin_id, action, target_type, target_id, details)
+ VALUES (?, 'update_dish', 'dish', ?, ?)`,
+ [adminId, id, JSON.stringify(req.body)]
+ );
+
+ res.json({
+ success: true,
+ message: '菜品更新成功'
+ });
+ } catch (error) {
+ console.error('更新菜品失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '更新菜品失败'
+ });
+ }
+});
+
+// 添加菜品
+router.post('/dishes', authenticateToken, requireAdmin, async (req, res) => {
+ try {
+ const {
+ name,
+ description,
+ category_id,
+ price,
+ canteen_id,
+ image,
+ calories,
+ protein,
+ fat,
+ carbs,
+ tags,
+ status = 'available'
+ } = req.body;
+
+ const adminId = req.user.userId;
+
+ // 验证必填字段
+ if (!name || !price) {
+ return res.status(400).json({
+ success: false,
+ message: '菜品名称和价格为必填项'
+ });
+ }
+
+ // 数字标签映射规则
+ const tagMap = {
+ '1': '减脂',
+ '2': '增肌',
+ '3': '高蛋白',
+ '4': '低碳水',
+ '5': '低脂肪',
+ '6': '低热量',
+ '7': '均衡营养',
+ '8': '维生素丰富',
+ '9': '膳食纤维',
+ '10': '健身推荐'
+ };
+
+ // 转换数字标签为实际标签值
+ let processedTags = tags;
+ if (tags) {
+ const numTags = tags.split(',');
+ processedTags = numTags
+ .filter(num => tagMap[num])
+ .map(num => tagMap[num])
+ .join(',');
+ }
+
+ // 获取食堂名称(如果提供了canteen_id)
+ let canteen_name = null;
+ if (canteen_id) {
+ const [canteens] = await pool.query(
+ 'SELECT name FROM canteens WHERE id = ?',
+ [canteen_id]
+ );
+ if (canteens.length > 0) {
+ canteen_name = canteens[0].name;
+ }
+ }
+
+ // 插入菜品数据
+ const [result] = await pool.query(`
+ INSERT INTO dishes (
+ name, description, category_id, price, canteen_id, canteen_name,
+ image, calories, protein, fat, carbs, tags,
+ status, approval_status, created_by, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'approved', ?, NOW())
+ `, [
+ name,
+ description || null,
+ category_id || null,
+ price,
+ canteen_id || null,
+ canteen_name,
+ image || null,
+ calories || null,
+ protein || null,
+ fat || null,
+ carbs || null,
+ processedTags || null,
+ status,
+ adminId
+ ]);
+
+ // 记录管理员操作日志
+ await pool.query(
+ `INSERT INTO admin_logs (admin_id, action, target_type, target_id, details)
+ VALUES (?, 'create_dish', 'dish', ?, ?)`,
+ [adminId, result.insertId, JSON.stringify(req.body)]
+ );
+
+ res.json({
+ success: true,
+ message: '菜品添加成功',
+ data: {
+ dishId: result.insertId
+ }
+ });
+ } catch (error) {
+ console.error('添加菜品失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '添加菜品失败'
+ });
+ }
+});
+
+// 删除菜品
+router.delete('/dishes/:id', authenticateToken, requireAdmin, async (req, res) => {
+ try {
+ const { id } = req.params;
+ const adminId = req.user.userId;
+
+ await pool.query('DELETE FROM dishes WHERE id = ?', [id]);
+
+ // 记录管理员操作日志
+ await pool.query(
+ `INSERT INTO admin_logs (admin_id, action, target_type, target_id, details)
+ VALUES (?, 'delete_dish', 'dish', ?, ?)`,
+ [adminId, id, '{}']
+ );
+
+ res.json({
+ success: true,
+ message: '菜品删除成功'
+ });
+ } catch (error) {
+ console.error('删除菜品失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '删除菜品失败'
+ });
+ }
+});
+
+// ============= 统计信息 =============
+
+// 获取管理员仪表板统计信息
+router.get('/dashboard/stats', authenticateToken, requireAdmin, async (req, res) => {
+ try {
+ // 用户总数
+ const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users');
+
+ // 菜品总数
+ const [dishCount] = await pool.query('SELECT COUNT(*) as count FROM dishes');
+
+ // 待审核菜品数
+ const [pendingCount] = await pool.query(
+ "SELECT COUNT(*) as count FROM user_dishes WHERE status = 'pending'"
+ );
+
+ // 今日新增用户
+ const [todayUsers] = await pool.query(
+ 'SELECT COUNT(*) as count FROM users WHERE DATE(created_at) = CURDATE()'
+ );
+
+ // 最近操作日志
+ const [recentLogs] = await pool.query(`
+ SELECT
+ al.*,
+ u.username as admin_name
+ FROM admin_logs al
+ JOIN users u ON al.admin_id = u.id
+ ORDER BY al.created_at DESC
+ LIMIT 10
+ `);
+
+ res.json({
+ success: true,
+ data: {
+ userCount: userCount[0].count,
+ dishCount: dishCount[0].count,
+ pendingDishCount: pendingCount[0].count,
+ todayNewUsers: todayUsers[0].count,
+ recentLogs
+ }
+ });
+ } catch (error) {
+ console.error('获取统计信息失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取统计信息失败'
+ });
+ }
+});
+
+export default router;
+
diff --git a/src/src/backend/routes/auth.js b/src/src/backend/routes/auth.js
new file mode 100644
index 0000000..cd904a8
--- /dev/null
+++ b/src/src/backend/routes/auth.js
@@ -0,0 +1,291 @@
+import express from 'express';
+import bcrypt from 'bcryptjs';
+import jwt from 'jsonwebtoken';
+import { pool } from '../config/database.js';
+import { authenticateToken } from '../middleware/auth.js';
+
+const router = express.Router();
+
+// 生成JWT令牌
+const generateToken = (user) => {
+ return jwt.sign(
+ {
+ id: user.id,
+ userId: user.id, // 添加userId字段,供管理员权限验证使用
+ username: user.username,
+ role: user.role || 'user' // 添加role字段
+ },
+ process.env.JWT_SECRET,
+ { expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
+ );
+};
+
+// 注册
+router.post('/register', async (req, res) => {
+ const connection = await pool.getConnection();
+
+ try {
+ const {
+ username,
+ password,
+ phone,
+ email,
+ name,
+ // 健康档案信息
+ gender,
+ height,
+ weight,
+ age,
+ healthGoal,
+ activityLevel,
+ dietaryPreferences,
+ allergies
+ } = req.body;
+
+ // 验证必填字段
+ if (!username || !password || !phone) {
+ return res.status(400).json({
+ success: false,
+ message: '请填写所有必填字段'
+ });
+ }
+
+ // 开始事务
+ await connection.beginTransaction();
+
+ // 检查用户名是否已存在
+ const [existing] = await connection.query(
+ 'SELECT id FROM users WHERE username = ? OR phone = ?',
+ [username, phone]
+ );
+
+ if (existing.length > 0) {
+ await connection.rollback();
+ return res.status(400).json({
+ success: false,
+ message: '用户名或手机号已被注册'
+ });
+ }
+
+ // 加密密码
+ const hashedPassword = await bcrypt.hash(password, 10);
+
+ // 插入新用户
+ const [result] = await connection.query(
+ 'INSERT INTO users (username, password, phone, email, name) VALUES (?, ?, ?, ?, ?)',
+ [username, hashedPassword, phone, email || null, name || username]
+ );
+
+ const userId = result.insertId;
+
+ // 如果提供了健康档案信息,创建健康档案
+ if (gender || height || weight) {
+ // 处理 JSON 字段
+ const dietaryPrefsJson = dietaryPreferences ? JSON.stringify(dietaryPreferences) : JSON.stringify([]);
+ const allergiesJson = allergies ? JSON.stringify(allergies) : JSON.stringify([]);
+
+ // 计算目标营养值(基于性别、身高、体重、目标)
+ let targetCalories = 2000;
+ let targetProtein = 60;
+ let targetFat = 60;
+ let targetCarbs = 250;
+
+ if (height && weight) {
+ const heightM = parseFloat(height) / 100;
+ const weightKg = parseFloat(weight);
+ const bmi = weightKg / (heightM * heightM);
+
+ // 根据健身目标调整目标值
+ if (healthGoal === '减重' || healthGoal === '减脂') {
+ targetCalories = Math.round(weightKg * 25);
+ targetProtein = Math.round(weightKg * 1.5);
+ targetFat = Math.round(weightKg * 0.8);
+ targetCarbs = Math.round(weightKg * 2.5);
+ } else if (healthGoal === '增肌') {
+ targetCalories = Math.round(weightKg * 35);
+ targetProtein = Math.round(weightKg * 2.0);
+ targetFat = Math.round(weightKg * 1.0);
+ targetCarbs = Math.round(weightKg * 4.0);
+ } else if (healthGoal === '增重') {
+ targetCalories = Math.round(weightKg * 40);
+ targetProtein = Math.round(weightKg * 1.8);
+ targetFat = Math.round(weightKg * 1.2);
+ targetCarbs = Math.round(weightKg * 5.0);
+ } else {
+ // 保持健康
+ targetCalories = Math.round(weightKg * 30);
+ targetProtein = Math.round(weightKg * 1.2);
+ targetFat = Math.round(weightKg * 1.0);
+ targetCarbs = Math.round(weightKg * 3.5);
+ }
+ }
+
+ await connection.query(
+ `INSERT INTO health_profiles
+ (user_id, gender, height, weight, age, health_goal, activity_level,
+ dietary_preferences, allergies, target_calories, target_protein, target_fat, target_carbs)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ userId,
+ gender || '未设置',
+ height ? parseFloat(height) : null,
+ weight ? parseFloat(weight) : null,
+ age ? parseInt(age) : null,
+ healthGoal || '保持',
+ activityLevel || '轻度活动',
+ dietaryPrefsJson,
+ allergiesJson,
+ targetCalories,
+ targetProtein,
+ targetFat,
+ targetCarbs
+ ]
+ );
+ } else {
+ // 创建默认健康档案
+ await connection.query(
+ `INSERT INTO health_profiles
+ (user_id, gender, height, weight, age, health_goal, activity_level,
+ dietary_preferences, allergies, target_calories, target_protein, target_fat, target_carbs)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ userId,
+ '未设置',
+ null,
+ null,
+ null,
+ '保持',
+ '轻度活动',
+ JSON.stringify([]),
+ JSON.stringify([]),
+ 2000,
+ 60,
+ 60,
+ 250
+ ]
+ );
+ }
+
+ // 提交事务
+ await connection.commit();
+
+ // 获取新用户信息
+ const [users] = await connection.query(
+ 'SELECT id, username, phone, email, name, avatar, balance, created_at FROM users WHERE id = ?',
+ [userId]
+ );
+
+ const user = users[0];
+ const token = generateToken(user);
+
+ res.status(201).json({
+ success: true,
+ message: '注册成功',
+ data: {
+ user,
+ token
+ }
+ });
+ } catch (error) {
+ await connection.rollback();
+ console.error('注册错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '注册失败,请稍后重试'
+ });
+ } finally {
+ connection.release();
+ }
+});
+
+// 登录
+router.post('/login', async (req, res) => {
+ try {
+ const { username, password } = req.body;
+
+ // 验证必填字段
+ if (!username || !password) {
+ return res.status(400).json({
+ success: false,
+ message: '请输入用户名和密码'
+ });
+ }
+
+ // 查找用户(支持用户名或手机号登录)
+ const [users] = await pool.query(
+ 'SELECT id, username, password, phone, email, name, avatar, balance, role, created_at FROM users WHERE username = ? OR phone = ?',
+ [username, username]
+ );
+
+ if (users.length === 0) {
+ return res.status(401).json({
+ success: false,
+ message: '用户名或密码错误'
+ });
+ }
+
+ const user = users[0];
+
+ // 验证密码
+ const isValidPassword = await bcrypt.compare(password, user.password);
+
+ if (!isValidPassword) {
+ return res.status(401).json({
+ success: false,
+ message: '用户名或密码错误'
+ });
+ }
+
+ // 生成令牌
+ const token = generateToken(user);
+
+ // 移除密码字段
+ delete user.password;
+
+ res.json({
+ success: true,
+ message: '登录成功',
+ data: {
+ user,
+ token
+ }
+ });
+ } catch (error) {
+ console.error('登录错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '登录失败,请稍后重试'
+ });
+ }
+});
+
+// 获取当前用户信息
+router.get('/me', authenticateToken, async (req, res) => {
+ try {
+ const [users] = await pool.query(
+ 'SELECT id, username, phone, email, name, avatar, balance, role, created_at FROM users WHERE id = ?',
+ [req.user.id]
+ );
+
+ if (users.length === 0) {
+ return res.status(404).json({
+ success: false,
+ message: '用户不存在'
+ });
+ }
+
+ res.json({
+ success: true,
+ data: users[0]
+ });
+ } catch (error) {
+ console.error('获取用户信息错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取用户信息失败'
+ });
+ }
+});
+
+export default router;
+
diff --git a/src/src/backend/routes/banners.js b/src/src/backend/routes/banners.js
new file mode 100644
index 0000000..a673131
--- /dev/null
+++ b/src/src/backend/routes/banners.js
@@ -0,0 +1,27 @@
+import express from 'express';
+import { pool } from '../config/database.js';
+
+const router = express.Router();
+
+// 获取活动轮播图
+router.get('/', async (req, res) => {
+ try {
+ const [banners] = await pool.query(
+ 'SELECT * FROM banners WHERE status = "active" ORDER BY sort_order ASC'
+ );
+
+ res.json({
+ success: true,
+ data: banners
+ });
+ } catch (error) {
+ console.error('获取轮播图错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取轮播图失败'
+ });
+ }
+});
+
+export default router;
+
diff --git a/src/src/backend/routes/canteens.js b/src/src/backend/routes/canteens.js
new file mode 100644
index 0000000..0de4e63
--- /dev/null
+++ b/src/src/backend/routes/canteens.js
@@ -0,0 +1,70 @@
+import express from 'express';
+import { pool } from '../config/database.js';
+
+const router = express.Router();
+
+// 获取所有食堂
+router.get('/', async (req, res) => {
+ try {
+ const [canteens] = await pool.query(
+ 'SELECT * FROM canteens ORDER BY rating DESC'
+ );
+
+ res.json({
+ success: true,
+ data: canteens
+ });
+ } catch (error) {
+ console.error('获取食堂列表错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取食堂列表失败'
+ });
+ }
+});
+
+// 获取食堂详情
+router.get('/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ const [canteens] = await pool.query(
+ 'SELECT * FROM canteens WHERE id = ?',
+ [id]
+ );
+
+ if (canteens.length === 0) {
+ return res.status(404).json({
+ success: false,
+ message: '食堂不存在'
+ });
+ }
+
+ // 获取该食堂的菜品
+ const [dishes] = await pool.query(
+ `SELECT dishes.*, categories.name as category
+ FROM dishes
+ LEFT JOIN categories ON dishes.category_id = categories.id
+ WHERE dishes.canteen_id = ? AND dishes.status = "available"
+ ORDER BY dishes.sales_count DESC`,
+ [id]
+ );
+
+ res.json({
+ success: true,
+ data: {
+ canteen: canteens[0],
+ dishes
+ }
+ });
+ } catch (error) {
+ console.error('获取食堂详情错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取食堂详情失败'
+ });
+ }
+});
+
+export default router;
+
diff --git a/src/src/backend/routes/cart.js b/src/src/backend/routes/cart.js
new file mode 100644
index 0000000..96ab2db
--- /dev/null
+++ b/src/src/backend/routes/cart.js
@@ -0,0 +1,189 @@
+import express from 'express';
+import { pool } from '../config/database.js';
+import { authenticateToken } from '../middleware/auth.js';
+
+const router = express.Router();
+
+// 获取购物车
+router.get('/', authenticateToken, async (req, res) => {
+ try {
+ const [cartItems] = await pool.query(
+ `SELECT cart_items.*,
+ dishes.name, dishes.price, dishes.image,
+ dishes.canteen_id, dishes.canteen_name
+ FROM cart_items
+ JOIN dishes ON cart_items.dish_id = dishes.id
+ WHERE cart_items.user_id = ?`,
+ [req.user.id]
+ );
+
+ // 计算总价
+ const totalPrice = cartItems.reduce((sum, item) => {
+ return sum + (item.price * item.quantity);
+ }, 0);
+
+ res.json({
+ success: true,
+ data: {
+ items: cartItems,
+ totalPrice: parseFloat(totalPrice.toFixed(2))
+ }
+ });
+ } catch (error) {
+ console.error('获取购物车错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取购物车失败'
+ });
+ }
+});
+
+// 添加到购物车
+router.post('/', authenticateToken, async (req, res) => {
+ try {
+ const { dish_id, quantity = 1 } = req.body;
+
+ if (!dish_id) {
+ return res.status(400).json({
+ success: false,
+ message: '请提供菜品ID'
+ });
+ }
+
+ // 检查菜品是否存在
+ const [dishes] = await pool.query(
+ 'SELECT id FROM dishes WHERE id = ? AND status = "available"',
+ [dish_id]
+ );
+
+ if (dishes.length === 0) {
+ return res.status(404).json({
+ success: false,
+ message: '菜品不存在或已下架'
+ });
+ }
+
+ // 检查购物车中是否已有该菜品
+ const [existing] = await pool.query(
+ 'SELECT id, quantity FROM cart_items WHERE user_id = ? AND dish_id = ?',
+ [req.user.id, dish_id]
+ );
+
+ if (existing.length > 0) {
+ // 更新数量
+ await pool.query(
+ 'UPDATE cart_items SET quantity = quantity + ? WHERE id = ?',
+ [quantity, existing[0].id]
+ );
+ } else {
+ // 添加新项
+ await pool.query(
+ 'INSERT INTO cart_items (user_id, dish_id, quantity) VALUES (?, ?, ?)',
+ [req.user.id, dish_id, quantity]
+ );
+ }
+
+ res.json({
+ success: true,
+ message: '已添加到购物车'
+ });
+ } catch (error) {
+ console.error('添加到购物车错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '添加到购物车失败'
+ });
+ }
+});
+
+// 更新购物车项数量
+router.put('/:id', authenticateToken, async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { quantity } = req.body;
+
+ if (quantity < 1) {
+ return res.status(400).json({
+ success: false,
+ message: '数量必须大于0'
+ });
+ }
+
+ const [result] = await pool.query(
+ 'UPDATE cart_items SET quantity = ? WHERE id = ? AND user_id = ?',
+ [quantity, id, req.user.id]
+ );
+
+ if (result.affectedRows === 0) {
+ return res.status(404).json({
+ success: false,
+ message: '购物车项不存在'
+ });
+ }
+
+ res.json({
+ success: true,
+ message: '更新成功'
+ });
+ } catch (error) {
+ console.error('更新购物车错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '更新购物车失败'
+ });
+ }
+});
+
+// 删除购物车项
+router.delete('/:id', authenticateToken, async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ const [result] = await pool.query(
+ 'DELETE FROM cart_items WHERE id = ? AND user_id = ?',
+ [id, req.user.id]
+ );
+
+ if (result.affectedRows === 0) {
+ return res.status(404).json({
+ success: false,
+ message: '购物车项不存在'
+ });
+ }
+
+ res.json({
+ success: true,
+ message: '删除成功'
+ });
+ } catch (error) {
+ console.error('删除购物车项错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '删除购物车项失败'
+ });
+ }
+});
+
+// 清空购物车
+router.delete('/', authenticateToken, async (req, res) => {
+ try {
+ await pool.query(
+ 'DELETE FROM cart_items WHERE user_id = ?',
+ [req.user.id]
+ );
+
+ res.json({
+ success: true,
+ message: '购物车已清空'
+ });
+ } catch (error) {
+ console.error('清空购物车错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '清空购物车失败'
+ });
+ }
+});
+
+export default router;
+
diff --git a/src/src/backend/routes/categories.js b/src/src/backend/routes/categories.js
new file mode 100644
index 0000000..f323c27
--- /dev/null
+++ b/src/src/backend/routes/categories.js
@@ -0,0 +1,27 @@
+import express from 'express';
+import { pool } from '../config/database.js';
+
+const router = express.Router();
+
+// 获取所有分类
+router.get('/', async (req, res) => {
+ try {
+ const [categories] = await pool.query(
+ 'SELECT * FROM categories ORDER BY sort_order ASC'
+ );
+
+ res.json({
+ success: true,
+ data: categories
+ });
+ } catch (error) {
+ console.error('获取分类列表错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取分类列表失败'
+ });
+ }
+});
+
+export default router;
+
diff --git a/src/src/backend/routes/checkNutrition.js b/src/src/backend/routes/checkNutrition.js
new file mode 100644
index 0000000..bcae414
--- /dev/null
+++ b/src/src/backend/routes/checkNutrition.js
@@ -0,0 +1,72 @@
+import express from 'express';
+import { pool } from '../config/database.js';
+
+const router = express.Router();
+
+// 检查标签为西餐、清淡、增肌的菜品中缺少营养成分信息的记录
+router.get('/nutrition-check', async (req, res) => {
+ try {
+ // 查询标签为西餐、清淡、增肌的菜品中缺少营养成分信息的记录
+ const [rows] = await pool.execute(`
+ SELECT
+ id,
+ name,
+ tags,
+ calories,
+ protein,
+ fat,
+ carbs,
+ CASE
+ WHEN calories IS NULL OR calories = 0 THEN '缺少热量信息'
+ WHEN protein IS NULL OR protein = 0 THEN '缺少蛋白质信息'
+ WHEN fat IS NULL OR fat = 0 THEN '缺少脂肪信息'
+ WHEN carbs IS NULL OR carbs = 0 THEN '缺少碳水信息'
+ ELSE '营养信息完整'
+ END AS nutrition_status
+ FROM
+ dishes
+ WHERE
+ tags LIKE '%西餐%' OR
+ tags LIKE '%清淡%' OR
+ tags LIKE '%增肌%'
+ ORDER BY
+ CASE
+ WHEN calories IS NULL OR calories = 0 THEN 1
+ WHEN protein IS NULL OR protein = 0 THEN 2
+ WHEN fat IS NULL OR fat = 0 THEN 3
+ WHEN carbs IS NULL OR carbs = 0 THEN 4
+ ELSE 5
+ END,
+ id
+ `);
+
+ // 统计缺少营养信息的菜品数量
+ const missingCalories = rows.filter(row => row.calories === null || row.calories === 0).length;
+ const missingProtein = rows.filter(row => row.protein === null || row.protein === 0).length;
+ const missingFat = rows.filter(row => row.fat === null || row.fat === 0).length;
+ const missingCarbs = rows.filter(row => row.carbs === null || row.carbs === 0).length;
+
+ const statistics = {
+ totalDishes: rows.length,
+ missingCalories,
+ missingProtein,
+ missingFat,
+ missingCarbs
+ };
+
+ res.json({
+ success: true,
+ data: rows,
+ statistics
+ });
+ } catch (error) {
+ console.error('检查营养信息时出错:', error);
+ res.status(500).json({
+ success: false,
+ message: '检查营养信息时出错',
+ error: error.message
+ });
+ }
+});
+
+export default router;
diff --git a/src/src/backend/routes/coupons.js b/src/src/backend/routes/coupons.js
new file mode 100644
index 0000000..a17581f
--- /dev/null
+++ b/src/src/backend/routes/coupons.js
@@ -0,0 +1,119 @@
+import express from 'express';
+import { pool } from '../config/database.js';
+import { authenticateToken } from '../middleware/auth.js';
+
+const router = express.Router();
+
+// 获取可领取的优惠券
+router.get('/available', authenticateToken, async (req, res) => {
+ try {
+ const [coupons] = await pool.query(
+ `SELECT * FROM coupons
+ WHERE status = "active"
+ AND start_date <= NOW()
+ AND end_date >= NOW()
+ AND used_quantity < total_quantity
+ ORDER BY value DESC`
+ );
+
+ res.json({
+ success: true,
+ data: coupons
+ });
+ } catch (error) {
+ console.error('获取优惠券列表错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取优惠券列表失败'
+ });
+ }
+});
+
+// 获取用户的优惠券
+router.get('/my', authenticateToken, async (req, res) => {
+ try {
+ const { status = 'unused' } = req.query;
+
+ const [userCoupons] = await pool.query(
+ `SELECT user_coupons.*, coupons.*
+ FROM user_coupons
+ JOIN coupons ON user_coupons.coupon_id = coupons.id
+ WHERE user_coupons.user_id = ? AND user_coupons.status = ?
+ ORDER BY user_coupons.created_at DESC`,
+ [req.user.id, status]
+ );
+
+ res.json({
+ success: true,
+ data: userCoupons
+ });
+ } catch (error) {
+ console.error('获取用户优惠券错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取用户优惠券失败'
+ });
+ }
+});
+
+// 领取优惠券
+router.post('/:id/claim', authenticateToken, async (req, res) => {
+ const connection = await pool.getConnection();
+
+ try {
+ await connection.beginTransaction();
+
+ const { id } = req.params;
+
+ // 检查优惠券是否可用
+ const [coupons] = await connection.query(
+ `SELECT * FROM coupons
+ WHERE id = ?
+ AND status = "active"
+ AND start_date <= NOW()
+ AND end_date >= NOW()
+ AND used_quantity < total_quantity
+ FOR UPDATE`,
+ [id]
+ );
+
+ if (coupons.length === 0) {
+ throw new Error('优惠券不存在或已过期');
+ }
+
+ // 检查用户是否已领取
+ const [existing] = await connection.query(
+ 'SELECT id FROM user_coupons WHERE user_id = ? AND coupon_id = ?',
+ [req.user.id, id]
+ );
+
+ if (existing.length > 0) {
+ throw new Error('您已经领取过该优惠券');
+ }
+
+ // 领取优惠券
+ await connection.query(
+ 'INSERT INTO user_coupons (user_id, coupon_id, status) VALUES (?, ?, "unused")',
+ [req.user.id, id]
+ );
+
+ await connection.commit();
+
+ res.json({
+ success: true,
+ message: '领取成功'
+ });
+ } catch (error) {
+ await connection.rollback();
+ console.error('领取优惠券错误:', error);
+ res.status(500).json({
+ success: false,
+ message: error.message || '领取优惠券失败'
+ });
+ } finally {
+ connection.release();
+ }
+});
+
+export default router;
+
diff --git a/src/src/backend/routes/dishes.js b/src/src/backend/routes/dishes.js
new file mode 100644
index 0000000..6b7067a
--- /dev/null
+++ b/src/src/backend/routes/dishes.js
@@ -0,0 +1,471 @@
+import express from 'express';
+import { pool } from '../config/database.js';
+import { authenticateToken, optionalAuth } from '../middleware/auth.js';
+
+const router = express.Router();
+
+// 获取菜品列表(支持分页、筛选、排序)
+router.get('/', async (req, res) => {
+ try {
+ const {
+ page = 1,
+ limit = 20,
+ category,
+ canteen_id,
+ keyword,
+ sort = 'default',
+ order = 'desc'
+ } = req.query;
+
+ const offset = (page - 1) * limit;
+ let whereConditions = ['dishes.status = "available"'];
+ let queryParams = [];
+
+ // 分类筛选
+ if (category && category !== '全部') {
+ whereConditions.push('categories.name = ?');
+ queryParams.push(category);
+ }
+
+ // 食堂筛选
+ if (canteen_id) {
+ whereConditions.push('dishes.canteen_id = ?');
+ queryParams.push(canteen_id);
+ }
+
+ // 关键词搜索
+ if (keyword) {
+ whereConditions.push('(dishes.name LIKE ? OR dishes.description LIKE ?)');
+ queryParams.push(`%${keyword}%`, `%${keyword}%`);
+ }
+
+ // 排序
+ let orderBy = '';
+ switch (sort) {
+ case 'sales':
+ orderBy = `dishes.sales_count ${order.toUpperCase()}`;
+ break;
+ case 'price':
+ orderBy = `dishes.price ${order.toUpperCase()}`;
+ break;
+ case 'rating':
+ orderBy = `dishes.rating ${order.toUpperCase()}`;
+ break;
+ default:
+ // 综合排序
+ orderBy = '(dishes.sales_count * 0.6 + dishes.rating * 200) DESC';
+ }
+
+ const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
+
+ // 查询总数
+ const [countResult] = await pool.query(
+ `SELECT COUNT(*) as total
+ FROM dishes
+ LEFT JOIN categories ON dishes.category_id = categories.id
+ ${whereClause}`,
+ queryParams
+ );
+
+ const total = countResult[0].total;
+
+ // 查询菜品列表
+ const [dishes] = await pool.query(
+ `SELECT dishes.*, categories.name as category
+ FROM dishes
+ LEFT JOIN categories ON dishes.category_id = categories.id
+ ${whereClause}
+ ORDER BY ${orderBy}
+ LIMIT ? OFFSET ?`,
+ [...queryParams, parseInt(limit), parseInt(offset)]
+ );
+
+ res.json({
+ success: true,
+ data: {
+ dishes,
+ pagination: {
+ page: parseInt(page),
+ limit: parseInt(limit),
+ total,
+ totalPages: Math.ceil(total / limit)
+ }
+ }
+ });
+ } catch (error) {
+ console.error('获取菜品列表错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取菜品列表失败'
+ });
+ }
+});
+
+// 获取推荐菜品(基于健康档案的个性化推荐)
+router.get('/recommended', optionalAuth, async (req, res) => {
+ try {
+ const { limit = 6, category, random } = req.query;
+ const userId = req.user?.id; // 从token中获取用户ID(可选)
+
+ console.log('推荐菜品请求 - 用户ID:', userId, '限制:', limit, '分类:', category, '随机:', random);
+
+ let dishes;
+ let isPersonalized = false;
+
+ // 如果用户已登录,使用基于健康档案的个性化推荐
+ if (userId) {
+ // 1. 获取用户健康档案
+ const [healthProfiles] = await pool.query(
+ 'SELECT * FROM health_profiles WHERE user_id = ?',
+ [userId]
+ );
+
+ const healthProfile = healthProfiles[0];
+
+ // 2. 获取用户今日已摄入的营养(如果meal_records表存在)
+ let consumed = {
+ consumed_calories: 0,
+ consumed_protein: 0,
+ consumed_fat: 0,
+ consumed_carbs: 0
+ };
+
+ try {
+ const today = new Date().toISOString().split('T')[0];
+ const [todayNutrition] = await pool.query(
+ `SELECT
+ COALESCE(SUM(calories), 0) as consumed_calories,
+ COALESCE(SUM(protein), 0) as consumed_protein,
+ COALESCE(SUM(fat), 0) as consumed_fat,
+ COALESCE(SUM(carbs), 0) as consumed_carbs
+ FROM meal_records
+ WHERE user_id = ? AND DATE(meal_time) = ?`,
+ [userId, today]
+ );
+ consumed = todayNutrition[0];
+ } catch (e) {
+ // meal_records表可能不存在,使用默认值
+ console.log('用餐记录表不存在,使用默认营养摄入值');
+ }
+
+ // 3. 计算还需摄入的营养(基于健康目标)
+ const targetCalories = healthProfile?.target_calories || 2000;
+ const targetProtein = healthProfile?.target_protein || 60;
+ const targetFat = healthProfile?.target_fat || 60;
+ const targetCarbs = healthProfile?.target_carbs || 250;
+
+ const remainingCalories = Math.max(0, targetCalories - consumed.consumed_calories);
+ const remainingProtein = Math.max(0, targetProtein - consumed.consumed_protein);
+ const remainingFat = Math.max(0, targetFat - consumed.consumed_fat);
+ const remainingCarbs = Math.max(0, targetCarbs - consumed.consumed_carbs);
+
+ // 4. 获取用户过敏食物和饮食偏好
+ let allergies = [];
+ let dietaryPreferences = [];
+
+ if (healthProfile) {
+ try {
+ allergies = healthProfile.allergies
+ ? (typeof healthProfile.allergies === 'string'
+ ? JSON.parse(healthProfile.allergies)
+ : healthProfile.allergies)
+ : [];
+ dietaryPreferences = healthProfile.dietary_preferences
+ ? (typeof healthProfile.dietary_preferences === 'string'
+ ? JSON.parse(healthProfile.dietary_preferences)
+ : healthProfile.dietary_preferences)
+ : [];
+ } catch (e) {
+ console.error('解析健康档案JSON失败:', e);
+ allergies = [];
+ dietaryPreferences = [];
+ }
+ }
+
+ // 5. 构建推荐查询
+ let whereConditions = ['dishes.status = "available"'];
+ let queryParams = [];
+
+ // 添加分类筛选
+ if (category) {
+ whereConditions.push('(categories.name = ? OR dishes.tags LIKE ?)');
+ queryParams.push(category, `%${category}%`);
+ }
+
+ // 排除过敏食物(简化:通过tags字段过滤)
+ if (Array.isArray(allergies) && allergies.length > 0) {
+ allergies.forEach(allergy => {
+ whereConditions.push('NOT FIND_IN_SET(?, dishes.tags)');
+ queryParams.push(allergy);
+ });
+ }
+
+ // 获取已浏览过的菜品ID,避免重复推荐(如果browsing_history表存在)
+ let viewedDishIds = [];
+ try {
+ const [viewedDishes] = await pool.query(
+ `SELECT DISTINCT dish_id
+ FROM browsing_history
+ WHERE user_id = ?
+ AND browsing_time > DATE_SUB(NOW(), INTERVAL 7 DAY)`,
+ [userId]
+ );
+ viewedDishIds = viewedDishes.map(d => d.dish_id);
+
+ if (viewedDishIds.length > 0) {
+ whereConditions.push('dishes.id NOT IN (?)');
+ queryParams.push(viewedDishIds);
+ }
+ } catch (e) {
+ // browsing_history表可能不存在,忽略此条件
+ console.log('浏览历史表不存在,跳过浏览历史过滤');
+ }
+
+ const whereClause = whereConditions.join(' AND ');
+
+ // 6. 计算推荐评分(基于营养需求匹配度)
+ const orderBy = random === 'true'
+ ? 'RAND()'
+ : 'nutrition_score ASC, (dishes.rating * 0.3 + dishes.sales_count * 0.0001) DESC';
+
+ [dishes] = await pool.query(
+ `SELECT
+ dishes.*,
+ categories.name as category,
+ (
+ CASE
+ WHEN ? > 0 AND dishes.calories > 0 THEN
+ ABS(dishes.protein / dishes.calories - ? / ?) * 100
+ ELSE 0
+ END +
+ CASE
+ WHEN ? > 0 THEN
+ ABS(dishes.calories - ?) / ? * 10
+ ELSE 0
+ END
+ ) AS nutrition_score
+ FROM dishes
+ LEFT JOIN categories ON dishes.category_id = categories.id
+ WHERE ${whereClause}
+ ORDER BY ${orderBy}
+ LIMIT ?`,
+ [
+ ...queryParams.slice(0, -1),
+ remainingCalories,
+ remainingProtein,
+ targetProtein,
+ remainingCalories,
+ remainingCalories / 3,
+ remainingCalories,
+ ...queryParams.slice(-1),
+ parseInt(limit)
+ ]
+ );
+
+ isPersonalized = healthProfile ? true : false;
+
+ // 如果推荐数量不足,补充热销菜品
+ if (dishes.length < limit) {
+ const remaining = limit - dishes.length;
+ const existingIds = dishes.map(d => d.id);
+
+ let hotQuery = `SELECT dishes.*, categories.name as category
+ FROM dishes
+ LEFT JOIN categories ON dishes.category_id = categories.id
+ WHERE dishes.status = "available"`;
+ let hotParams = [];
+
+ if (category) {
+ hotQuery += ` AND (categories.name = ? OR dishes.tags LIKE ?)`;
+ hotParams.push(category, `%${category}%`);
+ }
+
+ if (existingIds.length > 0) {
+ hotQuery += ` AND dishes.id NOT IN (?)`;
+ hotParams.push(existingIds);
+ }
+
+ hotQuery += ` ORDER BY (dishes.sales_count * 0.6 + dishes.rating * 200) DESC LIMIT ?`;
+ hotParams.push(remaining);
+
+ const [hotDishes] = await pool.query(hotQuery, hotParams);
+
+ dishes = [...dishes, ...hotDishes];
+ }
+ } else {
+ // 未登录用户,返回热销菜品或随机菜品
+ let guestQuery = `SELECT dishes.*, categories.name as category
+ FROM dishes
+ LEFT JOIN categories ON dishes.category_id = categories.id
+ WHERE dishes.status = "available"`;
+ let guestParams = [];
+
+ if (category) {
+ guestQuery += ` AND (categories.name = ? OR dishes.tags LIKE ?)`;
+ guestParams.push(category, `%${category}%`);
+ }
+
+ // 根据random参数决定排序方式
+ if (random === 'true') {
+ guestQuery += ` ORDER BY RAND() LIMIT ?`;
+ } else {
+ guestQuery += ` ORDER BY (dishes.sales_count * 0.6 + dishes.rating * 200) DESC LIMIT ?`;
+ }
+ guestParams.push(parseInt(limit));
+
+ [dishes] = await pool.query(guestQuery, guestParams);
+ }
+
+ // 为每个菜品添加匹配度(简化版)
+ const dishesWithMatchRate = dishes.map(dish => ({
+ ...dish,
+ match_rate: isPersonalized ? 85 + Math.floor(Math.random() * 15) : 80 + Math.floor(Math.random() * 10),
+ canteen_name: dish.canteen_name || '校园食堂'
+ }));
+
+ res.json({
+ success: true,
+ data: dishesWithMatchRate,
+ meta: {
+ personalized: isPersonalized,
+ count: dishesWithMatchRate.length
+ }
+ });
+ } catch (error) {
+ console.error('获取推荐菜品错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取推荐菜品失败'
+ });
+ }
+});
+
+// 获取热销榜单
+router.get('/hot', async (req, res) => {
+ try {
+ const { limit = 5 } = req.query;
+
+ const [dishes] = await pool.query(
+ `SELECT dishes.*, categories.name as category
+ FROM dishes
+ LEFT JOIN categories ON dishes.category_id = categories.id
+ WHERE dishes.status = "available"
+ ORDER BY dishes.sales_count DESC
+ LIMIT ?`,
+ [parseInt(limit)]
+ );
+
+ res.json({
+ success: true,
+ data: {
+ dishes
+ }
+ });
+ } catch (error) {
+ console.error('获取热销榜单错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取热销榜单失败'
+ });
+ }
+});
+
+// 获取菜品详情
+router.get('/:id', optionalAuth, async (req, res) => {
+ try {
+ const { id } = req.params;
+ const userId = req.user?.id; // 从token中获取用户ID(可选)
+
+ const [dishes] = await pool.query(
+ `SELECT dishes.*, categories.name as category
+ FROM dishes
+ LEFT JOIN categories ON dishes.category_id = categories.id
+ WHERE dishes.id = ?`,
+ [id]
+ );
+
+ if (dishes.length === 0) {
+ return res.status(404).json({
+ success: false,
+ message: '菜品不存在'
+ });
+ }
+
+ // 获取相似菜品推荐
+ const dish = dishes[0];
+ const [similarDishes] = await pool.query(
+ `SELECT dishes.*, categories.name as category
+ FROM dishes
+ LEFT JOIN categories ON dishes.category_id = categories.id
+ WHERE dishes.id != ?
+ AND dishes.status = "available"
+ AND (dishes.category_id = ? OR dishes.canteen_id = ?)
+ ORDER BY RAND()
+ LIMIT 4`,
+ [id, dish.category_id, dish.canteen_id]
+ );
+
+ // 如果用户已登录,记录浏览历史
+ if (userId) {
+ try {
+ await pool.query(
+ `INSERT INTO browsing_history (user_id, dish_id, category_id)
+ VALUES (?, ?, ?)`,
+ [userId, id, dish.category_id]
+ );
+ } catch (historyError) {
+ // 浏览历史记录失败不影响主流程
+ console.warn('记录浏览历史失败:', historyError.message);
+ }
+ }
+
+ res.json({
+ success: true,
+ data: {
+ dish,
+ similarDishes
+ }
+ });
+ } catch (error) {
+ console.error('获取菜品详情错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取菜品详情失败'
+ });
+ }
+});
+
+// 搜索菜品
+router.get('/search/:keyword', async (req, res) => {
+ try {
+ const { keyword } = req.params;
+ const { limit = 20 } = req.query;
+
+ const [dishes] = await pool.query(
+ `SELECT dishes.*, categories.name as category
+ FROM dishes
+ LEFT JOIN categories ON dishes.category_id = categories.id
+ WHERE dishes.status = "available"
+ AND (dishes.name LIKE ? OR dishes.description LIKE ? OR dishes.tags LIKE ?)
+ ORDER BY dishes.sales_count DESC
+ LIMIT ?`,
+ [`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, parseInt(limit)]
+ );
+
+ res.json({
+ success: true,
+ data: {
+ dishes
+ }
+ });
+ } catch (error) {
+ console.error('搜索菜品错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '搜索菜品失败'
+ });
+ }
+});
+
+export default router;
+
diff --git a/src/src/backend/routes/favorites.js b/src/src/backend/routes/favorites.js
new file mode 100644
index 0000000..ffaa923
--- /dev/null
+++ b/src/src/backend/routes/favorites.js
@@ -0,0 +1,149 @@
+import express from 'express';
+import { pool } from '../config/database.js';
+import { authenticateToken } from '../middleware/auth.js';
+
+const router = express.Router();
+
+// 获取用户的收藏列表
+router.get('/', authenticateToken, async (req, res) => {
+ try {
+ const userId = req.user.userId;
+
+ const [favorites] = await pool.query(`
+ SELECT
+ f.id as favorite_id,
+ f.created_at as favorited_at,
+ d.*
+ FROM favorites f
+ JOIN dishes d ON f.dish_id = d.id
+ WHERE f.user_id = ?
+ ORDER BY f.created_at DESC
+ `, [userId]);
+
+ res.json({
+ success: true,
+ data: favorites
+ });
+ } catch (error) {
+ console.error('获取收藏列表失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取收藏列表失败'
+ });
+ }
+});
+
+// 添加收藏
+router.post('/', authenticateToken, async (req, res) => {
+ try {
+ const userId = req.user.userId;
+ const { dishId } = req.body;
+
+ if (!dishId) {
+ return res.status(400).json({
+ success: false,
+ message: '菜品ID不能为空'
+ });
+ }
+
+ // 检查菜品是否存在
+ const [dishes] = await pool.query(
+ 'SELECT id FROM dishes WHERE id = ?',
+ [dishId]
+ );
+
+ if (dishes.length === 0) {
+ return res.status(404).json({
+ success: false,
+ message: '菜品不存在'
+ });
+ }
+
+ // 添加收藏(如果已存在会被UNIQUE约束拦截)
+ try {
+ await pool.query(
+ 'INSERT INTO favorites (user_id, dish_id) VALUES (?, ?)',
+ [userId, dishId]
+ );
+
+ res.json({
+ success: true,
+ message: '收藏成功'
+ });
+ } catch (error) {
+ if (error.code === 'ER_DUP_ENTRY') {
+ res.status(400).json({
+ success: false,
+ message: '已经收藏过该菜品'
+ });
+ } else {
+ throw error;
+ }
+ }
+ } catch (error) {
+ console.error('添加收藏失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '添加收藏失败'
+ });
+ }
+});
+
+// 取消收藏
+router.delete('/:dishId', authenticateToken, async (req, res) => {
+ try {
+ const userId = req.user.userId;
+ const { dishId } = req.params;
+
+ const [result] = await pool.query(
+ 'DELETE FROM favorites WHERE user_id = ? AND dish_id = ?',
+ [userId, dishId]
+ );
+
+ if (result.affectedRows === 0) {
+ return res.status(404).json({
+ success: false,
+ message: '该菜品未收藏'
+ });
+ }
+
+ res.json({
+ success: true,
+ message: '取消收藏成功'
+ });
+ } catch (error) {
+ console.error('取消收藏失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '取消收藏失败'
+ });
+ }
+});
+
+// 检查是否已收藏
+router.get('/check/:dishId', authenticateToken, async (req, res) => {
+ try {
+ const userId = req.user.userId;
+ const { dishId } = req.params;
+
+ const [favorites] = await pool.query(
+ 'SELECT id FROM favorites WHERE user_id = ? AND dish_id = ?',
+ [userId, dishId]
+ );
+
+ res.json({
+ success: true,
+ data: {
+ isFavorited: favorites.length > 0
+ }
+ });
+ } catch (error) {
+ console.error('检查收藏状态失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '检查收藏状态失败'
+ });
+ }
+});
+
+export default router;
diff --git a/src/src/backend/routes/health.js b/src/src/backend/routes/health.js
new file mode 100644
index 0000000..22d23fa
--- /dev/null
+++ b/src/src/backend/routes/health.js
@@ -0,0 +1,683 @@
+import express from 'express';
+import { pool } from '../config/database.js';
+import { authenticateToken } from '../middleware/auth.js';
+
+const router = express.Router();
+
+// 所有路由都需要认证
+router.use(authenticateToken);
+
+// 获取健康档案
+router.get('/profile', async (req, res) => {
+ try {
+ const userId = req.user.id;
+
+ const [profiles] = await pool.query(
+ 'SELECT * FROM health_profiles WHERE user_id = ?',
+ [userId]
+ );
+
+ if (profiles.length === 0) {
+ // 如果没有健康档案,创建默认档案
+ const [result] = await pool.query(
+ `INSERT INTO health_profiles (user_id) VALUES (?)`,
+ [userId]
+ );
+
+ const [newProfile] = await pool.query(
+ 'SELECT * FROM health_profiles WHERE id = ?',
+ [result.insertId]
+ );
+
+ return res.json({
+ success: true,
+ data: {
+ ...newProfile[0],
+ allergies: newProfile[0].allergies || [],
+ chronic_diseases: newProfile[0].chronic_diseases || [],
+ dietary_preferences: newProfile[0].dietary_preferences || []
+ }
+ });
+ }
+
+ res.json({
+ success: true,
+ data: {
+ ...profiles[0],
+ allergies: profiles[0].allergies || [],
+ chronic_diseases: profiles[0].chronic_diseases || [],
+ dietary_preferences: profiles[0].dietary_preferences || []
+ }
+ });
+ } catch (error) {
+ console.error('获取健康档案失败:', error);
+ res.status(500).json({ success: false, message: '服务器错误' });
+ }
+});
+
+// 更新健康档案
+router.put('/profile', async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const {
+ height,
+ weight,
+ age,
+ gender,
+ allergies,
+ chronic_diseases,
+ dietary_preferences,
+ target_calories,
+ target_protein,
+ target_fat,
+ target_carbs,
+ activity_level,
+ health_goal
+ } = req.body;
+
+ // 构建更新字段
+ const updates = [];
+ const values = [];
+
+ if (height !== undefined) {
+ updates.push('height = ?');
+ values.push(height);
+ }
+ if (weight !== undefined) {
+ updates.push('weight = ?');
+ values.push(weight);
+ }
+ if (age !== undefined) {
+ updates.push('age = ?');
+ values.push(age);
+ }
+ if (gender !== undefined) {
+ updates.push('gender = ?');
+ values.push(gender);
+ }
+ if (allergies !== undefined) {
+ updates.push('allergies = ?');
+ values.push(JSON.stringify(allergies));
+ }
+ if (chronic_diseases !== undefined) {
+ updates.push('chronic_diseases = ?');
+ values.push(JSON.stringify(chronic_diseases));
+ }
+ if (dietary_preferences !== undefined) {
+ updates.push('dietary_preferences = ?');
+ values.push(JSON.stringify(dietary_preferences));
+ }
+ if (target_calories !== undefined) {
+ updates.push('target_calories = ?');
+ values.push(target_calories);
+ }
+ if (target_protein !== undefined) {
+ updates.push('target_protein = ?');
+ values.push(target_protein);
+ }
+ if (target_fat !== undefined) {
+ updates.push('target_fat = ?');
+ values.push(target_fat);
+ }
+ if (target_carbs !== undefined) {
+ updates.push('target_carbs = ?');
+ values.push(target_carbs);
+ }
+ if (activity_level !== undefined) {
+ updates.push('activity_level = ?');
+ values.push(activity_level);
+ }
+ if (health_goal !== undefined) {
+ updates.push('health_goal = ?');
+ values.push(health_goal);
+ }
+
+ if (updates.length === 0) {
+ return res.status(400).json({ success: false, message: '没有要更新的字段' });
+ }
+
+ values.push(userId);
+
+ // 使用 ON DUPLICATE KEY UPDATE 来处理插入或更新
+ await pool.query(
+ `UPDATE health_profiles SET ${updates.join(', ')} WHERE user_id = ?`,
+ values
+ );
+
+ // 返回更新后的档案
+ const [profiles] = await pool.query(
+ 'SELECT * FROM health_profiles WHERE user_id = ?',
+ [userId]
+ );
+
+ res.json({
+ success: true,
+ data: profiles[0]
+ });
+ } catch (error) {
+ console.error('更新健康档案失败:', error);
+ res.status(500).json({ success: false, message: '服务器错误' });
+ }
+});
+
+// 记录用餐
+router.post('/meal-records', async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { dish_id, quantity = 1, meal_time, meal_type } = req.body;
+
+ console.log('记录用餐请求:', { userId, dish_id, quantity, meal_time, meal_type });
+
+ // 获取菜品营养信息(包含菜品名称)
+ const [dishes] = await pool.query(
+ 'SELECT name, calories, protein, fat, carbs FROM dishes WHERE id = ?',
+ [dish_id]
+ );
+
+ if (dishes.length === 0) {
+ return res.status(404).json({ success: false, message: '菜品不存在' });
+ }
+
+ const dish = dishes[0];
+ console.log('菜品营养信息:', dish);
+
+ // 处理时间格式:将ISO字符串转换为MySQL DATETIME格式
+ const mealTimeFormatted = meal_time ? new Date(meal_time).toISOString().slice(0, 19).replace('T', ' ') : new Date().toISOString().slice(0, 19).replace('T', ' ');
+
+ // 根据时间自动判断餐次类型
+ let mealTypeValue = meal_type;
+ if (!mealTypeValue) {
+ const hour = new Date(meal_time || new Date()).getHours();
+ if (hour >= 6 && hour < 10) mealTypeValue = '早餐';
+ else if (hour >= 10 && hour < 14) mealTypeValue = '午餐';
+ else if (hour >= 14 && hour < 18) mealTypeValue = '加餐';
+ else mealTypeValue = '晚餐';
+ }
+
+ await pool.query(
+ `INSERT INTO meal_records
+ (user_id, dish_id, dish_name, quantity, meal_time, meal_type, calories, protein, fat, carbs)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ userId,
+ dish_id,
+ dish.name || '未知菜品',
+ quantity,
+ mealTimeFormatted,
+ mealTypeValue,
+ (dish.calories || 0) * quantity,
+ (dish.protein || 0) * quantity,
+ (dish.fat || 0) * quantity,
+ (dish.carbs || 0) * quantity
+ ]
+ );
+
+ res.json({
+ success: true,
+ message: '用餐记录添加成功',
+ data: {
+ calories: (dish.calories || 0) * quantity,
+ protein: (dish.protein || 0) * quantity,
+ fat: (dish.fat || 0) * quantity,
+ carbs: (dish.carbs || 0) * quantity
+ }
+ });
+ } catch (error) {
+ console.error('添加用餐记录失败:', error);
+ res.status(500).json({ success: false, message: '服务器错误: ' + error.message });
+ }
+});
+
+// 获取用餐记录
+router.get('/meal-records', async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { start_date, end_date, limit = 50, offset = 0 } = req.query;
+
+ let query = `
+ SELECT
+ mr.*,
+ d.name as dish_name,
+ d.image as dish_image,
+ d.category
+ FROM meal_records mr
+ LEFT JOIN dishes d ON mr.dish_id = d.id
+ WHERE mr.user_id = ?
+ `;
+
+ const params = [userId];
+
+ if (start_date) {
+ query += ' AND DATE(mr.meal_time) >= ?';
+ params.push(start_date);
+ }
+
+ if (end_date) {
+ query += ' AND DATE(mr.meal_time) <= ?';
+ params.push(end_date);
+ }
+
+ query += ' ORDER BY mr.meal_time DESC LIMIT ? OFFSET ?';
+ params.push(parseInt(limit), parseInt(offset));
+
+ const [records] = await pool.query(query, params);
+
+ res.json({
+ success: true,
+ data: {
+ records,
+ total: records.length
+ }
+ });
+ } catch (error) {
+ console.error('获取用餐记录失败:', error);
+ res.status(500).json({ success: false, message: '服务器错误' });
+ }
+});
+
+// 获取今日营养摄入
+router.get('/today-nutrition', async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const today = new Date().toISOString().split('T')[0];
+
+ const [result] = await pool.query(
+ `SELECT
+ SUM(calories) as total_calories,
+ SUM(protein) as total_protein,
+ SUM(fat) as total_fat,
+ SUM(carbs) as total_carbs,
+ COUNT(*) as meal_count
+ FROM meal_records
+ WHERE user_id = ? AND DATE(meal_time) = ?`,
+ [userId, today]
+ );
+
+ // 获取目标营养
+ const [profiles] = await pool.query(
+ 'SELECT target_calories, target_protein, target_fat, target_carbs FROM health_profiles WHERE user_id = ?',
+ [userId]
+ );
+
+ const nutrition = result[0];
+ const target = profiles[0] || {};
+
+ res.json({
+ success: true,
+ data: {
+ current: {
+ calories: nutrition.total_calories || 0,
+ protein: nutrition.total_protein || 0,
+ fat: nutrition.total_fat || 0,
+ carbs: nutrition.total_carbs || 0,
+ meal_count: nutrition.meal_count
+ },
+ target: {
+ calories: target.target_calories || 2000,
+ protein: target.target_protein || 60,
+ fat: target.target_fat || 60,
+ carbs: target.target_carbs || 250
+ }
+ }
+ });
+ } catch (error) {
+ console.error('获取今日营养摄入失败:', error);
+ res.status(500).json({ success: false, message: '服务器错误' });
+ }
+});
+
+// 生成营养报告
+router.post('/reports/generate', async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { report_date, report_type = 'daily' } = req.body;
+
+ const reportDate = report_date || new Date().toISOString().split('T')[0];
+
+ // 计算时间范围
+ let startDate, endDate;
+ if (report_type === 'daily') {
+ startDate = endDate = reportDate;
+ } else if (report_type === 'weekly') {
+ // 获取这一周的开始和结束日期
+ const date = new Date(reportDate);
+ const day = date.getDay();
+ const diff = date.getDate() - day + (day === 0 ? -6 : 1);
+ startDate = new Date(date.setDate(diff)).toISOString().split('T')[0];
+ endDate = new Date(date.setDate(diff + 6)).toISOString().split('T')[0];
+ } else if (report_type === 'monthly') {
+ const date = new Date(reportDate);
+ startDate = new Date(date.getFullYear(), date.getMonth(), 1).toISOString().split('T')[0];
+ endDate = new Date(date.getFullYear(), date.getMonth() + 1, 0).toISOString().split('T')[0];
+ }
+
+ // 查询期间的用餐记录
+ const [records] = await pool.query(
+ `SELECT
+ SUM(calories) as total_calories,
+ SUM(protein) as total_protein,
+ SUM(fat) as total_fat,
+ SUM(carbs) as total_carbs,
+ COUNT(*) as meal_count,
+ AVG(calories) as avg_calories
+ FROM meal_records
+ WHERE user_id = ? AND DATE(meal_time) BETWEEN ? AND ?`,
+ [userId, startDate, endDate]
+ );
+
+ // 获取健康档案
+ const [profiles] = await pool.query(
+ 'SELECT * FROM health_profiles WHERE user_id = ?',
+ [userId]
+ );
+
+ const record = records[0];
+ const profile = profiles[0] || {};
+
+ // 计算目标完成率
+ const calories_goal_rate = profile.target_calories
+ ? (record.total_calories / profile.target_calories * 100).toFixed(2)
+ : null;
+ const protein_goal_rate = profile.target_protein
+ ? (record.total_protein / profile.target_protein * 100).toFixed(2)
+ : null;
+ const fat_goal_rate = profile.target_fat
+ ? (record.total_fat / profile.target_fat * 100).toFixed(2)
+ : null;
+ const carbs_goal_rate = profile.target_carbs
+ ? (record.total_carbs / profile.target_carbs * 100).toFixed(2)
+ : null;
+
+ // 计算健康评分(简单算法)
+ let health_score = 80;
+ if (calories_goal_rate) {
+ const diff = Math.abs(calories_goal_rate - 100);
+ if (diff < 10) health_score += 10;
+ else if (diff < 20) health_score += 5;
+ else health_score -= 10;
+ }
+
+ // 生成建议
+ const suggestions = [];
+ if (calories_goal_rate < 90) {
+ suggestions.push('您的热量摄入偏低,建议适当增加饮食');
+ } else if (calories_goal_rate > 110) {
+ suggestions.push('您的热量摄入偏高,建议控制饮食');
+ }
+
+ if (protein_goal_rate < 80) {
+ suggestions.push('蛋白质摄入不足,建议多食用肉类、蛋类、豆类');
+ }
+
+ // 保存或更新报告
+ await pool.query(
+ `INSERT INTO nutrition_reports
+ (user_id, report_date, report_type, total_calories, total_protein, total_fat, total_carbs,
+ meal_count, avg_calories, calories_goal_rate, protein_goal_rate, fat_goal_rate, carbs_goal_rate,
+ health_score, suggestions)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ total_calories = VALUES(total_calories),
+ total_protein = VALUES(total_protein),
+ total_fat = VALUES(total_fat),
+ total_carbs = VALUES(total_carbs),
+ meal_count = VALUES(meal_count),
+ avg_calories = VALUES(avg_calories),
+ calories_goal_rate = VALUES(calories_goal_rate),
+ protein_goal_rate = VALUES(protein_goal_rate),
+ fat_goal_rate = VALUES(fat_goal_rate),
+ carbs_goal_rate = VALUES(carbs_goal_rate),
+ health_score = VALUES(health_score),
+ suggestions = VALUES(suggestions)`,
+ [
+ userId,
+ reportDate,
+ report_type,
+ record.total_calories || 0,
+ record.total_protein || 0,
+ record.total_fat || 0,
+ record.total_carbs || 0,
+ record.meal_count,
+ record.avg_calories || 0,
+ calories_goal_rate,
+ protein_goal_rate,
+ fat_goal_rate,
+ carbs_goal_rate,
+ health_score,
+ JSON.stringify(suggestions)
+ ]
+ );
+
+ res.json({
+ success: true,
+ message: '营养报告生成成功',
+ data: {
+ report_date: reportDate,
+ report_type,
+ total_calories: record.total_calories || 0,
+ total_protein: record.total_protein || 0,
+ total_fat: record.total_fat || 0,
+ total_carbs: record.total_carbs || 0,
+ meal_count: record.meal_count,
+ health_score,
+ suggestions
+ }
+ });
+ } catch (error) {
+ console.error('生成营养报告失败:', error);
+ res.status(500).json({ success: false, message: '服务器错误' });
+ }
+});
+
+// 获取营养报告列表
+router.get('/reports', async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { start_date, end_date, limit = 30, offset = 0 } = req.query;
+
+ let query = `
+ SELECT * FROM nutrition_reports
+ WHERE user_id = ?
+ `;
+
+ const params = [userId];
+
+ if (start_date) {
+ query += ' AND report_date >= ?';
+ params.push(start_date);
+ }
+
+ if (end_date) {
+ query += ' AND report_date <= ?';
+ params.push(end_date);
+ }
+
+ query += ' ORDER BY report_date DESC LIMIT ? OFFSET ?';
+ params.push(parseInt(limit), parseInt(offset));
+
+ const [reports] = await pool.query(query, params);
+
+ res.json({
+ success: true,
+ data: {
+ reports: reports.map(r => ({
+ ...r,
+ suggestions: r.suggestions || []
+ })),
+ total: reports.length
+ }
+ });
+ } catch (error) {
+ console.error('获取营养报告列表失败:', error);
+ res.status(500).json({ success: false, message: '服务器错误' });
+ }
+});
+
+// 获取营养报告详情
+router.get('/reports/:id', async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const reportId = req.params.id;
+
+ const [reports] = await pool.query(
+ 'SELECT * FROM nutrition_reports WHERE id = ? AND user_id = ?',
+ [reportId, userId]
+ );
+
+ if (reports.length === 0) {
+ return res.status(404).json({ success: false, message: '报告不存在' });
+ }
+
+ res.json({
+ success: true,
+ data: {
+ ...reports[0],
+ suggestions: reports[0].suggestions || []
+ }
+ });
+ } catch (error) {
+ console.error('获取营养报告详情失败:', error);
+ res.status(500).json({ success: false, message: '服务器错误' });
+ }
+});
+
+// 获取周/月报告统计
+router.get('/reports/period/:type', async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { type } = req.params; // 'week' or 'month'
+
+ // 计算日期范围
+ const endDate = new Date();
+ let startDate = new Date();
+ let days = 7;
+
+ if (type === 'week') {
+ startDate.setDate(endDate.getDate() - 6); // 最近7天
+ days = 7;
+ } else if (type === 'month') {
+ startDate.setDate(endDate.getDate() - 29); // 最近30天
+ days = 30;
+ }
+
+ const startDateStr = startDate.toISOString().split('T')[0];
+ const endDateStr = endDate.toISOString().split('T')[0];
+
+ // 获取每日营养数据
+ const [dailyData] = await pool.query(
+ `SELECT
+ DATE(meal_time) as date,
+ SUM(calories) as total_calories,
+ SUM(protein) as total_protein,
+ SUM(fat) as total_fat,
+ SUM(carbs) as total_carbs,
+ COUNT(*) as meal_count
+ FROM meal_records
+ WHERE user_id = ? AND DATE(meal_time) BETWEEN ? AND ?
+ GROUP BY DATE(meal_time)
+ ORDER BY date ASC`,
+ [userId, startDateStr, endDateStr]
+ );
+
+ // 获取健康档案目标
+ const [profiles] = await pool.query(
+ 'SELECT target_calories, target_protein, target_fat, target_carbs FROM health_profiles WHERE user_id = ?',
+ [userId]
+ );
+
+ // 确保所有目标值都有默认值
+ const target = {
+ target_calories: (profiles[0]?.target_calories) || 2000,
+ target_protein: (profiles[0]?.target_protein) || 80,
+ target_fat: (profiles[0]?.target_fat) || 65,
+ target_carbs: (profiles[0]?.target_carbs) || 250
+ };
+
+ // 计算统计数据
+ const totals = dailyData.reduce((acc, day) => {
+ acc.calories += parseFloat(day.total_calories) || 0;
+ acc.protein += parseFloat(day.total_protein) || 0;
+ acc.fat += parseFloat(day.total_fat) || 0;
+ acc.carbs += parseFloat(day.total_carbs) || 0;
+ acc.meal_count += day.meal_count;
+ return acc;
+ }, { calories: 0, protein: 0, fat: 0, carbs: 0, meal_count: 0 });
+
+ const daysWithData = dailyData.length || 1;
+ const averages = {
+ calories: Math.round(totals.calories / daysWithData),
+ protein: Math.round(totals.protein / daysWithData),
+ fat: Math.round(totals.fat / daysWithData),
+ carbs: Math.round(totals.carbs / daysWithData)
+ };
+
+ // 计算达成率
+ const goalRates = {
+ calories: Math.round((averages.calories / target.target_calories) * 100),
+ protein: Math.round((averages.protein / target.target_protein) * 100),
+ fat: Math.round((averages.fat / target.target_fat) * 100),
+ carbs: Math.round((averages.carbs / target.target_carbs) * 100)
+ };
+
+ // 生成建议
+ const suggestions = [];
+ if (goalRates.calories < 90) {
+ suggestions.push('✅ 热量摄入偏低,建议适当增加饮食');
+ } else if (goalRates.calories > 110) {
+ suggestions.push('⚠️ 热量摄入偏高,建议控制饮食');
+ } else {
+ suggestions.push('✅ 热量摄入良好,继续保持');
+ }
+
+ if (goalRates.protein < 80) {
+ suggestions.push('💡 蛋白质摄入不足,建议多食用肉类、蛋类、豆类');
+ } else {
+ suggestions.push('✅ 蛋白质摄入良好,继续保持');
+ }
+
+ if (goalRates.carbs > 120) {
+ suggestions.push('⚠️ 碳水摄入偏高,建议适当控制主食');
+ }
+
+ suggestions.push('🥗 每日蔬菜摄入建议增至500g');
+
+ res.json({
+ success: true,
+ data: {
+ period: type,
+ days: days,
+ dailyData: dailyData.map(d => ({
+ date: d.date,
+ calories: parseFloat(d.total_calories) || 0,
+ protein: parseFloat(d.total_protein) || 0,
+ fat: parseFloat(d.total_fat) || 0,
+ carbs: parseFloat(d.total_carbs) || 0,
+ meal_count: d.meal_count
+ })),
+ totals,
+ averages,
+ target,
+ goalRates,
+ suggestions,
+ achievements: {
+ consecutive_days: dailyData.length,
+ goal_achieved_days: dailyData.filter(d => {
+ const rate = (parseFloat(d.total_calories) / target.target_calories) * 100;
+ return rate >= 90 && rate <= 110;
+ }).length,
+ perfect_days: dailyData.filter(d => {
+ const cRate = (parseFloat(d.total_calories) / target.target_calories) * 100;
+ const pRate = (parseFloat(d.total_protein) / target.target_protein) * 100;
+ return cRate >= 95 && cRate <= 105 && pRate >= 90;
+ }).length
+ }
+ }
+ });
+ } catch (error) {
+ console.error('获取周期报告统计失败:', error);
+ res.status(500).json({ success: false, message: '服务器错误: ' + error.message });
+ }
+});
+
+export default router;
+
diff --git a/src/src/backend/routes/orders.js b/src/src/backend/routes/orders.js
new file mode 100644
index 0000000..e31f8df
--- /dev/null
+++ b/src/src/backend/routes/orders.js
@@ -0,0 +1,294 @@
+import express from 'express';
+import { pool } from '../config/database.js';
+import { authenticateToken } from '../middleware/auth.js';
+
+const router = express.Router();
+
+// 生成订单号
+function generateOrderNo() {
+ const date = new Date();
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ const seconds = String(date.getSeconds()).padStart(2, '0');
+ const random = Math.floor(Math.random() * 1000).toString().padStart(3, '0');
+
+ return `${year}${month}${day}${hours}${minutes}${seconds}${random}`;
+}
+
+// 获取订单列表
+router.get('/', authenticateToken, async (req, res) => {
+ try {
+ const { status, page = 1, limit = 10 } = req.query;
+ const offset = (page - 1) * limit;
+
+ let whereClause = 'WHERE orders.user_id = ?';
+ let queryParams = [req.user.id];
+
+ if (status && status !== 'all') {
+ whereClause += ' AND orders.status = ?';
+ queryParams.push(status);
+ }
+
+ // 获取订单列表
+ const [orders] = await pool.query(
+ `SELECT * FROM orders
+ ${whereClause}
+ ORDER BY orders.created_at DESC
+ LIMIT ? OFFSET ?`,
+ [...queryParams, parseInt(limit), parseInt(offset)]
+ );
+
+ // 获取每个订单的订单项
+ for (let order of orders) {
+ const [items] = await pool.query(
+ 'SELECT * FROM order_items WHERE order_id = ?',
+ [order.id]
+ );
+ order.items = items;
+ }
+
+ // 获取总数
+ const [countResult] = await pool.query(
+ `SELECT COUNT(*) as total FROM orders ${whereClause}`,
+ queryParams
+ );
+
+ res.json({
+ success: true,
+ data: {
+ orders,
+ pagination: {
+ page: parseInt(page),
+ limit: parseInt(limit),
+ total: countResult[0].total
+ }
+ }
+ });
+ } catch (error) {
+ console.error('获取订单列表错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取订单列表失败'
+ });
+ }
+});
+
+// 获取订单详情
+router.get('/:id', authenticateToken, async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ const [orders] = await pool.query(
+ 'SELECT * FROM orders WHERE id = ? AND user_id = ?',
+ [id, req.user.id]
+ );
+
+ if (orders.length === 0) {
+ return res.status(404).json({
+ success: false,
+ message: '订单不存在'
+ });
+ }
+
+ const order = orders[0];
+
+ // 获取订单项
+ const [items] = await pool.query(
+ 'SELECT * FROM order_items WHERE order_id = ?',
+ [order.id]
+ );
+
+ order.items = items;
+
+ res.json({
+ success: true,
+ data: order
+ });
+ } catch (error) {
+ console.error('获取订单详情错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取订单详情失败'
+ });
+ }
+});
+
+// 创建订单
+router.post('/', authenticateToken, async (req, res) => {
+ const connection = await pool.getConnection();
+
+ try {
+ await connection.beginTransaction();
+
+ const { items, remark } = req.body;
+
+ if (!items || items.length === 0) {
+ return res.status(400).json({
+ success: false,
+ message: '订单项不能为空'
+ });
+ }
+
+ // 验证所有菜品并计算总价
+ let totalPrice = 0;
+ let canteenId = null;
+ let canteenName = null;
+
+ for (let item of items) {
+ const [dishes] = await connection.query(
+ 'SELECT id, name, price, image, canteen_id, canteen_name FROM dishes WHERE id = ? AND status = "available"',
+ [item.dish_id]
+ );
+
+ if (dishes.length === 0) {
+ throw new Error(`菜品ID ${item.dish_id} 不存在或已下架`);
+ }
+
+ const dish = dishes[0];
+
+ // 检查是否来自同一个食堂
+ if (canteenId === null) {
+ canteenId = dish.canteen_id;
+ canteenName = dish.canteen_name;
+ } else if (canteenId !== dish.canteen_id) {
+ throw new Error('订单中的菜品必须来自同一个食堂');
+ }
+
+ totalPrice += dish.price * item.quantity;
+ item.dish = dish;
+ }
+
+ // 生成订单号
+ const orderNo = generateOrderNo();
+
+ // 创建订单
+ const [orderResult] = await connection.query(
+ 'INSERT INTO orders (order_no, user_id, canteen_id, canteen_name, total_price, status, remark) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [orderNo, req.user.id, canteenId, canteenName, totalPrice, 'pending', remark]
+ );
+
+ const orderId = orderResult.insertId;
+
+ // 创建订单项
+ for (let item of items) {
+ await connection.query(
+ 'INSERT INTO order_items (order_id, dish_id, dish_name, dish_image, price, quantity) VALUES (?, ?, ?, ?, ?, ?)',
+ [orderId, item.dish_id, item.dish.name, item.dish.image, item.dish.price, item.quantity]
+ );
+
+ // 更新菜品销量
+ await connection.query(
+ 'UPDATE dishes SET sales_count = sales_count + ? WHERE id = ?',
+ [item.quantity, item.dish_id]
+ );
+ }
+
+ // 清空购物车
+ await connection.query(
+ 'DELETE FROM cart_items WHERE user_id = ?',
+ [req.user.id]
+ );
+
+ await connection.commit();
+
+ res.json({
+ success: true,
+ message: '订单创建成功',
+ data: {
+ orderId,
+ orderNo
+ }
+ });
+ } catch (error) {
+ await connection.rollback();
+ console.error('创建订单错误:', error);
+ res.status(500).json({
+ success: false,
+ message: error.message || '创建订单失败'
+ });
+ } finally {
+ connection.release();
+ }
+});
+
+// 取消订单
+router.put('/:id/cancel', authenticateToken, async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ // 检查订单状态
+ const [orders] = await pool.query(
+ 'SELECT * FROM orders WHERE id = ? AND user_id = ?',
+ [id, req.user.id]
+ );
+
+ if (orders.length === 0) {
+ return res.status(404).json({
+ success: false,
+ message: '订单不存在'
+ });
+ }
+
+ const order = orders[0];
+
+ if (order.status !== 'pending') {
+ return res.status(400).json({
+ success: false,
+ message: '只能取消待取餐的订单'
+ });
+ }
+
+ // 更新订单状态
+ await pool.query(
+ 'UPDATE orders SET status = "cancelled" WHERE id = ?',
+ [id]
+ );
+
+ res.json({
+ success: true,
+ message: '订单已取消'
+ });
+ } catch (error) {
+ console.error('取消订单错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '取消订单失败'
+ });
+ }
+});
+
+// 完成订单
+router.put('/:id/complete', authenticateToken, async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ const [result] = await pool.query(
+ 'UPDATE orders SET status = "completed" WHERE id = ? AND user_id = ? AND status = "pending"',
+ [id, req.user.id]
+ );
+
+ if (result.affectedRows === 0) {
+ return res.status(400).json({
+ success: false,
+ message: '订单不存在或状态不正确'
+ });
+ }
+
+ res.json({
+ success: true,
+ message: '订单已完成'
+ });
+ } catch (error) {
+ console.error('完成订单错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '完成订单失败'
+ });
+ }
+});
+
+export default router;
+
diff --git a/src/src/backend/routes/reviews.js b/src/src/backend/routes/reviews.js
new file mode 100644
index 0000000..2e5bbaa
--- /dev/null
+++ b/src/src/backend/routes/reviews.js
@@ -0,0 +1,162 @@
+import express from 'express';
+import { pool } from '../config/database.js';
+import { authenticateToken, optionalAuth } from '../middleware/auth.js';
+
+const router = express.Router();
+
+// 确保reviews表存在
+async function ensureReviewsTable() {
+ try {
+ const createTableQuery = `
+ CREATE TABLE IF NOT EXISTS reviews (
+ id INT PRIMARY KEY AUTO_INCREMENT,
+ user_id INT NOT NULL,
+ dish_id INT NOT NULL,
+ order_id INT,
+ user_name VARCHAR(50),
+ user_avatar VARCHAR(500),
+ rating INT NOT NULL CHECK (rating >= 1 AND rating <= 5),
+ content TEXT,
+ images TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_dish (dish_id),
+ INDEX idx_user (user_id),
+ INDEX idx_created_at (created_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+ `;
+
+ await pool.query(createTableQuery);
+ console.log('确保reviews表已创建');
+ } catch (error) {
+ console.error('创建reviews表时出错:', error);
+ }
+}
+
+// 初始化表
+ensureReviewsTable();
+
+// 获取菜品评论
+router.get('/dish/:dish_id', optionalAuth, async (req, res) => {
+ try {
+ const { dish_id } = req.params;
+ const { page = 1, limit = 10 } = req.query;
+ const offset = (page - 1) * limit;
+
+ const [reviews] = await pool.query(
+ `SELECT * FROM reviews
+ WHERE dish_id = ?
+ ORDER BY created_at DESC
+ LIMIT ? OFFSET ?`,
+ [dish_id, parseInt(limit), parseInt(offset)]
+ );
+
+ const [countResult] = await pool.query(
+ 'SELECT COUNT(*) as total FROM reviews WHERE dish_id = ?',
+ [dish_id]
+ );
+
+ res.json({
+ success: true,
+ data: {
+ reviews,
+ pagination: {
+ page: parseInt(page),
+ limit: parseInt(limit),
+ total: countResult[0].total
+ }
+ }
+ });
+ } catch (error) {
+ console.error('获取评论错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取评论失败'
+ });
+ }
+});
+
+// 创建评论
+router.post('/', authenticateToken, async (req, res) => {
+ try {
+ console.log('收到评价提交请求:', req.body);
+ // 兼容前端传递的comment参数
+ const { dish_id, order_id, rating, content, comment, images = [] } = req.body;
+ const reviewContent = content || comment;
+ console.log('处理后的评价内容:', { dish_id, rating, reviewContent });
+
+ if (!dish_id || !rating) {
+ return res.status(400).json({
+ success: false,
+ message: '请提供菜品ID和评分'
+ });
+ }
+
+ if (rating < 1 || rating > 5) {
+ return res.status(400).json({
+ success: false,
+ message: '评分必须在1-5之间'
+ });
+ }
+
+ // 获取用户信息
+ console.log('获取用户信息:', { userId: req.user.id });
+ const [users] = await pool.query(
+ 'SELECT name, avatar FROM users WHERE id = ?',
+ [req.user.id]
+ );
+
+ const user = users[0];
+ if (!user) {
+ console.error('用户不存在:', req.user.id);
+ return res.status(404).json({
+ success: false,
+ message: '用户信息不存在'
+ });
+ }
+ console.log('用户信息:', user);
+
+ // 创建评论
+ console.log('插入评论数据:', { dish_id, rating, reviewContent });
+ await pool.query(
+ 'INSERT INTO reviews (user_id, dish_id, order_id, user_name, user_avatar, rating, content, images) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
+ [req.user.id, dish_id, order_id, user.name, user.avatar, rating, reviewContent, JSON.stringify(images)]
+ );
+ console.log('评论插入成功');
+
+ // 更新菜品评分(即使失败也不影响评论的保存)
+ try {
+ const [ratingResult] = await pool.query(
+ 'SELECT AVG(rating) as avg_rating FROM reviews WHERE dish_id = ?',
+ [dish_id]
+ );
+
+ // 确保avg_rating是有效的数字
+ const avgRating = ratingResult[0].avg_rating;
+ const formattedRating = avgRating && !isNaN(avgRating) ? parseFloat(avgRating.toFixed(1)) : 0;
+ console.log('更新菜品评分:', { dish_id, avgRating, formattedRating });
+
+ await pool.query(
+ 'UPDATE dishes SET rating = ? WHERE id = ?',
+ [formattedRating, dish_id]
+ );
+ console.log('评分更新成功');
+ } catch (ratingError) {
+ console.error('更新评分失败,但评论已保存:', ratingError);
+ // 继续执行,不中断流程
+ }
+
+ res.json({
+ success: true,
+ message: '评论成功'
+ });
+ } catch (error) {
+ console.error('创建评论错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '创建评论失败'
+ });
+ }
+});
+
+export default router;
+
diff --git a/src/src/backend/routes/userDishes.js b/src/src/backend/routes/userDishes.js
new file mode 100644
index 0000000..2f59bd3
--- /dev/null
+++ b/src/src/backend/routes/userDishes.js
@@ -0,0 +1,193 @@
+import express from 'express';
+import { pool } from '../config/database.js';
+import { authenticateToken } from '../middleware/auth.js';
+
+const router = express.Router();
+
+// 获取用户上传的菜品列表
+router.get('/', authenticateToken, async (req, res) => {
+ try {
+ const userId = req.user.userId;
+
+ const [dishes] = await pool.query(`
+ SELECT * FROM user_dishes
+ WHERE user_id = ?
+ ORDER BY created_at DESC
+ `, [userId]);
+
+ res.json({
+ success: true,
+ data: dishes
+ });
+ } catch (error) {
+ console.error('获取上传菜品列表失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取上传菜品列表失败'
+ });
+ }
+});
+
+// 上传新菜品(直接添加到dishes表)
+router.post('/', authenticateToken, async (req, res) => {
+ try {
+ const userId = req.user.userId;
+ const {
+ name,
+ description,
+ category,
+ price,
+ canteen_id,
+ window_number,
+ image_url,
+ calories,
+ protein,
+ fat,
+ carbs,
+ ingredients,
+ allergens,
+ spicy_level
+ } = req.body;
+
+ // 验证必填字段
+ if (!name) {
+ return res.status(400).json({
+ success: false,
+ message: '菜品名称不能为空'
+ });
+ }
+
+ // 先记录到user_dishes表(用于用户历史查看)
+ const ingredientsJson = Array.isArray(ingredients) ? JSON.stringify(ingredients) : ingredients;
+ const allergensJson = Array.isArray(allergens) ? JSON.stringify(allergens) : allergens;
+
+ const [userDishResult] = await pool.query(`
+ INSERT INTO user_dishes (
+ user_id, name, description, category, price, canteen_id,
+ window_number, image_url, calories, protein, fat, carbs,
+ ingredients, allergens, spicy_level, status
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'approved')
+ `, [
+ userId,
+ name,
+ description || null,
+ category || null,
+ price || null,
+ canteen_id || null,
+ window_number || null,
+ image_url || null,
+ calories || null,
+ protein || null,
+ fat || null,
+ carbs || null,
+ ingredientsJson,
+ allergensJson,
+ spicy_level || 0
+ ]);
+
+ // 直接添加到dishes表
+ const [dishResult] = await pool.query(`
+ INSERT INTO dishes (
+ name, description, category, price, canteen_id,
+ window_number, image, calories, protein, fat, carbs,
+ ingredients, spicy_level, status, created_by
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'available', ?)
+ `, [
+ name,
+ description || null,
+ category || null,
+ price || null,
+ canteen_id || null,
+ window_number || null,
+ image_url || null,
+ calories || null,
+ protein || null,
+ fat || null,
+ carbs || null,
+ ingredientsJson,
+ spicy_level || 0,
+ userId
+ ]);
+
+ res.json({
+ success: true,
+ message: '菜品添加成功!',
+ data: {
+ id: dishResult.insertId,
+ userDishId: userDishResult.insertId
+ }
+ });
+ } catch (error) {
+ console.error('上传菜品失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '上传菜品失败: ' + error.message
+ });
+ }
+});
+
+// 获取单个上传菜品详情
+router.get('/:id', authenticateToken, async (req, res) => {
+ try {
+ const userId = req.user.userId;
+ const { id } = req.params;
+
+ const [dishes] = await pool.query(`
+ SELECT * FROM user_dishes
+ WHERE id = ? AND user_id = ?
+ `, [id, userId]);
+
+ if (dishes.length === 0) {
+ return res.status(404).json({
+ success: false,
+ message: '菜品不存在'
+ });
+ }
+
+ res.json({
+ success: true,
+ data: dishes[0]
+ });
+ } catch (error) {
+ console.error('获取菜品详情失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取菜品详情失败'
+ });
+ }
+});
+
+// 删除上传的菜品(仅限待审核状态)
+router.delete('/:id', authenticateToken, async (req, res) => {
+ try {
+ const userId = req.user.userId;
+ const { id } = req.params;
+
+ // 只能删除自己上传的且状态为pending的菜品
+ const [result] = await pool.query(`
+ DELETE FROM user_dishes
+ WHERE id = ? AND user_id = ? AND status = 'pending'
+ `, [id, userId]);
+
+ if (result.affectedRows === 0) {
+ return res.status(400).json({
+ success: false,
+ message: '无法删除该菜品(可能已被审核或不存在)'
+ });
+ }
+
+ res.json({
+ success: true,
+ message: '删除成功'
+ });
+ } catch (error) {
+ console.error('删除菜品失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '删除菜品失败'
+ });
+ }
+});
+
+export default router;
+
diff --git a/src/src/backend/routes/users.js b/src/src/backend/routes/users.js
new file mode 100644
index 0000000..81755d7
--- /dev/null
+++ b/src/src/backend/routes/users.js
@@ -0,0 +1,112 @@
+import express from 'express';
+import { pool } from '../config/database.js';
+import { authenticateToken } from '../middleware/auth.js';
+
+const router = express.Router();
+
+// 获取用户信息
+router.get('/profile', authenticateToken, async (req, res) => {
+ try {
+ const [users] = await pool.query(
+ 'SELECT id, username, phone, email, name, avatar, balance, created_at FROM users WHERE id = ?',
+ [req.user.id]
+ );
+
+ if (users.length === 0) {
+ return res.status(404).json({
+ success: false,
+ message: '用户不存在'
+ });
+ }
+
+ // 获取统计数据
+ const [orderCount] = await pool.query(
+ 'SELECT COUNT(*) as total FROM orders WHERE user_id = ?',
+ [req.user.id]
+ );
+
+ const [pendingCount] = await pool.query(
+ 'SELECT COUNT(*) as total FROM orders WHERE user_id = ? AND status = "pending"',
+ [req.user.id]
+ );
+
+ const [favoriteCount] = await pool.query(
+ 'SELECT COUNT(*) as total FROM favorites WHERE user_id = ?',
+ [req.user.id]
+ );
+
+ const [couponCount] = await pool.query(
+ 'SELECT COUNT(*) as total FROM user_coupons WHERE user_id = ? AND status = "unused"',
+ [req.user.id]
+ );
+
+ res.json({
+ success: true,
+ data: {
+ user: users[0],
+ stats: {
+ orderCount: orderCount[0].total,
+ pendingCount: pendingCount[0].total,
+ favoriteCount: favoriteCount[0].total,
+ couponCount: couponCount[0].total
+ }
+ }
+ });
+ } catch (error) {
+ console.error('获取用户信息错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取用户信息失败'
+ });
+ }
+});
+
+// 更新用户信息
+router.put('/profile', authenticateToken, async (req, res) => {
+ try {
+ const { name, email, avatar } = req.body;
+ const updates = [];
+ const values = [];
+
+ if (name) {
+ updates.push('name = ?');
+ values.push(name);
+ }
+ if (email) {
+ updates.push('email = ?');
+ values.push(email);
+ }
+ if (avatar) {
+ updates.push('avatar = ?');
+ values.push(avatar);
+ }
+
+ if (updates.length === 0) {
+ return res.status(400).json({
+ success: false,
+ message: '没有要更新的字段'
+ });
+ }
+
+ values.push(req.user.id);
+
+ await pool.query(
+ `UPDATE users SET ${updates.join(', ')} WHERE id = ?`,
+ values
+ );
+
+ res.json({
+ success: true,
+ message: '更新成功'
+ });
+ } catch (error) {
+ console.error('更新用户信息错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '更新用户信息失败'
+ });
+ }
+});
+
+export default router;
+
diff --git a/src/src/backend/server.js b/src/src/backend/server.js
new file mode 100644
index 0000000..33c1119
--- /dev/null
+++ b/src/src/backend/server.js
@@ -0,0 +1,106 @@
+import express from 'express';
+import cors from 'cors';
+import dotenv from 'dotenv';
+import { testConnection } from './config/database.js';
+
+// 导入路由
+import authRoutes from './routes/auth.js';
+import dishRoutes from './routes/dishes.js';
+import canteenRoutes from './routes/canteens.js';
+import categoryRoutes from './routes/categories.js';
+
+
+import favoriteRoutes from './routes/favorites.js';
+import reviewRoutes from './routes/reviews.js';
+import bannerRoutes from './routes/banners.js';
+import userRoutes from './routes/users.js';
+
+import healthRoutes from './routes/health.js';
+import userDishesRoutes from './routes/userDishes.js';
+import adminRoutes from './routes/admin.js';
+import checkNutritionRoutes from './routes/checkNutrition.js';
+
+dotenv.config();
+
+const app = express();
+const PORT = process.env.PORT || 3000;
+
+// 中间件
+app.use(cors());
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+
+// 请求日志中间件
+app.use((req, res, next) => {
+ console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
+ next();
+});
+
+// API路由
+app.use('/api/auth', authRoutes);
+app.use('/api/dishes', dishRoutes);
+app.use('/api/canteens', canteenRoutes);
+app.use('/api/categories', categoryRoutes);
+
+
+app.use('/api/favorites', favoriteRoutes);
+app.use('/api/reviews', reviewRoutes);
+app.use('/api/banners', bannerRoutes);
+app.use('/api/users', userRoutes);
+
+app.use('/api/health', healthRoutes);
+app.use('/api/user-dishes', userDishesRoutes);
+app.use('/api/admin', adminRoutes);
+app.use('/api/check', checkNutritionRoutes);
+
+// 健康检查路由
+app.get('/api/healthcheck', (req, res) => {
+ res.json({
+ status: 'ok',
+ message: '校园食堂推荐系统API运行正常',
+ timestamp: new Date().toISOString()
+ });
+});
+
+// 404处理
+app.use((req, res) => {
+ res.status(404).json({
+ success: false,
+ message: '请求的资源不存在'
+ });
+});
+
+// 错误处理中间件
+app.use((err, req, res, next) => {
+ console.error('错误:', err);
+ res.status(err.status || 500).json({
+ success: false,
+ message: err.message || '服务器内部错误',
+ error: process.env.NODE_ENV === 'development' ? err : {}
+ });
+});
+
+// 启动服务器
+async function startServer() {
+ // 测试数据库连接
+ const dbConnected = await testConnection();
+
+ if (!dbConnected) {
+ console.error('⚠️ 数据库连接失败,请检查配置!');
+ console.log('提示:请先运行 npm run init-db 初始化数据库');
+ process.exit(1);
+ }
+
+ app.listen(PORT, () => {
+ console.log('');
+ console.log('='.repeat(50));
+ console.log(`🚀 服务器运行在: http://localhost:${PORT}`);
+ console.log(`📚 API文档: http://localhost:${PORT}/api/health`);
+ console.log(`🌍 环境: ${process.env.NODE_ENV || 'development'}`);
+ console.log('='.repeat(50));
+ console.log('');
+ });
+}
+
+startServer();
+
diff --git a/src/src/readme.txt b/src/src/readme.txt
new file mode 100644
index 0000000..c5f96b0
--- /dev/null
+++ b/src/src/readme.txt
@@ -0,0 +1,141 @@
+项目简介
+校园食堂推荐系统是一个面向大学生的智能食堂推荐和点餐平台。系统通过分析用户的口味偏好、用餐习惯和营养需求,为学生提供个性化的食堂和菜品推荐服务,帮助学生更高效地选择适合自己的餐饮。
+
+技术栈
+前端
+框架:React 18
+语言:TypeScript
+构建工具:Vite 5
+样式:TailwindCSS 3
+路由:React Router 6
+图表:ECharts
+图标:Lucide React
+
+后端
+运行环境:Node.js
+Web框架:Express 4
+数据库:MariaDB/MySQL
+ORM:mysql2/promise
+身份验证:JWT (JSON Web Token)
+环境配置:dotenv
+请求验证:express-validator
+密码加密:bcryptjs
+
+系统架构
+前端:基于React的单页应用(SPA),使用TypeScript确保类型安全
+后端:Node.js + Express构建的RESTful API服务
+数据库:MariaDB/MySQL关系型数据库
+部署:前后端分离架构,可独立部署
+
+环境要求
+必备软件
+Node.js:版本 >= 16
+下载地址:https://nodejs.org/
+
+MariaDB/MySQL:数据库服务
+MariaDB下载:https://mariadb.org/download/
+MySQL下载:https://www.mysql.com/downloads/
+
+系统依赖
+前端依赖:
+react,react-dom
+react-router-dom
+lucide-react
+echarts,echarts-for-react
+开发工具:typescript,vite,tailwindcss等
+
+后端依赖:
+express
+mysql2
+cors
+dotenv
+bcryptjs
+jsonwebtoken
+express-validator
+开发工具:nodemon
+
+安装与配置
+1. 环境准备
+确保已安装Node.js和MariaDB/MySQL
+创建数据库用户并设置密码
+
+2. 克隆项目
+git clone
+cd 校园食堂推荐app
+
+3. 配置数据库
+编辑 .env 文件,设置数据库连接信息:
+DB_HOST=localhost
+DB_PORT=3306
+DB_USER=root
+DB_PASSWORD=你的数据库密码
+DB_NAME=campus_canteen
+PORT=3000
+
+4. 安装依赖
+前端依赖
+cd app
+npm install
+
+后端依赖
+cd ../backend
+npm install
+
+5. 初始化数据库
+在backend目录下执行
+npm run init-db # 初始化数据库结构
+npm run init-admin # 创建管理员账号
+
+运行系统
+手动启动(推荐)
+启动后端服务
+cd backend
+npm run dev
+服务将运行在 http://localhost:3000
+
+启动前端服务
+cd app
+npm run dev
+前端将运行在 http://localhost:5173
+
+使用启动脚本(Windows)
+双击根目录下的 启动系统.bat 文件,系统将自动启动前后端服务。
+
+访问系统
+用户端:http://localhost:5173
+管理员端:http://localhost:5173/admin/login
+
+系统功能
+用户功能
+个性化食堂和菜品推荐
+菜品浏览和搜索
+营养信息查询
+点餐和购物车管理
+用户偏好设置
+
+管理员功能
+食堂信息管理
+菜品信息管理
+用户管理
+订单管理
+统计分析
+
+开发指南
+前端开发
+使用 npm run dev 启动开发服务器
+使用 npm run build 构建生产版本
+代码规范遵循TypeScript和React最佳实践
+
+后端开发
+使用 npm run dev 启动开发服务器(支持热重载)
+使用 node server.js 启动生产服务
+API接口遵循RESTful设计规范
+
+注意事项
+确保数据库服务正常运行
+
+首次使用必须完成数据库初始化
+
+生产环境请修改JWT密钥为更安全的值
+
+定期备份数据库以防止数据丢失
\ No newline at end of file