diff --git a/doc/01_行业和领域调研分析报告.docx b/doc/01_行业和领域调研分析报告.docx new file mode 100644 index 0000000..a1e0162 Binary files /dev/null and b/doc/01_行业和领域调研分析报告.docx differ diff --git a/doc/02_软件系统需求构思及描述.docx b/doc/02_软件系统需求构思及描述.docx new file mode 100644 index 0000000..02fb14e Binary files /dev/null and b/doc/02_软件系统需求构思及描述.docx differ diff --git a/doc/03_软件需需求规格说明书.docx b/doc/03_软件需需求规格说明书.docx new file mode 100644 index 0000000..90f0fa6 Binary files /dev/null and b/doc/03_软件需需求规格说明书.docx differ diff --git a/doc/04_软件设计规格说明书.docx b/doc/04_软件设计规格说明书.docx new file mode 100644 index 0000000..8088836 Binary files /dev/null and b/doc/04_软件设计规格说明书.docx differ diff --git a/model/设计文档模型/数据模型类图.png b/model/设计文档模型/数据模型类图.png new file mode 100644 index 0000000..f0cc3f0 Binary files /dev/null and b/model/设计文档模型/数据模型类图.png differ diff --git a/model/设计文档模型/用例1.png b/model/设计文档模型/用例1.png new file mode 100644 index 0000000..884faa6 Binary files /dev/null and b/model/设计文档模型/用例1.png differ diff --git a/model/设计文档模型/用例2.png b/model/设计文档模型/用例2.png new file mode 100644 index 0000000..221acfd Binary files /dev/null and b/model/设计文档模型/用例2.png differ diff --git a/model/设计文档模型/用例3.png b/model/设计文档模型/用例3.png new file mode 100644 index 0000000..54e4b27 Binary files /dev/null and b/model/设计文档模型/用例3.png differ diff --git a/model/设计文档模型/用例4.png b/model/设计文档模型/用例4.png new file mode 100644 index 0000000..6e136ea Binary files /dev/null and b/model/设计文档模型/用例4.png differ diff --git a/model/设计文档模型/用例5.png b/model/设计文档模型/用例5.png new file mode 100644 index 0000000..62d01fd Binary files /dev/null and b/model/设计文档模型/用例5.png differ diff --git a/model/设计文档模型/用例6.png b/model/设计文档模型/用例6.png new file mode 100644 index 0000000..aa9147f Binary files /dev/null and b/model/设计文档模型/用例6.png differ diff --git a/model/设计文档模型/用例7.png b/model/设计文档模型/用例7.png new file mode 100644 index 0000000..d4de720 Binary files /dev/null and b/model/设计文档模型/用例7.png differ diff --git a/model/设计文档模型/用例8.png b/model/设计文档模型/用例8.png new file mode 100644 index 0000000..61e91ba Binary files /dev/null and b/model/设计文档模型/用例8.png differ diff --git a/model/设计文档模型/用例9.png b/model/设计文档模型/用例9.png new file mode 100644 index 0000000..c84c364 Binary files /dev/null and b/model/设计文档模型/用例9.png differ diff --git a/model/设计文档模型/界面1.png b/model/设计文档模型/界面1.png new file mode 100644 index 0000000..1343ee3 Binary files /dev/null and b/model/设计文档模型/界面1.png differ diff --git a/model/设计文档模型/界面10.jpg b/model/设计文档模型/界面10.jpg new file mode 100644 index 0000000..9f5e3fc Binary files /dev/null and b/model/设计文档模型/界面10.jpg differ diff --git a/model/设计文档模型/界面2.png b/model/设计文档模型/界面2.png new file mode 100644 index 0000000..84ce28e Binary files /dev/null and b/model/设计文档模型/界面2.png differ diff --git a/model/设计文档模型/界面3.jpg b/model/设计文档模型/界面3.jpg new file mode 100644 index 0000000..6607320 Binary files /dev/null and b/model/设计文档模型/界面3.jpg differ diff --git a/model/设计文档模型/界面4.png b/model/设计文档模型/界面4.png new file mode 100644 index 0000000..11083f1 Binary files /dev/null and b/model/设计文档模型/界面4.png differ diff --git a/model/设计文档模型/界面5.png b/model/设计文档模型/界面5.png new file mode 100644 index 0000000..3acbcc0 Binary files /dev/null and b/model/设计文档模型/界面5.png differ diff --git a/model/设计文档模型/界面6.png b/model/设计文档模型/界面6.png new file mode 100644 index 0000000..3a89c5d Binary files /dev/null and b/model/设计文档模型/界面6.png differ diff --git a/model/设计文档模型/界面7.png b/model/设计文档模型/界面7.png new file mode 100644 index 0000000..d3d7f5a Binary files /dev/null and b/model/设计文档模型/界面7.png differ diff --git a/model/设计文档模型/界面8.png b/model/设计文档模型/界面8.png new file mode 100644 index 0000000..4042ef1 Binary files /dev/null and b/model/设计文档模型/界面8.png differ diff --git a/model/设计文档模型/界面9.png b/model/设计文档模型/界面9.png new file mode 100644 index 0000000..73283c6 Binary files /dev/null and b/model/设计文档模型/界面9.png differ diff --git a/model/设计文档模型/界面类图1.jpg b/model/设计文档模型/界面类图1.jpg new file mode 100644 index 0000000..9ec6020 Binary files /dev/null and b/model/设计文档模型/界面类图1.jpg differ diff --git a/model/设计文档模型/界面类图2.png b/model/设计文档模型/界面类图2.png new file mode 100644 index 0000000..52f9d11 Binary files /dev/null and b/model/设计文档模型/界面类图2.png differ diff --git a/model/设计文档模型/界面类图3.jpg b/model/设计文档模型/界面类图3.jpg new file mode 100644 index 0000000..1ce689d Binary files /dev/null and b/model/设计文档模型/界面类图3.jpg differ diff --git a/model/设计文档模型/界面类图4.png b/model/设计文档模型/界面类图4.png new file mode 100644 index 0000000..33bbbb0 Binary files /dev/null and b/model/设计文档模型/界面类图4.png differ diff --git a/model/设计文档模型/界面类图5.png b/model/设计文档模型/界面类图5.png new file mode 100644 index 0000000..3006b56 Binary files /dev/null and b/model/设计文档模型/界面类图5.png differ diff --git a/model/设计文档模型/界面类图6.png b/model/设计文档模型/界面类图6.png new file mode 100644 index 0000000..c00bf15 Binary files /dev/null and b/model/设计文档模型/界面类图6.png differ diff --git a/model/设计文档模型/界面类图7.png b/model/设计文档模型/界面类图7.png new file mode 100644 index 0000000..e16974c Binary files /dev/null and b/model/设计文档模型/界面类图7.png differ diff --git a/model/设计文档模型/界面类图8.jpg b/model/设计文档模型/界面类图8.jpg new file mode 100644 index 0000000..0d64b76 Binary files /dev/null and b/model/设计文档模型/界面类图8.jpg differ diff --git a/model/设计文档模型/设计类图.png b/model/设计文档模型/设计类图.png new file mode 100644 index 0000000..0a2e0c9 Binary files /dev/null and b/model/设计文档模型/设计类图.png differ diff --git a/model/设计文档模型/部署图.png b/model/设计文档模型/部署图.png new file mode 100644 index 0000000..a8d368f Binary files /dev/null and b/model/设计文档模型/部署图.png differ diff --git a/model/需求文档模型/分析类图.png b/model/需求文档模型/分析类图.png new file mode 100644 index 0000000..871df51 Binary files /dev/null and b/model/需求文档模型/分析类图.png differ diff --git a/model/需求文档模型/用例1.png b/model/需求文档模型/用例1.png new file mode 100644 index 0000000..eca1af2 Binary files /dev/null and b/model/需求文档模型/用例1.png differ diff --git a/model/需求文档模型/用例2.png b/model/需求文档模型/用例2.png new file mode 100644 index 0000000..c50d77f Binary files /dev/null and b/model/需求文档模型/用例2.png differ diff --git a/model/需求文档模型/用例3.png b/model/需求文档模型/用例3.png new file mode 100644 index 0000000..3f956d5 Binary files /dev/null and b/model/需求文档模型/用例3.png differ diff --git a/model/需求文档模型/用例4.png b/model/需求文档模型/用例4.png new file mode 100644 index 0000000..7e5eccc Binary files /dev/null and b/model/需求文档模型/用例4.png differ diff --git a/model/需求文档模型/用例5.png b/model/需求文档模型/用例5.png new file mode 100644 index 0000000..080448e Binary files /dev/null and b/model/需求文档模型/用例5.png differ diff --git a/model/需求文档模型/用例6.drawio.png b/model/需求文档模型/用例6.drawio.png new file mode 100644 index 0000000..705e1aa Binary files /dev/null and b/model/需求文档模型/用例6.drawio.png differ diff --git a/model/需求文档模型/用例7.png b/model/需求文档模型/用例7.png new file mode 100644 index 0000000..d4de720 Binary files /dev/null and b/model/需求文档模型/用例7.png differ diff --git a/model/需求文档模型/用例8.png b/model/需求文档模型/用例8.png new file mode 100644 index 0000000..61e91ba Binary files /dev/null and b/model/需求文档模型/用例8.png differ diff --git a/model/需求文档模型/用例9.png b/model/需求文档模型/用例9.png new file mode 100644 index 0000000..c84c364 Binary files /dev/null and b/model/需求文档模型/用例9.png differ diff --git a/model/需求文档模型/需求模型.drawio.png b/model/需求文档模型/需求模型.drawio.png new file mode 100644 index 0000000..6d67890 Binary files /dev/null and b/model/需求文档模型/需求模型.drawio.png differ diff --git a/other/05_软件工程课程设计汇报.pptx b/other/05_软件工程课程设计汇报.pptx new file mode 100644 index 0000000..ef994fb Binary files /dev/null and b/other/05_软件工程课程设计汇报.pptx differ diff --git a/other/06_软件开发项目的个人自评报告.xlsx b/other/06_软件开发项目的个人自评报告.xlsx new file mode 100644 index 0000000..fb45ff9 Binary files /dev/null and b/other/06_软件开发项目的个人自评报告.xlsx differ diff --git a/other/07_软件开发项目的团队自评报告.xlsx b/other/07_软件开发项目的团队自评报告.xlsx new file mode 100644 index 0000000..f84b9e3 Binary files /dev/null and b/other/07_软件开发项目的团队自评报告.xlsx differ diff --git a/other/08_230140025李子靖-实践总结报告.docx b/other/08_230140025李子靖-实践总结报告.docx new file mode 100644 index 0000000..2a69c35 Binary files /dev/null and b/other/08_230140025李子靖-实践总结报告.docx differ diff --git a/other/08_230140126曹志州-实践总结报告.docx b/other/08_230140126曹志州-实践总结报告.docx new file mode 100644 index 0000000..bf58251 Binary files /dev/null and b/other/08_230140126曹志州-实践总结报告.docx differ diff --git a/other/08_230340021李新-实践总结报告.docx b/other/08_230340021李新-实践总结报告.docx new file mode 100644 index 0000000..8964003 Binary files /dev/null and b/other/08_230340021李新-实践总结报告.docx differ diff --git a/other/08_230340025袁枫程羽-实践总结报告.docx b/other/08_230340025袁枫程羽-实践总结报告.docx new file mode 100644 index 0000000..486dc7a Binary files /dev/null and b/other/08_230340025袁枫程羽-实践总结报告.docx differ diff --git a/other/08_230340035魏子贺-实践总结报告.docx b/other/08_230340035魏子贺-实践总结报告.docx new file mode 100644 index 0000000..f022253 Binary files /dev/null and b/other/08_230340035魏子贺-实践总结报告.docx differ diff --git a/other/09_演示运行视频.mp4 b/other/09_演示运行视频.mp4 new file mode 100644 index 0000000..89a2b26 Binary files /dev/null and b/other/09_演示运行视频.mp4 differ diff --git a/other/10_项目宣传海报.png b/other/10_项目宣传海报.png new file mode 100644 index 0000000..96a7c77 Binary files /dev/null and b/other/10_项目宣传海报.png differ diff --git a/src/src/app/.gitignore b/src/src/app/.gitignore new file mode 100644 index 0000000..a221c8b --- /dev/null +++ b/src/src/app/.gitignore @@ -0,0 +1,39 @@ +# Dependencies +node_modules +/.pnp +.pnp.js + +# Testing +/coverage + +# Production +/dist +/build + +# Misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# TypeScript +*.tsbuildinfo + diff --git a/src/src/app/index.html b/src/src/app/index.html new file mode 100644 index 0000000..64f9b90 --- /dev/null +++ b/src/src/app/index.html @@ -0,0 +1,14 @@ + + + + + + + 校园食堂推荐 + + +
+ + + + diff --git a/src/src/app/package.json b/src/src/app/package.json new file mode 100644 index 0000000..dd3f542 --- /dev/null +++ b/src/src/app/package.json @@ -0,0 +1,30 @@ +{ + "name": "campus-canteen-app", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "lucide-react": "^0.294.0", + "echarts": "^5.4.3", + "echarts-for-react": "^3.0.2" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.2.2", + "vite": "^5.0.8" + } +} + diff --git a/src/src/app/src/App.tsx b/src/src/app/src/App.tsx new file mode 100644 index 0000000..d6881fb --- /dev/null +++ b/src/src/app/src/App.tsx @@ -0,0 +1,145 @@ +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' +import { useState, useEffect } from 'react' +import { getToken, removeToken } from './api/request' +import { getCurrentUser } from './api' + +// 页面组件 +import SplashPage from './pages/Splash' +import LoginPage from './pages/Login' +import RegisterPage from './pages/Register' +import HomePage from './pages/Home' +import MenuPage from './pages/Menu' +import PersonalFilePage from './pages/PersonalFile' +import ReportPage from './pages/Report' +import ProfilePage from './pages/Profile' +import ProfileEditPage from './pages/ProfileEdit' +import MealDetailPage from './pages/MealDetail' +import SearchPage from './pages/Search' +import FavoritesPage from './pages/Favorites' +import MealRecordsPage from './pages/MealRecords' +import ReviewsPage from './pages/Reviews' +import SettingsPage from './pages/Settings' +import NotFoundPage from './pages/NotFound' +import AddDishPage from './pages/AddDish' +import MyDishesPage from './pages/MyDishes' + +// 管理员页面 +import AdminLoginPage from './pages/AdminLogin' +import AdminDashboardPage from './pages/AdminDashboard' +import AdminUsersPage from './pages/AdminUsers' +import AdminDishesPage from './pages/AdminDishes' + +// Layout +import MainLayout from './components/Layout/MainLayout' +import AdminLayout from './components/Layout/AdminLayout' + +function App() { + const [isLoggedIn, setIsLoggedIn] = useState(false) + const [isChecking, setIsChecking] = useState(true) + const [showSplash, setShowSplash] = useState(true) + + useEffect(() => { + const timer = setTimeout(() => { + setShowSplash(false) + }, 2000) + return () => clearTimeout(timer) + }, []) + + useEffect(() => { + // 检查登录状态 + checkLoginStatus() + }, []) + + const checkLoginStatus = async () => { + try { + // 先检查本地是否有token + const token = getToken() + + // 如果没有token,直接设置为未登录状态 + if (!token) { + setIsLoggedIn(false) + setIsChecking(false) + return + } + + // 验证token是否有效 + const response = await getCurrentUser() + setIsLoggedIn(response.success) + // 同步更新本地存储的用户状态 + if (!response.success) { + removeToken() + } + } catch (error) { + console.log('当前未登录或token已过期,使用游客模式') + // 清除可能存在的无效token + removeToken() + setIsLoggedIn(false) + } finally { + setIsChecking(false) + } + } + + const handleLogin = () => { + setIsLoggedIn(true) + } + + const handleLogout = () => { + removeToken() + // 清除可能存在的管理员状态 + localStorage.removeItem('isAdmin') + setIsLoggedIn(false) + // 确保退出登录后立即跳转到登录页面,使用window.location.href进行完全刷新和跳转 + window.location.href = '/login' + } + + if (showSplash || isChecking) { + return + } + + return ( + + + : + } /> + } /> + + {/* 管理员登录(独立路由,不需要登录) */} + } /> + + {/* 管理员后台(使用独立的AdminLayout) */} + }> + } /> + } /> + } /> + + + {/* 需要登录的用户页面(使用MainLayout) */} + : + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + + ) +} + +export default App + diff --git a/src/src/app/src/api/admin.ts b/src/src/app/src/api/admin.ts new file mode 100644 index 0000000..2a93c0a --- /dev/null +++ b/src/src/app/src/api/admin.ts @@ -0,0 +1,94 @@ +import { get, post, put, del } from './request'; + +// ============= 待审核菜品管理 ============= + +// 获取待审核菜品列表 +export async function getPendingDishes(status = 'pending') { + return get(`/admin/pending-dishes?status=${status}`); +} + +// 审核通过菜品 +export async function approveDish(id: number) { + return put(`/admin/dishes/${id}/approve`, {}); +} + +// 拒绝菜品 +export async function rejectDish(id: number, reason: string) { + return put(`/admin/dishes/${id}/reject`, { reason }); +} + +// ============= 用户管理 ============= + +// 获取用户列表 +export async function getUsers(params?: { + page?: number; + limit?: number; + keyword?: string; +}) { + const query = new URLSearchParams(params as Record).toString(); + return get(`/admin/users?${query}`); +} + +// 更新用户信息 +export async function updateUser(id: number, data: { + role?: string; + name?: string; + phone?: string; + email?: string; +}) { + return put(`/admin/users/${id}`, data); +} + +// 重置用户密码 +export async function resetUserPassword(id: number, newPassword: string) { + return put(`/admin/users/${id}/reset-password`, { newPassword }); +} + +// ============= 菜品管理 ============= + +// 获取所有菜品列表 +export async function getAllDishes(params?: { + page?: number; + limit?: number; + keyword?: string; + approval_status?: string; +}) { + const query = new URLSearchParams(params as Record).toString(); + return get(`/admin/dishes?${query}`); +} + +// 更新菜品信息 +export async function updateDish(id: number, data: any) { + return put(`/admin/dishes/${id}`, data); +} + +// 删除菜品 +export async function deleteDish(id: number) { + return del(`/admin/dishes/${id}`); +} + +// 创建菜品 +export async function createDish(data: { + name: string; + description?: string; + category_id?: number | null; + price: number; + canteen_id?: number | null; + image?: string | null; + calories?: number | null; + protein?: number | null; + fat?: number | null; + carbs?: number | null; + tags?: string | null; + status?: string; +}) { + return post('/admin/dishes', data); +} + +// ============= 统计信息 ============= + +// 获取管理员仪表板统计信息 +export async function getDashboardStats() { + return get('/admin/dashboard/stats'); +} + diff --git a/src/src/app/src/api/auth.ts b/src/src/app/src/api/auth.ts new file mode 100644 index 0000000..855a259 --- /dev/null +++ b/src/src/app/src/api/auth.ts @@ -0,0 +1,40 @@ +import { get, post, setToken } from './request'; + +// 登录 +export async function login(username: string, password: string) { + const res = await post('/auth/login', { username, password }); + if (res.success && res.data.token) { + setToken(res.data.token); + } + return res; +} + +// 注册 +export async function register(userData: { + username: string; + password: string; + name: string; + phone: string; + email?: string; + // 健康档案信息 + gender?: string; + height?: number | null; + weight?: number | null; + age?: number | null; + healthGoal?: string; + activityLevel?: string; + dietaryPreferences?: string[]; + allergies?: string[]; +}) { + const res = await post('/auth/register', userData); + if (res.success && res.data.token) { + setToken(res.data.token); + } + return res; +} + +// 获取当前用户信息 +export function getCurrentUser() { + return get('/auth/me'); +} + diff --git a/src/src/app/src/api/banners.ts b/src/src/app/src/api/banners.ts new file mode 100644 index 0000000..492064b --- /dev/null +++ b/src/src/app/src/api/banners.ts @@ -0,0 +1,13 @@ +import { get } from './request'; + +// 获取轮播图 +export function getBanners() { + return get('/banners'); +} + + + + + + + diff --git a/src/src/app/src/api/canteens.ts b/src/src/app/src/api/canteens.ts new file mode 100644 index 0000000..61c2172 --- /dev/null +++ b/src/src/app/src/api/canteens.ts @@ -0,0 +1,18 @@ +import { get } from './request'; + +// 获取所有食堂 +export function getCanteens() { + return get('/canteens'); +} + +// 获取食堂详情 +export function getCanteenDetail(id: number | string) { + return get(`/canteens/${id}`); +} + + + + + + + diff --git a/src/src/app/src/api/categories.ts b/src/src/app/src/api/categories.ts new file mode 100644 index 0000000..96c4be5 --- /dev/null +++ b/src/src/app/src/api/categories.ts @@ -0,0 +1,13 @@ +import { get } from './request'; + +// 获取所有分类 +export function getCategories() { + return get('/categories'); +} + + + + + + + diff --git a/src/src/app/src/api/dishes.ts b/src/src/app/src/api/dishes.ts new file mode 100644 index 0000000..072bf02 --- /dev/null +++ b/src/src/app/src/api/dishes.ts @@ -0,0 +1,43 @@ +import { get } from './request'; + +// 获取菜品列表 +export function getDishes(params: { + category?: string; + canteen_id?: number; + search?: string; + limit?: number; + offset?: number; +} = {}) { + return get('/dishes', params); +} + +// 获取推荐菜品(基于健康档案的个性化推荐) +export function getRecommendedDishes(limit: number = 6, category?: string, random?: boolean) { + return get('/dishes/recommended', { + limit, + category, + random: random ? 'true' : undefined + }); +} + +// 获取热销榜单 +export function getHotDishes(limit: number = 5) { + return get('/dishes/hot', { limit }); +} + +// 获取菜品详情 +export function getDishDetail(id: number | string) { + return get(`/dishes/${id}`); +} + +// 搜索菜品 +export function searchDishes(keyword: string) { + return get(`/dishes/search/${keyword}`); +} + + + + + + + diff --git a/src/src/app/src/api/favorites.ts b/src/src/app/src/api/favorites.ts new file mode 100644 index 0000000..8b11caa --- /dev/null +++ b/src/src/app/src/api/favorites.ts @@ -0,0 +1,21 @@ +import { get, post, del } from './request'; + +// 获取收藏列表 +export async function getFavorites() { + return get('/favorites'); +} + +// 添加收藏 +export async function addFavorite(dishId: number) { + return post('/favorites', { dishId }); +} + +// 取消收藏 +export async function removeFavorite(dishId: number) { + return del(`/favorites/${dishId}`); +} + +// 检查是否已收藏 +export async function checkFavorite(dishId: number) { + return get(`/favorites/check/${dishId}`); +} diff --git a/src/src/app/src/api/health.ts b/src/src/app/src/api/health.ts new file mode 100644 index 0000000..751bc3c --- /dev/null +++ b/src/src/app/src/api/health.ts @@ -0,0 +1,81 @@ +import { get, post, put } from './request'; + +// 获取健康档案 +export function getHealthProfile() { + return get('/health/profile'); +} + +// 更新健康档案 +export function updateHealthProfile(data: { + height?: number; + weight?: number; + age?: number; + gender?: string; + allergies?: string[]; + chronic_diseases?: string[]; + dietary_preferences?: string[]; + target_calories?: number; + target_protein?: number; + target_fat?: number; + target_carbs?: number; +}) { + return put('/health/profile', data); +} + +// 获取营养报告列表 +export function getNutritionReports(params: { + start_date?: string; + end_date?: string; + limit?: number; + offset?: number; +} = {}) { + return get('/health/reports', params); +} + +// 获取营养报告详情 +export function getNutritionReportDetail(id: number | string) { + return get(`/health/reports/${id}`); +} + +// 获取今日营养摄入 +export function getTodayNutrition() { + return get('/health/today-nutrition'); +} + +// 记录用餐 +export const recordMeal = (data: { + dish_id: number + quantity?: number + meal_time?: string + meal_type?: string +}) => { + return post('/health/meal-records', data) +} + +// 获取用餐记录 +export const getMealRecords = (params: { + start_date?: string + end_date?: string + limit?: number + offset?: number +} = {}) => { + return get('/health/meal-records', params) +} + +// 获取周/月报告统计 +export const getPeriodReport = (type: 'week' | 'month') => { + return get(`/health/reports/period/${type}`) +} + +// 生成营养报告 +export const generateReport = (data: { + report_date?: string + report_type?: 'daily' | 'weekly' | 'monthly' +}) => { + return post('/health/reports/generate', data) +} + + + + + diff --git a/src/src/app/src/api/index.ts b/src/src/app/src/api/index.ts new file mode 100644 index 0000000..5563306 --- /dev/null +++ b/src/src/app/src/api/index.ts @@ -0,0 +1,18 @@ +// API统一导出 +export * from './auth'; +export * from './dishes'; +export * from './canteens'; +export * from './categories'; +export * from './banners'; +export * from './favorites'; +export * from './users'; +export * from './health'; +export * from './reviews'; +export * from './request'; + + + + + + + diff --git a/src/src/app/src/api/request.ts b/src/src/app/src/api/request.ts new file mode 100644 index 0000000..7767693 --- /dev/null +++ b/src/src/app/src/api/request.ts @@ -0,0 +1,144 @@ +// API请求基础配置 +const API_BASE_URL = 'http://localhost:3000/api'; + +// Token管理 +export const getToken = (): string | null => { + const token = localStorage.getItem('token'); + // 如果没有token,返回一个包含test_user的测试token用于开发测试 + if (!token) { + return 'test_user_token_for_development'; + } + return token; +}; + +export const setToken = (token: string): void => { + localStorage.setItem('token', token); +}; + +export const removeToken = (): void => { + localStorage.removeItem('token'); + localStorage.removeItem('isAdmin'); +}; + +// 获取是否为管理员 +export const isAdmin = (): boolean => { + return localStorage.getItem('isAdmin') === 'true'; +}; + +// 设置管理员状态 +export const setAdminStatus = (admin: boolean): void => { + localStorage.setItem('isAdmin', String(admin)); +}; + +// 通用请求方法 +async function request( + url: string, + options: RequestInit = {} +): Promise { + const token = getToken(); + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + try { + const response = await fetch(`${API_BASE_URL}${url}`, { + ...options, + headers, + }); + + const data = await response.json(); + + // 处理401错误(未授权)- 不要自动跳转,让调用方决定如何处理 + if (response.status === 401) { + // 只清除token,不跳转 + removeToken(); + // 返回友好的错误信息,让页面可以优雅处理 + return { + success: false, + message: '需要登录', + isAuthenticated: false + } as unknown as T; + } + + if (!response.ok) { + throw new Error(data.message || '请求失败'); + } + + return data; + } catch (error) { + console.log('API请求错误,但系统将继续运行:', error); + // 对于非关键请求,返回一个默认值而不是抛出错误 + if (options.method === 'GET') { + return { + success: false, + message: '获取数据失败,显示默认内容', + data: [] + } as unknown as T; + } + throw error; + } +} + +// GET请求 +export function get(url: string, params: Record = {}): Promise { + const queryString = new URLSearchParams( + Object.entries(params).reduce((acc, [key, value]) => { + if (value !== undefined && value !== null) { + acc[key] = String(value); + } + return acc; + }, {} as Record) + ).toString(); + + const fullUrl = queryString ? `${url}?${queryString}` : url; + + return request(fullUrl, { + method: 'GET', + }); +} + +// POST请求 +export function post(url: string, data: any = {}): Promise { + return request(url, { + method: 'POST', + body: JSON.stringify(data), + }); +} + +// PUT请求 +export function put(url: string, data: any = {}): Promise { + return request(url, { + method: 'PUT', + body: JSON.stringify(data), + }); +} + +// DELETE请求 +export function del(url: string): Promise { + return request(url, { + method: 'DELETE', + }); +} + +export default { + get, + post, + put, + del, + setToken, + removeToken, + getToken, +}; + + + + + + + diff --git a/src/src/app/src/api/reviews.ts b/src/src/app/src/api/reviews.ts new file mode 100644 index 0000000..aeae3b0 --- /dev/null +++ b/src/src/app/src/api/reviews.ts @@ -0,0 +1,26 @@ +import { get, post } from './request'; + +// 获取菜品评论 +export function getDishReviews(dish_id: number, params: { + limit?: number; + offset?: number; +} = {}) { + return get(`/reviews/dish/${dish_id}`, params); +} + +// 创建评论 +export function createReview(reviewData: { + dish_id: number; + rating: number; + comment?: string; + images?: string[]; +}) { + return post('/reviews', reviewData); +} + + + + + + + diff --git a/src/src/app/src/api/userDishes.ts b/src/src/app/src/api/userDishes.ts new file mode 100644 index 0000000..39d9732 --- /dev/null +++ b/src/src/app/src/api/userDishes.ts @@ -0,0 +1,40 @@ +import { get, post, del } from './request'; + +// 用户上传菜品数据类型 +export interface UserDishData { + name: string; + description?: string; + category?: string; + price?: number; + canteen_id?: number; + window_number?: string; + image_url?: string; + calories?: number; + protein?: number; + fat?: number; + carbs?: number; + ingredients?: string[] | string; + allergens?: string[] | string; + spicy_level?: number; +} + +// 获取用户上传的菜品列表 +export async function getUserDishes() { + return get('/user-dishes'); +} + +// 上传新菜品 +export async function uploadDish(dishData: UserDishData) { + return post('/user-dishes', dishData); +} + +// 获取单个上传菜品详情 +export async function getUserDishDetail(id: number) { + return get(`/user-dishes/${id}`); +} + +// 删除上传的菜品(仅限待审核状态) +export async function deleteUserDish(id: number) { + return del(`/user-dishes/${id}`); +} + diff --git a/src/src/app/src/api/users.ts b/src/src/app/src/api/users.ts new file mode 100644 index 0000000..92ce7bb --- /dev/null +++ b/src/src/app/src/api/users.ts @@ -0,0 +1,33 @@ +import { get, put } from './request'; + +// 获取用户资料 +export function getUserProfile() { + return get('/users/profile'); +} + +// 更新用户资料 +export function updateUserProfile(userData: { + nickname?: string; + avatar?: string; + phone?: string; + email?: string; + school?: string; + student_id?: string; + college?: string; + class_name?: string; + bio?: string; +}) { + return put('/users/profile', userData); +} + +// 获取用户统计数据 +export function getUserStats() { + return get('/users/stats'); +} + + + + + + + diff --git a/src/src/app/src/components/Common/Button.tsx b/src/src/app/src/components/Common/Button.tsx new file mode 100644 index 0000000..8613aed --- /dev/null +++ b/src/src/app/src/components/Common/Button.tsx @@ -0,0 +1,54 @@ +import { ReactNode } from 'react' + +interface ButtonProps { + children: ReactNode + onClick?: () => void + variant?: 'primary' | 'secondary' | 'outline' | 'ghost' + size?: 'sm' | 'md' | 'lg' + fullWidth?: boolean + disabled?: boolean + type?: 'button' | 'submit' | 'reset' + className?: string +} + +const Button = ({ + children, + onClick, + variant = 'primary', + size = 'md', + fullWidth = false, + disabled = false, + type = 'button', + className = '', +}: ButtonProps) => { + const baseClasses = 'rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed' + + const variantClasses = { + primary: 'bg-gradient-to-r from-primary to-pink-500 text-white hover:shadow-lg', + secondary: 'bg-secondary text-white hover:bg-secondary/90', + outline: 'border-2 border-primary text-primary hover:bg-primary hover:text-white', + ghost: 'text-gray-700 hover:bg-gray-100', + } + + const sizeClasses = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-6 py-2.5 text-base', + lg: 'px-8 py-3.5 text-lg', + } + + const widthClass = fullWidth ? 'w-full' : '' + + return ( + + ) +} + +export default Button + diff --git a/src/src/app/src/components/Common/EmptyState.tsx b/src/src/app/src/components/Common/EmptyState.tsx new file mode 100644 index 0000000..7ded9b7 --- /dev/null +++ b/src/src/app/src/components/Common/EmptyState.tsx @@ -0,0 +1,28 @@ +import { ReactNode } from 'react' +import Button from './Button' + +interface EmptyStateProps { + icon: ReactNode + title: string + description: string + actionText?: string + onAction?: () => void +} + +const EmptyState = ({ icon, title, description, actionText, onAction }: EmptyStateProps) => { + return ( +
+
{icon}
+

{title}

+

{description}

+ {actionText && onAction && ( + + )} +
+ ) +} + +export default EmptyState + diff --git a/src/src/app/src/components/Common/Input.tsx b/src/src/app/src/components/Common/Input.tsx new file mode 100644 index 0000000..d8ec633 --- /dev/null +++ b/src/src/app/src/components/Common/Input.tsx @@ -0,0 +1,57 @@ +import { ReactNode } from 'react' + +interface InputProps { + label?: string + type?: string + value: string + onChange: (value: string) => void + placeholder?: string + icon?: ReactNode + error?: string + required?: boolean + disabled?: boolean +} + +const Input = ({ + label, + type = 'text', + value, + onChange, + placeholder, + icon, + error, + required, + disabled, +}: InputProps) => { + return ( +
+ {label && ( + + )} +
+ {icon && ( +
+ {icon} +
+ )} + onChange(e.target.value)} + placeholder={placeholder} + disabled={disabled} + className={`w-full px-4 py-3 ${icon ? 'pl-11' : ''} border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all ${ + error ? 'border-red-500' : 'border-gray-300' + } ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}`} + /> +
+ {error &&

{error}

} +
+ ) +} + +export default Input + diff --git a/src/src/app/src/components/Common/MealCard.tsx b/src/src/app/src/components/Common/MealCard.tsx new file mode 100644 index 0000000..ff0778e --- /dev/null +++ b/src/src/app/src/components/Common/MealCard.tsx @@ -0,0 +1,99 @@ +import { Heart, MapPin, Star } from 'lucide-react' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' + +interface MealCardProps { + id: string + name: string + image: string + price: number + location: string + rating: number + calories: number + protein: number + isFavorite?: boolean + matchRate?: number + onToggleFavorite?: () => void + onMarkEaten?: () => void +} + +const MealCard = ({ + id, + name, + image, + price, + location, + rating, + calories, + protein, + isFavorite = false, + matchRate, + onToggleFavorite, + onMarkEaten, +}: MealCardProps) => { + const [favorite, setFavorite] = useState(isFavorite) + const navigate = useNavigate() + + const handleFavoriteClick = (e: React.MouseEvent) => { + e.stopPropagation() + setFavorite(!favorite) + onToggleFavorite?.() + } + + const handleCardClick = () => { + navigate(`/meal/${id}`) + } + + return ( +
+ {/* 图片区域 */} +
+ {name} + {matchRate && ( +
+ {matchRate}% 匹配 +
+ )} + +
+ + {/* 信息区域 */} +
+

{name}

+ +
+ + {location} +
+ +
+
+ + {rating} +
+ ¥{price} +
+ + {/* 营养信息 */} +
+ 热量: {calories}千卡 + 蛋白质: {protein}g +
+
+
+ ) +} + +export default MealCard + diff --git a/src/src/app/src/components/Layout/AdminLayout.tsx b/src/src/app/src/components/Layout/AdminLayout.tsx new file mode 100644 index 0000000..a2ba5da --- /dev/null +++ b/src/src/app/src/components/Layout/AdminLayout.tsx @@ -0,0 +1,200 @@ +import { useNavigate, Outlet, useLocation } from 'react-router-dom' +import { + LayoutDashboard, Users, UtensilsCrossed, + LogOut, Menu, X +} from 'lucide-react' +import { useState } from 'react' + +export default function AdminLayout() { + const navigate = useNavigate() + const location = useLocation() + const [sidebarOpen, setSidebarOpen] = useState(false) + + const handleLogout = () => { + if (confirm('确定要退出管理员后台吗?')) { + localStorage.removeItem('isAdmin') + localStorage.removeItem('token') + // 使用window.location.href进行完全刷新和跳转,确保直接返回管理员登录界面 + window.location.href = '/admin/login' + } + } + + const menuItems = [ + { + icon: LayoutDashboard, + label: '仪表板', + path: '/admin/dashboard', + description: '系统概览与统计' + }, + { + icon: UtensilsCrossed, + label: '菜品管理', + path: '/admin/dishes', + description: '管理所有菜品' + }, + { + icon: Users, + label: '用户管理', + path: '/admin/users', + description: '管理用户账号' + }, + ] + + const isActive = (path: string) => location.pathname === path + + return ( +
+ {/* 侧边栏 - 桌面版 */} + + + {/* 移动端侧边栏遮罩 */} + {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 ( +
+ {/* 顶部导航栏 */} +
+ +

+ + 上传菜品 +

+
+ + {/* 表单 */} +
+ {/* 基本信息 */} +
+

基本信息

+ + {/* 菜品名称 */} +
+ + +
+ + {/* 菜品描述 */} +
+ +