diff --git a/backend/prisma/prisma/dev.db b/backend/prisma/prisma/dev.db index cc34a11..be2cdc3 100644 Binary files a/backend/prisma/prisma/dev.db and b/backend/prisma/prisma/dev.db differ diff --git a/backend/src/routes/loans.js b/backend/src/routes/loans.js index 7ff20b6..f680f22 100644 --- a/backend/src/routes/loans.js +++ b/backend/src/routes/loans.js @@ -114,105 +114,48 @@ router.get('/books/search', requireAuth, async (req, res, next) => { }); // 3. 馆员借出图书给学生 -router.post('/lend', requireAuth, async (req, res, next) => { - try { - if (req.user.role !== 'LIBRARIAN' && req.user.role !== 'ADMIN') { - return res.status(403).json({ message: 'Access denied. Librarian or Admin only.' }); - } - - const { userId, bookId } = req.body; - if (!userId || !bookId) { - return res.status(400).json({ message: 'userId and bookId are required' }); - } - - // 查询学生 - const student = await prisma.user.findUnique({ - where: { id: parseInt(userId) } - }); - if (!student || student.role !== 'STUDENT') { - return res.status(404).json({ message: 'Student not found' }); - } - - // 查询图书 - const book = await prisma.book.findUnique({ - where: { id: parseInt(bookId) } - }); - if (!book) { - return res.status(404).json({ message: 'Book not found' }); - } - if (book.availableCopies <= 0) { - return res.status(400).json({ message: 'No available copies of this book' }); - } - - // 检查是否重复借阅同一本未还 - const existingLoan = await prisma.loan.findFirst({ - where: { - userId: student.id, - bookId: book.id, - returnDate: null - } - }); - if (existingLoan) { - return res.status(400).json({ message: 'Student already borrowed this book and not returned' }); - } +// backend/src/routes/loans.js - // 检查学生资格:借阅数量限制 - const currentCount = await getCurrentBorrowCount(student.id); - if (currentCount >= MAX_BORROW_LIMIT) { - return res.status(400).json({ message: `Student has already borrowed ${MAX_BORROW_LIMIT} books. Cannot lend more.` }); - } - // 检查逾期 - const hasOverdue = await hasOverdueLoans(student.id); - if (hasOverdue) { - return res.status(400).json({ message: 'Student has overdue books. Please return them first.' }); - } - - // 创建借阅记录 - const checkoutDate = new Date(); - const dueDate = new Date(); - dueDate.setDate(dueDate.getDate() + LOAN_DURATION_DAYS); - - const loan = await prisma.loan.create({ - data: { - userId: student.id, - bookId: book.id, - checkoutDate, - dueDate, - fineAmount: 0, - finePaid: false, - fineForgiven: false - } - }); - - // 减少图书可借副本数 - await prisma.book.update({ - where: { id: book.id }, - data: { availableCopies: { decrement: 1 } } - }); +router.post('/lend', requireAuth, async (req, res, next) => { + const { userId, bookId } = req.body; - // 审计日志 - await prisma.auditLog.create({ - data: { - userId: req.user.id, - action: 'LEND_BOOK', - entity: 'Loan', - entityId: loan.id, - detail: `Librarian ${req.user.email} lent "${book.title}" to student ${student.email}. Due date: ${dueDate.toISOString()}` - } + try { + // 使用 Prisma 事务:要么全部成功,要么全部失败 + const result = await prisma.$transaction(async (tx) => { + + // 1. 检查库存 + const book = await tx.book.findUnique({ where: { id: parseInt(bookId) } }); + if (!book) throw new Error('找不到该书籍'); + if (book.availableCopies <= 0) throw new Error('库存不足,无法借阅'); + + // 2. 检查该学生是否已经借过这本书还没还 + const existing = await tx.loan.findFirst({ + where: { userId: parseInt(userId), bookId: parseInt(bookId), returnDate: null } + }); + if (existing) throw new Error('该学生已借阅此书且尚未归还'); + + // 3. 创建借书记录 + const loan = await tx.loan.create({ + data: { + userId: parseInt(userId), + bookId: parseInt(bookId), + checkoutDate: new Date(), + dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30天期 + } + }); + + // 4. 【核心】自动减少库存 + await tx.book.update({ + where: { id: parseInt(bookId) }, + data: { availableCopies: { decrement: 1 } } // 自动减 1 + }); + + return loan; }); - res.status(201).json({ - message: 'Book lent successfully', - loan: { - id: loan.id, - bookTitle: book.title, - studentName: student.name, - checkoutDate, - dueDate - } - }); + res.json({ message: '借书成功', loan: result }); } catch (error) { - next(error); + res.status(400).json({ message: error.message }); } }); @@ -221,37 +164,41 @@ router.post('/lend', requireAuth, async (req, res, next) => { // 4. 获取当前登录用户的个人借阅历史 +// backend/src/routes/loans.js + router.get('/my-history', requireAuth, async (req, res, next) => { try { - // 从 requireAuth 中间件获取当前用户的 ID - const userId = req.user.id; + const userId = req.user.id; // 从中间件获取当前登录用户ID const history = await prisma.loan.findMany({ where: { userId: userId, }, include: { + // 关键点:这里决定了返回的数据里包含哪些书籍信息 book: { select: { title: true, author: true, isbn: true, genre: true, + totalCopies: true, // 书籍总馆藏数 + availableCopies: true, // 书籍当前可借数 }, }, }, orderBy: { - checkoutDate: 'desc', // 按借出时间降序排列 + checkoutDate: 'desc', }, }); - // 处理一下数据,增加一个状态字段方便前端显示 + // 处理状态逻辑(已归还/借阅中/逾期) const processedHistory = history.map(loan => { - let status = 'ON_LOAN'; // 借阅中 + let status = 'ON_LOAN'; if (loan.returnDate) { - status = 'RETURNED'; // 已归还 + status = 'RETURNED'; } else if (new Date(loan.dueDate) < new Date()) { - status = 'OVERDUE'; // 已逾期 + status = 'OVERDUE'; } return { @@ -265,7 +212,37 @@ router.get('/my-history', requireAuth, async (req, res, next) => { next(error); } }); +// 5.归还书籍接口 +router.post('/return/:loanId', requireAuth, async (req, res, next) => { + const { loanId } = req.params; + try { + const result = await prisma.$transaction(async (tx) => { + + // 1. 查找这条借书记录 + const loan = await tx.loan.findUnique({ where: { id: parseInt(loanId) } }); + if (!loan) throw new Error('找不到借阅记录'); + if (loan.returnDate) throw new Error('此书已在之前归还'); + + // 2. 更新归还日期 + const updatedLoan = await tx.loan.update({ + where: { id: parseInt(loanId) }, + data: { returnDate: new Date() } + }); + + // 3. 【核心】自动恢复库存 + await tx.book.update({ + where: { id: loan.bookId }, + data: { availableCopies: { increment: 1 } } // 自动加 1 + }); + + return updatedLoan; + }); + res.json({ message: '归还成功,库存已恢复', loan: result }); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}); module.exports = router; \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1a5c562..5e1f0fa 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,12 @@ +import { useState } from 'react'; import MyHistory from './reader/MyHistory'; -import BookSearch from './pages/BookSearch'; // 保留搜索组件 +import BookSearch from './pages/BookSearch'; // 队友的组件 import './App.css'; function App() { + // 定义一个状态,用来记录当前停留在哪个页面,默认显示你的 "history" + const [activeTab, setActiveTab] = useState('history'); + return (
@@ -15,26 +19,33 @@ function App() {
- 图书检索 - 我的借阅 - 个人中心 + {/* 点击时,把状态设置为 search */} + setActiveTab('search')} + className={`cursor-pointer transition-colors pb-1 ${activeTab === 'search' ? 'border-b-2 border-white' : 'hover:text-blue-200'}`} + > + 图书检索 + + + {/* 点击时,把状态设置为 history */} + setActiveTab('history')} + className={`cursor-pointer transition-colors pb-1 ${activeTab === 'history' ? 'border-b-2 border-white' : 'hover:text-blue-200'}`} + > + 我的借阅 + + + + 个人中心 +
- {/* 主体内容区域 - 把两个功能都放进去 */} -
- - {/* 1. 图书搜索功能 */} -
- -
- - {/* 2. 借阅历史功能 */} -
- -
- + {/* 主体内容区域:根据 activeTab 的值,决定显示哪个组件 */} +
+ {activeTab === 'search' && } + {activeTab === 'history' && }
{/* 页脚 */} diff --git a/frontend/src/reader/MyHistory.jsx b/frontend/src/reader/MyHistory.jsx index 473ce99..00f6927 100644 --- a/frontend/src/reader/MyHistory.jsx +++ b/frontend/src/reader/MyHistory.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -// 引入 shadcn/ui 组件 (确保之前运行过 npx shadcn@latest add table card badge) +// 引入 shadcn/ui 组件 (已修正路径并删除重复导入) import { Table, TableBody, @@ -7,9 +7,10 @@ import { TableHead, TableHeader, TableRow, -} from "@/components/ui/table"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; +} from "../components/ui/table"; +import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; +import { Badge } from "../components/ui/badge"; +// 补全漏掉的图标导入 import { Loader2, BookOpen, AlertCircle } from "lucide-react"; const MyHistory = () => { @@ -21,7 +22,6 @@ const MyHistory = () => { const fetchHistory = async () => { try { setLoading(true); - // 获取 Token (假设存放在 localStorage) const token = localStorage.getItem('token'); const response = await fetch('http://localhost:3001/api/loans/my-history', { @@ -100,7 +100,7 @@ const MyHistory = () => { - 图书详情 + 图书详情 借阅日期 应还期限 归还日期 @@ -112,9 +112,24 @@ const MyHistory = () => { loans.map((loan) => ( -
- {loan.book.title} - {loan.book.author} | ISBN: {loan.book.isbn} +
+ + {loan.book.title} + + + {loan.book.author} | ISBN: {loan.book.isbn} + + +
+ + 馆藏剩余: {loan.book.availableCopies ?? '加载中'} / {loan.book.totalCopies ?? '-'} + + {loan.book.availableCopies === 0 && ( + + 已罄 + + )} +
diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 9930e28..3caf3f9 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,10 +1,21 @@ +// frontend/vite.config.js import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -import tailwindcss from '@tailwindcss/vite' +// 新增:引入 node 的 path 模块 +import path from 'path' +import { fileURLToPath } from 'url' +// "type": "module",需要这样获取 __dirname +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// https://vite.dev/config/ export default defineConfig({ - plugins: [ - react(), - tailwindcss(), - ], + plugins: [react()], + // --- 新增:配置路径别名 --- + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, }) \ No newline at end of file