diff --git a/backend/package-lock.json b/backend/package-lock.json index 2acf6b9..98ad68f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1378,7 +1378,10 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" diff --git a/backend/prisma/prisma/dev.db b/backend/prisma/prisma/dev.db index 240560f..106cc27 100644 Binary files a/backend/prisma/prisma/dev.db and b/backend/prisma/prisma/dev.db differ diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f33388f..2c39672 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -32,8 +32,11 @@ model User { email String @unique passwordHash String studentId String? @unique + employeeId String? @unique // 新增:馆员工号 role Role @default(STUDENT) createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + loans Loan[] ratings Rating[] holds Hold[] @@ -43,14 +46,16 @@ model User { } model Book { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) title String author String - isbn String @unique + isbn String @unique genre String description String? - language String @default("English") - createdAt DateTime @default(now()) + language String @default("English") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + copies Copy[] ratings Rating[] holds Hold[] @@ -58,17 +63,19 @@ model Book { } model Copy { - id Int @id @default(autoincrement()) - bookId Int - book Book @relation(fields: [bookId], references: [id]) - barcode String @unique - floor Int - libraryArea String - shelfNo String - shelfLevel Int - status CopyStatus @default(AVAILABLE) - createdAt DateTime @default(now()) - loans Loan[] + id Int @id @default(autoincrement()) + bookId Int + 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 + + loans Loan[] } model Loan { @@ -78,10 +85,12 @@ model Loan { checkoutDate DateTime @default(now()) dueDate DateTime returnDate DateTime? - fineAmount Float? @default(0) - finePaid Boolean? @default(false) - fineForgiven Boolean? @default(false) + fineAmount Float @default(0) + finePaid Boolean @default(false) + fineForgiven Boolean @default(false) createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + copy Copy @relation(fields: [copyId], references: [id]) user User @relation(fields: [userId], references: [id]) } @@ -92,7 +101,8 @@ model Rating { userId Int stars Int @default(1) createdAt DateTime @default(now()) - book Book @relation(fields: [bookId], references: [id]) + + book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id]) @@unique([bookId, userId]) @@ -104,7 +114,8 @@ model Hold { userId Int status HoldStatus @default(WAITING) createdAt DateTime @default(now()) - book Book @relation(fields: [bookId], references: [id]) + + book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id]) } @@ -113,16 +124,19 @@ model Wishlist { bookId Int userId Int createdAt DateTime @default(now()) - book Book @relation(fields: [bookId], references: [id]) + + book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id]) @@unique([bookId, userId]) } model Config { - id Int @id @default(autoincrement()) - key String @unique - value String + id Int @id @default(autoincrement()) + key String @unique + value String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model AuditLog { @@ -133,20 +147,10 @@ model AuditLog { entityId Int? detail String? createdAt DateTime @default(now()) + user User? @relation(fields: [userId], references: [id]) } -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") -} - model Announcement { id Int @id @default(autoincrement()) title String @@ -156,6 +160,7 @@ model Announcement { expiryDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + publishers AnnouncementPublisher[] } @@ -163,6 +168,7 @@ model AnnouncementPublisher { id Int @id @default(autoincrement()) userId Int announcementId Int + user User @relation(fields: [userId], references: [id]) announcement Announcement @relation(fields: [announcementId], references: [id], onDelete: Cascade) diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js index c397c37..dbdd17f 100644 --- a/backend/prisma/seed.js +++ b/backend/prisma/seed.js @@ -2,119 +2,576 @@ const { PrismaClient } = require('@prisma/client'); const bcrypt = require('bcrypt'); const prisma = new PrismaClient(); +// 配置常量 +const CONFIG = { + PASSWORD_HASH_ROUNDS: 10, + DEFAULT_PASSWORD: 'password123', + ADMIN_PASSWORD: 'admin123', + LIBRARIAN_PASSWORD: 'lib123', + STUDENT_PASSWORD: 'student123', +}; + +// 预定义的测试账号 +const TEST_ACCOUNTS = { + admin: { + email: 'admin@library.com', + password: CONFIG.ADMIN_PASSWORD, + name: '系统管理员', + }, + librarians: [ + { + employeeId: 'LIB001', + password: CONFIG.LIBRARIAN_PASSWORD, + name: '张明', + }, + { + employeeId: 'LIB002', + password: CONFIG.LIBRARIAN_PASSWORD, + name: '李华', + }, + { + employeeId: 'LIB003', + password: CONFIG.LIBRARIAN_PASSWORD, + name: '王芳', + }, + ], + students: [ + { + studentId: 'S2021001', + email: 'student1@university.edu', + password: CONFIG.STUDENT_PASSWORD, + name: '张三', + }, + { + studentId: 'S2021002', + email: 'student2@university.edu', + password: CONFIG.STUDENT_PASSWORD, + name: '李四', + }, + { + studentId: 'S2021003', + email: 'student3@university.edu', + password: CONFIG.STUDENT_PASSWORD, + name: '王五', + }, + { + studentId: 'S2021004', + email: 'student4@university.edu', + password: CONFIG.STUDENT_PASSWORD, + name: '赵六', + }, + { + studentId: 'S2021005', + email: 'student5@university.edu', + password: CONFIG.STUDENT_PASSWORD, + name: '孙七', + }, + ], +}; + +// 图书数据 +const BOOKS_DATA = [ + // Technology + { + title: 'The Pragmatic Programmer', + author: 'David Thomas & Andrew Hunt', + isbn: '978-0201616224', + genre: 'Technology', + description: 'A must-read for any programmer, filled with practical advice and best practices.', + language: 'English', + }, + { + title: 'Clean Code', + author: 'Robert C. Martin', + isbn: '978-0132350884', + genre: 'Technology', + description: 'A handbook of agile software craftsmanship.', + language: 'English', + }, + { + title: 'Designing Data-Intensive Applications', + author: 'Martin Kleppmann', + isbn: '978-1449373320', + genre: 'Technology', + description: 'The big ideas behind reliable, scalable, and maintainable systems.', + language: 'English', + }, + { + title: "You Don't Know JS", + author: 'Kyle Simpson', + isbn: '978-1491904244', + genre: 'Technology', + description: 'Deep dive into JavaScript language features.', + language: 'English', + }, + // Fiction + { + title: 'The Great Gatsby', + author: 'F. Scott Fitzgerald', + isbn: '978-0743273565', + genre: 'Fiction', + description: 'A story of decadence and excess in Jazz Age America.', + language: 'English', + }, + { + title: 'To Kill a Mockingbird', + author: 'Harper Lee', + isbn: '978-0446310789', + genre: 'Fiction', + description: 'A powerful story of racial injustice in the American South.', + language: 'English', + }, + { + title: '1984', + author: 'George Orwell', + isbn: '978-0451524935', + genre: 'Fiction', + description: 'A dystopian novel about totalitarianism and surveillance.', + language: 'English', + }, + { + title: 'Pride and Prejudice', + author: 'Jane Austen', + isbn: '978-0141439518', + genre: 'Fiction', + description: 'A classic romance novel about manners and marriage.', + language: 'English', + }, + // Science + { + title: 'A Brief History of Time', + author: 'Stephen Hawking', + isbn: '978-0553380163', + genre: 'Science', + description: 'From the Big Bang to black holes.', + language: 'English', + }, + { + title: 'The Selfish Gene', + author: 'Richard Dawkins', + isbn: '978-0199291151', + genre: 'Science', + description: 'A gene-centered view of evolution.', + language: 'English', + }, + { + title: 'Cosmos', + author: 'Carl Sagan', + isbn: '978-0345539434', + genre: 'Science', + description: 'A journey through space and time.', + language: 'English', + }, + { + title: 'The Double Helix', + author: 'James Watson', + isbn: '978-0743216302', + genre: 'Science', + description: 'The story of the discovery of DNA structure.', + language: 'English', + }, + // History + { + title: 'Sapiens', + author: 'Yuval Noah Harari', + isbn: '978-0062316097', + genre: 'History', + description: 'A brief history of humankind.', + language: 'English', + }, + { + title: 'Guns, Germs, and Steel', + author: 'Jared Diamond', + isbn: '978-0393317558', + genre: 'History', + description: 'The fates of human societies.', + language: 'English', + }, + { + title: 'The Silk Roads', + author: 'Peter Frankopan', + isbn: '978-1101912379', + genre: 'History', + description: 'A new history of the world.', + language: 'English', + }, + // Management + { + title: 'The Lean Startup', + author: 'Eric Ries', + isbn: '978-0307887894', + genre: 'Management', + description: 'How today\'s entrepreneurs use continuous innovation.', + language: 'English', + }, + { + title: 'Good to Great', + author: 'Jim Collins', + isbn: '978-0066620992', + genre: 'Management', + description: 'Why some companies make the leap and others don\'t.', + language: 'English', + }, + { + title: 'Drive', + author: 'Daniel H. Pink', + isbn: '978-1594484803', + genre: 'Management', + description: 'The surprising truth about what motivates us.', + language: 'English', + }, + // Chinese Books + { + title: '三体', + author: '刘慈欣', + isbn: '978-7536692930', + genre: 'Science Fiction', + description: '中国科幻文学的里程碑之作。', + language: 'Chinese', + }, + { + title: '活着', + author: '余华', + isbn: '978-7506365437', + genre: 'Fiction', + description: '讲述了一个人历尽世间沧桑和磨难的一生。', + language: 'Chinese', + }, +]; + +// 系统配置 +const SYSTEM_CONFIGS = [ + { key: 'FINE_RATE_PER_DAY', value: '0.50', description: '每日逾期罚款金额(元)' }, + { key: 'MAX_BORROW_STUDENT', value: '3', description: '学生最大借阅数量' }, + { key: 'LOAN_DURATION_DAYS', value: '30', description: '默认借阅天数' }, + { key: 'MAX_RENEW_TIMES', value: '2', description: '最大续借次数' }, + { key: 'RENEW_DURATION_DAYS', value: '15', description: '续借天数' }, + { key: 'LIBRARY_NAME', value: '大学图书馆管理系统', description: '图书馆名称' }, + { key: 'LIBRARY_HOURS', value: '周一至周五 8:00-22:00,周末 9:00-21:00', description: '开放时间' }, + { key: 'CONTACT_EMAIL', value: 'library@university.edu', description: '联系邮箱' }, + { key: 'CONTACT_PHONE', value: '123-4567-8901', description: '联系电话' }, +]; + +// 辅助函数 +function generateISBN(index) { + return `978-${String(index).padStart(10, '0')}`; +} + +function generateBarcode(bookId, copyNumber) { + return `BC-${String(bookId).padStart(6, '0')}-${String(copyNumber).padStart(3, '0')}`; +} + +function getRandomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function getRandomElement(array) { + return array[Math.floor(Math.random() * array.length)]; +} + +// 主函数 async function main() { - // 清空现有数据(避免重复键冲突) - await prisma.auditLog.deleteMany(); - await prisma.hold.deleteMany(); - await prisma.wishlist.deleteMany(); - await prisma.rating.deleteMany(); - await prisma.loan.deleteMany(); - await prisma.book.deleteMany(); - await prisma.user.deleteMany(); - await prisma.config.deleteMany(); - - // 创建用户 - const adminPassword = await bcrypt.hash('admin123', 10); - const librarianPassword = await bcrypt.hash('lib123', 10); - const studentPassword = await bcrypt.hash('student123', 10); - - await prisma.user.create({ + console.log('🚀 开始初始化数据库...\n'); + + // ==================== 清空现有数据 ==================== + console.log('📦 清空现有数据...'); + + const deleteOperations = [ + { name: '审计日志', model: prisma.auditLog }, + { name: '公告发布者', model: prisma.announcementPublisher }, + { name: '公告', model: prisma.announcement }, + { name: '预约', model: prisma.hold }, + { name: '心愿单', model: prisma.wishlist }, + { name: '评分', model: prisma.rating }, + { name: '借阅记录', model: prisma.loan }, + { name: '副本', model: prisma.copy }, + { name: '图书', model: prisma.book }, + { name: '用户', model: prisma.user }, + { name: '馆员', model: prisma.librarian }, + { name: '配置', model: prisma.config }, + ]; + + for (const op of deleteOperations) { + try { + await op.model.deleteMany(); + console.log(` ✓ 清空${op.name}`); + } catch (error) { + // 某些表可能不存在,忽略错误 + } + } + + console.log('\n✅ 数据清空完成\n'); + + // ==================== 创建用户 ==================== + console.log('👥 创建用户账号...'); + + // 创建管理员 + const adminPasswordHash = await bcrypt.hash(TEST_ACCOUNTS.admin.password, CONFIG.PASSWORD_HASH_ROUNDS); + const admin = await prisma.user.create({ data: { - name: 'Admin User', - email: 'admin@library.com', - passwordHash: adminPassword, + name: TEST_ACCOUNTS.admin.name, + email: TEST_ACCOUNTS.admin.email, + passwordHash: adminPasswordHash, role: 'ADMIN', }, }); + console.log(` ✓ 管理员: ${admin.email} / ${TEST_ACCOUNTS.admin.password}`); - await prisma.user.create({ - data: { - name: 'Librarian User', - email: 'librarian@library.com', - passwordHash: librarianPassword, - role: 'LIBRARIAN', - }, - }); + // 创建学生 + const students = []; + const studentPasswordHash = await bcrypt.hash(CONFIG.STUDENT_PASSWORD, CONFIG.PASSWORD_HASH_ROUNDS); + + for (const studentData of TEST_ACCOUNTS.students) { + const student = await prisma.user.create({ + data: { + name: studentData.name, + email: studentData.email, + studentId: studentData.studentId, + passwordHash: studentPasswordHash, + role: 'STUDENT', + }, + }); + students.push(student); + console.log(` ✓ 学生: ${student.studentId} - ${student.name} (${student.email})`); + } - await prisma.user.create({ - data: { - name: 'Student One', - email: 'student1@university.edu', - passwordHash: studentPassword, - studentId: 'S12345', - role: 'STUDENT', - }, - }); + // 创建馆员(使用 User 表,role='LIBRARIAN') +const librarianPasswordHash = await bcrypt.hash(CONFIG.LIBRARIAN_PASSWORD, CONFIG.PASSWORD_HASH_ROUNDS); - await prisma.user.create({ +for (const librarianData of TEST_ACCOUNTS.librarians) { + const librarian = await prisma.user.create({ data: { - name: 'Student Two', - email: 'student2@university.edu', - passwordHash: studentPassword, - studentId: 'S67890', - role: 'STUDENT', + email: `${librarianData.employeeId.toLowerCase()}@library.com`, // 根据工号生成一个登录邮箱 + name: librarianData.name, + employeeId: librarianData.employeeId, // 确保你的 User 模型有这个字段 + passwordHash: librarianPasswordHash, + role: 'LIBRARIAN', // 角色设置为馆员 }, }); + console.log(` ✓ 馆员: ${librarian.employeeId} - ${librarian.name} / ${CONFIG.LIBRARIAN_PASSWORD}`); +} - // 图书数据 - const booksData = [ - // Technology - { title: 'The Pragmatic Programmer', author: 'David Thomas', genre: 'Technology', available: true }, - { title: 'Clean Code', author: 'Robert C. Martin', genre: 'Technology', available: true }, - { title: 'Designing Data-Intensive Applications', author: 'Martin Kleppmann', genre: 'Technology', available: true }, - { title: "You Don't Know JS", author: 'Kyle Simpson', genre: 'Technology', available: false }, - // Fiction - { title: 'The Great Gatsby', author: 'F. Scott Fitzgerald', genre: 'Fiction', available: true }, - { title: 'To Kill a Mockingbird', author: 'Harper Lee', genre: 'Fiction', available: true }, - { title: '1984', author: 'George Orwell', genre: 'Fiction', available: true }, - { title: 'Pride and Prejudice', author: 'Jane Austen', genre: 'Fiction', available: false }, - // Science - { title: 'A Brief History of Time', author: 'Stephen Hawking', genre: 'Science', available: true }, - { title: 'The Selfish Gene', author: 'Richard Dawkins', genre: 'Science', available: true }, - { title: 'Cosmos', author: 'Carl Sagan', genre: 'Science', available: true }, - { title: 'The Double Helix', author: 'James Watson', genre: 'Science', available: false }, - // History - { title: 'Sapiens', author: 'Yuval Noah Harari', genre: 'History', available: true }, - { title: 'Guns, Germs, and Steel', author: 'Jared Diamond', genre: 'History', available: true }, - { title: 'The Silk Roads', author: 'Peter Frankopan', genre: 'History', available: true }, - { title: "A People's History of the United States", author: 'Howard Zinn', genre: 'History', available: false }, - // Management - { title: 'The Lean Startup', author: 'Eric Ries', genre: 'Management', available: true }, - { title: 'Good to Great', author: 'Jim Collins', genre: 'Management', available: true }, - { title: 'Drive', author: 'Daniel H. Pink', genre: 'Management', available: true }, - { title: 'The Five Dysfunctions of a Team', author: 'Patrick Lencioni', genre: 'Management', available: false }, - ]; + console.log('\n✅ 用户创建完成\n'); - for (const book of booksData) { - await prisma.book.create({ - data: { - title: book.title, - author: book.author, - isbn: `ISBN-${Math.random().toString(36).substring(2, 10)}`, - genre: book.genre, - description: `${book.title} is a great read.`, - language: 'English', - shelfLocation: `${book.genre}-${Math.floor(Math.random() * 100)}`, - available: book.available, // 保留旧字段 - totalCopies: 1, // 新增 - availableCopies: book.available ? 1 : 0 // 新增:根据 available 初始化 + // ==================== 创建系统配置 ==================== + console.log('⚙️ 创建系统配置...'); + + for (const config of SYSTEM_CONFIGS) { + await prisma.config.create({ + data: { + key: config.key, + value: config.value, + }, + }); + console.log(` ✓ ${config.key} = ${config.value} (${config.description})`); + } + + console.log('\n✅ 系统配置创建完成\n'); + + // ==================== 创建图书和副本 ==================== + console.log('📚 创建图书和副本...'); + + const floorOptions = [1, 2, 3, 4, 5]; + const areaOptions = { + 'Technology': '科技图书区', + 'Fiction': '文学小说区', + 'Science': '自然科学区', + 'History': '历史地理区', + 'Management': '管理科学区', + 'Science Fiction': '科幻小说区', + }; + const shelfOptions = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; + + let bookCount = 0; + let copyCount = 0; + + for (const bookData of BOOKS_DATA) { + // 创建图书 + const book = await prisma.book.create({ + data: { + title: bookData.title, + author: bookData.author, + isbn: bookData.isbn, + genre: bookData.genre, + description: bookData.description, + language: bookData.language, + }, + }); + + // 为每本书创建 1-5 个副本 + const numberOfCopies = getRandomInt(1, 5); + const floor = getRandomElement(floorOptions); + const area = areaOptions[bookData.genre] || '综合图书区'; + const shelf = getRandomElement(shelfOptions); + + for (let i = 0; i < numberOfCopies; i++) { + const status = Math.random() > 0.3 ? 'AVAILABLE' : getRandomElement(['BORROWED', 'AVAILABLE']); + + await prisma.copy.create({ + data: { + bookId: book.id, + barcode: generateBarcode(book.id, i + 1), + floor: floor, + libraryArea: area, + shelfNo: shelf, + shelfLevel: getRandomInt(1, 5), + status: status, + }, + }); + copyCount++; } - }); + + bookCount++; } - // 添加配置项 - await prisma.config.create({ + console.log(` ✓ 创建了 ${bookCount} 本图书,共 ${copyCount} 个副本`); + + console.log('\n✅ 图书创建完成\n'); + + // ==================== 创建示例借阅记录 ==================== + console.log('📋 创建示例借阅记录...'); + + if (students.length > 0) { + const allCopies = await prisma.copy.findMany({ + where: { status: 'AVAILABLE' }, + take: 10, + }); + + const loanCount = Math.min(5, allCopies.length); + + for (let i = 0; i < loanCount; i++) { + const student = students[i % students.length]; + const copy = allCopies[i]; + + const checkoutDate = new Date(); + checkoutDate.setDate(checkoutDate.getDate() - getRandomInt(1, 60)); + + const dueDate = new Date(checkoutDate); + dueDate.setDate(dueDate.getDate() + 30); + + const isReturned = Math.random() > 0.5; + const returnDate = isReturned ? new Date(dueDate.getTime() + getRandomInt(-5, 10) * 24 * 60 * 60 * 1000) : null; + + let fineAmount = 0; + if (returnDate && returnDate > dueDate) { + const diffDays = Math.ceil((returnDate - dueDate) / (1000 * 60 * 60 * 24)); + fineAmount = diffDays * 0.5; + } + + await prisma.loan.create({ + data: { + copyId: copy.id, + userId: student.id, + checkoutDate: checkoutDate, + dueDate: dueDate, + returnDate: returnDate, + fineAmount: fineAmount, + finePaid: fineAmount > 0 && Math.random() > 0.5, + }, + }); + + // 更新副本状态 + await prisma.copy.update({ + where: { id: copy.id }, + data: { status: isReturned ? 'AVAILABLE' : 'BORROWED' }, + }); + } + + console.log(` ✓ 创建了 ${loanCount} 条借阅记录`); + } + + console.log('\n✅ 示例数据创建完成\n'); + + // ==================== 创建示例评分 ==================== + console.log('⭐ 创建示例评分...'); + + const allBooks = await prisma.book.findMany({ take: 10 }); + let ratingCount = 0; + + for (const book of allBooks) { + if (students.length > 0 && Math.random() > 0.5) { + const ratingStudents = students.slice(0, getRandomInt(1, 3)); + + for (const student of ratingStudents) { + try { + await prisma.rating.create({ + data: { + bookId: book.id, + userId: student.id, + stars: getRandomInt(3, 5), + }, + }); + ratingCount++; + } catch (error) { + // 忽略重复评分 + } + } + } + } + + console.log(` ✓ 创建了 ${ratingCount} 条评分`); + + console.log('\n✅ 评分创建完成\n'); + + // ==================== 创建审计日志 ==================== + console.log('📝 创建审计日志...'); + + await prisma.auditLog.create({ data: { - key: 'FINE_RATE_PER_DAY', - value: '0.50', + userId: admin.id, + action: 'SYSTEM_INIT', + entity: 'System', + detail: '系统初始化完成,种子数据已加载', }, }); + + console.log(' ✓ 审计日志创建完成'); - console.log('Seed data inserted successfully'); + // ==================== 输出测试账号信息 ==================== + console.log('\n' + '='.repeat(60)); + console.log('🎉 数据库初始化完成!'); + console.log('='.repeat(60)); + + console.log('\n📋 测试账号信息:'); + console.log('-'.repeat(40)); + + console.log('\n👑 管理员 (Admin):'); + console.log(` 邮箱: ${TEST_ACCOUNTS.admin.email}`); + console.log(` 密码: ${TEST_ACCOUNTS.admin.password}`); + console.log(` 姓名: ${TEST_ACCOUNTS.admin.name}`); + + console.log('\n📚 馆员 (Librarian):'); + TEST_ACCOUNTS.librarians.forEach((lib, index) => { + console.log(` ${index + 1}. 工号: ${lib.employeeId} / 密码: ${lib.password} (${lib.name})`); + }); + + console.log('\n🎓 学生 (Student):'); + TEST_ACCOUNTS.students.slice(0, 3).forEach((student, index) => { + console.log(` ${index + 1}. 学号: ${student.studentId} / 密码: ${student.password} (${student.name})`); + console.log(` 邮箱: ${student.email}`); + }); + if (TEST_ACCOUNTS.students.length > 3) { + console.log(` ... 还有 ${TEST_ACCOUNTS.students.length - 3} 个学生账号`); + } + + console.log('\n📖 统计数据:'); + console.log(` 图书总数: ${bookCount}`); + console.log(` 副本总数: ${copyCount}`); + console.log(` 学生总数: ${students.length}`); + console.log(` 馆员总数: ${librarians.length}`); + + console.log('\n💡 提示:'); + console.log(' 1. 馆员登录请使用统一登录接口,选择 "librarian" 类型'); + console.log(' 2. 学生和管理员登录使用邮箱'); + console.log(' 3. 馆员登录使用工号'); + + console.log('\n' + '='.repeat(60) + '\n'); } +// 执行主函数 main() .catch((e) => { + console.error('\n❌ 种子数据初始化失败:'); console.error(e); process.exit(1); }) diff --git a/backend/src/app.js b/backend/src/app.js index fe2f16b..299b5f3 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -1,13 +1,57 @@ const express = require('express'); const cors = require('cors'); +<<<<<<< HEAD +// 导入所有路由 const readersRouter = require('./routes/readers'); const authRouter = require('./routes/auth'); const loansRouter = require('./routes/loans'); +const booksRouter = require('./routes/books'); // ✅ 添加图书路由 +======= +const readersRouter = require('./routes/readers'); +const authRouter = require('./routes/auth'); +const loansRouter = require('./routes/loans'); +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 const announcementsRouter = require('./routes/announcements'); const app = express(); +<<<<<<< HEAD +// CORS 配置 +app.use(cors({ + origin: ['http://localhost:5173', 'http://localhost:3000', 'http://127.0.0.1:5173'], + credentials: true +})); +app.use(express.json()); + +// 健康检查 +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + message: 'Library API is running', + timestamp: new Date().toISOString() + }); +}); + +// API 路由挂载 +app.use('/api/readers', readersRouter); +app.use('/api/auth', authRouter); // 认证路由(登录、注册) +app.use('/api/books', booksRouter); // ✅ 图书管理路由 +app.use('/api/loans', loansRouter); // 借阅管理路由 +app.use('/api/announcements', announcementsRouter); + +// 404 处理 +app.use((req, res) => { + res.status(404).json({ + success: false, + message: `Route ${req.method} ${req.path} not found` + }); +}); + +// 全局错误处理 +app.use((error, req, res, next) => { + // Prisma 唯一约束冲突 +======= app.use(cors()); app.use(express.json()); @@ -26,19 +70,54 @@ app.use((req, res) => { }); app.use((error, req, res, next) => { +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 if (error && error.code === 'P2002') { const target = Array.isArray(error.meta?.target) ? error.meta.target.join(', ') : 'field'; return res.status(409).json({ +<<<<<<< HEAD + success: false, + message: `A record with that ${target} already exists.`, + error: error.code + }); + } + + // Prisma 记录不存在 + if (error && error.code === 'P2025') { + return res.status(404).json({ + success: false, + message: 'Record not found', + error: error.code + }); + } + + // JWT 错误 + if (error && error.name === 'JsonWebTokenError') { + return res.status(401).json({ + success: false, + message: 'Invalid token', + }); + } + + if (error && error.name === 'TokenExpiredError') { + return res.status(401).json({ + success: false, + message: 'Token expired', +======= message: `A record with that ${target} already exists.`, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 }); } console.error('Unhandled error:', error); res.status(error?.statusCode || 500).json({ +<<<<<<< HEAD + success: false, +======= +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 message: error?.message || 'Internal server error', }); }); diff --git a/backend/src/lib/prisma.js b/backend/src/lib/prisma.js index 0dbc802..b2bbc98 100644 --- a/backend/src/lib/prisma.js +++ b/backend/src/lib/prisma.js @@ -1,5 +1,18 @@ +<<<<<<< HEAD +// lib/prisma.js +const { PrismaClient } = require('@prisma/client'); + +const prisma = new PrismaClient({ + log: process.env.NODE_ENV === 'development' + ? ['query', 'info', 'warn', 'error'] + : ['error'] +}); + +module.exports = prisma; +======= const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); module.exports = prisma; +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index f1f105d..6c8a68d 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -1,6 +1,9 @@ const prisma = require('../lib/prisma'); const { verifyToken } = require('../lib/token'); +<<<<<<< HEAD +======= const { toPublicUser } = require('../lib/user'); +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 function extractBearerToken(authorizationHeader) { if (!authorizationHeader) { @@ -27,7 +30,11 @@ async function requireAuth(req, res, next) { try { const payload = verifyToken(token); +<<<<<<< HEAD + const userId = Number(payload.sub || payload.id); +======= const userId = Number(payload.sub); +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 if (!userId) { return res.status(401).json({ message: 'Token payload is invalid.' }); @@ -35,22 +42,76 @@ async function requireAuth(req, res, next) { const user = await prisma.user.findUnique({ where: { id: userId }, +<<<<<<< HEAD + select: { + id: true, + name: true, + email: true, + studentId: true, + employeeId: true, + role: true, + } +======= +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 }); if (!user) { return res.status(401).json({ message: 'User no longer exists.' }); } +<<<<<<< HEAD + req.user = user; + req.auth = payload; + next(); + } catch (error) { + return res.status(401).json({ + message: error.message || 'Invalid or expired token.', +======= req.auth = payload; req.user = toPublicUser(user); next(); } catch (error) { return res.status(401).json({ message: 'Invalid or expired token.', +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 }); } } +<<<<<<< HEAD +// 馆员权限检查(包括管理员) +function requireLibrarian(req, res, next) { + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } + + if (req.user.role !== 'LIBRARIAN' && req.user.role !== 'ADMIN') { + return res.status(403).json({ message: 'Librarian or Admin access required' }); + } + + next(); +} + +// 仅管理员权限 +function requireAdmin(req, res, next) { + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } + + if (req.user.role !== 'ADMIN') { + return res.status(403).json({ message: 'Admin access required' }); + } + + next(); +} + +module.exports = { + requireAuth, + requireLibrarian, + requireAdmin, +}; +======= module.exports = { requireAuth, }; +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index df4f56f..6cc16e1 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -8,6 +8,665 @@ const { signLibrarianToken } = require('../lib/librarianToken'); const router = express.Router(); const prisma = new PrismaClient(); +<<<<<<< HEAD +// ==================== 辅助函数 ==================== + +function validateEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +} + +function validatePassword(password) { + return password && password.length >= 6; +} + +// ==================== 统一登录接口 ==================== + +router.post('/login', async (req, res) => { + const { email, password, type } = req.body; + + // 基本验证 + if (!email || !password) { + return res.status(400).json({ error: '邮箱和密码都是必需的' }); + } + + try { + // 学生登录 + if (type === 'student' || !type) { + const user = await prisma.user.findUnique({ + where: { email }, + select: { + id: true, + name: true, + email: true, + passwordHash: true, + role: true, + studentId: true, + } + }); + + if (!user) { + return res.status(401).json({ error: '用户不存在', type: 'student' }); + } + + if (user.role === 'LIBRARIAN' || user.role === 'ADMIN') { + return res.status(401).json({ + error: user.role === 'ADMIN' ? '请使用管理员入口登录' : '请使用馆员入口登录', + type: user.role.toLowerCase() + }); + } + + const isValid = await bcrypt.compare(password, user.passwordHash); + if (!isValid) { + return res.status(401).json({ error: '密码错误', type: 'student' }); + } + + const token = signToken({ + sub: String(user.id), + id: user.id, + role: user.role, + email: user.email + }); + + const { passwordHash, ...userWithoutPassword } = user; + + return res.json({ + success: true, + message: '学生登录成功', + token, + user: userWithoutPassword + }); + } + + // 馆员登录 +if (type === 'librarian') { + // 从 User 表查询馆员 + const librarian = await prisma.user.findFirst({ + where: { + OR: [ + { email: email }, + { employeeId: email } + ], + role: 'LIBRARIAN' + }, + select: { + id: true, + name: true, + email: true, + employeeId: true, + passwordHash: true, + role: true + } + }); + + if (!librarian) { + return res.status(401).json({ error: '工号不存在', type: 'librarian' }); + } + + const isValid = await bcrypt.compare(password, librarian.passwordHash); + if (!isValid) { + return res.status(401).json({ error: '密码错误', type: 'librarian' }); + } + + const token = signToken({ + sub: String(librarian.id), + id: librarian.id, + role: librarian.role, + email: librarian.email + }); + + const { passwordHash, ...librarianWithoutPassword } = librarian; + + return res.json({ + success: true, + message: '馆员登录成功', + token, + librarian: librarianWithoutPassword + }); +} + + // 管理员登录 + if (type === 'admin') { + const user = await prisma.user.findUnique({ + where: { email }, + select: { + id: true, + name: true, + email: true, + passwordHash: true, + role: true, + } + }); + + if (!user) { + return res.status(401).json({ error: '用户不存在', type: 'admin' }); + } + + if (user.role !== 'ADMIN') { + return res.status(401).json({ error: '非管理员账号', type: 'admin' }); + } + + const isValid = await bcrypt.compare(password, user.passwordHash); + if (!isValid) { + return res.status(401).json({ error: '密码错误', type: 'admin' }); + } + + const token = signToken({ + sub: String(user.id), + id: user.id, + role: user.role, + email: user.email + }); + + const { passwordHash, ...userWithoutPassword } = user; + + return res.json({ + success: true, + message: '管理员登录成功', + token, + user: userWithoutPassword + }); + } + + return res.status(400).json({ error: '无效的登录类型' }); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ error: '登录过程中发生错误,请稍后重试' }); + } +}); + +// ==================== 学生登录接口 ==================== + +router.post('/login-student', async (req, res) => { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: '邮箱和密码都是必需的' }); + } + + try { + const user = await prisma.user.findUnique({ + where: { email }, + select: { + id: true, + name: true, + email: true, + passwordHash: true, + role: true, + studentId: true, + } + }); + + if (!user) { + return res.status(401).json({ error: '用户不存在' }); + } + + if (user.role !== 'STUDENT') { + return res.status(401).json({ error: '该账号不是学生账号' }); + } + + const isValid = await bcrypt.compare(password, user.passwordHash); + if (!isValid) { + return res.status(401).json({ error: '密码错误' }); + } + + const token = signToken({ + sub: String(user.id), + id: user.id, + role: user.role, + email: user.email + }); + + const { passwordHash, ...userWithoutPassword } = user; + + return res.json({ + success: true, + message: '学生登录成功', + token, + user: userWithoutPassword + }); + } catch (error) { + console.error('Student login error:', error); + res.status(500).json({ error: '登录过程中发生错误' }); + } +}); + +// ==================== 馆员登录接口 ==================== + +router.post('/login-librarian', async (req, res) => { + const { employeeId, password } = req.body; + + if (!employeeId || !password) { + return res.status(400).json({ error: '工号和密码都是必需的' }); + } + + try { + // 从 User 表查询馆员 + const librarian = await prisma.user.findFirst({ + where: { + OR: [ + { email: employeeId }, + { employeeId: employeeId } + ], + role: 'LIBRARIAN' + }, + select: { + id: true, + name: true, + email: true, + employeeId: true, + passwordHash: true, + role: true + } + }); + + if (!librarian) { + return res.status(401).json({ error: '工号不存在' }); + } + + const isValid = await bcrypt.compare(password, librarian.passwordHash); + if (!isValid) { + return res.status(401).json({ error: '密码错误' }); + } + + const token = signToken({ + sub: String(librarian.id), + id: librarian.id, + role: librarian.role, + email: librarian.email + }); + + const { passwordHash, ...librarianWithoutPassword } = librarian; + + return res.json({ + success: true, + message: '馆员登录成功', + token, + librarian: librarianWithoutPassword + }); + } catch (error) { + console.error('Librarian login error:', error); + res.status(500).json({ error: '登录过程中发生错误' }); + } +}); + +// ==================== 管理员登录接口 ==================== + +router.post('/login-admin', async (req, res) => { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: '邮箱和密码都是必需的' }); + } + + try { + const user = await prisma.user.findUnique({ + where: { email }, + select: { + id: true, + name: true, + email: true, + passwordHash: true, + role: true, + } + }); + + if (!user) { + return res.status(401).json({ error: '用户不存在' }); + } + + if (user.role !== 'ADMIN') { + return res.status(401).json({ error: '非管理员账号' }); + } + + const isValid = await bcrypt.compare(password, user.passwordHash); + if (!isValid) { + return res.status(401).json({ error: '密码错误' }); + } + + const token = signToken({ + sub: String(user.id), + id: user.id, + role: user.role, + email: user.email + }); + + const { passwordHash, ...userWithoutPassword } = user; + + return res.json({ + success: true, + message: '管理员登录成功', + token, + user: userWithoutPassword + }); + } catch (error) { + console.error('Admin login error:', error); + res.status(500).json({ error: '登录过程中发生错误' }); + } +}); + +// ==================== 馆员注册接口 ==================== + +router.post('/register', async (req, res) => { + const { employeeId, name, password } = req.body; + + // 验证输入 + if (!employeeId || !name || !password) { + return res.status(400).json({ + error: '工号、姓名和密码都是必需的', + fields: { + employeeId: !employeeId, + name: !name, + password: !password + } + }); + } + + if (employeeId.length < 3) { + return res.status(400).json({ error: '工号长度不能少于3位' }); + } + + if (name.length < 2) { + return res.status(400).json({ error: '姓名长度不能少于2位' }); + } + + if (password.length < 6) { + return res.status(400).json({ error: '密码长度不能少于6位' }); + } + + try { + // 检查工号是否已存在(在 User 表中) + const existing = await prisma.user.findFirst({ + where: { employeeId } + }); + + if (existing) { + return res.status(409).json({ error: '该工号已被注册' }); + } + + // 加密密码 + const hashedPassword = await bcrypt.hash(password, 10); + + // 创建馆员到 User 表 + const librarian = await prisma.user.create({ + data: { + email: `${employeeId.toLowerCase()}@librarian.com`, // 生成默认邮箱 + employeeId, + name, + passwordHash: hashedPassword, + role: 'LIBRARIAN' + }, + select: { + id: true, + employeeId: true, + name: true, + role: true, + createdAt: true, + } + }); + + return res.status(201).json({ + success: true, + message: '注册成功', + librarian + }); + } catch (error) { + console.error('Librarian registration error:', error); + res.status(500).json({ error: '注册失败,请稍后重试' }); + } +}); + +// ==================== 学生注册接口 ==================== + +router.post('/register-student', async (req, res) => { + const { studentId, name, email, password } = req.body; + + // 验证输入 + if (!studentId || !name || !email || !password) { + return res.status(400).json({ + error: '学号、姓名、邮箱和密码都是必需的' + }); + } + + if (studentId.length < 5) { + return res.status(400).json({ error: '学号格式不正确' }); + } + + if (name.length < 2) { + return res.status(400).json({ error: '姓名长度不能少于2位' }); + } + + if (!validateEmail(email)) { + return res.status(400).json({ error: '邮箱格式不正确' }); + } + + if (!validatePassword(password)) { + return res.status(400).json({ error: '密码长度不能少于6位' }); + } + + try { + // 检查邮箱或学号是否已存在 + const existing = await prisma.user.findFirst({ + where: { + OR: [ + { email }, + { studentId } + ] + } + }); + + if (existing) { + if (existing.email === email) { + return res.status(409).json({ error: '该邮箱已被注册' }); + } + if (existing.studentId === studentId) { + return res.status(409).json({ error: '该学号已被注册' }); + } + } + + // 加密密码 + const hashedPassword = await bcrypt.hash(password, 10); + + // 创建学生 + const student = await prisma.user.create({ + data: { + email, + name, + studentId, + passwordHash: hashedPassword, + role: 'STUDENT', + }, + select: { + id: true, + name: true, + email: true, + studentId: true, + role: true, + createdAt: true, + } + }); + + // 记录审计日志 + try { + await prisma.auditLog.create({ + data: { + userId: student.id, + action: 'STUDENT_REGISTER', + entity: 'User', + entityId: student.id, + detail: `Student ${studentId} (${name}) registered` + } + }); + } catch (logError) { + console.error('Failed to create audit log:', logError); + } + + return res.status(201).json({ + success: true, + message: '注册成功', + student + }); + } catch (error) { + console.error('Student registration error:', error); + res.status(500).json({ error: '注册失败,请稍后重试' }); + } +}); + +// ==================== 验证 Token 接口 ==================== + +router.get('/verify', async (req, res) => { + const authHeader = req.headers.authorization; + + if (!authHeader) { + return res.status(401).json({ error: '未提供认证令牌' }); + } + + const token = authHeader.replace('Bearer ', ''); + + try { + // 尝试作为用户 token 验证 + try { + const { verifyToken } = require('../lib/token'); + const payload = verifyToken(token); + + const user = await prisma.user.findUnique({ + where: { id: payload.id }, + select: { + id: true, + name: true, + email: true, + role: true, + studentId: true, + } + }); + + if (user) { + return res.json({ + valid: true, + type: 'user', + user + }); + } + } catch (userError) { + // 用户 token 验证失败,尝试馆员 token + } + + // 尝试作为馆员 token 验证 + try { + const { verifyLibrarianToken } = require('../lib/librarianToken'); + const payload = verifyLibrarianToken(token); + + const librarian = await prisma.librarian.findUnique({ + where: { id: payload.id }, + select: { + id: true, + employeeId: true, + name: true, + } + }); + + if (librarian) { + return res.json({ + valid: true, + type: 'librarian', + librarian + }); + } + } catch (librarianError) { + // 馆员 token 验证失败 + } + + return res.status(401).json({ valid: false, error: '无效的令牌' }); + } catch (error) { + console.error('Token verification error:', error); + res.status(500).json({ error: '令牌验证失败' }); + } +}); + +// ==================== 修改密码接口 ==================== + +router.post('/change-password', async (req, res) => { + const authHeader = req.headers.authorization; + + if (!authHeader) { + return res.status(401).json({ error: '未提供认证令牌' }); + } + + const token = authHeader.replace('Bearer ', ''); + const { oldPassword, newPassword } = req.body; + + if (!oldPassword || !newPassword) { + return res.status(400).json({ error: '旧密码和新密码都是必需的' }); + } + + if (!validatePassword(newPassword)) { + return res.status(400).json({ error: '新密码长度不能少于6位' }); + } + + try { + // 尝试作为用户修改密码 + try { + const { verifyToken } = require('../lib/token'); + const payload = verifyToken(token); + + const user = await prisma.user.findUnique({ + where: { id: payload.id } + }); + + if (user) { + const isValid = await bcrypt.compare(oldPassword, user.passwordHash); + if (!isValid) { + return res.status(401).json({ error: '旧密码错误' }); + } + + const hashedPassword = await bcrypt.hash(newPassword, 10); + await prisma.user.update({ + where: { id: user.id }, + data: { passwordHash: hashedPassword } + }); + + return res.json({ success: true, message: '密码修改成功' }); + } + } catch (userError) { + // 用户 token 验证失败,尝试馆员 token + } + + // 尝试作为馆员修改密码 + try { + const { verifyLibrarianToken } = require('../lib/librarianToken'); + const payload = verifyLibrarianToken(token); + + const librarian = await prisma.librarian.findUnique({ + where: { id: payload.id } + }); + + if (librarian) { + const isValid = await bcrypt.compare(oldPassword, librarian.password); + if (!isValid) { + return res.status(401).json({ error: '旧密码错误' }); + } + + const hashedPassword = await bcrypt.hash(newPassword, 10); + await prisma.librarian.update({ + where: { id: librarian.id }, + data: { password: hashedPassword } + }); + + return res.json({ success: true, message: '密码修改成功' }); + } + } catch (librarianError) { + // 馆员 token 验证失败 + } + + return res.status(401).json({ error: '无效的认证令牌' }); + } catch (error) { + console.error('Password change error:', error); + res.status(500).json({ error: '密码修改失败' }); + } +}); + +module.exports = router; +======= // --- ͳһ¼ӿ (ѧͼԱԱ) --- router.post('/login', async (req, res) => { const { email, password, type } = req.body; @@ -179,3 +838,4 @@ router.post('/login-admin', async (req, res) => { }); module.exports = router; +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 diff --git a/backend/src/routes/books.js b/backend/src/routes/books.js index d527a0d..bf83a84 100644 --- a/backend/src/routes/books.js +++ b/backend/src/routes/books.js @@ -1,7 +1,11 @@ const express = require('express'); const prisma = require('../lib/prisma'); +<<<<<<< HEAD +const { requireAuth } = require('../middleware/auth'); +======= const { requireLibrarianAuth } = require('../middleware/librarianAuth'); +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 const router = express.Router(); @@ -14,6 +18,23 @@ const BOOK_SELECT = { description: true, language: true, createdAt: true, +<<<<<<< HEAD + updatedAt: true, +}; + +// ==================== 权限检查中间件 ==================== +function checkLibrarianOrAdmin(req, res, next) { + if (!req.user) { + return res.status(401).json({ error: '未认证' }); + } + if (req.user.role !== 'LIBRARIAN' && req.user.role !== 'ADMIN') { + return res.status(403).json({ error: '权限不足,需要馆员或管理员权限' }); + } + next(); +} + +// ==================== 公开接口(无需认证) ==================== +======= }; const BOOK_DETAIL_INCLUDE = { @@ -106,6 +127,7 @@ async function writeAuditLog(action, entityId, detail) { console.error('Failed to write audit log:', error); } } +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 // 获取所有图书 router.get('/', async (req, res) => { @@ -114,22 +136,66 @@ router.get('/', async (req, res) => { orderBy: { id: 'asc' }, include: { copies: { +<<<<<<< HEAD + select: { + id: true, + barcode: true, + status: true, + floor: true, + libraryArea: true, + shelfNo: true, + shelfLevel: true, + } +======= select: { status: true } +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 } } }); const booksWithCount = books.map(book => { const availableCopies = book.copies.filter(c => c.status === 'AVAILABLE').length; +<<<<<<< HEAD + const firstCopy = book.copies[0] || {}; + return { + id: book.id, + title: book.title, + author: book.author, + isbn: book.isbn, + genre: book.genre, + description: book.description, + language: book.language, + createdAt: book.createdAt, + updatedAt: book.updatedAt, + availableCopies: availableCopies, + totalCopies: book.copies.length, + floor: firstCopy.floor || 1, + libraryArea: firstCopy.libraryArea || '', + shelfNo: firstCopy.shelfNo || 'A', + shelfLevel: firstCopy.shelfLevel || 1, + copies: book.copies +======= return { ...book, availableCopies: availableCopies, totalCopies: book.copies.length +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 }; }); res.json({ data: booksWithCount }); } catch (error) { +<<<<<<< HEAD + console.error('Failed to fetch books:', error); + res.status(500).json({ error: 'Failed to fetch books', detail: error.message }); + } +}); + +// 图书搜索功能 +router.get('/search', async (req, res) => { + try { + const { title, author, keyword } = req.query; +======= res.status(500).json({ error: 'Failed to fetch books', detail: error.message, @@ -142,10 +208,15 @@ router.get('/search', async (req, res) => { try { const { title, author, keyword } = req.query; +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 const whereCondition = {}; if (title || author || keyword) { whereCondition.OR = []; +<<<<<<< HEAD + if (title) whereCondition.OR.push({ title: { contains: title } }); + if (author) whereCondition.OR.push({ author: { contains: author } }); +======= if (title) { whereCondition.OR.push({ title: { contains: title } }); @@ -155,6 +226,7 @@ router.get('/search', async (req, res) => { whereCondition.OR.push({ author: { contains: author } }); } +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 if (keyword) { whereCondition.OR.push( { title: { contains: keyword } }, @@ -189,6 +261,11 @@ router.get('/search', async (req, res) => { }; }); +<<<<<<< HEAD + res.json({ success: true, data: booksWithCount, count: booksWithCount.length }); + } catch (error) { + res.status(500).json({ success: false, error: 'Failed to search books', detail: error.message }); +======= res.json({ success: true, data: booksWithCount, @@ -200,14 +277,20 @@ router.get('/search', async (req, res) => { error: 'Failed to search books', detail: error.message, }); +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 } }); // 获取单本图书详情 router.get('/:id', async (req, res) => { +<<<<<<< HEAD + const bookId = Number(req.params.id); + if (isNaN(bookId)) { +======= const bookId = Number.parseInt(req.params.id, 10); if (Number.isNaN(bookId)) { +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 return res.status(400).json({ error: 'Invalid book id' }); } @@ -215,9 +298,17 @@ router.get('/:id', async (req, res) => { const book = await prisma.book.findUnique({ where: { id: bookId }, include: { +<<<<<<< HEAD + copies: { + select: { id: true, barcode: true, floor: true, libraryArea: true, shelfNo: true, shelfLevel: true, status: true } + }, + ratings: { + include: { user: { select: { id: true, name: true } } } +======= ...BOOK_DETAIL_INCLUDE, copies: { select: { id: true, barcode: true, floor: true, libraryArea: true, shelfNo: true, shelfLevel: true, status: true } +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 } } }); @@ -226,12 +317,15 @@ router.get('/:id', async (req, res) => { return res.status(404).json({ error: 'Book not found' }); } +<<<<<<< HEAD +======= const ratingCount = book.ratings.length; const averageRating = ratingCount === 0 ? null : Number((book.ratings.reduce((sum, rating) => sum + rating.stars, 0) / ratingCount).toFixed(2)); +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 const availableCopies = book.copies.filter(c => c.status === 'AVAILABLE').length; res.json({ @@ -240,6 +334,44 @@ router.get('/:id', async (req, res) => { ...book, availableCopies: availableCopies, totalCopies: book.copies.length, +<<<<<<< HEAD + } + }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch book detail', detail: error.message }); + } +}); + +// ==================== 馆员/管理员接口(需要认证) ==================== + +// 添加图书 +router.post('/', requireAuth, checkLibrarianOrAdmin, async (req, res) => { + try { + const { + title, author, isbn, genre, description, language, + floor, libraryArea, shelfNo, shelfLevel + } = req.body; + + if (!title || !author || !isbn || !genre) { + return res.status(400).json({ error: '书名、作者、ISBN和分类是必填项' }); + } + + // 检查 ISBN 是否已存在 + const existingBook = await prisma.book.findUnique({ where: { isbn: isbn.trim() } }); + if (existingBook) { + return res.status(409).json({ error: '该 ISBN 已存在' }); + } + + // 创建图书 + const book = await prisma.book.create({ + data: { + title: title.trim(), + author: author.trim(), + isbn: isbn.trim(), + genre: genre.trim(), + description: description?.trim() || null, + language: language?.trim() || 'English', +======= stats: { averageRating, activeLoans: book.loans.filter((loan) => !loan.returnDate).length, @@ -278,10 +410,178 @@ router.post('/', requireLibrarianAuth, async (req, res) => { genre, description, language, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 }, select: BOOK_SELECT, }); +<<<<<<< HEAD + // 创建默认副本(使用前端传来的位置信息) + await prisma.copy.create({ + data: { + bookId: book.id, + barcode: `BC-${book.id}-1`, + floor: floor || 1, + libraryArea: libraryArea || `${genre}区`, + shelfNo: shelfNo || 'A', + shelfLevel: shelfLevel || 1, + status: 'AVAILABLE' + } + }); + + // 记录审计日志 + await prisma.auditLog.create({ + data: { + userId: req.user.role === 'ADMIN' ? req.user.id : null, + action: 'CREATE_BOOK', + entity: 'Book', + entityId: book.id, + detail: `${req.user.role === 'LIBRARIAN' ? '馆员' : '管理员'} ${req.user.name || req.user.email} 添加了图书《${book.title}》` + } + }); + + // 返回完整的图书信息(包含副本) + const fullBook = await prisma.book.findUnique({ + where: { id: book.id }, + include: { + copies: { + select: { id: true, barcode: true, status: true, floor: true, libraryArea: true, shelfNo: true, shelfLevel: true } + } + } + }); + + const availableCopies = fullBook.copies.filter(c => c.status === 'AVAILABLE').length; + const firstCopy = fullBook.copies[0] || {}; + + res.status(201).json({ + success: true, + message: '图书添加成功', + book: { + ...fullBook, + availableCopies, + totalCopies: fullBook.copies.length, + floor: firstCopy.floor, + libraryArea: firstCopy.libraryArea, + shelfNo: firstCopy.shelfNo, + shelfLevel: firstCopy.shelfLevel, + } + }); + } catch (error) { + console.error('Create book error:', error); + res.status(500).json({ error: '添加图书失败' }); + } +}); + +// 更新图书信息 +router.put('/:id', requireAuth, checkLibrarianOrAdmin, async (req, res) => { + try { + const bookId = Number(req.params.id); + const { + title, author, isbn, genre, description, language, + floor, libraryArea, shelfNo, shelfLevel + } = req.body; + + if (isNaN(bookId)) { + return res.status(400).json({ error: '无效的图书ID' }); + } + + if (!title || !author || !isbn || !genre) { + return res.status(400).json({ error: '书名、作者、ISBN和分类是必填项' }); + } + + // 检查图书是否存在 + const existingBook = await prisma.book.findUnique({ where: { id: bookId } }); + if (!existingBook) { + return res.status(404).json({ error: '图书不存在' }); + } + + // 检查 ISBN 是否被其他图书使用 + if (isbn.trim() !== existingBook.isbn) { + const isbnConflict = await prisma.book.findUnique({ where: { isbn: isbn.trim() } }); + if (isbnConflict) { + return res.status(409).json({ error: '该 ISBN 已被其他图书使用' }); + } + } + + // 更新图书 + const updatedBook = await prisma.book.update({ + where: { id: bookId }, + data: { + title: title.trim(), + author: author.trim(), + isbn: isbn.trim(), + genre: genre.trim(), + description: description?.trim() || null, + language: language?.trim() || 'English', + }, + select: BOOK_SELECT, + }); + + // 如果传入了位置信息,更新第一个副本的位置 + if (floor || libraryArea || shelfNo || shelfLevel) { + const firstCopy = await prisma.copy.findFirst({ where: { bookId: bookId } }); + if (firstCopy) { + await prisma.copy.update({ + where: { id: firstCopy.id }, + data: { + floor: floor !== undefined ? floor : firstCopy.floor, + libraryArea: libraryArea !== undefined ? libraryArea : firstCopy.libraryArea, + shelfNo: shelfNo !== undefined ? shelfNo : firstCopy.shelfNo, + shelfLevel: shelfLevel !== undefined ? shelfLevel : firstCopy.shelfLevel, + } + }); + } + } + + // 记录审计日志 + await prisma.auditLog.create({ + data: { + userId: req.user.role === 'ADMIN' ? req.user.id : null, + action: 'UPDATE_BOOK', + entity: 'Book', + entityId: bookId, + detail: `${req.user.role === 'LIBRARIAN' ? '馆员' : '管理员'} ${req.user.name || req.user.email} 更新了图书《${title}》` + } + }); + + // 返回完整的图书信息 + const fullBook = await prisma.book.findUnique({ + where: { id: bookId }, + include: { + copies: { + select: { id: true, barcode: true, status: true, floor: true, libraryArea: true, shelfNo: true, shelfLevel: true } + } + } + }); + + const availableCopies = fullBook.copies.filter(c => c.status === 'AVAILABLE').length; + const firstCopy = fullBook.copies[0] || {}; + + res.json({ + success: true, + message: '图书更新成功', + book: { + ...fullBook, + availableCopies, + totalCopies: fullBook.copies.length, + floor: firstCopy.floor, + libraryArea: firstCopy.libraryArea, + shelfNo: firstCopy.shelfNo, + shelfLevel: firstCopy.shelfLevel, + } + }); + } catch (error) { + console.error('Update book error:', error); + res.status(500).json({ error: '更新图书失败' }); + } +}); + +// 删除图书 +router.delete('/:id', requireAuth, checkLibrarianOrAdmin, async (req, res) => { + const bookId = Number(req.params.id); + if (isNaN(bookId)) { + return res.status(400).json({ error: '无效的图书ID' }); +======= await writeAuditLog( 'CREATE_BOOK', book.id, @@ -311,11 +611,49 @@ router.delete('/:id', requireLibrarianAuth, async (req, res) => { if (Number.isNaN(bookId)) { return res.status(400).json({ error: 'Invalid book id' }); +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 } try { const book = await prisma.book.findUnique({ where: { id: bookId }, +<<<<<<< HEAD + include: { + copies: { + include: { + loans: { where: { returnDate: null } } + } + } + } + }); + + if (!book) { + return res.status(404).json({ error: '图书不存在' }); + } + + // 检查是否有未归还的借阅 + const hasActiveLoans = book.copies.some(copy => copy.loans.length > 0); + if (hasActiveLoans) { + return res.status(400).json({ error: '该图书有未归还的借阅记录,无法删除' }); + } + + await prisma.book.delete({ where: { id: bookId } }); + + await prisma.auditLog.create({ + data: { + userId: req.user.role === 'ADMIN' ? req.user.id : null, + action: 'DELETE_BOOK', + entity: 'Book', + entityId: bookId, + detail: `${req.user.role === 'LIBRARIAN' ? '馆员' : '管理员'} ${req.user.name || req.user.email} 删除了图书《${book.title}》` + } + }); + + res.json({ success: true, message: '图书删除成功' }); + } catch (error) { + console.error('Delete book error:', error); + res.status(500).json({ error: '删除图书失败' }); +======= select: { id: true, title: true, @@ -365,6 +703,7 @@ router.delete('/:id', requireLibrarianAuth, async (req, res) => { error: 'Failed to delete book', detail: error.message, }); +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 } }); diff --git a/backend/src/routes/loans.js b/backend/src/routes/loans.js index 26b51e9..0cc56eb 100644 --- a/backend/src/routes/loans.js +++ b/backend/src/routes/loans.js @@ -1,9 +1,279 @@ +<<<<<<< HEAD +// routes/loans.js - 馆员借书给学生 (完善版) + +======= +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 const express = require('express'); const prisma = require('../lib/prisma'); const { requireAuth } = require('../middleware/auth'); const router = express.Router(); +<<<<<<< HEAD +// 配置 +const LOAN_DURATION_DAYS = 30; + +// ==================== 权限检查中间件 ==================== +function checkLibrarianOrAdmin(req, res, next) { + if (!req.user) { + return res.status(401).json({ message: '未认证' }); + } + if (req.user.role !== 'LIBRARIAN' && req.user.role !== 'ADMIN') { + return res.status(403).json({ message: '权限不足,需要馆员或管理员权限' }); + } + next(); +} + +// ==================== 辅助函数 ==================== + +async function calculateDueDate(checkoutDate) { + const dueDate = new Date(checkoutDate); + dueDate.setDate(dueDate.getDate() + LOAN_DURATION_DAYS); + return dueDate; +} + +// ==================== 馆员专用 API ==================== + +// 馆员搜索学生 +router.get('/users/search', requireAuth, checkLibrarianOrAdmin, async (req, res) => { + try { + const keyword = (req.query.keyword || '').trim(); + + if (!keyword) { + return res.status(400).json({ message: '请输入搜索关键词' }); + } + + const students = await prisma.user.findMany({ + where: { + role: 'STUDENT', + OR: [ + { studentId: { contains: keyword } }, + { email: { contains: keyword } }, + { name: { contains: keyword } } + ] + }, + select: { + id: true, + name: true, + email: true, + studentId: true, + role: true, + createdAt: true, + } + }); + + // 获取每个学生的借阅统计 + const usersWithStats = await Promise.all(students.map(async (student) => { + const currentBorrowCount = await prisma.loan.count({ + where: { + userId: student.id, + returnDate: null + } + }); + + const overdueLoans = await prisma.loan.count({ + where: { + userId: student.id, + returnDate: null, + dueDate: { lt: new Date() } + } + }); + + const totalBorrowed = await prisma.loan.count({ + where: { userId: student.id } + }); + + return { + ...student, + stats: { + currentBorrowCount, + hasOverdue: overdueLoans > 0, + overdueCount: overdueLoans, + totalBorrowed, + } + }; + })); + + res.json({ + success: true, + users: usersWithStats + }); + } catch (error) { + console.error('Search students error:', error); + res.status(500).json({ message: '搜索学生失败' }); + } +}); + +// 馆员搜索图书 +router.get('/books/search', requireAuth, checkLibrarianOrAdmin, async (req, res) => { + try { + const keyword = (req.query.keyword || '').trim(); + + if (!keyword) { + return res.status(400).json({ message: '请输入搜索关键词' }); + } + + const books = await prisma.book.findMany({ + where: { + OR: [ + { title: { contains: keyword } }, + { isbn: { contains: keyword } }, + { author: { contains: keyword } } + ] + }, + include: { + copies: { + select: { + id: true, + barcode: true, + status: true, + floor: true, + libraryArea: true, + } + } + } + }); + + const booksWithAvailability = books.map(book => { + const availableCopies = book.copies.filter(c => c.status === 'AVAILABLE'); + const borrowedCopies = book.copies.filter(c => c.status === 'BORROWED'); + + return { + id: book.id, + title: book.title, + author: book.author, + isbn: book.isbn, + genre: book.genre, + description: book.description, + language: book.language, + availableCopies: availableCopies.length, + totalCopies: book.copies.length, + copies: book.copies.map(c => ({ + id: c.id, + barcode: c.barcode, + status: c.status, + location: `${c.libraryArea || ''} ${c.floor || ''}楼`.trim() + })) + }; + }); + + res.json({ + success: true, + books: booksWithAvailability + }); + } catch (error) { + console.error('Search books error:', error); + res.status(500).json({ message: '搜索图书失败' }); + } +}); + +// 馆员借书给学生 (R1.1.12) +router.post('/lend', requireAuth, checkLibrarianOrAdmin, async (req, res) => { + try { + const { userId, bookId, copyId } = req.body; + + if (!userId || !bookId) { + return res.status(400).json({ + success: false, + message: '请选择学生和图书' + }); + } + + const studentId = Number(userId); + const targetBookId = Number(bookId); + + // 验证学生 + const student = await prisma.user.findUnique({ + where: { id: studentId }, + select: { + id: true, + name: true, + email: true, + studentId: true, + role: true, + } + }); + + if (!student || student.role !== 'STUDENT') { + return res.status(404).json({ + success: false, + message: '学生不存在或不是学生账号' + }); + } + + // 验证图书 + const book = await prisma.book.findUnique({ + where: { id: targetBookId }, + include: { + copies: { + where: copyId ? { id: Number(copyId) } : { status: 'AVAILABLE' }, + take: 1 + } + } + }); + + if (!book) { + return res.status(404).json({ + success: false, + message: '图书不存在' + }); + } + + // 检查可用副本 + const availableCopies = copyId + ? book.copies + : book.copies.filter(copy => copy.status === 'AVAILABLE'); + + if (availableCopies.length === 0) { + return res.status(400).json({ + success: false, + message: '该图书没有可用副本' + }); + } + + // 检查学生是否已借阅此书(未归还) + const existingLoan = await prisma.loan.findFirst({ + where: { + userId: studentId, + copy: { bookId: targetBookId }, + returnDate: null + } + }); + + if (existingLoan) { + return res.status(400).json({ + success: false, + message: '该学生已经借阅了这本书且未归还' + }); + } + + // 检查学生是否有逾期图书 + const overdueLoans = await prisma.loan.findMany({ + where: { + userId: studentId, + returnDate: null, + dueDate: { lt: new Date() } + } + }); + + if (overdueLoans.length > 0) { + return res.status(400).json({ + success: false, + message: `该学生有 ${overdueLoans.length} 本逾期图书,请先归还后再借阅`, + overdueCount: overdueLoans.length + }); + } + + // 创建借阅记录 + const selectedCopy = availableCopies[0]; + const checkoutDate = new Date(); + const dueDate = await calculateDueDate(checkoutDate); + + const loan = await prisma.loan.create({ + data: { + userId: studentId, + copyId: selectedCopy.id, +======= // 配置:学生最大借阅数量(可以从 Config 表读取) const MAX_BORROW_STUDENT = 3; const LOAN_DURATION_DAYS = 30; @@ -99,11 +369,44 @@ router.post('/borrow/:bookId', requireAuth, async (req, res, next) => { data: { bookId: Number(bookId), userId, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 checkoutDate, dueDate, fineAmount: 0, finePaid: false, fineForgiven: false +<<<<<<< HEAD + }, + include: { + copy: { + include: { book: true } + }, + user: { + select: { + id: true, + name: true, + studentId: true, + email: true + } + } + } + }); + + // 更新副本状态 + await prisma.copy.update({ + where: { id: selectedCopy.id }, + data: { status: 'BORROWED' } + }); + + // 记录审计日志 + await prisma.auditLog.create({ + data: { + userId: req.user.role === 'ADMIN' ? req.user.id : null, + action: 'LIBRARIAN_LEND', + entity: 'Loan', + entityId: loan.id, + detail: `${req.user.role === 'LIBRARIAN' ? '馆员' : '管理员'} ${req.user.name || req.user.email} 将《${book.title}》借给学生 ${student.name} (${student.studentId}),应还日期: ${dueDate.toLocaleDateString()}` +======= } }); @@ -121,10 +424,332 @@ router.post('/borrow/:bookId', requireAuth, async (req, res, next) => { entity: 'Loan', entityId: loan.id, detail: `User ${userId} borrowed book ${bookId}` +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 } }); res.status(201).json({ +<<<<<<< HEAD + success: true, + message: `借书成功!《${book.title}》已借给 ${student.name}`, + loan: { + id: loan.id, + bookTitle: book.title, + bookAuthor: book.author, + studentName: student.name, + studentId: student.studentId, + checkoutDate: checkoutDate.toISOString(), + dueDate: dueDate.toISOString(), + copyBarcode: selectedCopy.barcode + } + }); + } catch (error) { + console.error('Lend book error:', error); + res.status(500).json({ + success: false, + message: '借书失败,请稍后重试' + }); + } +}); + +// ==================== 4. 馆员还书 (R1.1.13) ==================== + +// 获取当前在借记录 +router.get('/records', requireAuth, checkLibrarianOrAdmin, async (req, res) => { + try { + const { status } = req.query; // active, overdue, all + + let whereCondition = {}; + + if (status === 'active') { + whereCondition.returnDate = null; + } else if (status === 'overdue') { + whereCondition.returnDate = null; + whereCondition.dueDate = { lt: new Date() }; + } + + const loans = await prisma.loan.findMany({ + where: whereCondition, + include: { + user: { + select: { + id: true, + name: true, + email: true, + studentId: true + } + }, + copy: { + include: { + book: { + select: { + id: true, + title: true, + author: true, + isbn: true, + genre: true + } + } + } + } + }, + orderBy: [ + { returnDate: 'asc' }, + { dueDate: 'asc' } + ] + }); + + const loansWithStatus = loans.map(loan => { + const now = new Date(); + const isOverdue = !loan.returnDate && loan.dueDate < now; + const daysOverdue = isOverdue + ? Math.ceil((now - loan.dueDate) / (1000 * 60 * 60 * 24)) + : 0; + + return { + ...loan, + status: loan.returnDate ? 'returned' : (isOverdue ? 'overdue' : 'active'), + daysOverdue, + estimatedFine: isOverdue ? daysOverdue * 0.5 : 0 + }; + }); + + res.json({ + success: true, + loans: loansWithStatus, + stats: { + total: loans.length, + active: loansWithStatus.filter(l => l.status === 'active').length, + overdue: loansWithStatus.filter(l => l.status === 'overdue').length, + returned: loansWithStatus.filter(l => l.status === 'returned').length + } + }); + } catch (error) { + console.error('Fetch loan records error:', error); + res.status(500).json({ message: '获取借阅记录失败' }); + } +}); + +// 馆员还书 (R1.1.13) +router.post('/return', requireAuth, checkLibrarianOrAdmin, async (req, res) => { + try { + const { loanId, waiveFine } = req.body; + + if (!loanId) { + return res.status(400).json({ + success: false, + message: '请选择要归还的借阅记录' + }); + } + + // 查找借阅记录 + const loan = await prisma.loan.findUnique({ + where: { id: Number(loanId) }, + include: { + copy: { + include: { book: true } + }, + user: { + select: { + id: true, + name: true, + studentId: true + } + } + } + }); + + if (!loan) { + return res.status(404).json({ + success: false, + message: '借阅记录不存在' + }); + } + + if (loan.returnDate !== null) { + return res.status(400).json({ + success: false, + message: '该图书已经归还过了' + }); + } + + const returnDate = new Date(); + let fineAmount = 0; + + // 计算罚款 + if (returnDate > loan.dueDate) { + const diffDays = Math.ceil((returnDate - loan.dueDate) / (1000 * 60 * 60 * 24)); + fineAmount = diffDays * 0.5; // 每天 0.5 元 + } + + // 如果免除了罚款 + const finalFine = waiveFine ? 0 : fineAmount; + const fineForgiven = waiveFine && fineAmount > 0; + + // 更新借阅记录 + await prisma.loan.update({ + where: { id: Number(loanId) }, + data: { + returnDate, + fineAmount: finalFine, + finePaid: finalFine === 0, + fineForgiven + } + }); + + // 更新副本状态为可用 + await prisma.copy.update({ + where: { id: loan.copyId }, + data: { status: 'AVAILABLE' } + }); + + // 记录审计日志 + let logDetail = `${req.user.role === 'LIBRARIAN' ? '馆员' : '管理员'} ${req.user.name || req.user.email} 接收学生 ${loan.user?.name} 归还《${loan.copy?.book?.title}》`; + + if (finalFine > 0) { + logDetail += `,罚款 ${finalFine} 元`; + } + if (fineForgiven) { + logDetail += `(已免除原罚款 ${fineAmount} 元)`; + } + + await prisma.auditLog.create({ + data: { + userId: req.user.role === 'ADMIN' ? req.user.id : null, + action: 'LIBRARIAN_RETURN', + entity: 'Loan', + entityId: loan.id, + detail: logDetail + } + }); + + // 构建响应消息 + let message = `《${loan.copy?.book?.title}》已成功归还`; + if (finalFine > 0) { + message += `,逾期罚款 ${finalFine} 元`; + } + if (fineForgiven) { + message += `(已免除罚款)`; + } + + res.json({ + success: true, + message, + returnInfo: { + loanId: loan.id, + bookTitle: loan.copy?.book?.title, + studentName: loan.user?.name, + studentId: loan.user?.studentId, + checkoutDate: loan.checkoutDate, + dueDate: loan.dueDate, + returnDate: returnDate, + daysLate: fineAmount > 0 ? Math.ceil((returnDate - loan.dueDate) / (1000 * 60 * 60 * 24)) : 0, + fineAmount: finalFine, + originalFine: fineAmount, + fineForgiven + } + }); + } catch (error) { + console.error('Return book error:', error); + res.status(500).json({ + success: false, + message: '还书失败,请稍后重试' + }); + } +}); + +// 获取单个借阅记录详情 +router.get('/records/:id', requireAuth, checkLibrarianOrAdmin, async (req, res) => { + try { + const loanId = Number(req.params.id); + + const loan = await prisma.loan.findUnique({ + where: { id: loanId }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + studentId: true + } + }, + copy: { + include: { + book: { + select: { + id: true, + title: true, + author: true, + isbn: true, + genre: true, + description: true + } + } + } + } + } + }); + + if (!loan) { + return res.status(404).json({ message: '借阅记录不存在' }); + } + + const now = new Date(); + const isOverdue = !loan.returnDate && loan.dueDate < now; + const daysOverdue = isOverdue + ? Math.ceil((now - loan.dueDate) / (1000 * 60 * 60 * 24)) + : 0; + + res.json({ + success: true, + loan: { + ...loan, + status: loan.returnDate ? 'returned' : (isOverdue ? 'overdue' : 'active'), + daysOverdue, + estimatedFine: isOverdue ? daysOverdue * 0.5 : 0 + } + }); + } catch (error) { + console.error('Fetch loan detail error:', error); + res.status(500).json({ message: '获取借阅详情失败' }); + } +}); + +// ==================== 学生借还书接口 ==================== + +// 学生获取自己的借阅记录 +router.get('/me', requireAuth, async (req, res) => { + try { + const userId = req.user.id; + + const loans = await prisma.loan.findMany({ + where: { userId }, + include: { + copy: { + include: { + book: { + select: { + id: true, + title: true, + author: true, + isbn: true + } + } + } + } + }, + orderBy: { checkoutDate: 'desc' } + }); + + res.json({ + success: true, + loans + }); + } catch (error) { + console.error('Fetch my loans error:', error); + res.status(500).json({ message: '获取借阅记录失败' }); +======= message: 'Book borrowed successfully', loan: { id: loan.id, @@ -282,6 +907,7 @@ router.post('/admin/force-borrow/:bookId/:userId', requireAuth, async (req, res, res.status(201).json({ message: 'Force borrow successful', loan }); } catch (error) { next(error); +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 } }); diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..2d03ce7 --- /dev/null +++ b/backend/src/server.js @@ -0,0 +1,40 @@ +// server.js +const app = require('./app'); +const prisma = require('./lib/prisma'); + +const PORT = process.env.PORT || 3001; + +async function startServer() { + try { + // 测试数据库连接 + await prisma.$connect(); + console.log('✅ Database connected successfully'); + + // 启动服务器 + app.listen(PORT, () => { + console.log(` +╔═══════════════════════════════════════════════════════╗ +║ 📚 Library Management System API Server ║ +╠═══════════════════════════════════════════════════════╣ +║ 🚀 Server running on: http://localhost:${PORT} ║ +║ 📖 API Documentation: http://localhost:${PORT}/health ║ +║ 🔑 Auth endpoints: /api/auth/* ║ +║ 📕 Books endpoints: /api/books/* ║ +║ 📋 Loans endpoints: /api/loans/* ║ +╚═══════════════════════════════════════════════════════╝ + `); + }); + } catch (error) { + console.error('❌ Failed to start server:', error); + process.exit(1); + } +} + +// 优雅关闭 +process.on('SIGINT', async () => { + console.log('\n👋 Shutting down gracefully...'); + await prisma.$disconnect(); + process.exit(0); +}); + +startServer(); \ No newline at end of file diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json index babf8fb..3773ad4 100644 --- a/frontend/jsconfig.json +++ b/frontend/jsconfig.json @@ -3,6 +3,19 @@ "baseUrl": ".", "paths": { "@/*": ["./src/*"] +<<<<<<< HEAD + }, + "target": "ES6", + "module": "ESNext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "build"] +======= } } +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 } \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5ab7077..2d52fd2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -65,7 +65,10 @@ "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -612,6 +615,30 @@ "@noble/ciphers": "^1.0.0" } }, +<<<<<<< HEAD + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, +======= +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1120,7 +1147,10 @@ "resolved": "https://registry.npmmirror.com/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "engines": { "node": "^14.21.3 || >=16" }, @@ -3320,7 +3350,10 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "devOptional": true, "license": "MIT", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "dependencies": { "undici-types": "~7.19.0" } @@ -3331,7 +3364,10 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "dependencies": { "csstype": "^3.2.2" } @@ -3342,7 +3378,10 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "peerDependencies": { "@types/react": "^19.2.0" } @@ -3404,7 +3443,10 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "bin": { "acorn": "bin/acorn" }, @@ -3666,7 +3708,10 @@ } ], "license": "MIT", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4369,7 +4414,10 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5205,7 +5253,10 @@ "resolved": "https://registry.npmmirror.com/hono/-/hono-4.12.14.tgz", "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "engines": { "node": ">=16.9.0" } @@ -6613,7 +6664,10 @@ } ], "license": "MIT", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6866,7 +6920,10 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "engines": { "node": ">=0.10.0" } @@ -6876,7 +6933,10 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "dependencies": { "scheduler": "^0.27.0" }, @@ -7912,7 +7972,10 @@ "resolved": "https://registry.npmmirror.com/vite/-/vite-8.0.8.tgz", "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "license": "MIT", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -8236,7 +8299,10 @@ "resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", +<<<<<<< HEAD +======= "peer": true, +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/librarian/LibrarianApp.jsx b/frontend/src/librarian/LibrarianApp.jsx index 985db14..a72ee7d 100644 --- a/frontend/src/librarian/LibrarianApp.jsx +++ b/frontend/src/librarian/LibrarianApp.jsx @@ -2,11 +2,33 @@ import { useState, useEffect } from 'react' import LibrarianLogin from './LibrarianLogin' import LibrarianRegister from './LibrarianRegister' import LibrarianDashboard from './LibrarianDashboard' +<<<<<<< HEAD +import { isLibrarianAuthenticated, librarianLogout } from './api' +======= +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 function LibrarianApp() { const [isLoggedIn, setIsLoggedIn] = useState(false) const [librarian, setLibrarian] = useState(null) const [showRegister, setShowRegister] = useState(false) +<<<<<<< HEAD + const [loading, setLoading] = useState(true) + + useEffect(() => { + // 检查登录状态 + if (isLibrarianAuthenticated()) { + const savedLibrarian = localStorage.getItem('librarianInfo') + if (savedLibrarian) { + try { + setLibrarian(JSON.parse(savedLibrarian)) + setIsLoggedIn(true) + } catch (e) { + librarianLogout() + } + } + } + setLoading(false) +======= useEffect(() => { const token = localStorage.getItem('librarianToken') @@ -16,6 +38,7 @@ function LibrarianApp() { setIsLoggedIn(true) setLibrarian(JSON.parse(savedLibrarian)) } +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 }, []) const handleLogin = (user, token) => { @@ -27,8 +50,12 @@ function LibrarianApp() { } const handleLogout = () => { +<<<<<<< HEAD + librarianLogout() +======= localStorage.removeItem('librarianToken') localStorage.removeItem('librarianInfo') +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 setIsLoggedIn(false) setLibrarian(null) } @@ -37,7 +64,19 @@ function LibrarianApp() { setShowRegister(false) } +<<<<<<< HEAD + if (loading) { + return ( +
+
加载中...
+
+ ) + } + + if (isLoggedIn && librarian) { +======= if (isLoggedIn) { +>>>>>>> ddb6f928a0a4d415de4bcd19023920f056be6972 return } diff --git a/frontend/src/librarian/LibrarianBookManager.jsx b/frontend/src/librarian/LibrarianBookManager.jsx new file mode 100644 index 0000000..9d823d4 --- /dev/null +++ b/frontend/src/librarian/LibrarianBookManager.jsx @@ -0,0 +1,480 @@ +import { useEffect, useState } from 'react' +import { LIBRARIAN_API_URL } from './api' + +const initialForm = { + title: '', + author: '', + isbn: '', + genre: '', + description: '', + language: 'English', + floor: 1, + libraryArea: '', + shelfNo: 'A', + shelfLevel: 1, +}; + +function normalizeBookToForm(book) { + return { + title: book.title || '', + author: book.author || '', + isbn: book.isbn || '', + genre: book.genre || '', + description: book.description || '', + language: book.language || 'English', + floor: book.floor || 1, + libraryArea: book.libraryArea || '', + shelfNo: book.shelfNo || 'A', + shelfLevel: book.shelfLevel || 1, + }; +} + +function formatDate(value) { + if (!value) return '暂无' + return new Date(value).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +export default function LibrarianBookManager({ librarian, onBack, onLogout }) { + const [books, setBooks] = useState([]) + const [form, setForm] = useState(initialForm) + const [editingBookId, setEditingBookId] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [deletingId, setDeletingId] = useState(null) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const isEditing = editingBookId !== null + + const fetchBooks = async () => { + setLoading(true) + setError('') + try { + const response = await fetch(`${LIBRARIAN_API_URL}/books`) + const data = await response.json() + if (!response.ok) throw new Error(data.error || '获取图书列表失败') + setBooks(Array.isArray(data.data) ? data.data : []) + } catch (fetchError) { + setError(fetchError.message || '获取图书列表失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void fetchBooks() + }, []) + + const handleChange = (event) => { + const { name, value } = event.target + setForm((current) => ({ ...current, [name]: value })) + } + + const handleUnauthorized = () => { + setError('登录状态已失效,请重新登录') + if (onLogout) onLogout() + } + + const handleSubmit = async (event) => { + event.preventDefault() + setError('') + setSuccess('') + + if (!form.title.trim() || !form.author.trim() || !form.isbn.trim() || !form.genre.trim()) { + setError('请填写完整的图书基础信息') + return + } + + setSaving(true) + + try { + const token = localStorage.getItem('librarianToken') + const response = await fetch( + isEditing + ? `${LIBRARIAN_API_URL}/books/${editingBookId}` + : `${LIBRARIAN_API_URL}/books`, + { + method: isEditing ? 'PUT' : 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(form), + } + ) + + const data = await response.json() + + if (response.status === 401) { + handleUnauthorized() + return + } + + if (!response.ok) { + throw new Error(data.error || (isEditing ? '更新图书失败' : '新增图书失败')) + } + + setBooks((current) => { + if (isEditing) { + return current.map((book) => (book.id === data.book.id ? data.book : book)) + } + return [...current, data.book].sort((left, right) => left.id - right.id) + }) + setForm(initialForm) + setEditingBookId(null) + setSuccess(isEditing ? `已更新《${data.book.title}》` : '图书新增成功') + } catch (submitError) { + setError(submitError.message || (isEditing ? '更新图书失败' : '新增图书失败')) + } finally { + setSaving(false) + } + } + + const handleEdit = (book) => { + setEditingBookId(book.id) + setForm(normalizeBookToForm(book)) + setError('') + setSuccess('') + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + + const handleDelete = async (book) => { + const confirmed = window.confirm(`确定删除《${book.title}》吗?`) + if (!confirmed) return + + setDeletingId(book.id) + setError('') + setSuccess('') + + try { + const token = localStorage.getItem('librarianToken') + const response = await fetch(`${LIBRARIAN_API_URL}/books/${book.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + + const data = await response.json() + + if (response.status === 401) { + handleUnauthorized() + return + } + + if (!response.ok) { + throw new Error(data.error || '删除图书失败') + } + + setBooks((current) => current.filter((item) => item.id !== book.id)) + if (editingBookId === book.id) { + setEditingBookId(null) + setForm(initialForm) + } + setSuccess(`已删除《${book.title}》`) + } catch (deleteError) { + setError(deleteError.message || '删除图书失败') + } finally { + setDeletingId(null) + } + } + + const handleReset = () => { + setEditingBookId(null) + setForm(initialForm) + setError('') + setSuccess('') + } + + return ( +
+
+
+
+ +
+

图书管理

+

新增图书并管理现有馆藏记录

+
+
+ +
+
+ 当前管理员: + + {librarian?.name}({librarian?.employeeId}) + +
+ +
+
+
+ +
+
+
+

+ {isEditing ? '编辑图书' : '新增图书'} +

+

+ {isEditing ? '可在这里修正图书信息和书架位置' : '带 * 的字段为必填项'} +

+
+ + {isEditing && ( +
+ 正在编辑已有馆藏记录。修改完成后点击"保存修改",或点"取消编辑"返回新增模式。 +
+ )} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {/* 书架位置字段 */} +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +