Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified backend/prisma/prisma/dev.db
Binary file not shown.
179 changes: 78 additions & 101 deletions backend/src/routes/loans.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});

Expand All @@ -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 {
Expand All @@ -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;
45 changes: 28 additions & 17 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen bg-slate-100">

Expand All @@ -15,26 +19,33 @@ function App() {
</div>

<div className="flex space-x-6 text-sm font-medium">
<span className="cursor-pointer hover:text-blue-200 transition-colors">图书检索</span>
<span className="cursor-pointer border-b-2 border-white pb-1">我的借阅</span>
<span className="cursor-pointer hover:text-blue-200 transition-colors">个人中心</span>
{/* 点击时,把状态设置为 search */}
<span
onClick={() => setActiveTab('search')}
className={`cursor-pointer transition-colors pb-1 ${activeTab === 'search' ? 'border-b-2 border-white' : 'hover:text-blue-200'}`}
>
图书检索
</span>

{/* 点击时,把状态设置为 history */}
<span
onClick={() => setActiveTab('history')}
className={`cursor-pointer transition-colors pb-1 ${activeTab === 'history' ? 'border-b-2 border-white' : 'hover:text-blue-200'}`}
>
我的借阅
</span>

<span className="cursor-pointer hover:text-blue-200 transition-colors">
个人中心
</span>
</div>
</div>
</nav>

{/* 主体内容区域 - 把两个功能都放进去 */}
<main className="container mx-auto px-4 max-w-5xl space-y-12">

{/* 1. 图书搜索功能 */}
<section>
<BookSearch />
</section>

{/* 2. 借阅历史功能 */}
<section>
<MyHistory />
</section>

{/* 主体内容区域:根据 activeTab 的值,决定显示哪个组件 */}
<main className="container mx-auto px-4 max-w-5xl">
{activeTab === 'search' && <BookSearch />}
{activeTab === 'history' && <MyHistory />}
</main>

{/* 页脚 */}
Expand Down
33 changes: 24 additions & 9 deletions frontend/src/reader/MyHistory.jsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import React, { useEffect, useState } from 'react';
// 引入 shadcn/ui 组件 (确保之前运行过 npx shadcn@latest add table card badge)
// 引入 shadcn/ui 组件 (已修正路径并删除重复导入)
import {
Table,
TableBody,
TableCell,
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 = () => {
Expand All @@ -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', {
Expand Down Expand Up @@ -100,7 +100,7 @@ const MyHistory = () => {
<Table>
<TableHeader className="bg-slate-50">
<TableRow>
<TableHead className="w-[30%]">图书详情</TableHead>
<TableHead className="w-[35%]">图书详情</TableHead>
<TableHead>借阅日期</TableHead>
<TableHead>应还期限</TableHead>
<TableHead>归还日期</TableHead>
Expand All @@ -112,9 +112,24 @@ const MyHistory = () => {
loans.map((loan) => (
<TableRow key={loan.id} className="hover:bg-slate-50/50 transition-colors">
<TableCell>
<div className="flex flex-col">
<span className="font-semibold text-slate-900">{loan.book.title}</span>
<span className="text-xs text-slate-500">{loan.book.author} | ISBN: {loan.book.isbn}</span>
<div className="flex flex-col gap-1">
<span className="font-semibold text-slate-900 leading-tight">
{loan.book.title}
</span>
<span className="text-xs text-slate-500">
{loan.book.author} | ISBN: {loan.book.isbn}
</span>

<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] bg-slate-100 px-2 py-0.5 rounded border border-slate-200 text-slate-500 font-medium">
馆藏剩余: {loan.book.availableCopies ?? '加载中'} / {loan.book.totalCopies ?? '-'}
</span>
{loan.book.availableCopies === 0 && (
<span className="text-[10px] text-red-500 font-bold">
已罄
</span>
)}
</div>
</div>
</TableCell>
<TableCell className="text-sm text-slate-600">
Expand Down
21 changes: 16 additions & 5 deletions frontend/vite.config.js
Original file line number Diff line number Diff line change
@@ -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"),
},
},
})