From b6e96f51d47bc2c78440799db516881eb94370ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E4=B8=BD=E5=A8=9C?= <2464268402@qq.com> Date: Mon, 8 Jun 2026 19:39:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9B=BE=E4=B9=A6?= =?UTF-8?q?=E9=A2=84=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/prisma/prisma/dev.db | Bin 139264 -> 139264 bytes backend/prisma/schema.prisma | 183 ++++++-- backend/src/index.js | 19 +- backend/src/routes/reader-borrow.js | 191 +++++++-- backend/src/services/expireReservations.js | 77 ++++ frontend/src/App.jsx | 28 +- frontend/src/components/ReaderLayout.jsx | 463 +++++++++++++++++---- frontend/src/pages/BookSearch.jsx | 39 +- frontend/src/pages/HomePage.jsx | 16 +- frontend/src/pages/SearchPage.jsx | 38 +- frontend/src/reader/MyHistory.jsx | 21 +- frontend/src/reader/MyReservations.jsx | 93 +++++ 12 files changed, 963 insertions(+), 205 deletions(-) create mode 100644 backend/src/services/expireReservations.js create mode 100644 frontend/src/reader/MyReservations.jsx diff --git a/backend/prisma/prisma/dev.db b/backend/prisma/prisma/dev.db index 2ecea57bdbe97877d0766945ed7329b3ae68e40f..8bf089a16028dc37b7edd29cbb7bd4ddfd7ae7a6 100644 GIT binary patch delta 1301 zcma)*Z)h839LMiza?M?udw$(&7<1O9QW)-b$=#)Cb48^|layfB*feo%oUpdZwivsl z>6)%@+8xdpz8FmFI0iCbY$Q(hmxw8pN!M3`44Ehh;+wj;A#Q^qXqj$4$<;L7`ocHQ z-NX0!JfGk5yL*l;F~^pelh4=HGatU-CF9lo=Nx8?7byHIUcgI5v-ml-nhZ@y>Xxv@ z&`h2a6ZWz6I9I&!TA>m7=chtl2+v;$^`SuVpOERK4Z|!2f5Vq>6)wODn1$o;09N1! z_!<`B6ZojOB@+Xn4S*!f7_3NkiV`<};=wc_&zH$R;UV0EyYLHKg9RwUB{&a1!VOp^ z9@5oU6k3BG+L$`mQBfqCUb%0b?wV&H|?kkyCb!kc3D7z zrBL_H+bZ9)k(W9IXQ$wDxi{gfna<}Q-hD;oy-)F^1X*x9H}jWEsAOyWea|hG_dLT( z?Sk0RzL{SV^lXj4_tzRT@+-ubA#KW_g>Gmivn_3 z3iZ3MtGxdyx-H=23Qq&9Pg_UvrT*>W8hldW0xikJ;B$!HuJg&q6hcYM)Mxs7{e*~S$Kf1*0<2xL2 zG^@0A-J##GHhG|8O700vzBxRTc6r29d~Yxtn^vX*A$e*$$^hBbG!Tu zg|=CdgT{TK-e7-pGLgl%FXr!j{Hcwc-FD~n;_Yjv(3t`~+p){{|JLRo-TF-KA#0nh zNTG4q-y0l^_`{X0!-d)9n>>=pCgS!=XJm(07brAWErzQWqc^EqQGRvq lr%sP<>7lt=3s<$QF=f@dX*K$l(#=rpHY-~?x4mv({l8a4dYAwJ delta 638 zcmZoTz|nAkV}cYD>*t9wPC#;F!V-Np9=@{-e9t!X8LZ-)xIl9AS-a%|j1H4a{jC^z zCU5ZfVYJ`;*Pnd?9}j;o1OFfXZTuVg*YNjl7JHDvKXHNZii zf5HEle=q+I{>_`^K6LX>4B*?m`M;lnJXZt*7kefr=VFd`99I0^eA9Rz^6K!Eb01>Q z1j3fhiUOYO(=*i>FS0ZmTJTIa&}Ee4Hq|w?&^58pH8wV${$88WTM$VgN-rgeiD8;9 zqXzrACt{6;=E!;s%ymr-&5-m!1Q2>S&bcg8Y&0~3Ycep@H89mRG&O>0f(syNn#IF& zf`NfC6XH4(T~jk%6GL4~3#d&<0!W&saqxRFFfeR^YckR`G}g7SfO-k22_}Hh#60oC z(d`Gd8M7IIpgZqbG=E4V18gNN^e$0mDkyz{mjP3txUX z7hy8TrNE}l^AQ7oGv7bHO5QKL z`8=<9vbo=I=W^ZUO5%L9SyAB-=k`Z48I{VI1vuwSXBS{n1d5e#Zl5E`l)?r81pu+i diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c9c9f6b..e77b82c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -7,33 +7,15 @@ datasource db { url = "file:./prisma/dev.db" } -enum Role { - STUDENT - LIBRARIAN - ADMIN -} - -enum HoldStatus { - WAITING - READY - CANCELLED -} - -enum CopyStatus { - AVAILABLE - BORROWED - LOST - DAMAGED -} - +<<<<<<< HEAD model User { id Int @id @default(autoincrement()) name String email String @unique passwordHash String studentId String? @unique - employeeId String? @unique // 馆员工号(你的功能需要) - role Role @default(STUDENT) + employeeId String? @unique + role String @default("STUDENT") isBlocked Boolean @default(false) blockReason String? blockedAt DateTime? @@ -46,9 +28,10 @@ model User { auditLogs AuditLog[] dueReminderLogs DueReminderLog[] announcementPublishers AnnouncementPublisher[] - sentMessages Message[] @relation("MessageSender") // 站内信(你的功能需要) - receivedMessages Message[] @relation("MessageReceiver") // 站内信(你的功能需要) + sentMessages Message[] @relation("MessageSender") + receivedMessages Message[] @relation("MessageReceiver") reminderLogs ReminderLog[] + reservations Reservation[] } model Book { @@ -69,38 +52,100 @@ model Book { } model Copy { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) bookId Int - book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) - barcode String @unique + book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) + barcode String @unique floor Int? libraryArea String? shelfNo String? shelfLevel Int? - status CopyStatus @default(AVAILABLE) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + status String @default("AVAILABLE") // AVAILABLE, BORROWED, LOST, DAMAGED, RESERVED + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt loans Loan[] + reservations Reservation[] +======= +enum Role { + STUDENT + LIBRARIAN + ADMIN +} + +enum HoldStatus { + WAITING + READY + CANCELLED +} + +model User { + id Int @id @default(autoincrement()) + name String + email String @unique + passwordHash String + studentId String? @unique + role Role @default(STUDENT) + createdAt DateTime @default(now()) + loans Loan[] + ratings Rating[] + holds Hold[] + wishlists Wishlist[] + auditLogs AuditLog[] +} + +model Book { + id Int @id @default(autoincrement()) + title String + author String + isbn String @unique + genre String + description String? + language String @default("English") + shelfLocation String? + available Boolean @default(true) // 保留兼容 + totalCopies Int @default(1) // 新增 + availableCopies Int @default(1) // 新增 + createdAt DateTime @default(now()) + loans Loan[] + ratings Rating[] + holds Hold[] + wishlists Wishlist[] +>>>>>>> af9ecfbeebfa89b807d4957f9b88257908c13b6b } model Loan { id Int @id @default(autoincrement()) +<<<<<<< HEAD copyId Int userId Int - barcode String @unique // 借阅条形码 BC-xxxxxx-xxx + barcode String @unique checkoutDate DateTime @default(now()) dueDate DateTime returnDate DateTime? fineAmount Float @default(0) finePaid Boolean @default(false) fineForgiven Boolean @default(false) - renewCount Int @default(0) // 续借次数(你的功能需要) + renewCount Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt copy Copy @relation(fields: [copyId], references: [id]) user User @relation(fields: [userId], references: [id]) reminderLogs ReminderLog[] dueReminderLogs DueReminderLog[] +======= + bookId Int + userId Int + checkoutDate DateTime @default(now()) + dueDate DateTime + returnDate DateTime? + fineAmount Float? @default(0) + finePaid Boolean? @default(false) + fineForgiven Boolean? @default(false) + renewCount Int @default(0) + createdAt DateTime @default(now()) + book Book @relation(fields: [bookId], references: [id]) + user User @relation(fields: [userId], references: [id]) +>>>>>>> af9ecfbeebfa89b807d4957f9b88257908c13b6b } model Rating { @@ -108,23 +153,38 @@ model Rating { bookId Int userId Int stars Int @default(1) - review String? // 评论内容(你的功能需要) +<<<<<<< HEAD + review String? createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt // 评论更新时间(你的功能需要) + updatedAt DateTime @updatedAt book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) +======= + createdAt DateTime @default(now()) + book Book @relation(fields: [bookId], references: [id]) +>>>>>>> af9ecfbeebfa89b807d4957f9b88257908c13b6b user User @relation(fields: [userId], references: [id]) @@unique([bookId, userId]) } model Hold { +<<<<<<< HEAD + id Int @id @default(autoincrement()) + bookId Int + userId Int + status String @default("WAITING") // WAITING, READY, CANCELLED + createdAt DateTime @default(now()) + book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) +======= id Int @id @default(autoincrement()) bookId Int userId Int status HoldStatus @default(WAITING) createdAt DateTime @default(now()) - book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) + book Book @relation(fields: [bookId], references: [id]) user User @relation(fields: [userId], references: [id]) +>>>>>>> af9ecfbeebfa89b807d4957f9b88257908c13b6b } model Wishlist { @@ -132,18 +192,28 @@ model Wishlist { bookId Int userId Int createdAt DateTime @default(now()) +<<<<<<< HEAD book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) +======= + book Book @relation(fields: [bookId], references: [id]) +>>>>>>> af9ecfbeebfa89b807d4957f9b88257908c13b6b user User @relation(fields: [userId], references: [id]) @@unique([bookId, userId]) } model Config { +<<<<<<< HEAD id Int @id @default(autoincrement()) key String @unique value String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt +======= + id Int @id @default(autoincrement()) + key String @unique + value String +>>>>>>> af9ecfbeebfa89b807d4957f9b88257908c13b6b } model AuditLog { @@ -156,6 +226,7 @@ model AuditLog { createdAt DateTime @default(now()) user User? @relation(fields: [userId], references: [id]) } +<<<<<<< HEAD model Announcement { id Int @id @default(autoincrement()) @@ -196,24 +267,23 @@ model BackupLog { filename String filepath String sizeBytes BigInt - status String @default("completed") // completed, failed, restored - type String @default("manual") // manual, scheduled + status String @default("completed") + type String @default("manual") note String? createdAt DateTime @default(now()) restoredAt DateTime? - createdBy Int? // 管理员 userId + createdBy Int? } -// 图书到期提醒日志模型 model ReminderLog { id Int @id @default(autoincrement()) userId Int loanId Int - bookTitle String // 图书名称 - dueDate DateTime // 图书到期日期 - sendStatus String @default("success") // success, failed + bookTitle String + dueDate DateTime + sendStatus String @default("success") sendTime DateTime @default(now()) - errorMessage String? // 如果发送失败,记录错误信息 + errorMessage String? createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) loan Loan @relation(fields: [loanId], references: [id], onDelete: Cascade) @@ -232,4 +302,33 @@ model DueReminderLog { loan Loan @relation(fields: [loanId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id]) book Book @relation(fields: [bookId], references: [id]) +} + +// 新增:预约模型 +model Reservation { + id Int @id @default(autoincrement()) + copyId Int + userId Int + status String @default("PENDING") // PENDING, CONFIRMED, EXPIRED, CANCELLED + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + copy Copy @relation(fields: [copyId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@index([copyId]) + @@index([userId]) + @@index([status, expiresAt]) +======= +model Librarian { + id Int @id @default(autoincrement()) + employeeId String @unique @map("employee_id") + name String + password String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("librarians") +>>>>>>> af9ecfbeebfa89b807d4957f9b88257908c13b6b } \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js index 09a4250..f27387a 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -27,13 +27,14 @@ const blocklistRouter = require("./routes/blocklist"); const remindersRouter = require('./routes/reminders'); // 图书到期提醒路由 const backupService = require("./services/backup"); const { runDueReminderJob, getNextDueReminderTime } = require('./services/dueReminder'); +const { expireReservations } = require('./services/expireReservations'); // 新增:预约过期清理 const app = express(); const port = Number(process.env.PORT) || 3001; // 必须的中间件 app.use(cors()); -app.use(express.json({ limit: '10mb' })); +app.use(express.json()); app.use(express.urlencoded({ extended: false })); // 健康检查 @@ -177,6 +178,19 @@ async function startServer() { startReminderScheduler(); // 启动图书到期提醒定时任务 startDueReminderScheduler(); + // ========== 新增:预约过期清理定时任务(每5分钟执行一次) ========== + cron.schedule('*/5 * * * *', async () => { + try { + const result = await expireReservations(); + if (result.processed > 0) { + console.log(`[${new Date().toISOString()}] 📅 预约过期清理: 处理 ${result.processed} 个,释放 ${result.released} 个副本`); + } + } catch (error) { + console.error(`[${new Date().toISOString()}] ❌ 预约过期清理失败:`, error.message); + } + }); + console.log('⏰ 预约过期清理定时任务已启动(每5分钟执行一次)'); + app.listen(port, () => { console.log(` ╔═══════════════════════════════════════════════════════╗ @@ -190,6 +204,7 @@ async function startServer() { ║ 💾 Backup endpoints: /api/backups/* ║ ║ ⏰ Auto backup every ${BACKUP_INTERVAL_MS / 3600000} hours ║ ║ 📧 Reminder endpoints: /api/librarian/reminders/* ║ +║ 📅 Reservation expiry cleanup: every 5 minutes ║ ╚═══════════════════════════════════════════════════════╝ `); }); @@ -224,4 +239,4 @@ process.on('SIGTERM', () => { void shutdown('SIGTERM'); }); -startServer(); +startServer(); \ No newline at end of file diff --git a/backend/src/routes/reader-borrow.js b/backend/src/routes/reader-borrow.js index a8ca704..c509e81 100644 --- a/backend/src/routes/reader-borrow.js +++ b/backend/src/routes/reader-borrow.js @@ -250,6 +250,8 @@ async function handlePayFine(req, res) { } } +// ==================== 原有功能(保留) ==================== + // 获取我的借阅列表(包括已归还和未归还) router.get('/my-borrows', requireAuth, async (req, res) => { try { @@ -297,15 +299,17 @@ router.get('/available-copies/:bookId', requireAuth, async (req, res) => { } }); -// 借阅图书(选择具体副本) +// ==================== 修改:借阅改为预约 ==================== +// 预约图书(原来的直接借阅改为预约,2小时内到馆确认) router.post('/borrow/:copyId', requireAuth, async (req, res) => { if (req.user.isBlocked) { - return res.status(403).json({ message: `您的账号已被封禁,无法借阅书籍。封禁原因:${req.user.blockReason || '违反图书馆相关规定'}` }); + return res.status(403).json({ message: `您的账号已被封禁,无法预约书籍。封禁原因:${req.user.blockReason || '违反图书馆相关规定'}` }); } try { const copyId = parseInt(req.params.copyId); + // 1. 检查副本是否存在且可预约 const copy = await prisma.copy.findUnique({ where: { id: copyId }, include: { book: true } @@ -316,16 +320,31 @@ router.post('/borrow/:copyId', requireAuth, async (req, res) => { } if (copy.status !== 'AVAILABLE') { - return res.status(400).json({ message: '该副本不可借' }); + return res.status(400).json({ message: '该副本不可预约(已被借出或损坏)' }); + } + + // 2. 检查用户是否有未完成的预约(防止重复预约同一本书) + const existingReservation = await prisma.reservation.findFirst({ + where: { + userId: req.user.id, + copyId: copyId, + status: 'PENDING' + } + }); + + if (existingReservation) { + return res.status(400).json({ message: '您已预约过这本书,请尽快到图书馆借出' }); } - const currentCount = await prisma.loan.count({ + // 3. 检查用户当前借阅数量限制 + const currentBorrowCount = await prisma.loan.count({ where: { userId: req.user.id, returnDate: null } }); - if (currentCount >= MAX_BORROW_LIMIT) { - return res.status(400).json({ message: `最多同时借阅${MAX_BORROW_LIMIT}本书` }); + if (currentBorrowCount >= MAX_BORROW_LIMIT) { + return res.status(400).json({ message: `您当前借阅数量已达上限(${MAX_BORROW_LIMIT}本),请先归还部分图书` }); } + // 4. 检查是否已借阅过这本书(未归还) const existingLoan = await prisma.loan.findFirst({ where: { userId: req.user.id, @@ -337,58 +356,152 @@ router.post('/borrow/:copyId', requireAuth, async (req, res) => { return res.status(400).json({ message: '您已借阅过这本书,请先归还' }); } - const dueDate = new Date(); - dueDate.setDate(dueDate.getDate() + 14); + // 5. 创建预约记录,并临时锁定副本状态 + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 2); // 2小时后过期 - const barcode = await generateUniqueBarcode(); - - const loan = await prisma.loan.create({ - data: { - copyId: copyId, + // 使用事务:创建预约 + 更新副本状态为 RESERVED + const reservation = await prisma.$transaction(async (tx) => { + // 再次确认副本仍然可用(防止并发) + const currentCopy = await tx.copy.findUnique({ + where: { id: copyId } + }); + + if (currentCopy.status !== 'AVAILABLE') { + throw new Error('该副本已被他人预约或借出'); + } + + // 更新副本状态为预约锁定 + await tx.copy.update({ + where: { id: copyId }, + data: { status: 'RESERVED' } + }); + + // 创建预约记录 + return tx.reservation.create({ + data: { + copyId: copyId, + userId: req.user.id, + expiresAt: expiresAt, + status: 'PENDING' + } + }); + }); + + writeAuditLog({ + userId: req.user.id, + action: 'RESERVE_BOOK', + entity: 'Reservation', + entityId: reservation.id, + detail: `读者 ${req.user.email} 预约了《${copy.book.title}》(副本 ${copyId}),有效期至 ${expiresAt.toISOString()}`, + }); + + res.status(201).json({ + success: true, + message: `预约成功!请在2小时内到图书馆借出《${copy.book.title}》,否则预约将自动失效。`, + reservation: { + id: reservation.id, + bookTitle: copy.book.title, + copyBarcode: copy.barcode, + expiresAt: expiresAt, + expiresInMinutes: 120 + } + }); + } catch (error) { + console.error('预约失败:', error); + res.status(500).json({ message: error.message || '预约失败' }); + } +}); + +// ==================== 新增:预约相关接口 ==================== + +// 获取我的预约列表 +router.get('/my-reservations', requireAuth, async (req, res) => { + try { + const reservations = await prisma.reservation.findMany({ + where: { userId: req.user.id, - barcode, - dueDate: dueDate, - fineAmount: 0, - finePaid: false, - fineForgiven: false, - renewCount: 0 + status: { in: ['PENDING', 'EXPIRED', 'CANCELLED'] } }, include: { copy: { include: { book: true } } - } + }, + orderBy: { createdAt: 'desc' } }); - await prisma.copy.update({ - where: { id: copyId }, - data: { status: 'BORROWED' } + const now = new Date(); + const formattedReservations = reservations.map(res => ({ + id: res.id, + bookTitle: res.copy.book.title, + bookAuthor: res.copy.book.author, + copyBarcode: res.copy.barcode, + status: res.status, + expiresAt: res.expiresAt, + isExpired: res.status === 'PENDING' && new Date(res.expiresAt) < now, + createdAt: res.createdAt + })); + + res.json({ success: true, reservations: formattedReservations }); + } catch (error) { + console.error('获取预约列表失败:', error); + res.status(500).json({ success: false, message: '获取预约列表失败' }); + } +}); + +// 取消预约 +router.post('/cancel-reservation/:reservationId', requireAuth, async (req, res) => { + try { + const reservationId = parseInt(req.params.reservationId); + + const reservation = await prisma.reservation.findUnique({ + where: { id: reservationId }, + include: { copy: true, user: true } + }); + + if (!reservation) { + return res.status(404).json({ success: false, message: '预约记录不存在' }); + } + + if (reservation.userId !== req.user.id) { + return res.status(403).json({ success: false, message: '无权取消此预约' }); + } + + if (reservation.status !== 'PENDING') { + return res.status(400).json({ success: false, message: `预约状态为 ${reservation.status},无法取消` }); + } + + // 使用事务:更新预约状态 + 释放副本 + await prisma.$transaction(async (tx) => { + await tx.reservation.update({ + where: { id: reservationId }, + data: { status: 'CANCELLED' } + }); + + await tx.copy.update({ + where: { id: reservation.copyId }, + data: { status: 'AVAILABLE' } + }); }); writeAuditLog({ userId: req.user.id, - action: 'BORROW_BOOK', - entity: 'Loan', - entityId: loan.id, - detail: `读者 ${req.user.email} 自助借阅《${loan.copy.book.title}》(副本 ${copyId})`, + action: 'CANCEL_RESERVATION', + entity: 'Reservation', + entityId: reservationId, + detail: `读者 ${req.user.email} 取消了预约`, }); - res.status(201).json({ - message: '借阅成功', - loan: { - id: loan.id, - barcode: loan.barcode, - bookTitle: loan.copy.book.title, - copyBarcode: loan.copy.barcode, - dueDate: loan.dueDate - } - }); + res.json({ success: true, message: '预约已取消,库存已释放' }); } catch (error) { - console.error(error); - res.status(500).json({ message: '借阅失败' }); + console.error('取消预约失败:', error); + res.status(500).json({ success: false, message: '取消预约失败' }); } }); +// ==================== 原有功能(保留) ==================== + // 续借图书 - 使用 copyId router.post('/renew', requireAuth, async (req, res) => { if (req.user.isBlocked) { diff --git a/backend/src/services/expireReservations.js b/backend/src/services/expireReservations.js new file mode 100644 index 0000000..c28a048 --- /dev/null +++ b/backend/src/services/expireReservations.js @@ -0,0 +1,77 @@ +// backend/src/services/expireReservations.js +const prisma = require('../lib/prisma'); + +/** + * 扫描并释放过期的预约 + * 将过期的 PENDING 状态预约标记为 EXPIRED,并释放对应的副本库存 + */ +async function expireReservations() { + const now = new Date(); + + console.log(`[${new Date().toISOString()}] 开始扫描过期预约...`); + + try { + // 查找所有过期的 PENDING 预约 + const expiredReservations = await prisma.reservation.findMany({ + where: { + status: 'PENDING', + expiresAt: { lt: now } + }, + include: { + copy: { + include: { book: true } + }, + user: true + } + }); + + if (expiredReservations.length === 0) { + console.log('没有过期的预约需要处理'); + return { processed: 0, released: 0 }; + } + + console.log(`发现 ${expiredReservations.length} 个过期预约,开始处理...`); + + let releasedCount = 0; + + for (const reservation of expiredReservations) { + try { + // 使用事务:更新预约状态 + 释放副本 + await prisma.$transaction(async (tx) => { + // 更新预约状态为 EXPIRED + await tx.reservation.update({ + where: { id: reservation.id }, + data: { status: 'EXPIRED' } + }); + + // 释放副本(如果副本状态仍然是 RESERVED) + const copy = await tx.copy.findUnique({ + where: { id: reservation.copyId } + }); + + if (copy.status === 'RESERVED') { + await tx.copy.update({ + where: { id: reservation.copyId }, + data: { status: 'AVAILABLE' } + }); + } + }); + + releasedCount++; + console.log(`✅ 已释放预约 #${reservation.id}: 图书 ${reservation.copy.book.title}, 用户 ${reservation.user.email}`); + + } catch (error) { + console.error(`处理预约 #${reservation.id} 失败:`, error.message); + } + } + + console.log(`处理完成:共处理 ${expiredReservations.length} 个过期预约,成功释放 ${releasedCount} 个副本`); + + return { processed: expiredReservations.length, released: releasedCount }; + } catch (error) { + console.error('扫描过期预约失败:', error); + throw error; + } +} + +module.exports = { expireReservations }; \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ab15788..2947a36 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,7 @@ import { Routes, Route, useNavigate } from 'react-router-dom'; import BookSearch from './pages/BookSearch'; import HomePage from './pages/HomePage'; import MyHistory from './reader/MyHistory'; +import MyReservations from './reader/MyReservations'; import UnifiedLogin from './pages/UnifiedLogin'; import Register from './pages/Register'; import AdminDashboard from './pages/AdminDashboard'; @@ -16,12 +17,10 @@ import SystemConfig from './pages/SystemConfig'; import AdminBackupPage from './pages/AdminBackupPage'; import AdminBlocklist from './pages/AdminBlocklist'; - function App() { const navigate = useNavigate(); const [isLoggedIn, setIsLoggedIn] = useState(false); const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState('search'); useEffect(() => { const token = localStorage.getItem('token') || localStorage.getItem('librarianToken'); @@ -41,16 +40,15 @@ function App() { localStorage.removeItem('librarianToken'); localStorage.removeItem('librarianInfo'); setIsLoggedIn(false); - setActiveTab('search'); navigate('/login'); }; - if (loading) return
Loading...
; + if (loading) return
加载中...
; return ( } /> - } /> + } /> } /> } /> } /> @@ -58,15 +56,19 @@ function App() { } /> } /> } /> + } /> } /> } /> - - ) : ( - - ) - } /> + + ) : ( + + ) + } + /> } /> } /> } /> @@ -74,4 +76,4 @@ function App() { ); } -export default App; +export default App; \ No newline at end of file diff --git a/frontend/src/components/ReaderLayout.jsx b/frontend/src/components/ReaderLayout.jsx index ddf19b7..619f500 100644 --- a/frontend/src/components/ReaderLayout.jsx +++ b/frontend/src/components/ReaderLayout.jsx @@ -1,93 +1,400 @@ -import { Link, useNavigate, useLocation } from 'react-router-dom'; -import { Home, Search, BookOpen, Bell, MessageSquare, LogOut } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import ReaderLayout from '../components/ReaderLayout'; +import DueReminderBanner from '../components/DueReminderBanner'; -export default function ReaderLayout({ children, user, onLogout }) { +function SearchPage() { const navigate = useNavigate(); - const location = useLocation(); - - const navLinks = [ - { to: '/', label: '首页', icon: Home }, - { to: '/search', label: '搜书', icon: Search }, - { to: '/history', label: '借阅记录', icon: BookOpen }, - { to: '/announcements', label: '公告通知', icon: Bell }, - { to: '/messages', label: '消息', icon: MessageSquare }, - ]; - - const handleLogout = () => { - localStorage.removeItem('token'); - localStorage.removeItem('user'); - if (onLogout) onLogout(); - navigate('/login'); + const [user, setUser] = useState(null); + const [searchTitle, setSearchTitle] = useState(''); + const [searchAuthor, setSearchAuthor] = useState(''); + const [searchKeyword, setSearchKeyword] = useState(''); + const [books, setBooks] = useState([]); + const [loading, setLoading] = useState(false); + const [searched, setSearched] = useState(false); + const [message, setMessage] = useState(''); + const [selectedBook, setSelectedBook] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(''); + const [availableCopies, setAvailableCopies] = useState({}); + const [showCopies, setShowCopies] = useState(null); + const [bookRatings, setBookRatings] = useState(null); + + useEffect(() => { + const userData = localStorage.getItem('user'); + if (userData) { + try { + setUser(JSON.parse(userData)); + } catch (e) { + console.error('Failed to parse user data'); + } + } + }, []); + + const getToken = () => localStorage.getItem('token'); + + const handleSearch = async () => { + setLoading(true); + setSearched(true); + setMessage(''); + + try { + const params = new URLSearchParams(); + if (searchTitle) params.append('title', searchTitle); + if (searchAuthor) params.append('author', searchAuthor); + if (searchKeyword) params.append('keyword', searchKeyword); + + const response = await fetch(`http://localhost:3001/books/search?${params}`); + const result = await response.json(); + + if (result.success) { + setBooks(result.data); + } else { + setBooks([]); + } + } catch (error) { + console.error('Search error:', error); + setBooks([]); + } finally { + setLoading(false); + } + }; + + const fetchAvailableCopies = async (bookId) => { + const token = getToken(); + if (!token) { + setMessage('请先登录'); + return; + } + + try { + const response = await fetch(`http://localhost:3001/api/reader/available-copies/${bookId}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (response.status === 401) { + localStorage.removeItem('token'); + setMessage('登录已过期,请重新登录'); + setTimeout(() => navigate('/login'), 1500); + return; + } + + const data = await response.json(); + + if (data.copies && Array.isArray(data.copies)) { + setAvailableCopies(prev => ({ ...prev, [bookId]: data.copies })); + } else { + setAvailableCopies(prev => ({ ...prev, [bookId]: [] })); + } + setShowCopies(showCopies === bookId ? null : bookId); + } catch (error) { + setMessage('获取副本列表失败'); + } + }; + + const handleBorrowCopy = async (copyId, bookTitle) => { + const token = getToken(); + if (!token) { + setMessage('请先登录'); + return; + } + + setMessage(''); + try { + const response = await fetch(`http://localhost:3001/api/reader/borrow/${copyId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + } + }); + + const data = await response.json(); + + if (response.ok) { + setMessage(`借阅成功!${bookTitle}`); + handleSearch(); + setShowCopies(null); + } else { + setMessage(data.message || '借阅失败'); + } + } catch (error) { + setMessage('借阅失败: ' + error.message); + } + }; + + const fetchBookRatings = async (bookId) => { + try { + const response = await fetch(`http://localhost:3001/api/ratings/book/${bookId}`); + if (response.ok) { + const data = await response.json(); + setBookRatings(data); + } + } catch (error) { + console.error('Failed to fetch ratings:', error); + } + }; + + const handleViewDetails = async (bookId) => { + setDetailLoading(true); + setDetailError(''); + + try { + const response = await fetch(`http://localhost:3001/books/${bookId}`); + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.error || 'Failed to load book detail'); + } + + setSelectedBook(result.data); + await fetchBookRatings(bookId); + } catch (error) { + setDetailError(error.message); + } finally { + setDetailLoading(false); + } + }; + + const closeDetails = () => { + setSelectedBook(null); + setDetailError(''); + setBookRatings(null); + }; + + const handleReset = () => { + setSearchTitle(''); + setSearchAuthor(''); + setSearchKeyword(''); + setBooks([]); + setSearched(false); + setMessage(''); }; - const getGreeting = () => { - const hour = new Date().getHours(); - if (hour < 12) return '早上好'; - if (hour < 18) return '下午好'; - return '晚上好'; + const StarRatingDisplay = ({ value, size = 'md' }) => { + const sizeClass = size === 'sm' ? 'text-sm' : 'text-lg'; + return ( + + {'★'.repeat(value)}{'☆'.repeat(5 - value)} + + ); }; return ( -
-
-
-
-
-
📚
-
-

图书馆管理系统

-

读者端

-
- - {user?.role === 'ADMIN' ? '管理员' : - user?.role === 'LIBRARIAN' ? '馆员' : - '读者'} - + + + +
+

图书搜索

+

搜索并浏览图书馆的图书

+
+ + {message && ( +
+ {message} +
+ )} + +
+

+ 🔍 搜索图书 +

+
+
+ + setSearchTitle(e.target.value)} + placeholder="请输入书名" + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500" + /> +
+
+ + setSearchAuthor(e.target.value)} + placeholder="请输入作者名" + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500" + /> +
+
+ + setSearchKeyword(e.target.value)} + placeholder="请输入关键词" + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500" + /> +
+
+
+ + +
+
+ + {searched && ( + <> +

+ 📚 搜索结果 ({books.length}) +

+ {books.length === 0 ? ( +
+ 未找到图书
+ ) : ( +
+ {books.map((book) => ( +
+

{book.title}

+

作者: {book.author}

+

ISBN: {book.isbn}

+

库存: {book.availableCopies || 0} / {book.totalCopies || 1}

+

+ 评分: + {book.averageRating !== null ? ( + {'★'.repeat(Math.round(book.averageRating))}{'☆'.repeat(5 - Math.round(book.averageRating))} + ) : ( + 暂无评分 + )} + {book.averageRating !== null && ( + ({book.averageRating}/5, {book.totalRatings}条评价) + )} +

+
+ + {book.availableCopies > 0 && ( + + )} +
+ + {showCopies === book.id && availableCopies[book.id] && availableCopies[book.id].length > 0 && ( +
+ 可用副本: + {availableCopies[book.id].map(copy => ( +
+ + 条码: {copy.barcode} | 位置: {copy.floor}F {copy.libraryArea} {copy.shelfNo}-{copy.shelfLevel} + + +
+ ))} +
+ )} -
- - -
-
-

{getGreeting()}

-

{user?.name || '读者'}

+ {showCopies === book.id && (!availableCopies[book.id] || availableCopies[book.id].length === 0) && ( +
+ 目前没有可用副本 +
+ )}
- -
+ ))}
+ )} + + )} + + {selectedBook && ( +
+
e.stopPropagation()} className="bg-white rounded-xl p-6 max-w-2xl w-full mx-4 shadow-2xl max-h-[90vh] overflow-y-auto"> +

{selectedBook.title}

+ +
+

作者: {selectedBook.author}

+

ISBN: {selectedBook.isbn}

+

分类: {selectedBook.genre}

+

语言: {selectedBook.language || 'English'}

+

库存: {selectedBook.availableCopies || 0} / {selectedBook.totalCopies || 1}

+ {bookRatings && bookRatings.totalRatings > 0 && ( +

+ 评分: ({bookRatings.averageRating.toFixed(1)}/5, {bookRatings.totalRatings}条评价) +

+ )} +
+ + {selectedBook.description && ( +
+ 简介: +

{selectedBook.description}

+
+ )} + + {bookRatings && bookRatings.ratings && bookRatings.ratings.length > 0 && ( +
+ 最新评价: +
+ {bookRatings.ratings.slice(0, 5).map((rating) => ( +
+
+
+ {rating.user?.name || '匿名用户'} + +
+ + {new Date(rating.createdAt).toLocaleDateString()} + +
+ {rating.review && ( +

{rating.review}

+ )} +
+ ))} +
+ {bookRatings.totalRatings > 5 && ( +

+ 还有 {bookRatings.totalRatings - 5} 条评价... +

+ )} +
+ )} + + {bookRatings && bookRatings.totalRatings === 0 && ( +
+ 暂无评价 +
+ )} + +
-
- -
- {children} -
-
+ )} + ); } + +export default SearchPage; diff --git a/frontend/src/pages/BookSearch.jsx b/frontend/src/pages/BookSearch.jsx index 27a5c76..b0518ba 100644 --- a/frontend/src/pages/BookSearch.jsx +++ b/frontend/src/pages/BookSearch.jsx @@ -92,7 +92,8 @@ function BookSearch() { } }; - const handleBorrowCopy = async (copyId, bookTitle) => { + // 修改:借阅改为预约 + const handleReserveCopy = async (copyId, bookTitle) => { const token = getToken(); if (!token) { setMessage('请先登录'); @@ -111,15 +112,21 @@ function BookSearch() { const data = await response.json(); - if (response.ok) { - setMessage(`借阅成功!${bookTitle}`); + if (response.ok && data.success) { + setMessage(`✅ 预约成功!《${bookTitle}》请在2小时内到图书馆借出,否则预约将自动失效。`); handleSearch(); setShowCopies(null); + // 可选:询问是否查看预约列表 + setTimeout(() => { + if (window.confirm('预约成功!是否查看我的预约?')) { + navigate('/my-reservations'); + } + }, 500); } else { - setMessage(data.message || '借阅失败'); + setMessage(data.message || '预约失败'); } } catch (error) { - setMessage('借阅失败: ' + error.message); + setMessage('预约失败: ' + error.message); } }; @@ -185,12 +192,22 @@ function BookSearch() {
-

图书搜索

-

搜索并浏览图书馆的图书

+
+
+

图书搜索

+

搜索并浏览图书馆的图书

+
+ +
{message && ( -
+
{message}
)} @@ -302,10 +319,10 @@ function BookSearch() { 条码: {copy.barcode} | 位置: {copy.floor}F {copy.libraryArea} {copy.shelfNo}-{copy.shelfLevel}
))} @@ -397,4 +414,4 @@ function BookSearch() { ); } -export default BookSearch; +export default BookSearch; \ No newline at end of file diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx index 01cb8fc..af06c79 100644 --- a/frontend/src/pages/HomePage.jsx +++ b/frontend/src/pages/HomePage.jsx @@ -34,7 +34,7 @@ function HomePage() {

欢迎回来,在这里您可以搜索和借阅图书。

-
+
navigate('/search')} className="bg-white p-6 rounded-lg shadow-lg hover:shadow-xl transition cursor-pointer" @@ -59,6 +59,18 @@ function HomePage() {
+
navigate('/my-reservations')} + className="bg-white p-6 rounded-lg shadow-lg hover:shadow-xl transition cursor-pointer" + > +
📅
+

我的预约

+

查看和管理您的图书预约

+ +
+
navigate('/announcements')} className="bg-white p-6 rounded-lg shadow-lg hover:shadow-xl transition cursor-pointer" @@ -87,4 +99,4 @@ function HomePage() { ); } -export default HomePage; +export default HomePage; \ No newline at end of file diff --git a/frontend/src/pages/SearchPage.jsx b/frontend/src/pages/SearchPage.jsx index 619f500..afd0fcb 100644 --- a/frontend/src/pages/SearchPage.jsx +++ b/frontend/src/pages/SearchPage.jsx @@ -92,7 +92,8 @@ function SearchPage() { } }; - const handleBorrowCopy = async (copyId, bookTitle) => { + // 修改:借阅改为预约 + const handleReserveCopy = async (copyId, bookTitle) => { const token = getToken(); if (!token) { setMessage('请先登录'); @@ -111,15 +112,20 @@ function SearchPage() { const data = await response.json(); - if (response.ok) { - setMessage(`借阅成功!${bookTitle}`); + if (response.ok && data.success) { + setMessage(`✅ 预约成功!《${bookTitle}》请在2小时内到图书馆借出,否则预约将自动失效。`); handleSearch(); setShowCopies(null); + setTimeout(() => { + if (window.confirm('预约成功!是否查看我的预约?')) { + navigate('/my-reservations'); + } + }, 500); } else { - setMessage(data.message || '借阅失败'); + setMessage(data.message || '预约失败'); } } catch (error) { - setMessage('借阅失败: ' + error.message); + setMessage('预约失败: ' + error.message); } }; @@ -185,12 +191,22 @@ function SearchPage() {
-

图书搜索

-

搜索并浏览图书馆的图书

+
+
+

图书搜索

+

搜索并浏览图书馆的图书

+
+ +
{message && ( -
+
{message}
)} @@ -302,10 +318,10 @@ function SearchPage() { 条码: {copy.barcode} | 位置: {copy.floor}F {copy.libraryArea} {copy.shelfNo}-{copy.shelfLevel}
))} @@ -397,4 +413,4 @@ function SearchPage() { ); } -export default SearchPage; +export default SearchPage; \ No newline at end of file diff --git a/frontend/src/reader/MyHistory.jsx b/frontend/src/reader/MyHistory.jsx index 2de1913..56616fc 100644 --- a/frontend/src/reader/MyHistory.jsx +++ b/frontend/src/reader/MyHistory.jsx @@ -15,7 +15,6 @@ function MyHistory() { const [review, setReview] = useState(''); const [userRatings, setUserRatings] = useState({}); const [submitting, setSubmitting] = useState(false); - // 支付相关状态 const [showPaymentModal, setShowPaymentModal] = useState(false); const [selectedLoan, setSelectedLoan] = useState(null); const [paymentAmount, setPaymentAmount] = useState(0); @@ -196,18 +195,16 @@ function MyHistory() { const handleReturn = async (loanId) => { const loan = history.find(l => l.id === loanId); - // 如果有罚款需要支付,先显示支付弹窗,不立即归还 if (needsFinePayment(loan)) { setSelectedLoan(loan); setPaymentAmount(getEstimatedFine(loan)); setPaymentMethod('alipay'); setPaymentSuccess(false); - setPendingReturnLoan(loanId); // 标记这是待归还的订单 + setPendingReturnLoan(loanId); setShowPaymentModal(true); return; } - // 没有罚款,直接执行归还 const token = localStorage.getItem('token'); try { const response = await fetch(`http://localhost:3001/api/reader/return/${loanId}`, { @@ -251,7 +248,7 @@ function MyHistory() { const closePaymentModal = () => { setShowPaymentModal(false); - setPendingReturnLoan(null); // 取消支付时清空待归还状态 + setPendingReturnLoan(null); }; const openRatingModal = (loan) => { @@ -401,8 +398,18 @@ function MyHistory() {
-

借阅记录

-

查看和管理您的借阅记录

+
+
+

借阅记录

+

查看和管理您的借阅记录

+
+ +
{message && ( diff --git a/frontend/src/reader/MyReservations.jsx b/frontend/src/reader/MyReservations.jsx new file mode 100644 index 0000000..5eba85c --- /dev/null +++ b/frontend/src/reader/MyReservations.jsx @@ -0,0 +1,93 @@ +import { useState, useEffect } from 'react'; + +function MyReservations() { + const [reservations, setReservations] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchReservations(); + }, []); + + const fetchReservations = async () => { + try { + const token = localStorage.getItem('token'); + const response = await fetch('/api/reader/my-reservations', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + if (response.ok) { + const data = await response.json(); + setReservations(data); + } + } catch (error) { + console.error('获取预约记录失败:', error); + } finally { + setLoading(false); + } + }; + + const handleCancel = async (reservationId) => { + if (!window.confirm('确定要取消这个预约吗?')) return; + try { + const token = localStorage.getItem('token'); + const response = await fetch(`/api/reader/cancel-reservation/${reservationId}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + if (response.ok) { + setReservations(reservations.filter(r => r.id !== reservationId)); + } + } catch (error) { + console.error('取消预约失败:', error); + } + }; + + if (loading) { + return
加载中...
; + } + + return ( +
+

我的预约

+ + {reservations.length === 0 ? ( +
+

暂无预约记录

+
+ ) : ( +
+ {reservations.map((reservation) => ( +
+
+
+

{reservation.bookTitle || '未知书籍'}

+

作者: {reservation.bookAuthor}

+

+ 预约时间: {new Date(reservation.createdAt).toLocaleString()} +

+

+ 状态: {reservation.status === 'PENDING' ? '待处理' : + reservation.status === 'EXPIRED' ? '已过期' : '已取消'} +

+
+ {reservation.status === 'PENDING' && ( + + )} +
+
+ ))} +
+ )} +
+ ); +} + +export default MyReservations;