first commit
42
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
# Copilot Instructions
|
||||
|
||||
<!-- Use this file to provide workspace-specific custom instructions to Copilot. For more details, visit https://code.visualstudio.com/docs/copilot/copilot-customization#_use-a-githubcopilotinstructionsmd-file -->
|
||||
|
||||
## Project Overview
|
||||
This is an intelligent library management system built with Next.js, TypeScript, and PostgreSQL. The system supports both admin and student interfaces with comprehensive CRUD operations.
|
||||
|
||||
## Database Information
|
||||
- Database: PostgreSQL (127.0.0.1:5432)
|
||||
- Username: feie9454
|
||||
- Password: (empty)
|
||||
- Schema: library
|
||||
|
||||
## Code Style Guidelines
|
||||
- Use TypeScript for all files
|
||||
- Use Tailwind CSS for styling
|
||||
- Follow Next.js App Router conventions
|
||||
- Use meaningful variable and function names
|
||||
- Add proper error handling and validation
|
||||
- Use server components where appropriate
|
||||
- Implement proper authentication and authorization
|
||||
|
||||
## Key Features to Implement
|
||||
1. Book Management (CRUD operations, batch import)
|
||||
2. Student Account Management
|
||||
3. Borrowing/Returning/Renewal System
|
||||
4. Reservation System
|
||||
5. Fine Management
|
||||
6. Book Reviews and Recommendations
|
||||
7. Admin Dashboard
|
||||
8. Student Portal
|
||||
|
||||
## Database Schema
|
||||
The system uses tables: books, students, admins, borrow_records, reservations, fines, reviews, system_settings
|
||||
Refer to the SQL files for complete schema definition.
|
||||
|
||||
## Architecture Patterns
|
||||
- Use API routes for database operations
|
||||
- Implement proper data validation
|
||||
- Use React Query/SWR for data fetching
|
||||
- Separate business logic from UI components
|
||||
- Use environment variables for configuration
|
||||
22
.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "启动开发服务器",
|
||||
"type": "shell",
|
||||
"command": "bun",
|
||||
"args": [
|
||||
"dev"
|
||||
],
|
||||
"group": "build",
|
||||
"isBackground": true,
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "new"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
217
README.md
@ -1,36 +1,213 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# 智能图书馆管理系统
|
||||
|
||||
## Getting Started
|
||||
基于Next.js、TypeScript和PostgreSQL构建的现代化图书馆管理系统,支持管理员和学生双界面操作。
|
||||
|
||||
First, run the development server:
|
||||
## 🚀 功能特性
|
||||
|
||||
### 管理员功能
|
||||
- 📚 **图书管理**: 添加、编辑、删除图书,支持批量导入
|
||||
- 👥 **学生管理**: 学生账户管理,权限控制
|
||||
- 📊 **借阅管理**: 处理借阅、归还、续借操作
|
||||
- 💰 **罚款管理**: 自动计算逾期罚款,处理缴费
|
||||
- 📈 **数据统计**: 实时统计和图表展示
|
||||
- ⚙️ **系统设置**: 灵活的系统参数配置
|
||||
|
||||
### 学生功能
|
||||
- 🔍 **图书检索**: 按书名、作者、ISBN等多维度搜索
|
||||
- 📖 **借阅管理**: 查看当前借阅、续借操作
|
||||
- 📅 **预约系统**: 预约已借出的热门图书
|
||||
- ⭐ **图书评价**: 对借阅图书进行评分和评论
|
||||
- 📋 **借阅历史**: 查看完整的借阅记录
|
||||
- 💳 **账户状态**: 实时查看借阅额度和罚款信息
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **前端**: Next.js 15, React 19, TypeScript
|
||||
- **样式**: Tailwind CSS
|
||||
- **数据库**: PostgreSQL
|
||||
- **ORM**: Drizzle ORM
|
||||
- **状态管理**: TanStack Query (React Query)
|
||||
- **表单处理**: React Hook Form + Zod
|
||||
- **包管理**: Bun
|
||||
- **图标**: Lucide React
|
||||
|
||||
## 📋 系统要求
|
||||
|
||||
- Node.js 18.18+ 或 20+
|
||||
- Bun 1.0+
|
||||
- PostgreSQL 12+
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 克隆项目
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd library_cs_design
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
### 3. 配置环境变量
|
||||
复制 `.env.example` 为 `.env.local` 并配置:
|
||||
```env
|
||||
# 数据库配置
|
||||
DATABASE_URL="postgresql://feie9454@127.0.0.1:5432/library"
|
||||
DB_HOST="127.0.0.1"
|
||||
DB_PORT="5432"
|
||||
DB_USER="feie9454"
|
||||
DB_PASSWORD=""
|
||||
DB_NAME="library"
|
||||
|
||||
# NextAuth配置
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="your-secret-key-here"
|
||||
```
|
||||
|
||||
### 4. 设置数据库
|
||||
确保PostgreSQL服务运行,并创建名为 `library` 的数据库和模式:
|
||||
```sql
|
||||
CREATE DATABASE library;
|
||||
\c library;
|
||||
CREATE SCHEMA library;
|
||||
```
|
||||
|
||||
运行SQL脚本导入数据库结构和示例数据(参考附件中的SQL文件)。
|
||||
|
||||
### 5. 启动开发服务器
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
应用将在 http://localhost:3000 启动。
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
## 📁 项目结构
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router页面
|
||||
│ ├── admin/ # 管理员界面
|
||||
│ ├── student/ # 学生界面
|
||||
│ ├── books/ # 图书相关页面
|
||||
│ └── api/ # API路由
|
||||
├── components/ # 可复用组件
|
||||
├── lib/ # 工具库
|
||||
│ ├── db/ # 数据库配置和模式
|
||||
│ ├── types/ # TypeScript类型定义
|
||||
│ └── utils/ # 工具函数
|
||||
└── hooks/ # 自定义React Hooks
|
||||
```
|
||||
|
||||
## Learn More
|
||||
## 🗄️ 数据库设计
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
### 核心表结构
|
||||
- `books` - 图书信息表
|
||||
- `students` - 学生信息表
|
||||
- `admins` - 管理员信息表
|
||||
- `borrow_records` - 借阅记录表
|
||||
- `reservations` - 预约记录表
|
||||
- `fines` - 罚款记录表
|
||||
- `reviews` - 图书评价表
|
||||
- `system_settings` - 系统设置表
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
### 主要特性
|
||||
- 支持图书多作者存储(数组类型)
|
||||
- 自动触发器处理库存数量
|
||||
- 完善的索引设计优化查询性能
|
||||
- 支持软删除和状态管理
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
## 🔗 API接口
|
||||
|
||||
## Deploy on Vercel
|
||||
### 图书相关
|
||||
- `GET /api/books` - 获取图书列表(支持分页和搜索)
|
||||
- `POST /api/books` - 添加新图书
|
||||
- `GET /api/books/[id]` - 获取图书详情
|
||||
- `PUT /api/books/[id]` - 更新图书信息
|
||||
- `DELETE /api/books/[id]` - 删除图书(软删除)
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
### 借阅相关
|
||||
- `POST /api/borrow` - 借阅图书
|
||||
- `POST /api/borrow/return` - 归还图书
|
||||
- `POST /api/borrow/renew` - 续借图书
|
||||
- `GET /api/borrow` - 获取借阅记录
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
### 学生相关
|
||||
- `GET /api/students` - 获取学生列表
|
||||
- `POST /api/students` - 添加新学生
|
||||
|
||||
## 🎨 界面截图
|
||||
|
||||
### 主页
|
||||
- 现代化设计的欢迎页面
|
||||
- 清晰的功能模块展示
|
||||
- 快速访问入口
|
||||
|
||||
### 管理员控制台
|
||||
- 实时数据统计面板
|
||||
- 快速操作按钮
|
||||
- 最近活动展示
|
||||
|
||||
### 学生门户
|
||||
- 个人借阅状态概览
|
||||
- 当前借阅和预约管理
|
||||
- 便捷的操作界面
|
||||
|
||||
## 🧪 开发说明
|
||||
|
||||
### 代码规范
|
||||
- 使用TypeScript严格模式
|
||||
- ESLint + Prettier代码格式化
|
||||
- 组件使用函数式风格
|
||||
- API使用RESTful设计
|
||||
|
||||
### 数据库操作
|
||||
- 使用Drizzle ORM进行类型安全的数据库操作
|
||||
- 支持事务处理确保数据一致性
|
||||
- 合理使用索引优化查询性能
|
||||
|
||||
### 状态管理
|
||||
- 使用React Query管理服务器状态
|
||||
- 自动缓存和后台更新
|
||||
- 乐观更新提升用户体验
|
||||
|
||||
## 🔒 安全特性
|
||||
|
||||
- 密码哈希存储(bcrypt)
|
||||
- SQL注入防护(参数化查询)
|
||||
- 输入验证和清理
|
||||
- 权限控制和访问限制
|
||||
|
||||
## 📝 待实现功能
|
||||
|
||||
- [ ] 用户认证和会话管理
|
||||
- [ ] 图书批量导入功能
|
||||
- [ ] 邮件通知系统
|
||||
- [ ] 移动端响应式优化
|
||||
- [ ] 数据导出功能
|
||||
- [ ] 系统日志记录
|
||||
- [ ] 高级统计报表
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork项目
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用MIT许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
|
||||
|
||||
## 👥 作者
|
||||
|
||||
- **开发者** - 智能图书馆系统
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
- Next.js团队提供的优秀框架
|
||||
- Tailwind CSS的美观样式系统
|
||||
- Drizzle ORM的类型安全数据库操作
|
||||
- 所有开源项目贡献者
|
||||
|
||||
220
bun.lock
@ -4,9 +4,25 @@
|
||||
"": {
|
||||
"name": "library_cs_design",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@tanstack/react-query": "^5.80.10",
|
||||
"@tanstack/react-query-devtools": "^5.80.10",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/pg": "^8.15.4",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"lucide-react": "^0.520.0",
|
||||
"next": "15.3.4",
|
||||
"next-auth": "^4.24.11",
|
||||
"pg": "^8.16.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.58.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^3.25.67",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@ -26,12 +42,70 @@
|
||||
|
||||
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" } }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="],
|
||||
|
||||
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||
|
||||
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.5", "", { "os": "android", "cpu": "arm" }, "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.5", "", { "os": "android", "cpu": "arm64" }, "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.5", "", { "os": "android", "cpu": "x64" }, "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.5", "", { "os": "linux", "cpu": "arm" }, "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.5", "", { "os": "none", "cpu": "arm64" }, "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.5", "", { "os": "none", "cpu": "x64" }, "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.5", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
|
||||
@ -50,6 +124,8 @@
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.2", "", { "dependencies": { "@eslint/core": "^0.15.0", "levn": "^0.4.1" } }, "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg=="],
|
||||
|
||||
"@hookform/resolvers": ["@hookform/resolvers@5.1.1", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-J/NVING3LMAEvexJkyTLjruSm7aOFx7QX21pzkiJfMoNG0wl5aFEjLTl7ay7IQb9EWY6AkrBy7tHL2Alijpdcg=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
|
||||
@ -142,10 +218,14 @@
|
||||
|
||||
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
||||
|
||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.11.0", "", {}, "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
@ -180,8 +260,18 @@
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.10", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.10", "@tailwindcss/oxide": "4.1.10", "postcss": "^8.4.41", "tailwindcss": "4.1.10" } }, "sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.80.10", "", {}, "sha512-mUNQOtzxkjL6jLbyChZoSBP6A5gQDVRUiPvW+/zw/9ftOAz+H754zCj3D8PwnzPKyHzGkQ9JbH48ukhym9LK1Q=="],
|
||||
|
||||
"@tanstack/query-devtools": ["@tanstack/query-devtools@5.80.0", "", {}, "sha512-D6gH4asyjaoXrCOt5vG5Og/YSj0D/TxwNQgtLJIgWbhbWCC/emu2E92EFoVHh4ppVWg1qT2gKHvKyQBEFZhCuA=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.80.10", "", { "dependencies": { "@tanstack/query-core": "5.80.10" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-6zM098J8sLy9oU60XAdzUlAH4wVzoMVsWUWiiE/Iz4fd67PplxeyL4sw/MPcVJJVhbwGGXCsHn9GrQt2mlAzig=="],
|
||||
|
||||
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.80.10", "", { "dependencies": { "@tanstack/query-devtools": "5.80.0" }, "peerDependencies": { "@tanstack/react-query": "^5.80.10", "react": "^18 || ^19" } }, "sha512-6JL63fSc7kxyGOLV2w466SxhMn/m7LZk/ximQciy6OpVt+n2A8Mq3S0QwhIzfm4WEwLK/F3OELfzRToQburnYA=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="],
|
||||
|
||||
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
@ -190,6 +280,8 @@
|
||||
|
||||
"@types/node": ["@types/node@20.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="],
|
||||
|
||||
"@types/pg": ["@types/pg@8.15.4", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="],
|
||||
@ -292,10 +384,14 @@
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||
@ -314,6 +410,8 @@
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
@ -324,6 +422,8 @@
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
@ -336,6 +436,8 @@
|
||||
|
||||
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
|
||||
|
||||
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||
|
||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
@ -348,6 +450,10 @@
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.31.1", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.2", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-PUjYKWtzOzPtdtQlTHQG3qfv4Y0XT8+Eas6UbxCmxTj7qgMf+39dDujf1BP1I+qqZtw9uzwTh8jYtkMuCq+B0Q=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.44.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-zGAqBzWWkVSFjZpwPOrmCrgO++1kZ5H/rZ4qTGeGOe18iXGVJWf3WPfHOVwFIbmi8kHjfJstC6rJomzGx8g/dQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
@ -370,6 +476,10 @@
|
||||
|
||||
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.5", "@esbuild/android-arm": "0.25.5", "@esbuild/android-arm64": "0.25.5", "@esbuild/android-x64": "0.25.5", "@esbuild/darwin-arm64": "0.25.5", "@esbuild/darwin-x64": "0.25.5", "@esbuild/freebsd-arm64": "0.25.5", "@esbuild/freebsd-x64": "0.25.5", "@esbuild/linux-arm": "0.25.5", "@esbuild/linux-arm64": "0.25.5", "@esbuild/linux-ia32": "0.25.5", "@esbuild/linux-loong64": "0.25.5", "@esbuild/linux-mips64el": "0.25.5", "@esbuild/linux-ppc64": "0.25.5", "@esbuild/linux-riscv64": "0.25.5", "@esbuild/linux-s390x": "0.25.5", "@esbuild/linux-x64": "0.25.5", "@esbuild/netbsd-arm64": "0.25.5", "@esbuild/netbsd-x64": "0.25.5", "@esbuild/openbsd-arm64": "0.25.5", "@esbuild/openbsd-x64": "0.25.5", "@esbuild/sunos-x64": "0.25.5", "@esbuild/win32-arm64": "0.25.5", "@esbuild/win32-ia32": "0.25.5", "@esbuild/win32-x64": "0.25.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ=="],
|
||||
|
||||
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@9.29.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.1", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.29.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ=="],
|
||||
@ -538,6 +648,8 @@
|
||||
|
||||
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
|
||||
|
||||
"jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
|
||||
@ -588,6 +700,10 @@
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.520.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Mvo4mGi1X0iygX3x1Zy5UwjeiBvJOTZe7/c9Z0vLo67E9yOtIIuYusfE2HDwvR3FM5osU0tqTc2tn1aKtfUV8w=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
@ -616,8 +732,14 @@
|
||||
|
||||
"next": ["next@15.3.4", "", { "dependencies": { "@next/env": "15.3.4", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.4", "@next/swc-darwin-x64": "15.3.4", "@next/swc-linux-arm64-gnu": "15.3.4", "@next/swc-linux-arm64-musl": "15.3.4", "@next/swc-linux-x64-gnu": "15.3.4", "@next/swc-linux-x64-musl": "15.3.4", "@next/swc-win32-arm64-msvc": "15.3.4", "@next/swc-win32-x64-msvc": "15.3.4", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA=="],
|
||||
|
||||
"next-auth": ["next-auth@4.24.11", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", "cookie": "^0.7.0", "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", "uuid": "^8.3.2" }, "peerDependencies": { "@auth/core": "0.34.2", "next": "^12.2.5 || ^13 || ^14 || ^15", "nodemailer": "^6.6.5", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, "optionalPeers": ["@auth/core", "nodemailer"] }, "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw=="],
|
||||
|
||||
"oauth": ["oauth@0.9.15", "", {}, "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||
@ -632,6 +754,10 @@
|
||||
|
||||
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
|
||||
|
||||
"oidc-token-hash": ["oidc-token-hash@5.1.0", "", {}, "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA=="],
|
||||
|
||||
"openid-client": ["openid-client@5.7.1", "", { "dependencies": { "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||
@ -648,6 +774,22 @@
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"pg": ["pg@8.16.2", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.2", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.6" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-OtLWF0mKLmpxelOt9BqVq83QV6bTfsS0XLegIeAKqKjurRnRKie1Dc1iL89MugmSLhftxw6NNCyZhm1yQFLMEQ=="],
|
||||
|
||||
"pg-cloudflare": ["pg-cloudflare@1.2.6", "", {}, "sha512-uxmJAnmIgmYgnSFzgOf2cqGQBzwnRYcrEgXuFjJNEkpedEIPBSEzxY7ph4uA9k1mI+l/GR0HjPNS6FKNZe8SBQ=="],
|
||||
|
||||
"pg-connection-string": ["pg-connection-string@2.9.1", "", {}, "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="],
|
||||
|
||||
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
||||
|
||||
"pg-pool": ["pg-pool@3.10.1", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg=="],
|
||||
|
||||
"pg-protocol": ["pg-protocol@1.10.2", "", {}, "sha512-Ci7jy8PbaWxfsck2dwZdERcDG2A0MG8JoQILs+uZNjABFuBuItAZCWUNz8sXRDMoui24rJw7WlXqgpMdBSN/vQ=="],
|
||||
|
||||
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
|
||||
|
||||
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||
@ -656,8 +798,22 @@
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||
|
||||
"postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
|
||||
|
||||
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
|
||||
|
||||
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
||||
|
||||
"preact": ["preact@10.26.9", "", {}, "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA=="],
|
||||
|
||||
"preact-render-to-string": ["preact-render-to-string@5.2.6", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
@ -668,6 +824,8 @@
|
||||
|
||||
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.58.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
@ -716,8 +874,14 @@
|
||||
|
||||
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
@ -746,6 +910,8 @@
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.10", "", {}, "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA=="],
|
||||
|
||||
"tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
|
||||
@ -782,6 +948,8 @@
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
@ -794,10 +962,16 @@
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zod": ["zod@3.25.67", "", {}, "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw=="],
|
||||
@ -836,12 +1010,58 @@
|
||||
|
||||
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"sharp/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
239
create_library.sql
Normal file
@ -0,0 +1,239 @@
|
||||
SET search_path TO library, public;
|
||||
|
||||
-- 清理可能存在的旧数据(可选,测试时方便重跑)
|
||||
-- 请注意,CASCADE 会删除关联数据,请谨慎在生产环境使用
|
||||
-- DELETE FROM fines;
|
||||
-- DELETE FROM reviews;
|
||||
-- DELETE FROM reservations;
|
||||
-- DELETE FROM borrow_records;
|
||||
-- DELETE FROM admins;
|
||||
-- DELETE FROM students;
|
||||
-- DELETE FROM books;
|
||||
-- DELETE FROM system_settings WHERE setting_key NOT IN ('fine_per_day', 'freeze_threshold', 'max_borrow_default'); -- 保留核心设置
|
||||
|
||||
-- 0. 系统参数 (你已插入,这里可以补充更多,如果需要)
|
||||
INSERT INTO system_settings(setting_key, setting_value) VALUES
|
||||
('max_renew_times', '2'), -- 最大续借次数
|
||||
('borrow_duration_days', '30'), -- 默认借阅时长(天)
|
||||
('reservation_expiry_days', '3'), -- 预约保留天数
|
||||
('admin_default_password_hash', 'xxxx'); -- 示例:管理员默认密码哈希
|
||||
-- ON CONFLICT (setting_key) DO UPDATE SET setting_value = EXCLUDED.setting_value; -- 如果要允许更新
|
||||
|
||||
-- 1. 管理员信息 (Admins)
|
||||
INSERT INTO admins (emp_no, name, position, phone, privilege_lv) VALUES
|
||||
('A001', '张管理', '馆长', '13800138000', 0), -- 最高权限
|
||||
('A002', '李协助', '图书管理员', '13900139000', 1),
|
||||
('A003', '王登记', '流通部职员', '13700137000', 2);
|
||||
|
||||
-- 2. 图书信息 (Books)
|
||||
-- 注意: available_copies 初始设为 total_copies,后续借阅会通过触发器减少
|
||||
INSERT INTO books (isbn, title, authors, publisher, publish_date, price, classification_no, location, total_copies, available_copies, status, description, cover_url) VALUES
|
||||
('9787111624927', '深入理解计算机系统', ARRAY['Randal E. Bryant', 'David R. O''Hallaron'], '机械工业出版社', '2019-05-01', 139.00, 'TP301', 'A区2架3层', 10, 10, 'normal', '计算机系统的经典之作。', 'http://example.com/cover1.jpg'),
|
||||
('9787030598007', '数学之美', ARRAY['吴军'], '科学出版社', '2020-01-01', 68.00, 'O1', 'B区1架1层', 15, 15, 'normal', '通俗易懂的数学科普读物。', 'http://example.com/cover2.jpg'),
|
||||
('9787544270878', '百年孤独', ARRAY['加西亚·马尔克斯'], '南海出版公司', '2011-06-01', 39.50, 'I775.45', 'C区5架2层', 8, 8, 'normal', '魔幻现实主义文学的代表作。', 'http://example.com/cover3.jpg'),
|
||||
('9787115478850', 'Python编程:从入门到实践', ARRAY['Eric Matthes'], '人民邮电出版社', '2018-03-01', 89.00, 'TP312PY', 'A区3架1层', 12, 12, 'normal', 'Python入门畅销书。', 'http://example.com/cover4.jpg'),
|
||||
('9787508647009', '人类简史:从动物到上帝', ARRAY['尤瓦尔·赫拉利'], '中信出版社', '2014-11-01', 68.00, 'K02', 'D区1架1层', 20, 20, 'normal', '一部宏大的人类发展史。', 'http://example.com/cover5.jpg'),
|
||||
('9787532767406', '追风筝的人', ARRAY['卡勒德·胡赛尼'], '上海人民出版社', '2006-05-01', 29.00, 'I712.45', 'C区4架3层', 7, 7, 'normal', '关于爱、友谊、背叛和救赎的故事。', 'http://example.com/cover6.jpg'),
|
||||
('9787121362308', 'Java核心技术 卷I', ARRAY['Cay S. Horstmann'], '电子工业出版社', '2019-06-01', 128.00, 'TP312JA', 'A区3架2层', 5, 0, 'normal', 'Java经典教材,目前全部被借出,用于测试预约。', 'http://example.com/cover7.jpg'), -- 特意设置 available_copies 为 0
|
||||
('9787208159659', '三体全集', ARRAY['刘慈欣'], '重庆出版社', '2019-01-01', 168.00, 'I247.5', 'C区6架1层', 3, 3, 'damaged', '科幻巨作,其中一本有损坏。', 'http://example.com/cover8.jpg'),
|
||||
('9787559620187', '明朝那些事儿 (套装全7册)', ARRAY['当年明月'], '北京联合出版公司', '2017-08-01', 258.00, 'K248', 'D区2架1层', 6, 6, 'normal', '通俗易懂的明史。', 'http://example.com/cover9.jpg'),
|
||||
('9787111600815', '算法导论 (原书第3版)', ARRAY['Thomas H. Cormen'], '机械工业出版社', '2012-12-01', 128.00, 'TP301.6', 'A区2架4层', 9, 9, 'normal', '算法领域的圣经。', 'http://example.com/cover10.jpg'),
|
||||
('9780132350884', 'Clean Code', ARRAY['Robert C. Martin'], 'Prentice Hall', '2008-08-01', 50.00, 'TP311.1', 'A区1架5层', 4, 4, 'removed', '一本已下架的书,测试状态。', 'http://example.com/cover11.jpg');
|
||||
|
||||
|
||||
-- 3. 学生信息 (Students)
|
||||
-- max_borrow 使用默认值(通过 get_setting_int('max_borrow_default') 获取,即10)
|
||||
-- current_borrow 初始为0,会由触发器更新
|
||||
INSERT INTO students (stu_no, name, gender, department, major, grade, class, phone, email, account_status) VALUES
|
||||
('S2023001', '张三', 'M', '计算机学院', '软件工程', '2023', '01班', '13512345671', 'zhangsan@example.com', 'active'),
|
||||
('S2023002', '李四', 'F', '计算机学院', '人工智能', '2023', '02班', '13512345672', 'lisi@example.com', 'active'),
|
||||
('S2022003', '王五', 'M', '外国语学院', '英语', '2022', '01班', '13512345673', 'wangwu@example.com', 'active'),
|
||||
('S2021004', '赵六', 'F', '经济管理学院', '工商管理', '2021', '03班', '13512345674', 'zhaoliu@example.com', 'active'),
|
||||
('S2023005', '孙七', 'M', '计算机学院', '软件工程', '2023', '01班', '13512345675', 'sunqi@example.com', 'reported'), -- 测试挂失状态
|
||||
('S2022006', '周八', 'F', '人文学院', '历史学', '2022', '02班', '13512345676', 'zhouba@example.com', 'active'),
|
||||
('S2021007', '吴九', 'M', '理学院', '数学与应用数学', '2021', '01班', '13512345677', 'wujiu@example.com', 'active'),
|
||||
('S2023008', '郑十', 'F', '计算机学院', '网络工程', '2023', '03班', '13512345678', 'zhengshi@example.com', 'active');
|
||||
|
||||
-- 修改一个学生的 max_borrow,测试非默认值
|
||||
UPDATE students SET max_borrow = 15 WHERE stu_no = 'S2023001';
|
||||
UPDATE students SET max_borrow = 5 WHERE stu_no = 'S2021004'; -- 用于测试借阅上限
|
||||
|
||||
-- 4. 借阅记录 (Borrow Records)
|
||||
-- book_id 和 student_id 需要引用已存在的 ID
|
||||
-- due_date = borrow_date + (SELECT setting_value::int FROM system_settings WHERE setting_key = 'borrow_duration_days')
|
||||
-- fine_amount 初始为0, 逾期归还将由触发器在 fines 表中记录罚款
|
||||
-- 触发器 trg_sync_book_student 会在每次插入/更新/删除后执行
|
||||
|
||||
-- 获取 book_id 和 student_id 的示例 (实际使用时,你可能需要根据书名/学号查询得到)
|
||||
-- SELECT book_id FROM books WHERE isbn = '9787111624927'; -- 1
|
||||
-- SELECT student_id FROM students WHERE stu_no = 'S2023001'; -- 1
|
||||
|
||||
-- 借阅场景 1: 正常借出,未到期
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status) VALUES
|
||||
(1, 1, current_date - interval '10 days', current_date - interval '10 days' + interval '30 days', 'borrowed'), -- 张三借《深入理解计算机系统》
|
||||
(2, 2, current_date - interval '5 days', current_date - interval '5 days' + interval '30 days', 'borrowed'); -- 李四借《数学之美》
|
||||
|
||||
-- 借阅场景 2: 正常借出,今天到期
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status) VALUES
|
||||
(3, 1, current_date - interval '30 days', current_date, 'borrowed'); -- 张三借《百年孤独》,今天到期
|
||||
|
||||
-- 借阅场景 3: 已逾期,未归还
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status) VALUES
|
||||
(4, 3, current_date - interval '40 days', current_date - interval '10 days', 'overdue'); -- 王五借《Python编程》,已逾期10天
|
||||
|
||||
-- 借阅场景 4: 正常归还 (触发器 trg_calc_fine 不会产生罚款)
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, return_date, status) VALUES
|
||||
(5, 4, current_date - interval '20 days', current_date + interval '10 days', current_date - interval '2 days', 'returned'); -- 赵六借《人类简史》,已按时归还
|
||||
|
||||
-- 借阅场景 5: 逾期归还 (为了测试 trg_calc_fine,先插入为逾期,再UPDATE为归还)
|
||||
-- 步骤A: 先插入一条记录,状态为 'overdue' (或者 'borrowed' 但实际已过 due_date)
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status)
|
||||
VALUES (6, 5, current_date - interval '35 days', current_date - interval '5 days', 'overdue'); -- 孙七借《追风筝的人》
|
||||
-- 记录下这条 borrow_id (假设是 6,根据实际情况调整)
|
||||
-- 步骤B: 更新这条记录为 'returned',这将触发 trg_calc_fine
|
||||
-- UPDATE borrow_records
|
||||
-- SET status = 'returned', return_date = current_date
|
||||
-- WHERE borrow_id = (SELECT borrow_id FROM borrow_records WHERE student_id=5 AND book_id=6 AND status='overdue');
|
||||
-- (该update放到下面演示触发器部分)
|
||||
|
||||
-- 借阅场景 6: 书籍遗失
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status) VALUES
|
||||
(9, 6, current_date - interval '15 days', current_date + interval '15 days', 'lost'); -- 周八借《明朝那些事儿》,遗失
|
||||
|
||||
|
||||
-- 5. 图书预约 (Reservations)
|
||||
-- 只能预约 available_copies = 0 的书
|
||||
-- (book_id=7, 'Java核心技术 卷I', available_copies 初始为0)
|
||||
INSERT INTO reservations (book_id, student_id, reserve_date, status) VALUES
|
||||
(7, 1, current_date - interval '2 days', 'waiting'), -- 张三预约《Java核心技术》
|
||||
(7, 2, current_date - interval '1 day', 'waiting'); -- 李四预约《Java核心技术》
|
||||
|
||||
-- 假设一本被预约的书有归还了,管理员将其状态改为可取
|
||||
-- UPDATE reservations SET status = 'available' WHERE book_id = 7 AND student_id = 1;
|
||||
|
||||
-- 6. 图书评价 (Reviews)
|
||||
-- 学生通常评价已借阅过的书
|
||||
INSERT INTO reviews (book_id, student_id, rating, content, review_time) VALUES
|
||||
(5, 4, 5, '《人类简史》这本书太棒了,视角独特,值得一读!', now() - interval '1 day'), -- 赵六评价《人类简史》
|
||||
(1, 1, 4, '《深入理解计算机系统》有点难,但很有收获。', now()), -- 张三评价
|
||||
(2, 2, 5, '《数学之美》让我对数学有了新的认识。', now()); -- 李四评价
|
||||
|
||||
-- 尝试插入重复评价(应失败,因为有UNIQUE约束)
|
||||
-- INSERT INTO reviews (book_id, student_id, rating, content, review_time) VALUES
|
||||
-- (5, 4, 3, '第二次评价,内容一般。', now());
|
||||
|
||||
-- 7. 罚款记录 (Fines)
|
||||
-- 部分罚款会由 trg_calc_fine 自动生成 (当逾期借阅记录更新为 'returned' 时)
|
||||
-- 手动添加一些罚款记录:
|
||||
|
||||
-- 罚款场景1: 图书损坏 (假设管理员A002处理)
|
||||
INSERT INTO fines (student_id, amount, reason, status, issue_date, admin_id) VALUES
|
||||
( (SELECT student_id FROM students WHERE stu_no = 'S2022003'), -- 王五
|
||||
20.00,
|
||||
'损坏图书《Python编程》(ISBN:9787115478850)',
|
||||
'unpaid',
|
||||
current_date - interval '1 day',
|
||||
(SELECT admin_id FROM admins WHERE emp_no = 'A002')
|
||||
);
|
||||
|
||||
-- 罚款场景2: 图书遗失 (假设管理员A002处理)
|
||||
-- 对应 borrow_records 中周八遗失的《明朝那些事儿》
|
||||
INSERT INTO fines (student_id, amount, reason, status, issue_date, admin_id) VALUES
|
||||
( (SELECT student_id FROM students WHERE stu_no = 'S2022006'), -- 周八
|
||||
(SELECT price FROM books WHERE isbn = '9787559620187'), -- 按书价赔偿
|
||||
'遗失图书《明朝那些事儿》(ISBN:9787559620187)',
|
||||
'unpaid',
|
||||
current_date,
|
||||
(SELECT admin_id FROM admins WHERE emp_no = 'A002')
|
||||
);
|
||||
|
||||
-- 罚款场景3: 之前的逾期罚款,已缴纳
|
||||
INSERT INTO fines (student_id, amount, reason, status, issue_date, admin_id) VALUES
|
||||
( (SELECT student_id FROM students WHERE stu_no = 'S2021007'), -- 吴九
|
||||
5.50,
|
||||
'图书逾期11天 (旧记录)',
|
||||
'paid',
|
||||
current_date - interval '60 days',
|
||||
(SELECT admin_id FROM admins WHERE emp_no = 'A003')
|
||||
);
|
||||
|
||||
|
||||
----------------------------------------------------
|
||||
-- 测试触发器 trg_calc_fine 和 trg_freeze_account
|
||||
----------------------------------------------------
|
||||
DO $$
|
||||
DECLARE
|
||||
v_student_id_sunqi bigint;
|
||||
v_borrow_id_sunqi bigint;
|
||||
v_fine_per_day numeric;
|
||||
v_days_overdue int;
|
||||
v_expected_fine numeric;
|
||||
BEGIN
|
||||
-- 获取孙七的 student_id
|
||||
SELECT student_id INTO v_student_id_sunqi FROM students WHERE stu_no = 'S2023005';
|
||||
-- 获取孙七之前插入的逾期借阅记录 (book_id=6, 追风筝的人)
|
||||
SELECT borrow_id INTO v_borrow_id_sunqi
|
||||
FROM borrow_records
|
||||
WHERE student_id = v_student_id_sunqi AND book_id = (SELECT book_id FROM books WHERE isbn='9787532767406') AND status = 'overdue';
|
||||
|
||||
RAISE NOTICE '孙七(ID:%)的借阅记录ID: % 将被更新为已归还。', v_student_id_sunqi, v_borrow_id_sunqi;
|
||||
|
||||
-- 模拟孙七归还之前逾期的书 (book_id=6, 《追风筝的人》)
|
||||
-- 这将触发 trg_calc_fine
|
||||
UPDATE borrow_records
|
||||
SET status = 'returned', return_date = current_date
|
||||
WHERE borrow_id = v_borrow_id_sunqi;
|
||||
|
||||
RAISE NOTICE '已更新孙七的借阅记录。现在检查 fines 表是否自动产生罚款记录...';
|
||||
-- trg_calc_fine 会根据 borrow_records 中的 due_date 和 return_date 计算罚款并插入 fines 表
|
||||
-- 假设罚款汇率是 0.5/天,逾期5天,罚款应为 2.50
|
||||
-- (current_date - (current_date - interval '5 days')) * 0.50
|
||||
SELECT setting_value::numeric INTO v_fine_per_day FROM system_settings WHERE setting_key = 'fine_per_day';
|
||||
SELECT (current_date - (SELECT due_date FROM borrow_records WHERE borrow_id = v_borrow_id_sunqi)) INTO v_days_overdue;
|
||||
v_expected_fine := v_days_overdue * v_fine_per_day;
|
||||
RAISE NOTICE '预期罚款金额: % * % = %', v_days_overdue, v_fine_per_day, v_expected_fine;
|
||||
|
||||
-- 检查孙七的账户状态是否因为罚款超过阈值 (默认20) 而被冻结
|
||||
-- trg_freeze_account 会在 fines 表 INSERT/UPDATE/DELETE 后触发
|
||||
RAISE NOTICE '检查孙七账户是否因罚款自动冻结...';
|
||||
-- 如果孙七的罚款 (例如 2.50) 没有超过 freeze_threshold (默认20.00),账户不会冻结。
|
||||
-- 我们再为孙七手动添加一笔大额罚款,使其总欠款超过阈值
|
||||
IF (SELECT sum(amount) FROM fines WHERE student_id = v_student_id_sunqi AND status = 'unpaid') < (SELECT setting_value::numeric FROM system_settings WHERE setting_key = 'freeze_threshold') THEN
|
||||
RAISE NOTICE '孙七当前未付罚款未达冻结阈值,为其添加一笔大额罚款...';
|
||||
INSERT INTO fines (student_id, amount, reason, status, issue_date, admin_id) VALUES
|
||||
(v_student_id_sunqi, 25.00, '严重损坏图书赔偿', 'unpaid', current_date, (SELECT admin_id FROM admins WHERE emp_no='A001'));
|
||||
RAISE NOTICE '已为孙七添加大额罚款,再次检查账户状态。';
|
||||
END IF;
|
||||
|
||||
END $$;
|
||||
|
||||
-- 查询验证触发器效果
|
||||
RAISE NOTICE '---------- 查询验证 ----------';
|
||||
|
||||
RAISE NOTICE '1. 书籍可借数量和学生当前借阅量 (应由 trg_sync_book_student 更新):';
|
||||
SELECT book_id, title, total_copies, available_copies FROM books WHERE book_id <= 7;
|
||||
SELECT student_id, name, max_borrow, current_borrow, account_status FROM students WHERE student_id <= 5;
|
||||
|
||||
RAISE NOTICE '2. 孙七 (S2023005) 的罚款记录 (应有 trg_calc_fine 生成的逾期罚款 和 手动添加的罚款):';
|
||||
SELECT f.*, s.name as student_name
|
||||
FROM fines f JOIN students s ON f.student_id = s.student_id
|
||||
WHERE s.stu_no = 'S2023005';
|
||||
|
||||
RAISE NOTICE '3. 孙七 (S2023005) 的账户状态 (应由 trg_freeze_account 根据总欠款更新):';
|
||||
SELECT stu_no, name, account_status, current_borrow
|
||||
FROM students
|
||||
WHERE stu_no = 'S2023005';
|
||||
|
||||
RAISE NOTICE '4. 查看热门图书视图:';
|
||||
SELECT * FROM v_hot_books;
|
||||
|
||||
RAISE NOTICE '5. 查看院系借阅统计视图:';
|
||||
SELECT * FROM v_dept_borrow_stats;
|
||||
|
||||
RAISE NOTICE '6. 查看逾期详情视图:';
|
||||
SELECT od.*, b.title, s.name as student_name
|
||||
FROM v_overdue_details od
|
||||
JOIN books b ON od.book_id = b.book_id
|
||||
JOIN students s ON od.student_id = s.student_id;
|
||||
|
||||
RAISE NOTICE '测试数据插入完毕。';
|
||||
14
drizzle.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/lib/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dbCredentials: {
|
||||
host: process.env.DB_HOST!,
|
||||
port: parseInt(process.env.DB_PORT!),
|
||||
user: process.env.DB_USER!,
|
||||
password: process.env.DB_PASSWORD!,
|
||||
database: process.env.DB_NAME!,
|
||||
},
|
||||
});
|
||||
239
example_data.sql
Normal file
@ -0,0 +1,239 @@
|
||||
SET search_path TO library, public;
|
||||
|
||||
-- 清理可能存在的旧数据(可选,测试时方便重跑)
|
||||
-- 请注意,CASCADE 会删除关联数据,请谨慎在生产环境使用
|
||||
DELETE FROM fines;
|
||||
DELETE FROM reviews;
|
||||
DELETE FROM reservations;
|
||||
DELETE FROM borrow_records;
|
||||
DELETE FROM admins;
|
||||
DELETE FROM students;
|
||||
DELETE FROM books;
|
||||
DELETE FROM system_settings WHERE setting_key NOT IN ('fine_per_day', 'freeze_threshold', 'max_borrow_default'); -- 保留核心设置
|
||||
|
||||
-- 0. 系统参数 (你已插入,这里可以补充更多,如果需要)
|
||||
INSERT INTO system_settings(setting_key, setting_value) VALUES
|
||||
('max_renew_times', '2'), -- 最大续借次数
|
||||
('borrow_duration_days', '30'), -- 默认借阅时长(天)
|
||||
('reservation_expiry_days', '3'), -- 预约保留天数
|
||||
('admin_default_password_hash', 'xxxx'); -- 示例:管理员默认密码哈希
|
||||
-- ON CONFLICT (setting_key) DO UPDATE SET setting_value = EXCLUDED.setting_value; -- 如果要允许更新
|
||||
|
||||
-- 1. 管理员信息 (Admins)
|
||||
INSERT INTO admins (emp_no, name, position, phone, privilege_lv) VALUES
|
||||
('A001', '张管理', '馆长', '13800138000', 0), -- 最高权限
|
||||
('A002', '李协助', '图书管理员', '13900139000', 1),
|
||||
('A003', '王登记', '流通部职员', '13700137000', 2);
|
||||
|
||||
-- 2. 图书信息 (Books)
|
||||
-- 注意: available_copies 初始设为 total_copies,后续借阅会通过触发器减少
|
||||
INSERT INTO books (isbn, title, authors, publisher, publish_date, price, classification_no, location, total_copies, available_copies, status, description, cover_url) VALUES
|
||||
('9787111624927', '深入理解计算机系统', ARRAY['Randal E. Bryant', 'David R. O''Hallaron'], '机械工业出版社', '2019-05-01', 139.00, 'TP301', 'A区2架3层', 10, 10, 'normal', '计算机系统的经典之作。', 'http://example.com/cover1.jpg'),
|
||||
('9787030598007', '数学之美', ARRAY['吴军'], '科学出版社', '2020-01-01', 68.00, 'O1', 'B区1架1层', 15, 15, 'normal', '通俗易懂的数学科普读物。', 'http://example.com/cover2.jpg'),
|
||||
('9787544270878', '百年孤独', ARRAY['加西亚·马尔克斯'], '南海出版公司', '2011-06-01', 39.50, 'I775.45', 'C区5架2层', 8, 8, 'normal', '魔幻现实主义文学的代表作。', 'http://example.com/cover3.jpg'),
|
||||
('9787115478850', 'Python编程:从入门到实践', ARRAY['Eric Matthes'], '人民邮电出版社', '2018-03-01', 89.00, 'TP312PY', 'A区3架1层', 12, 12, 'normal', 'Python入门畅销书。', 'http://example.com/cover4.jpg'),
|
||||
('9787508647009', '人类简史:从动物到上帝', ARRAY['尤瓦尔·赫拉利'], '中信出版社', '2014-11-01', 68.00, 'K02', 'D区1架1层', 20, 20, 'normal', '一部宏大的人类发展史。', 'http://example.com/cover5.jpg'),
|
||||
('9787532767406', '追风筝的人', ARRAY['卡勒德·胡赛尼'], '上海人民出版社', '2006-05-01', 29.00, 'I712.45', 'C区4架3层', 7, 7, 'normal', '关于爱、友谊、背叛和救赎的故事。', 'http://example.com/cover6.jpg'),
|
||||
('9787121362308', 'Java核心技术 卷I', ARRAY['Cay S. Horstmann'], '电子工业出版社', '2019-06-01', 128.00, 'TP312JA', 'A区3架2层', 5, 0, 'normal', 'Java经典教材,目前全部被借出,用于测试预约。', 'http://example.com/cover7.jpg'), -- 特意设置 available_copies 为 0
|
||||
('9787208159659', '三体全集', ARRAY['刘慈欣'], '重庆出版社', '2019-01-01', 168.00, 'I247.5', 'C区6架1层', 3, 3, 'damaged', '科幻巨作,其中一本有损坏。', 'http://example.com/cover8.jpg'),
|
||||
('9787559620187', '明朝那些事儿 (套装全7册)', ARRAY['当年明月'], '北京联合出版公司', '2017-08-01', 258.00, 'K248', 'D区2架1层', 6, 6, 'normal', '通俗易懂的明史。', 'http://example.com/cover9.jpg'),
|
||||
('9787111600815', '算法导论 (原书第3版)', ARRAY['Thomas H. Cormen'], '机械工业出版社', '2012-12-01', 128.00, 'TP301.6', 'A区2架4层', 9, 9, 'normal', '算法领域的圣经。', 'http://example.com/cover10.jpg'),
|
||||
('9780132350884', 'Clean Code', ARRAY['Robert C. Martin'], 'Prentice Hall', '2008-08-01', 50.00, 'TP311.1', 'A区1架5层', 4, 4, 'removed', '一本已下架的书,测试状态。', 'http://example.com/cover11.jpg');
|
||||
|
||||
|
||||
-- 3. 学生信息 (Students)
|
||||
-- max_borrow 使用默认值(通过 get_setting_int('max_borrow_default') 获取,即10)
|
||||
-- current_borrow 初始为0,会由触发器更新
|
||||
INSERT INTO students (stu_no, name, gender, department, major, grade, class, phone, email, account_status) VALUES
|
||||
('S2023001', '张三', 'M', '计算机学院', '软件工程', '2023', '01班', '13512345671', 'zhangsan@example.com', 'active'),
|
||||
('S2023002', '李四', 'F', '计算机学院', '人工智能', '2023', '02班', '13512345672', 'lisi@example.com', 'active'),
|
||||
('S2022003', '王五', 'M', '外国语学院', '英语', '2022', '01班', '13512345673', 'wangwu@example.com', 'active'),
|
||||
('S2021004', '赵六', 'F', '经济管理学院', '工商管理', '2021', '03班', '13512345674', 'zhaoliu@example.com', 'active'),
|
||||
('S2023005', '孙七', 'M', '计算机学院', '软件工程', '2023', '01班', '13512345675', 'sunqi@example.com', 'reported'), -- 测试挂失状态
|
||||
('S2022006', '周八', 'F', '人文学院', '历史学', '2022', '02班', '13512345676', 'zhouba@example.com', 'active'),
|
||||
('S2021007', '吴九', 'M', '理学院', '数学与应用数学', '2021', '01班', '13512345677', 'wujiu@example.com', 'active'),
|
||||
('S2023008', '郑十', 'F', '计算机学院', '网络工程', '2023', '03班', '13512345678', 'zhengshi@example.com', 'active');
|
||||
|
||||
-- 修改一个学生的 max_borrow,测试非默认值
|
||||
UPDATE students SET max_borrow = 15 WHERE stu_no = 'S2023001';
|
||||
UPDATE students SET max_borrow = 5 WHERE stu_no = 'S2021004'; -- 用于测试借阅上限
|
||||
|
||||
-- 4. 借阅记录 (Borrow Records)
|
||||
-- book_id 和 student_id 需要引用已存在的 ID
|
||||
-- due_date = borrow_date + (SELECT setting_value::int FROM system_settings WHERE setting_key = 'borrow_duration_days')
|
||||
-- fine_amount 初始为0, 逾期归还将由触发器在 fines 表中记录罚款
|
||||
-- 触发器 trg_sync_book_student 会在每次插入/更新/删除后执行
|
||||
|
||||
-- 获取 book_id 和 student_id 的示例 (实际使用时,你可能需要根据书名/学号查询得到)
|
||||
-- SELECT book_id FROM books WHERE isbn = '9787111624927'; -- 1
|
||||
-- SELECT student_id FROM students WHERE stu_no = 'S2023001'; -- 1
|
||||
|
||||
-- 借阅场景 1: 正常借出,未到期
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status) VALUES
|
||||
(1, 1, current_date - interval '10 days', current_date - interval '10 days' + interval '30 days', 'borrowed'), -- 张三借《深入理解计算机系统》
|
||||
(2, 2, current_date - interval '5 days', current_date - interval '5 days' + interval '30 days', 'borrowed'); -- 李四借《数学之美》
|
||||
|
||||
-- 借阅场景 2: 正常借出,今天到期
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status) VALUES
|
||||
(3, 1, current_date - interval '30 days', current_date, 'borrowed'); -- 张三借《百年孤独》,今天到期
|
||||
|
||||
-- 借阅场景 3: 已逾期,未归还
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status) VALUES
|
||||
(4, 3, current_date - interval '40 days', current_date - interval '10 days', 'overdue'); -- 王五借《Python编程》,已逾期10天
|
||||
|
||||
-- 借阅场景 4: 正常归还 (触发器 trg_calc_fine 不会产生罚款)
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, return_date, status) VALUES
|
||||
(5, 4, current_date - interval '20 days', current_date + interval '10 days', current_date - interval '2 days', 'returned'); -- 赵六借《人类简史》,已按时归还
|
||||
|
||||
-- 借阅场景 5: 逾期归还 (为了测试 trg_calc_fine,先插入为逾期,再UPDATE为归还)
|
||||
-- 步骤A: 先插入一条记录,状态为 'overdue' (或者 'borrowed' 但实际已过 due_date)
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status)
|
||||
VALUES (6, 5, current_date - interval '35 days', current_date - interval '5 days', 'overdue'); -- 孙七借《追风筝的人》
|
||||
-- 记录下这条 borrow_id (假设是 6,根据实际情况调整)
|
||||
-- 步骤B: 更新这条记录为 'returned',这将触发 trg_calc_fine
|
||||
-- UPDATE borrow_records
|
||||
-- SET status = 'returned', return_date = current_date
|
||||
-- WHERE borrow_id = (SELECT borrow_id FROM borrow_records WHERE student_id=5 AND book_id=6 AND status='overdue');
|
||||
-- (该update放到下面演示触发器部分)
|
||||
|
||||
-- 借阅场景 6: 书籍遗失
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status) VALUES
|
||||
(9, 6, current_date - interval '15 days', current_date + interval '15 days', 'lost'); -- 周八借《明朝那些事儿》,遗失
|
||||
|
||||
|
||||
-- 5. 图书预约 (Reservations)
|
||||
-- 只能预约 available_copies = 0 的书
|
||||
-- (book_id=7, 'Java核心技术 卷I', available_copies 初始为0)
|
||||
INSERT INTO reservations (book_id, student_id, reserve_date, status) VALUES
|
||||
(7, 1, current_date - interval '2 days', 'waiting'), -- 张三预约《Java核心技术》
|
||||
(7, 2, current_date - interval '1 day', 'waiting'); -- 李四预约《Java核心技术》
|
||||
|
||||
-- 假设一本被预约的书有归还了,管理员将其状态改为可取
|
||||
-- UPDATE reservations SET status = 'available' WHERE book_id = 7 AND student_id = 1;
|
||||
|
||||
-- 6. 图书评价 (Reviews)
|
||||
-- 学生通常评价已借阅过的书
|
||||
INSERT INTO reviews (book_id, student_id, rating, content, review_time) VALUES
|
||||
(5, 4, 5, '《人类简史》这本书太棒了,视角独特,值得一读!', now() - interval '1 day'), -- 赵六评价《人类简史》
|
||||
(1, 1, 4, '《深入理解计算机系统》有点难,但很有收获。', now()), -- 张三评价
|
||||
(2, 2, 5, '《数学之美》让我对数学有了新的认识。', now()); -- 李四评价
|
||||
|
||||
-- 尝试插入重复评价(应失败,因为有UNIQUE约束)
|
||||
-- INSERT INTO reviews (book_id, student_id, rating, content, review_time) VALUES
|
||||
-- (5, 4, 3, '第二次评价,内容一般。', now());
|
||||
|
||||
-- 7. 罚款记录 (Fines)
|
||||
-- 部分罚款会由 trg_calc_fine 自动生成 (当逾期借阅记录更新为 'returned' 时)
|
||||
-- 手动添加一些罚款记录:
|
||||
|
||||
-- 罚款场景1: 图书损坏 (假设管理员A002处理)
|
||||
INSERT INTO fines (student_id, amount, reason, status, issue_date, admin_id) VALUES
|
||||
( (SELECT student_id FROM students WHERE stu_no = 'S2022003'), -- 王五
|
||||
20.00,
|
||||
'损坏图书《Python编程》(ISBN:9787115478850)',
|
||||
'unpaid',
|
||||
current_date - interval '1 day',
|
||||
(SELECT admin_id FROM admins WHERE emp_no = 'A002')
|
||||
);
|
||||
|
||||
-- 罚款场景2: 图书遗失 (假设管理员A002处理)
|
||||
-- 对应 borrow_records 中周八遗失的《明朝那些事儿》
|
||||
INSERT INTO fines (student_id, amount, reason, status, issue_date, admin_id) VALUES
|
||||
( (SELECT student_id FROM students WHERE stu_no = 'S2022006'), -- 周八
|
||||
(SELECT price FROM books WHERE isbn = '9787559620187'), -- 按书价赔偿
|
||||
'遗失图书《明朝那些事儿》(ISBN:9787559620187)',
|
||||
'unpaid',
|
||||
current_date,
|
||||
(SELECT admin_id FROM admins WHERE emp_no = 'A002')
|
||||
);
|
||||
|
||||
-- 罚款场景3: 之前的逾期罚款,已缴纳
|
||||
INSERT INTO fines (student_id, amount, reason, status, issue_date, admin_id) VALUES
|
||||
( (SELECT student_id FROM students WHERE stu_no = 'S2021007'), -- 吴九
|
||||
5.50,
|
||||
'图书逾期11天 (旧记录)',
|
||||
'paid',
|
||||
current_date - interval '60 days',
|
||||
(SELECT admin_id FROM admins WHERE emp_no = 'A003')
|
||||
);
|
||||
|
||||
|
||||
----------------------------------------------------
|
||||
-- 测试触发器 trg_calc_fine 和 trg_freeze_account
|
||||
----------------------------------------------------
|
||||
DO $$
|
||||
DECLARE
|
||||
v_student_id_sunqi bigint;
|
||||
v_borrow_id_sunqi bigint;
|
||||
v_fine_per_day numeric;
|
||||
v_days_overdue int;
|
||||
v_expected_fine numeric;
|
||||
BEGIN
|
||||
-- 获取孙七的 student_id
|
||||
SELECT student_id INTO v_student_id_sunqi FROM students WHERE stu_no = 'S2023005';
|
||||
-- 获取孙七之前插入的逾期借阅记录 (book_id=6, 追风筝的人)
|
||||
SELECT borrow_id INTO v_borrow_id_sunqi
|
||||
FROM borrow_records
|
||||
WHERE student_id = v_student_id_sunqi AND book_id = (SELECT book_id FROM books WHERE isbn='9787532767406') AND status = 'overdue';
|
||||
|
||||
RAISE NOTICE '孙七(ID:%)的借阅记录ID: % 将被更新为已归还。', v_student_id_sunqi, v_borrow_id_sunqi;
|
||||
|
||||
-- 模拟孙七归还之前逾期的书 (book_id=6, 《追风筝的人》)
|
||||
-- 这将触发 trg_calc_fine
|
||||
UPDATE borrow_records
|
||||
SET status = 'returned', return_date = current_date
|
||||
WHERE borrow_id = v_borrow_id_sunqi;
|
||||
|
||||
RAISE NOTICE '已更新孙七的借阅记录。现在检查 fines 表是否自动产生罚款记录...';
|
||||
-- trg_calc_fine 会根据 borrow_records 中的 due_date 和 return_date 计算罚款并插入 fines 表
|
||||
-- 假设罚款汇率是 0.5/天,逾期5天,罚款应为 2.50
|
||||
-- (current_date - (current_date - interval '5 days')) * 0.50
|
||||
SELECT setting_value::numeric INTO v_fine_per_day FROM system_settings WHERE setting_key = 'fine_per_day';
|
||||
SELECT (current_date - (SELECT due_date FROM borrow_records WHERE borrow_id = v_borrow_id_sunqi)) INTO v_days_overdue;
|
||||
v_expected_fine := v_days_overdue * v_fine_per_day;
|
||||
RAISE NOTICE '预期罚款金额: % * % = %', v_days_overdue, v_fine_per_day, v_expected_fine;
|
||||
|
||||
-- 检查孙七的账户状态是否因为罚款超过阈值 (默认20) 而被冻结
|
||||
-- trg_freeze_account 会在 fines 表 INSERT/UPDATE/DELETE 后触发
|
||||
RAISE NOTICE '检查孙七账户是否因罚款自动冻结...';
|
||||
-- 如果孙七的罚款 (例如 2.50) 没有超过 freeze_threshold (默认20.00),账户不会冻结。
|
||||
-- 我们再为孙七手动添加一笔大额罚款,使其总欠款超过阈值
|
||||
IF (SELECT sum(amount) FROM fines WHERE student_id = v_student_id_sunqi AND status = 'unpaid') < (SELECT setting_value::numeric FROM system_settings WHERE setting_key = 'freeze_threshold') THEN
|
||||
RAISE NOTICE '孙七当前未付罚款未达冻结阈值,为其添加一笔大额罚款...';
|
||||
INSERT INTO fines (student_id, amount, reason, status, issue_date, admin_id) VALUES
|
||||
(v_student_id_sunqi, 25.00, '严重损坏图书赔偿', 'unpaid', current_date, (SELECT admin_id FROM admins WHERE emp_no='A001'));
|
||||
RAISE NOTICE '已为孙七添加大额罚款,再次检查账户状态。';
|
||||
END IF;
|
||||
|
||||
END $$;
|
||||
|
||||
-- 查询验证触发器效果
|
||||
RAISE NOTICE '---------- 查询验证 ----------';
|
||||
|
||||
RAISE NOTICE '1. 书籍可借数量和学生当前借阅量 (应由 trg_sync_book_student 更新):';
|
||||
SELECT book_id, title, total_copies, available_copies FROM books WHERE book_id <= 7;
|
||||
SELECT student_id, name, max_borrow, current_borrow, account_status FROM students WHERE student_id <= 5;
|
||||
|
||||
RAISE NOTICE '2. 孙七 (S2023005) 的罚款记录 (应有 trg_calc_fine 生成的逾期罚款 和 手动添加的罚款):';
|
||||
SELECT f.*, s.name as student_name
|
||||
FROM fines f JOIN students s ON f.student_id = s.student_id
|
||||
WHERE s.stu_no = 'S2023005';
|
||||
|
||||
RAISE NOTICE '3. 孙七 (S2023005) 的账户状态 (应由 trg_freeze_account 根据总欠款更新):';
|
||||
SELECT stu_no, name, account_status, current_borrow
|
||||
FROM students
|
||||
WHERE stu_no = 'S2023005';
|
||||
|
||||
RAISE NOTICE '4. 查看热门图书视图:';
|
||||
SELECT * FROM v_hot_books;
|
||||
|
||||
RAISE NOTICE '5. 查看院系借阅统计视图:';
|
||||
SELECT * FROM v_dept_borrow_stats;
|
||||
|
||||
RAISE NOTICE '6. 查看逾期详情视图:';
|
||||
SELECT od.*, b.title, s.name as student_name
|
||||
FROM v_overdue_details od
|
||||
JOIN books b ON od.book_id = b.book_id
|
||||
JOIN students s ON od.student_id = s.student_id;
|
||||
|
||||
RAISE NOTICE '测试数据插入完毕。';
|
||||
@ -2,6 +2,11 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
images: {
|
||||
remotePatterns: [new URL('https://picsum.photos/**')],
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
18
package.json
@ -9,9 +9,25 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@tanstack/react-query": "^5.80.10",
|
||||
"@tanstack/react-query-devtools": "^5.80.10",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/pg": "^8.15.4",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"lucide-react": "^0.520.0",
|
||||
"next": "15.3.4",
|
||||
"next-auth": "^4.24.11",
|
||||
"pg": "^8.16.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.3.4"
|
||||
"react-hook-form": "^7.58.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
|
||||
469
sql/admin_query.sql
Normal file
@ -0,0 +1,469 @@
|
||||
-- 假设当前操作的管理员ID
|
||||
-- 在实际应用中,这个ID会从登录会话中获取
|
||||
-- SELECT admin_id INTO v_admin_id FROM admins WHERE emp_no = 'A001'; -- 示例:获取张管理的ID
|
||||
-- 为了简化示例,我们直接使用数字,假设 admin_id=1 是一个有效管理员
|
||||
-- 请根据你的 admins 表实际数据替换
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- (1). 图书信息管理(增删改查、批量导入)
|
||||
----------------------------------------------------------------------
|
||||
|
||||
-- A. 新增图书 (Create)
|
||||
INSERT INTO books (isbn, title, authors, publisher, publish_date, price, classification_no, location, total_copies, available_copies, description, cover_url)
|
||||
VALUES
|
||||
('9787121408881', 'Effective Java 中文版 (原书第3版)', ARRAY['Joshua Bloch'], '机械工业出版社', '2019-01-01', 119.00, 'TP312JA', 'A区3架5层', 5, 5, 'Java程序员必读的经典著作,包含90个实用条目。', 'http://example.com/cover_effective_java.jpg');
|
||||
|
||||
-- B. 查询图书 (Read)
|
||||
-- B1. 按ISBN精确查询
|
||||
SELECT * FROM books WHERE isbn = '9787111624927';
|
||||
|
||||
-- B2. 按书名模糊查询 (使用 trigram 索引 idx_books_title_trgm)
|
||||
SELECT * FROM books WHERE title % '计算机'; -- 查询包含“计算机”的
|
||||
SELECT * FROM books WHERE title ILIKE '%系统%'; -- 不区分大小写查询包含“系统”的
|
||||
|
||||
-- B3. 按作者查询 (使用 GIN 索引 idx_books_authors_gin)
|
||||
SELECT * FROM books WHERE authors @> ARRAY['吴军']; -- 查询作者包含“吴军”的
|
||||
|
||||
-- B4. 按分类号查询
|
||||
SELECT * FROM books WHERE classification_no = 'TP301';
|
||||
|
||||
-- B5. 查看所有“正常”状态的图书
|
||||
SELECT * FROM books WHERE status = 'normal';
|
||||
|
||||
-- C. 修改图书信息 (Update)
|
||||
-- 假设要修改 book_id 为 1 的图书信息
|
||||
UPDATE books
|
||||
SET
|
||||
price = 145.00,
|
||||
location = 'A区2架3层 (更新)',
|
||||
description = '计算机系统的经典之作,最新修订版。',
|
||||
updated_at = now()
|
||||
WHERE book_id = 1; -- 假设 book_id 为 1 的是《深入理解计算机系统》
|
||||
|
||||
-- 修改图书状态 (例如,将一本损坏的书修复后改为正常)
|
||||
UPDATE books
|
||||
SET status = 'normal', available_copies = available_copies + 1 -- 如果之前因损坏不可借
|
||||
WHERE isbn = '9787208159659' AND status = 'damaged'; -- 假设《三体全集》某本修复
|
||||
|
||||
-- D. 删除图书 (Delete) - 谨慎操作,通常建议逻辑删除或下架
|
||||
-- 注意:如果该书有关联的借阅记录、预约记录、评价记录,且外键设置了 ON DELETE CASCADE,则这些关联记录也会被删除。
|
||||
-- 如果设置了 ON DELETE RESTRICT (或默认),且存在关联记录,则删除会失败。
|
||||
-- 你的表结构中,borrow_records, reservations, reviews 都对 book_id 设置了 ON DELETE CASCADE。
|
||||
-- DELETE FROM books WHERE isbn = '9780132350884'; -- 删除已下架的《Clean Code》
|
||||
|
||||
-- 更好的方式是“下架”
|
||||
UPDATE books
|
||||
SET status = 'removed', available_copies = 0
|
||||
WHERE isbn = '9780132350884';
|
||||
|
||||
-- E. 批量导入图书 (Batch Import)
|
||||
INSERT INTO books (isbn, title, authors, publisher, total_copies, available_copies) VALUES
|
||||
('ISBN001', '批量导入书1', ARRAY['作者X'], '出版社A', 2, 2),
|
||||
('ISBN002', '批量导入书2', ARRAY['作者Y', '作者Z'], '出版社B', 3, 3);
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- (2). 学生账户管理
|
||||
----------------------------------------------------------------------
|
||||
-- A. 新增学生账户
|
||||
INSERT INTO students (stu_no, name, gender, department, major, grade, class, phone, email, max_borrow)
|
||||
VALUES
|
||||
('S2024001', '刘新', 'M', '物理学院', '应用物理', '2024', '01班', '13600001111', 'liuxin@example.com', (SELECT setting_value::int FROM system_settings WHERE setting_key='max_borrow_default'));
|
||||
|
||||
-- B. 查询学生账户
|
||||
-- B1. 按学号查询
|
||||
SELECT * FROM students WHERE stu_no = 'S2023001';
|
||||
|
||||
-- B2. 按姓名模糊查询
|
||||
SELECT * FROM students WHERE name LIKE '张%';
|
||||
|
||||
-- B3. 查询某院系所有学生
|
||||
SELECT * FROM students WHERE department = '计算机学院';
|
||||
|
||||
-- C. 修改学生账户信息
|
||||
-- C1. 修改学生联系方式
|
||||
UPDATE students
|
||||
SET phone = '13511112222', email = 'new_zhangsan@example.com'
|
||||
WHERE stu_no = 'S2023001';
|
||||
|
||||
-- C2. 修改学生账户状态 (例如:解挂、冻结/解冻 - 冻结通常由触发器完成,但管理员也可手动操作)
|
||||
-- 解挂
|
||||
UPDATE students SET account_status = 'active' WHERE stu_no = 'S2023005' AND account_status = 'reported';
|
||||
-- 手动冻结 (如果业务需要)
|
||||
UPDATE students SET account_status = 'frozen' WHERE stu_no = 'S2023001';
|
||||
-- 手动解冻 (例如,学生缴清罚款后,触发器未及时更新或有特殊情况)
|
||||
-- 首先确保罚款已清或问题已解决
|
||||
-- UPDATE fines SET status = 'paid' WHERE student_id = (SELECT student_id FROM students WHERE stu_no = 'S2023005') AND status = 'unpaid';
|
||||
-- 然后(如果触发器没有将所有欠款清零后的账户自动激活)
|
||||
-- UPDATE students SET account_status = 'active' WHERE stu_no = 'S2023005' AND account_status = 'frozen';
|
||||
|
||||
-- C3. 修改学生最大借阅量
|
||||
UPDATE students SET max_borrow = 12 WHERE stu_no = 'S2023001';
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- (3). 处理借阅、归还、续借请求
|
||||
----------------------------------------------------------------------
|
||||
-- A. 处理借阅请求 (学生在前台操作,管理员后台处理或确认)
|
||||
-- 假设学生 (stu_no='S2023002', 李四) 要借阅图书 (isbn='9787111624927', 深入理解计算机系统)
|
||||
-- 前提检查 (通常在应用层完成,但也可在存储过程中封装):
|
||||
-- 1. 图书可借: SELECT available_copies, status FROM books WHERE isbn = '9787111624927'; (available_copies > 0 and status = 'normal')
|
||||
-- 2. 学生账户正常: SELECT account_status FROM students WHERE stu_no = 'S2023002'; (account_status = 'active')
|
||||
-- 3. 学生未超借阅上限: SELECT current_borrow, max_borrow FROM students WHERE stu_no = 'S2023002'; (current_borrow < max_borrow)
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_student_id bigint;
|
||||
v_book_id bigint;
|
||||
v_available_copies int;
|
||||
v_book_status book_status_enum;
|
||||
v_account_status acct_status_enum;
|
||||
v_current_borrow int;
|
||||
v_max_borrow int;
|
||||
v_borrow_duration int;
|
||||
BEGIN
|
||||
SELECT student_id, account_status, current_borrow, max_borrow INTO v_student_id, v_account_status, v_current_borrow, v_max_borrow
|
||||
FROM students WHERE stu_no = 'S2023002';
|
||||
|
||||
SELECT book_id, available_copies, status INTO v_book_id, v_available_copies, v_book_status
|
||||
FROM books WHERE isbn = '9787111624927';
|
||||
|
||||
IF NOT FOUND THEN RAISE EXCEPTION '学生或图书不存在'; END IF;
|
||||
|
||||
IF v_book_status != 'normal' THEN RAISE EXCEPTION '图书状态异常,不可借阅: %', v_book_status; END IF;
|
||||
IF v_available_copies <= 0 THEN RAISE EXCEPTION '图书已无馆藏可借'; END IF;
|
||||
IF v_account_status != 'active' THEN RAISE EXCEPTION '学生账户状态异常,不可借阅: %', v_account_status; END IF;
|
||||
IF v_current_borrow >= v_max_borrow THEN RAISE EXCEPTION '已达到最大借阅量'; END IF;
|
||||
|
||||
SELECT setting_value::int INTO v_borrow_duration FROM system_settings WHERE setting_key = 'borrow_duration_days';
|
||||
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status)
|
||||
VALUES (v_book_id, v_student_id, current_date, current_date + (v_borrow_duration || ' days')::interval, 'borrowed');
|
||||
|
||||
RAISE NOTICE '借阅成功! Book ID: %, Student ID: %', v_book_id, v_student_id;
|
||||
-- 触发器 trg_sync_book_student 会自动更新 books.available_copies 和 students.current_borrow
|
||||
END $$;
|
||||
|
||||
-- B. 处理归还请求
|
||||
-- 假设要归还 borrow_id 为 (假设是1,根据实际情况查询得到) 的借阅记录
|
||||
-- SELECT borrow_id FROM borrow_records br JOIN students s ON br.student_id = s.student_id JOIN books b ON br.book_id = b.book_id
|
||||
-- WHERE s.stu_no='S2023001' AND b.isbn='9787111624927' AND br.status IN ('borrowed', 'overdue'); (假设这是张三借的《深入理解计算机系统》)
|
||||
|
||||
-- 获取特定借阅记录ID (例如,张三当前借阅的《深入理解计算机系统》)
|
||||
DO $$
|
||||
DECLARE
|
||||
v_borrow_id bigint;
|
||||
BEGIN
|
||||
SELECT br.borrow_id INTO v_borrow_id
|
||||
FROM borrow_records br
|
||||
JOIN students s ON br.student_id = s.student_id
|
||||
JOIN books b ON br.book_id = b.book_id
|
||||
WHERE s.stu_no = 'S2023001' AND b.isbn = '9787111624927' AND br.status IN ('borrowed', 'overdue')
|
||||
ORDER BY br.borrow_date DESC LIMIT 1; -- 获取最近一次未还的
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE NOTICE '未找到该学生对应的此书的当前借阅记录。';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '正在归还 borrow_id: %', v_borrow_id;
|
||||
|
||||
UPDATE borrow_records
|
||||
SET
|
||||
status = 'returned',
|
||||
return_date = current_date
|
||||
WHERE borrow_id = v_borrow_id;
|
||||
-- 触发器 trg_calc_fine 会在逾期归还时自动记录罚款
|
||||
-- 触发器 trg_sync_book_student 会自动更新 books.available_copies 和 students.current_borrow
|
||||
RAISE NOTICE '归还操作完成。';
|
||||
END $$;
|
||||
|
||||
|
||||
-- C. 处理续借请求
|
||||
-- 假设要续借 borrow_id 为 (假设是2,李四借的《数学之美》) 的借阅记录
|
||||
-- 续借条件检查 (应用层或存储过程):
|
||||
-- 1. 图书未被其他人预约: SELECT 1 FROM reservations WHERE book_id = _book_id_ AND status = 'waiting'; (应为空)
|
||||
-- 2. 未超最大续借次数: SELECT renew_times FROM borrow_records WHERE borrow_id = _borrow_id_;
|
||||
-- (renew_times < (SELECT setting_value::int FROM system_settings WHERE setting_key = 'max_renew_times'))
|
||||
-- 3. 学生账户正常,图书未逾期等。
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_borrow_id bigint;
|
||||
v_book_id bigint;
|
||||
v_renew_times int;
|
||||
v_max_renew_times int;
|
||||
v_is_reserved int;
|
||||
v_borrow_duration int;
|
||||
v_current_due_date date;
|
||||
v_current_status borrow_status_enum;
|
||||
BEGIN
|
||||
-- 假设李四 (S2023002) 续借《数学之美》 (isbn='9787030598007')
|
||||
SELECT br.borrow_id, br.book_id, br.renew_times, br.due_date, br.status
|
||||
INTO v_borrow_id, v_book_id, v_renew_times, v_current_due_date, v_current_status
|
||||
FROM borrow_records br
|
||||
JOIN students s ON br.student_id = s.student_id
|
||||
JOIN books b ON br.book_id = b.book_id
|
||||
WHERE s.stu_no = 'S2023002' AND b.isbn = '9787030598007' AND br.status = 'borrowed' -- 通常只能续借未逾期的
|
||||
ORDER BY br.borrow_date DESC LIMIT 1;
|
||||
|
||||
IF NOT FOUND THEN RAISE EXCEPTION '未找到该借阅记录或记录状态不符。'; END IF;
|
||||
IF v_current_status = 'overdue' THEN RAISE EXCEPTION '图书已逾期,不可续借,请先处理逾期。'; END IF;
|
||||
|
||||
|
||||
SELECT setting_value::int INTO v_max_renew_times FROM system_settings WHERE setting_key = 'max_renew_times';
|
||||
SELECT setting_value::int INTO v_borrow_duration FROM system_settings WHERE setting_key = 'borrow_duration_days';
|
||||
|
||||
IF v_renew_times >= v_max_renew_times THEN
|
||||
RAISE EXCEPTION '已达到最大续借次数 (%)', v_max_renew_times;
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO v_is_reserved FROM reservations
|
||||
WHERE book_id = v_book_id AND status = 'waiting'
|
||||
AND student_id != (SELECT student_id FROM students WHERE stu_no = 'S2023002'); -- 排除自己的预约
|
||||
|
||||
IF v_is_reserved > 0 THEN
|
||||
RAISE EXCEPTION '该图书已被他人预约,不可续借。';
|
||||
END IF;
|
||||
|
||||
UPDATE borrow_records
|
||||
SET
|
||||
due_date = v_current_due_date + (v_borrow_duration || ' days')::interval, -- 从当前应还日期开始延长
|
||||
renew_times = v_renew_times + 1
|
||||
WHERE borrow_id = v_borrow_id;
|
||||
|
||||
RAISE NOTICE '续借成功! Borrow ID: %,新应还日期: %', v_borrow_id, (SELECT due_date FROM borrow_records WHERE borrow_id=v_borrow_id);
|
||||
END $$;
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- (4). 管理预约队列
|
||||
----------------------------------------------------------------------
|
||||
-- A. 查看某本图书的预约队列 (例如 book_id = 7, Java核心技术)
|
||||
SELECT r.reservation_id, r.reserve_date, r.status, s.stu_no, s.name as student_name, s.email
|
||||
FROM reservations r
|
||||
JOIN students s ON r.student_id = s.student_id
|
||||
WHERE r.book_id = 7 AND r.status = 'waiting' -- 假设book_id 7是《Java核心技术 卷I》
|
||||
ORDER BY r.reserve_date ASC; -- 按预约日期升序,先预约的在前
|
||||
|
||||
-- B. 处理到书通知 (当一本被预约的书归还后)
|
||||
-- 假设 book_id = 7 的书有副本归还,管理员通知队列中的第一个学生
|
||||
-- B1. 找到第一个等待的学生
|
||||
DO $$
|
||||
DECLARE
|
||||
v_reservation_id bigint;
|
||||
v_student_id bigint;
|
||||
v_book_id int := 7; -- 假设是 Java 核心技术
|
||||
v_reservation_expiry_days int;
|
||||
BEGIN
|
||||
SELECT reservation_id, student_id INTO v_reservation_id, v_student_id
|
||||
FROM reservations
|
||||
WHERE book_id = v_book_id AND status = 'waiting'
|
||||
ORDER BY reserve_date ASC
|
||||
LIMIT 1;
|
||||
|
||||
IF FOUND THEN
|
||||
SELECT setting_value::int INTO v_reservation_expiry_days
|
||||
FROM system_settings WHERE setting_key = 'reservation_expiry_days';
|
||||
|
||||
UPDATE reservations
|
||||
SET status = 'available' -- (可以增加一个 notified_at timestamp 和 available_until timestamp 字段)
|
||||
-- available_until = current_timestamp + (v_reservation_expiry_days || ' days')::interval -- (如果表有此字段)
|
||||
WHERE reservation_id = v_reservation_id;
|
||||
RAISE NOTICE '已通知学生ID % (预约ID %) 图书 % 可取。', v_student_id, v_reservation_id, v_book_id;
|
||||
-- 实际应用中会发送邮件/短信
|
||||
ELSE
|
||||
RAISE NOTICE '图书 % 没有等待中的预约。', v_book_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- C. 学生未在规定时间内取书,预约自动或手动过期
|
||||
UPDATE reservations
|
||||
SET status = 'expired'
|
||||
WHERE status = 'available' AND reservation_id = _reservation_id_ ;
|
||||
-- AND available_until < current_timestamp; -- (如果表有 available_until 字段)
|
||||
|
||||
-- D. 管理员取消某个预约 (例如,学生请求取消)
|
||||
UPDATE reservations
|
||||
SET status = 'cancelled'
|
||||
WHERE reservation_id = _reservation_id_ ; -- 替换为实际预约ID
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- (5). 处理图书遗失、损坏等异常情况
|
||||
----------------------------------------------------------------------
|
||||
-- A. 处理图书遗失
|
||||
-- 假设学生 (stu_no='S2022006', 周八) 遗失了图书 (isbn='9787559620187', 明朝那些事儿)
|
||||
-- A1. 更新借阅记录状态 (如果该书是被借阅后遗失的)
|
||||
-- 你已经在测试数据中插入了一条 borrow_records 状态为 'lost' 的记录,这里假设是管理员新发现的遗失
|
||||
DO $$
|
||||
DECLARE
|
||||
v_student_id bigint;
|
||||
v_book_id bigint;
|
||||
v_borrow_id bigint;
|
||||
v_book_price numeric;
|
||||
v_admin_id bigint := (SELECT admin_id FROM admins WHERE emp_no = 'A002'); -- 操作管理员ID
|
||||
BEGIN
|
||||
SELECT student_id INTO v_student_id FROM students WHERE stu_no = 'S2022006';
|
||||
SELECT book_id, price INTO v_book_id, v_book_price FROM books WHERE isbn = '9787559620187';
|
||||
|
||||
-- 查找该学生对这本书的未还借阅记录
|
||||
SELECT borrow_id INTO v_borrow_id
|
||||
FROM borrow_records
|
||||
WHERE student_id = v_student_id AND book_id = v_book_id AND status IN ('borrowed', 'overdue')
|
||||
LIMIT 1;
|
||||
|
||||
IF FOUND THEN
|
||||
UPDATE borrow_records SET status = 'lost' WHERE borrow_id = v_borrow_id;
|
||||
RAISE NOTICE '借阅记录 % 已更新为遗失。', v_borrow_id;
|
||||
ELSE
|
||||
RAISE NOTICE '未找到该学生对此书的当前借阅记录,可能是在馆藏中发现遗失。';
|
||||
END IF;
|
||||
|
||||
-- A2. 更新图书信息状态和可借数量 (如果这本书之前是可借的)
|
||||
-- total_copies 一般不变,除非彻底报废且不再补充
|
||||
UPDATE books
|
||||
SET
|
||||
status = 'lost', -- 如果整本书遗失
|
||||
available_copies = GREATEST(0, available_copies - 1) -- 确保可借不为负
|
||||
WHERE book_id = v_book_id AND status != 'lost'; -- 避免重复操作,如果已是lost就不再减available_copies
|
||||
|
||||
-- A3. 生成罚款记录 (按书价赔偿)
|
||||
INSERT INTO fines (student_id, amount, reason, status, issue_date, admin_id)
|
||||
VALUES (v_student_id, v_book_price, '遗失图书《明朝那些事儿》(ISBN:9787559620187)', 'unpaid', current_date, v_admin_id);
|
||||
RAISE NOTICE '遗失罚款已记录。';
|
||||
-- 触发器 trg_freeze_account 会检查是否需要冻结账户
|
||||
-- 触发器 trg_sync_book_student 会因为 borrow_records 更新而调整 current_borrow
|
||||
END $$;
|
||||
|
||||
-- B. 处理图书损坏
|
||||
-- 假设发现馆藏图书 (isbn='9787208159659', 三体全集) 有一本损坏,需要记录并可能罚款最后借阅人
|
||||
DO $$
|
||||
DECLARE
|
||||
v_book_id bigint;
|
||||
v_last_borrower_student_id bigint;
|
||||
v_damage_fine_amount numeric := 20.00; -- 损坏赔偿金额
|
||||
v_admin_id bigint := (SELECT admin_id FROM admins WHERE emp_no = 'A002');
|
||||
BEGIN
|
||||
SELECT book_id INTO v_book_id FROM books WHERE isbn = '9787208159659';
|
||||
|
||||
-- B1. 更新图书状态,如果损坏到不可借阅,减少可借数量
|
||||
UPDATE books
|
||||
SET
|
||||
status = 'damaged',
|
||||
available_copies = GREATEST(0, available_copies - 1) -- 如果损坏导致不可借
|
||||
WHERE book_id = v_book_id;
|
||||
RAISE NOTICE '图书 % 状态已更新为损坏。', v_book_id;
|
||||
|
||||
-- B2. (可选) 查找最后借阅人并生成罚款
|
||||
SELECT student_id INTO v_last_borrower_student_id
|
||||
FROM borrow_records
|
||||
WHERE book_id = v_book_id AND return_date IS NOT NULL
|
||||
ORDER BY return_date DESC
|
||||
LIMIT 1;
|
||||
|
||||
IF FOUND THEN
|
||||
INSERT INTO fines (student_id, amount, reason, status, issue_date, admin_id)
|
||||
VALUES (v_last_borrower_student_id, v_damage_fine_amount, '损坏图书《三体全集》(ISBN:9787208159659)', 'unpaid', current_date, v_admin_id);
|
||||
RAISE NOTICE '已向最后借阅人 (学生ID: %) 记录损坏罚款。', v_last_borrower_student_id;
|
||||
-- 触发器 trg_freeze_account
|
||||
ELSE
|
||||
RAISE NOTICE '未找到该书的最后借阅人,或为馆藏期间损坏。';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- C. 图书下架 (例如,图书过于陈旧或残破不再流通)
|
||||
UPDATE books
|
||||
SET status = 'removed', available_copies = 0
|
||||
WHERE isbn = 'ISBN_TO_BE_REMOVED';
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- (6). 设置和管理罚款规则 (主要通过 system_settings 表)
|
||||
----------------------------------------------------------------------
|
||||
-- A. 查看当前罚款规则
|
||||
SELECT * FROM system_settings WHERE setting_key LIKE 'fine%';
|
||||
|
||||
-- B. 修改每日逾期罚款金额
|
||||
UPDATE system_settings
|
||||
SET setting_value = '0.30' -- 修改为每天0.3元
|
||||
WHERE setting_key = 'fine_per_day';
|
||||
|
||||
-- C. 修改欠款冻结账户的阈值
|
||||
UPDATE system_settings
|
||||
SET setting_value = '15.00' -- 修改为欠款15元冻结
|
||||
WHERE setting_key = 'freeze_threshold';
|
||||
|
||||
-- D. 添加新的罚款规则 (如果系统支持更复杂的规则,可能需要修改表结构或增加新表)
|
||||
-- 当前设计下,罚款规则比较简单,主要就是每日费率。
|
||||
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- (7). 生成各类统计报表 (主要使用视图和聚合查询)
|
||||
----------------------------------------------------------------------
|
||||
-- A. 查看热门图书 (借阅量前20,使用视图)
|
||||
SELECT * FROM v_hot_books;
|
||||
|
||||
-- B. 查看各院系借阅统计 (使用视图)
|
||||
SELECT * FROM v_dept_borrow_stats;
|
||||
|
||||
-- C. 查看图书逾期情况 (使用视图)
|
||||
SELECT vd.*, b.title AS book_title, s.name AS student_name, s.phone AS student_phone
|
||||
FROM v_overdue_details vd
|
||||
JOIN books b ON vd.book_id = b.book_id
|
||||
JOIN students s ON vd.student_id = s.student_id;
|
||||
|
||||
-- D. 自定义报表:某一时段内各类图书的借阅次数
|
||||
SELECT
|
||||
b.classification_no,
|
||||
COUNT(br.borrow_id) AS borrow_count
|
||||
FROM borrow_records br
|
||||
JOIN books b ON br.book_id = b.book_id
|
||||
WHERE br.borrow_date BETWEEN '2025-01-01' AND '2025-12-31' -- 示例时间段
|
||||
GROUP BY b.classification_no
|
||||
ORDER BY borrow_count DESC;
|
||||
|
||||
-- E. 自定义报表:每月借阅总量趋势
|
||||
SELECT
|
||||
to_char(borrow_date, 'YYYY-MM') AS borrow_month,
|
||||
COUNT(borrow_id) AS total_borrows
|
||||
FROM borrow_records
|
||||
GROUP BY borrow_month
|
||||
ORDER BY borrow_month;
|
||||
|
||||
-- F. 调用存储过程生成定期报表 (如果 sp_generate_circulation_stats 是设计为手动触发的)
|
||||
-- SELECT sp_generate_circulation_stats();
|
||||
-- 然后查询报表数据
|
||||
-- SELECT * FROM circulation_stats ORDER BY stat_date DESC;
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- (8). 系统参数设置 (主要通过 system_settings 表)
|
||||
----------------------------------------------------------------------
|
||||
-- A. 查看所有系统参数
|
||||
SELECT * FROM system_settings;
|
||||
|
||||
-- B. 修改默认最大借阅量
|
||||
UPDATE system_settings
|
||||
SET setting_value = '8'
|
||||
WHERE setting_key = 'max_borrow_default';
|
||||
-- 注意:这只影响未来新建学生账户的默认值,或在 sp_init_students 中使用此值批量更新。
|
||||
-- 已有学生的 max_borrow 需要单独修改,或在初始化脚本中覆盖。
|
||||
|
||||
-- C. 修改默认借阅期限(天数)
|
||||
UPDATE system_settings
|
||||
SET setting_value = '25'
|
||||
WHERE setting_key = 'borrow_duration_days';
|
||||
|
||||
-- D. 修改最大续借次数
|
||||
UPDATE system_settings
|
||||
SET setting_value = '1'
|
||||
WHERE setting_key = 'max_renew_times';
|
||||
|
||||
-- E. 修改预约保留天数
|
||||
UPDATE system_settings
|
||||
SET setting_value = '2'
|
||||
WHERE setting_key = 'reservation_expiry_days';
|
||||
|
||||
-- F. 添加新的系统参数 (如果应用需要)
|
||||
-- INSERT INTO system_settings (setting_key, setting_value)
|
||||
-- VALUES ('new_feature_xyz_enabled', 'true');
|
||||
30
sql/advance_query.sql
Normal file
@ -0,0 +1,30 @@
|
||||
-- 查询验证触发器效果
|
||||
---------- 查询验证 ----------
|
||||
|
||||
-- 1. 书籍可借数量和学生当前借阅量 (应由 trg_sync_book_student 更新):
|
||||
SELECT book_id, title, total_copies, available_copies FROM books WHERE book_id <= 7;
|
||||
SELECT student_id, name, max_borrow, current_borrow, account_status FROM students WHERE student_id <= 5;
|
||||
|
||||
-- 2. 孙七 (S2023005) 的罚款记录 (应有 trg_calc_fine 生成的逾期罚款 和 手动添加的罚款):
|
||||
SELECT f.*, s.name as student_name
|
||||
FROM fines f JOIN students s ON f.student_id = s.student_id
|
||||
WHERE s.stu_no = 'S2023005';
|
||||
|
||||
-- 3. 孙七 (S2023005) 的账户状态 (应由 trg_freeze_account 根据总欠款更新):
|
||||
SELECT stu_no, name, account_status, current_borrow
|
||||
FROM students
|
||||
WHERE stu_no = 'S2023005';
|
||||
|
||||
-- 4. 查看热门图书视图:
|
||||
SELECT * FROM v_hot_books;
|
||||
|
||||
-- 5. 查看院系借阅统计视图:
|
||||
SELECT * FROM v_dept_borrow_stats;
|
||||
|
||||
-- 6. 查看逾期详情视图:
|
||||
SELECT od.*, b.title, s.name as student_name
|
||||
FROM v_overdue_details od
|
||||
JOIN books b ON od.book_id = b.book_id
|
||||
JOIN students s ON od.student_id = s.student_id;
|
||||
|
||||
-- 测试数据插入完毕。
|
||||
346
sql/create_library.sql
Normal file
@ -0,0 +1,346 @@
|
||||
/* ---------- 基础设置 ---------- */
|
||||
CREATE SCHEMA IF NOT EXISTS library;
|
||||
SET search_path TO library,public;
|
||||
|
||||
/* ---------- ENUM 类型 ---------- */
|
||||
CREATE TYPE gender_enum AS ENUM ('M','F','O');
|
||||
CREATE TYPE book_status_enum AS ENUM ('normal','lost','damaged','removed');
|
||||
CREATE TYPE borrow_status_enum AS ENUM ('borrowed','returned','overdue','lost');
|
||||
CREATE TYPE reserve_status_enum AS ENUM ('waiting','available','cancelled','expired');
|
||||
CREATE TYPE acct_status_enum AS ENUM ('active','reported','frozen');
|
||||
|
||||
/* ---------- 系统参数表 ---------- */
|
||||
CREATE TABLE system_settings (
|
||||
setting_key text PRIMARY KEY,
|
||||
setting_value text NOT NULL
|
||||
);
|
||||
/* 罚款费率、冻结阈值等默认值 */
|
||||
INSERT INTO system_settings(setting_key,setting_value) VALUES
|
||||
('fine_per_day', '0.50'), -- 每本书每日罚款金额
|
||||
('freeze_threshold', '20.00'), -- 欠款 ≥ 此值自动冻结
|
||||
('max_borrow_default', '10'); -- 默认最大借阅量
|
||||
|
||||
/* ---------- 图书信息 ---------- */
|
||||
CREATE TABLE books (
|
||||
book_id bigserial PRIMARY KEY,
|
||||
isbn varchar(13) UNIQUE NOT NULL,
|
||||
title text NOT NULL,
|
||||
authors text[] NOT NULL,
|
||||
publisher text,
|
||||
publish_date date,
|
||||
price numeric(10,2),
|
||||
classification_no varchar(64),
|
||||
location varchar(255),
|
||||
total_copies int NOT NULL DEFAULT 1 CHECK(total_copies>0),
|
||||
available_copies int NOT NULL DEFAULT 1 CHECK(available_copies>=0),
|
||||
status book_status_enum NOT NULL DEFAULT 'normal',
|
||||
description text,
|
||||
cover_url text,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
/* ---------- 帮助函数:按键名返回 int 型系统参数 ---------- */
|
||||
CREATE OR REPLACE FUNCTION library.get_setting_int(p_key text)
|
||||
RETURNS int
|
||||
LANGUAGE sql
|
||||
STABLE -- ← 允许做 DEFAULT
|
||||
AS $$
|
||||
SELECT setting_value::int
|
||||
FROM library.system_settings
|
||||
WHERE setting_key = p_key;
|
||||
$$;
|
||||
|
||||
/* ---------- 学生信息 ---------- */
|
||||
CREATE TABLE library.students (
|
||||
student_id bigserial PRIMARY KEY,
|
||||
stu_no varchar(20) UNIQUE NOT NULL, -- 学号
|
||||
name varchar(64) NOT NULL,
|
||||
gender gender_enum,
|
||||
department varchar(128),
|
||||
major varchar(128),
|
||||
grade varchar(10),
|
||||
class varchar(50),
|
||||
phone varchar(20),
|
||||
email varchar(255),
|
||||
account_status acct_status_enum NOT NULL DEFAULT 'active',
|
||||
max_borrow int NOT NULL
|
||||
DEFAULT library.get_setting_int('max_borrow_default'),
|
||||
current_borrow int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
/* ---------- 管理员信息 ---------- */
|
||||
CREATE TABLE admins (
|
||||
admin_id bigserial PRIMARY KEY,
|
||||
emp_no varchar(20) UNIQUE NOT NULL,
|
||||
name varchar(64) NOT NULL,
|
||||
position varchar(64),
|
||||
phone varchar(20),
|
||||
privilege_lv int NOT NULL DEFAULT 1,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
/* ---------- 借阅记录 ---------- */
|
||||
CREATE TABLE borrow_records (
|
||||
borrow_id bigserial PRIMARY KEY,
|
||||
book_id bigint REFERENCES books(book_id) ON DELETE CASCADE,
|
||||
student_id bigint REFERENCES students(student_id) ON DELETE CASCADE,
|
||||
borrow_date date NOT NULL DEFAULT current_date,
|
||||
due_date date NOT NULL,
|
||||
return_date date,
|
||||
renew_times int NOT NULL DEFAULT 0,
|
||||
status borrow_status_enum NOT NULL DEFAULT 'borrowed',
|
||||
fine_amount numeric(10,2) NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
/* ---------- 图书预约 ---------- */
|
||||
CREATE TABLE reservations (
|
||||
reservation_id bigserial PRIMARY KEY,
|
||||
book_id bigint REFERENCES books(book_id) ON DELETE CASCADE,
|
||||
student_id bigint REFERENCES students(student_id) ON DELETE CASCADE,
|
||||
reserve_date date NOT NULL DEFAULT current_date,
|
||||
status reserve_status_enum NOT NULL DEFAULT 'waiting'
|
||||
);
|
||||
|
||||
/* ---------- 图书评价 ---------- */
|
||||
CREATE TABLE reviews (
|
||||
review_id bigserial PRIMARY KEY,
|
||||
book_id bigint REFERENCES books(book_id) ON DELETE CASCADE,
|
||||
student_id bigint REFERENCES students(student_id) ON DELETE CASCADE,
|
||||
rating int NOT NULL CHECK(rating BETWEEN 1 AND 5),
|
||||
content text,
|
||||
review_time timestamptz DEFAULT now(),
|
||||
UNIQUE(book_id,student_id) -- 一人一书仅一次评价
|
||||
);
|
||||
|
||||
/* ---------- 罚款记录 ---------- */
|
||||
CREATE TABLE fines (
|
||||
fine_id bigserial PRIMARY KEY,
|
||||
student_id bigint REFERENCES students(student_id) ON DELETE CASCADE,
|
||||
amount numeric(10,2) NOT NULL CHECK(amount>0),
|
||||
reason text,
|
||||
status varchar(10) NOT NULL CHECK(status IN ('unpaid','paid')),
|
||||
issue_date date NOT NULL DEFAULT current_date,
|
||||
admin_id bigint REFERENCES admins(admin_id)
|
||||
);
|
||||
|
||||
/* ---------- 拓展 ---------- */
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
|
||||
|
||||
/* ---------- 索引 ---------- */
|
||||
CREATE INDEX idx_books_title_trgm
|
||||
ON library.books USING gin (title gin_trgm_ops);
|
||||
CREATE INDEX idx_books_authors_gin
|
||||
ON library.books USING gin (authors);
|
||||
CREATE INDEX idx_br_student_status ON borrow_records(student_id,status);
|
||||
CREATE INDEX idx_reserve_book_wait ON reservations(status,book_id);
|
||||
CREATE INDEX idx_fines_student_unpaid ON fines(student_id) WHERE status='unpaid';
|
||||
|
||||
/* 要使用 trigram GIN 索引需启用扩展 */
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
/* ---------- 触发器函数 ---------- */
|
||||
|
||||
/* 1. 更新可借数量 & 学生当前借阅量 */
|
||||
CREATE OR REPLACE FUNCTION trg_sync_book_student() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- 更新图书可借册数
|
||||
UPDATE books SET available_copies = total_copies -
|
||||
(SELECT count(*) FROM borrow_records
|
||||
WHERE book_id = COALESCE(NEW.book_id,OLD.book_id)
|
||||
AND status IN ('borrowed','overdue'))
|
||||
WHERE book_id = COALESCE(NEW.book_id,OLD.book_id);
|
||||
|
||||
-- 更新学生当前借阅量
|
||||
UPDATE students SET current_borrow =
|
||||
(SELECT count(*) FROM borrow_records
|
||||
WHERE student_id = COALESCE(NEW.student_id,OLD.student_id)
|
||||
AND status IN ('borrowed','overdue'))
|
||||
WHERE student_id = COALESCE(NEW.student_id,OLD.student_id);
|
||||
|
||||
RETURN NULL;
|
||||
END; $$ LANGUAGE plpgsql;
|
||||
|
||||
/* 2. 归还时自动计算并记账罚款 */
|
||||
CREATE OR REPLACE FUNCTION trg_calc_fine() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
days_overdue int;
|
||||
rate numeric(10,2) := (SELECT setting_value::numeric FROM system_settings WHERE setting_key='fine_per_day');
|
||||
BEGIN
|
||||
IF TG_OP='UPDATE' AND NEW.status='returned' AND OLD.status IN ('borrowed','overdue') THEN
|
||||
days_overdue := GREATEST((NEW.return_date - NEW.due_date),0);
|
||||
IF days_overdue > 0 THEN
|
||||
INSERT INTO fines(student_id,amount,reason,status,admin_id)
|
||||
VALUES (NEW.student_id, days_overdue*rate,
|
||||
format('Overdue %s days for borrow_id=%s',days_overdue,NEW.borrow_id),
|
||||
'unpaid', NULL);
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END; $$ LANGUAGE plpgsql;
|
||||
|
||||
/* 3. 欠款超阈值自动冻结账户 */
|
||||
CREATE OR REPLACE FUNCTION library.trg_freeze_account() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
total_unpaid numeric(10,2);
|
||||
threshold numeric(10,2);
|
||||
v_relevant_student_id bigint;
|
||||
v_current_status library.acct_status_enum;
|
||||
v_new_status library.acct_status_enum;
|
||||
BEGIN
|
||||
-- 1. 获取最新的冻结阈值
|
||||
SELECT setting_value::numeric INTO threshold
|
||||
FROM library.system_settings
|
||||
WHERE setting_key = 'freeze_threshold';
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE WARNING 'System setting "freeze_threshold" not found. Account status will not be updated by trigger.';
|
||||
RETURN NULL; -- 或者 RAISE EXCEPTION;
|
||||
END IF;
|
||||
|
||||
-- 2. 确定相关的学生ID
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
v_relevant_student_id := OLD.student_id;
|
||||
ELSE -- INSERT or UPDATE
|
||||
v_relevant_student_id := NEW.student_id;
|
||||
END IF;
|
||||
|
||||
-- 如果没有相关的学生ID(理论上不应发生,因为student_id是FK且NOT NULL),则退出
|
||||
IF v_relevant_student_id IS NULL THEN
|
||||
RAISE WARNING 'trg_freeze_account: No relevant student_id found. TG_OP: %', TG_OP;
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- 3. 计算该学生未缴罚款总额
|
||||
SELECT COALESCE(sum(amount),0) INTO total_unpaid
|
||||
FROM library.fines
|
||||
WHERE student_id = v_relevant_student_id AND status = 'unpaid';
|
||||
|
||||
-- 4. 获取学生当前账户状态
|
||||
SELECT account_status INTO v_current_status
|
||||
FROM library.students
|
||||
WHERE student_id = v_relevant_student_id;
|
||||
|
||||
-- 5. 根据罚款确定新的目标状态
|
||||
-- 注意:原始逻辑是,如果欠款低于阈值,则账户变为 'active'。
|
||||
-- 这意味着如果账户之前是 'reported'(挂失),且欠款低于阈值,它也会被此触发器改为 'active'。
|
||||
-- 如果希望 'reported' 状态不受此罚款逻辑影响(除非因欠款被冻结),则需要更复杂的判断。
|
||||
-- 此处保持与你原触发器相似的逻辑,仅修复类型问题并优化。
|
||||
IF total_unpaid >= threshold THEN
|
||||
v_new_status := 'frozen'::library.acct_status_enum;
|
||||
ELSE
|
||||
-- 如果当前已经是 'frozen',则解冻为 'active'
|
||||
-- 如果当前不是 'frozen' (比如是 'active' 或 'reported'),且欠款未超限,则应保持其原状态,
|
||||
-- 而不是都强制变为 'active'。
|
||||
-- 为了更安全地处理 'reported' 状态,我们修改这里的逻辑:
|
||||
IF v_current_status = 'frozen'::library.acct_status_enum THEN
|
||||
v_new_status := 'active'::library.acct_status_enum;
|
||||
ELSE
|
||||
v_new_status := v_current_status; -- 保持现有状态 (active 或 reported)
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- 6. 如果计算出的新状态与当前状态不同,则更新学生账户状态
|
||||
IF v_current_status IS DISTINCT FROM v_new_status THEN
|
||||
UPDATE library.students
|
||||
SET account_status = v_new_status
|
||||
WHERE student_id = v_relevant_student_id;
|
||||
END IF;
|
||||
|
||||
RETURN NULL; -- AFTER 触发器通常返回 NULL
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
/* ---------- 触发器绑定 ---------- */
|
||||
CREATE TRIGGER trg_borrow_sync_aiud
|
||||
AFTER INSERT OR UPDATE OR DELETE ON borrow_records
|
||||
FOR EACH ROW EXECUTE FUNCTION trg_sync_book_student();
|
||||
|
||||
CREATE TRIGGER trg_borrow_calc_fine_upd
|
||||
AFTER UPDATE ON borrow_records
|
||||
FOR EACH ROW EXECUTE FUNCTION trg_calc_fine();
|
||||
|
||||
CREATE TRIGGER trg_fine_freeze_aiud
|
||||
AFTER INSERT OR UPDATE OR DELETE ON fines
|
||||
FOR EACH ROW EXECUTE FUNCTION trg_freeze_account();
|
||||
|
||||
/* ---------- 视图 ---------- */
|
||||
|
||||
/* (1) 当前热门图书:借阅量前 20 */
|
||||
CREATE OR REPLACE VIEW v_hot_books AS
|
||||
SELECT b.book_id, b.isbn, b.title,
|
||||
COUNT(br.borrow_id) AS borrow_cnt
|
||||
FROM books b
|
||||
JOIN borrow_records br ON br.book_id = b.book_id
|
||||
GROUP BY b.book_id
|
||||
ORDER BY borrow_cnt DESC
|
||||
LIMIT 20;
|
||||
|
||||
/* (2) 各院系借阅统计 */
|
||||
CREATE OR REPLACE VIEW v_dept_borrow_stats AS
|
||||
SELECT s.department,
|
||||
COUNT(br.borrow_id) AS total_borrows,
|
||||
COUNT(DISTINCT br.student_id) AS unique_readers
|
||||
FROM borrow_records br
|
||||
JOIN students s ON s.student_id = br.student_id
|
||||
GROUP BY s.department
|
||||
ORDER BY total_borrows DESC;
|
||||
|
||||
/* (3) 图书逾期情况 */
|
||||
CREATE OR REPLACE VIEW v_overdue_details AS
|
||||
SELECT br.borrow_id, br.book_id, br.student_id,
|
||||
br.due_date, CURRENT_DATE - br.due_date AS days_overdue
|
||||
FROM borrow_records br
|
||||
WHERE br.status='overdue';
|
||||
|
||||
/* ---------- 存储过程(plpgsql 函数) ---------- */
|
||||
|
||||
/* 1. 学期初批量初始化学生账户
|
||||
- 示例:EXECUTE library.sp_init_students('2025-Fall');
|
||||
*/
|
||||
CREATE OR REPLACE FUNCTION sp_init_students(term_code text)
|
||||
RETURNS void LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
-- 示例逻辑:所有学生 current_borrow 清零、状态激活
|
||||
UPDATE students SET current_borrow=0, account_status='active';
|
||||
RAISE NOTICE 'Students initialized for %', term_code;
|
||||
END; $$;
|
||||
|
||||
/* 2. 定期生成图书流通统计(存入自定义表) */
|
||||
CREATE TABLE IF NOT EXISTS circulation_stats (
|
||||
stat_date date PRIMARY KEY,
|
||||
total_borrows bigint,
|
||||
unique_readers bigint,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION sp_generate_circulation_stats()
|
||||
RETURNS void LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
INSERT INTO circulation_stats(stat_date,total_borrows,unique_readers)
|
||||
SELECT CURRENT_DATE,
|
||||
(SELECT COUNT(*) FROM borrow_records WHERE borrow_date=CURRENT_DATE),
|
||||
(SELECT COUNT(DISTINCT student_id) FROM borrow_records WHERE borrow_date=CURRENT_DATE);
|
||||
END; $$;
|
||||
|
||||
/* 3. 自动发送逾期提醒(示例写入提醒表) */
|
||||
CREATE TABLE IF NOT EXISTS overdue_notices (
|
||||
notice_id bigserial PRIMARY KEY,
|
||||
borrow_id bigint,
|
||||
student_id bigint,
|
||||
notice_time timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION sp_send_overdue_notices()
|
||||
RETURNS void LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
INSERT INTO overdue_notices(borrow_id,student_id)
|
||||
SELECT br.borrow_id, br.student_id
|
||||
FROM borrow_records br
|
||||
WHERE br.status='overdue'
|
||||
AND NOT EXISTS (SELECT 1 FROM overdue_notices onot WHERE onot.borrow_id=br.borrow_id);
|
||||
END; $$;
|
||||
|
||||
/* ---------- 完成 ---------- */
|
||||
COMMENT ON SCHEMA library IS '智能图书管理系统数据库架构(2025-06-21)';
|
||||
208
sql/example_data.sql
Normal file
@ -0,0 +1,208 @@
|
||||
SET search_path TO library, public;
|
||||
|
||||
-- 清理可能存在的旧数据(可选,测试时方便重跑)
|
||||
-- 请注意,CASCADE 会删除关联数据,请谨慎在生产环境使用
|
||||
DELETE FROM fines;
|
||||
DELETE FROM reviews;
|
||||
DELETE FROM reservations;
|
||||
DELETE FROM borrow_records;
|
||||
DELETE FROM admins;
|
||||
DELETE FROM students;
|
||||
DELETE FROM books;
|
||||
DELETE FROM system_settings WHERE setting_key NOT IN ('fine_per_day', 'freeze_threshold', 'max_borrow_default'); -- 保留核心设置
|
||||
|
||||
-- 0. 系统参数 (你已插入,这里可以补充更多,如果需要)
|
||||
INSERT INTO system_settings(setting_key, setting_value) VALUES
|
||||
('max_renew_times', '2'), -- 最大续借次数
|
||||
('borrow_duration_days', '30'), -- 默认借阅时长(天)
|
||||
('reservation_expiry_days', '3'), -- 预约保留天数
|
||||
('admin_default_password_hash', 'xxxx'); -- 示例:管理员默认密码哈希
|
||||
-- ON CONFLICT (setting_key) DO UPDATE SET setting_value = EXCLUDED.setting_value; -- 如果要允许更新
|
||||
|
||||
-- 1. 管理员信息 (Admins)
|
||||
INSERT INTO admins (emp_no, name, position, phone, privilege_lv) VALUES
|
||||
('A001', '张管理', '馆长', '13800138000', 0), -- 最高权限
|
||||
('A002', '李协助', '图书管理员', '13900139000', 1),
|
||||
('A003', '王登记', '流通部职员', '13700137000', 2);
|
||||
|
||||
-- 2. 图书信息 (Books)
|
||||
-- 注意: available_copies 初始设为 total_copies,后续借阅会通过触发器减少
|
||||
INSERT INTO books (isbn, title, authors, publisher, publish_date, price, classification_no, location, total_copies, available_copies, status, description, cover_url) VALUES
|
||||
('9787111624927', '深入理解计算机系统', ARRAY['Randal E. Bryant', 'David R. O''Hallaron'], '机械工业出版社', '2019-05-01', 139.00, 'TP301', 'A区2架3层', 10, 10, 'normal', '计算机系统的经典之作。', 'http://example.com/cover1.jpg'),
|
||||
('9787030598007', '数学之美', ARRAY['吴军'], '科学出版社', '2020-01-01', 68.00, 'O1', 'B区1架1层', 15, 15, 'normal', '通俗易懂的数学科普读物。', 'http://example.com/cover2.jpg'),
|
||||
('9787544270878', '百年孤独', ARRAY['加西亚·马尔克斯'], '南海出版公司', '2011-06-01', 39.50, 'I775.45', 'C区5架2层', 8, 8, 'normal', '魔幻现实主义文学的代表作。', 'http://example.com/cover3.jpg'),
|
||||
('9787115478850', 'Python编程:从入门到实践', ARRAY['Eric Matthes'], '人民邮电出版社', '2018-03-01', 89.00, 'TP312PY', 'A区3架1层', 12, 12, 'normal', 'Python入门畅销书。', 'http://example.com/cover4.jpg'),
|
||||
('9787508647009', '人类简史:从动物到上帝', ARRAY['尤瓦尔·赫拉利'], '中信出版社', '2014-11-01', 68.00, 'K02', 'D区1架1层', 20, 20, 'normal', '一部宏大的人类发展史。', 'http://example.com/cover5.jpg'),
|
||||
('9787532767406', '追风筝的人', ARRAY['卡勒德·胡赛尼'], '上海人民出版社', '2006-05-01', 29.00, 'I712.45', 'C区4架3层', 7, 7, 'normal', '关于爱、友谊、背叛和救赎的故事。', 'http://example.com/cover6.jpg'),
|
||||
('9787121362308', 'Java核心技术 卷I', ARRAY['Cay S. Horstmann'], '电子工业出版社', '2019-06-01', 128.00, 'TP312JA', 'A区3架2层', 5, 0, 'normal', 'Java经典教材,目前全部被借出,用于测试预约。', 'http://example.com/cover7.jpg'), -- 特意设置 available_copies 为 0
|
||||
('9787208159659', '三体全集', ARRAY['刘慈欣'], '重庆出版社', '2019-01-01', 168.00, 'I247.5', 'C区6架1层', 3, 3, 'damaged', '科幻巨作,其中一本有损坏。', 'http://example.com/cover8.jpg'),
|
||||
('9787559620187', '明朝那些事儿 (套装全7册)', ARRAY['当年明月'], '北京联合出版公司', '2017-08-01', 258.00, 'K248', 'D区2架1层', 6, 6, 'normal', '通俗易懂的明史。', 'http://example.com/cover9.jpg'),
|
||||
('9787111600815', '算法导论 (原书第3版)', ARRAY['Thomas H. Cormen'], '机械工业出版社', '2012-12-01', 128.00, 'TP301.6', 'A区2架4层', 9, 9, 'normal', '算法领域的圣经。', 'http://example.com/cover10.jpg'),
|
||||
('9780132350884', 'Clean Code', ARRAY['Robert C. Martin'], 'Prentice Hall', '2008-08-01', 50.00, 'TP311.1', 'A区1架5层', 4, 4, 'removed', '一本已下架的书,测试状态。', 'http://example.com/cover11.jpg');
|
||||
|
||||
|
||||
-- 3. 学生信息 (Students)
|
||||
-- max_borrow 使用默认值(通过 get_setting_int('max_borrow_default') 获取,即10)
|
||||
-- current_borrow 初始为0,会由触发器更新
|
||||
INSERT INTO students (stu_no, name, gender, department, major, grade, class, phone, email, account_status) VALUES
|
||||
('S2023001', '张三', 'M', '计算机学院', '软件工程', '2023', '01班', '13512345671', 'zhangsan@example.com', 'active'),
|
||||
('S2023002', '李四', 'F', '计算机学院', '人工智能', '2023', '02班', '13512345672', 'lisi@example.com', 'active'),
|
||||
('S2022003', '王五', 'M', '外国语学院', '英语', '2022', '01班', '13512345673', 'wangwu@example.com', 'active'),
|
||||
('S2021004', '赵六', 'F', '经济管理学院', '工商管理', '2021', '03班', '13512345674', 'zhaoliu@example.com', 'active'),
|
||||
('S2023005', '孙七', 'M', '计算机学院', '软件工程', '2023', '01班', '13512345675', 'sunqi@example.com', 'reported'), -- 测试挂失状态
|
||||
('S2022006', '周八', 'F', '人文学院', '历史学', '2022', '02班', '13512345676', 'zhouba@example.com', 'active'),
|
||||
('S2021007', '吴九', 'M', '理学院', '数学与应用数学', '2021', '01班', '13512345677', 'wujiu@example.com', 'active'),
|
||||
('S2023008', '郑十', 'F', '计算机学院', '网络工程', '2023', '03班', '13512345678', 'zhengshi@example.com', 'active');
|
||||
|
||||
-- 修改一个学生的 max_borrow,测试非默认值
|
||||
UPDATE students SET max_borrow = 15 WHERE stu_no = 'S2023001';
|
||||
UPDATE students SET max_borrow = 5 WHERE stu_no = 'S2021004'; -- 用于测试借阅上限
|
||||
|
||||
-- 4. 借阅记录 (Borrow Records)
|
||||
-- book_id 和 student_id 需要引用已存在的 ID
|
||||
-- due_date = borrow_date + (SELECT setting_value::int FROM system_settings WHERE setting_key = 'borrow_duration_days')
|
||||
-- fine_amount 初始为0, 逾期归还将由触发器在 fines 表中记录罚款
|
||||
-- 触发器 trg_sync_book_student 会在每次插入/更新/删除后执行
|
||||
|
||||
-- 获取 book_id 和 student_id 的示例 (实际使用时,你可能需要根据书名/学号查询得到)
|
||||
-- SELECT book_id FROM books WHERE isbn = '9787111624927'; -- 1
|
||||
-- SELECT student_id FROM students WHERE stu_no = 'S2023001'; -- 1
|
||||
|
||||
-- 借阅场景 1: 正常借出,未到期
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status) VALUES
|
||||
(1, 1, current_date - interval '10 days', current_date - interval '10 days' + interval '30 days', 'borrowed'), -- 张三借《深入理解计算机系统》
|
||||
(2, 2, current_date - interval '5 days', current_date - interval '5 days' + interval '30 days', 'borrowed'); -- 李四借《数学之美》
|
||||
|
||||
-- 借阅场景 2: 正常借出,今天到期
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status) VALUES
|
||||
(3, 1, current_date - interval '30 days', current_date, 'borrowed'); -- 张三借《百年孤独》,今天到期
|
||||
|
||||
-- 借阅场景 3: 已逾期,未归还
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status) VALUES
|
||||
(4, 3, current_date - interval '40 days', current_date - interval '10 days', 'overdue'); -- 王五借《Python编程》,已逾期10天
|
||||
|
||||
-- 借阅场景 4: 正常归还 (触发器 trg_calc_fine 不会产生罚款)
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, return_date, status) VALUES
|
||||
(5, 4, current_date - interval '20 days', current_date + interval '10 days', current_date - interval '2 days', 'returned'); -- 赵六借《人类简史》,已按时归还
|
||||
|
||||
-- 借阅场景 5: 逾期归还 (为了测试 trg_calc_fine,先插入为逾期,再UPDATE为归还)
|
||||
-- 步骤A: 先插入一条记录,状态为 'overdue' (或者 'borrowed' 但实际已过 due_date)
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status)
|
||||
VALUES (6, 5, current_date - interval '35 days', current_date - interval '5 days', 'overdue'); -- 孙七借《追风筝的人》
|
||||
-- 记录下这条 borrow_id (假设是 6,根据实际情况调整)
|
||||
-- 步骤B: 更新这条记录为 'returned',这将触发 trg_calc_fine
|
||||
-- UPDATE borrow_records
|
||||
-- SET status = 'returned', return_date = current_date
|
||||
-- WHERE borrow_id = (SELECT borrow_id FROM borrow_records WHERE student_id=5 AND book_id=6 AND status='overdue');
|
||||
-- (该update放到下面演示触发器部分)
|
||||
|
||||
-- 借阅场景 6: 书籍遗失
|
||||
INSERT INTO borrow_records (book_id, student_id, borrow_date, due_date, status) VALUES
|
||||
(9, 6, current_date - interval '15 days', current_date + interval '15 days', 'lost'); -- 周八借《明朝那些事儿》,遗失
|
||||
|
||||
|
||||
-- 5. 图书预约 (Reservations)
|
||||
-- 只能预约 available_copies = 0 的书
|
||||
-- (book_id=7, 'Java核心技术 卷I', available_copies 初始为0)
|
||||
INSERT INTO reservations (book_id, student_id, reserve_date, status) VALUES
|
||||
(7, 1, current_date - interval '2 days', 'waiting'), -- 张三预约《Java核心技术》
|
||||
(7, 2, current_date - interval '1 day', 'waiting'); -- 李四预约《Java核心技术》
|
||||
|
||||
-- 假设一本被预约的书有归还了,管理员将其状态改为可取
|
||||
-- UPDATE reservations SET status = 'available' WHERE book_id = 7 AND student_id = 1;
|
||||
|
||||
-- 6. 图书评价 (Reviews)
|
||||
-- 学生通常评价已借阅过的书
|
||||
INSERT INTO reviews (book_id, student_id, rating, content, review_time) VALUES
|
||||
(5, 4, 5, '《人类简史》这本书太棒了,视角独特,值得一读!', now() - interval '1 day'), -- 赵六评价《人类简史》
|
||||
(1, 1, 4, '《深入理解计算机系统》有点难,但很有收获。', now()), -- 张三评价
|
||||
(2, 2, 5, '《数学之美》让我对数学有了新的认识。', now()); -- 李四评价
|
||||
|
||||
-- 尝试插入重复评价(应失败,因为有UNIQUE约束)
|
||||
-- INSERT INTO reviews (book_id, student_id, rating, content, review_time) VALUES
|
||||
-- (5, 4, 3, '第二次评价,内容一般。', now());
|
||||
|
||||
-- 7. 罚款记录 (Fines)
|
||||
-- 部分罚款会由 trg_calc_fine 自动生成 (当逾期借阅记录更新为 'returned' 时)
|
||||
-- 手动添加一些罚款记录:
|
||||
|
||||
-- 罚款场景1: 图书损坏 (假设管理员A002处理)
|
||||
INSERT INTO fines (student_id, amount, reason, status, issue_date, admin_id) VALUES
|
||||
( (SELECT student_id FROM students WHERE stu_no = 'S2022003'), -- 王五
|
||||
20.00,
|
||||
'损坏图书《Python编程》(ISBN:9787115478850)',
|
||||
'unpaid',
|
||||
current_date - interval '1 day',
|
||||
(SELECT admin_id FROM admins WHERE emp_no = 'A002')
|
||||
);
|
||||
|
||||
-- 罚款场景2: 图书遗失 (假设管理员A002处理)
|
||||
-- 对应 borrow_records 中周八遗失的《明朝那些事儿》
|
||||
INSERT INTO fines (student_id, amount, reason, status, issue_date, admin_id) VALUES
|
||||
( (SELECT student_id FROM students WHERE stu_no = 'S2022006'), -- 周八
|
||||
(SELECT price FROM books WHERE isbn = '9787559620187'), -- 按书价赔偿
|
||||
'遗失图书《明朝那些事儿》(ISBN:9787559620187)',
|
||||
'unpaid',
|
||||
current_date,
|
||||
(SELECT admin_id FROM admins WHERE emp_no = 'A002')
|
||||
);
|
||||
|
||||
-- 罚款场景3: 之前的逾期罚款,已缴纳
|
||||
INSERT INTO fines (student_id, amount, reason, status, issue_date, admin_id) VALUES
|
||||
( (SELECT student_id FROM students WHERE stu_no = 'S2021007'), -- 吴九
|
||||
5.50,
|
||||
'图书逾期11天 (旧记录)',
|
||||
'paid',
|
||||
current_date - interval '60 days',
|
||||
(SELECT admin_id FROM admins WHERE emp_no = 'A003')
|
||||
);
|
||||
|
||||
|
||||
----------------------------------------------------
|
||||
-- 测试触发器 trg_calc_fine 和 trg_freeze_account
|
||||
----------------------------------------------------
|
||||
DO $$
|
||||
DECLARE
|
||||
v_student_id_sunqi bigint;
|
||||
v_borrow_id_sunqi bigint;
|
||||
v_fine_per_day numeric;
|
||||
v_days_overdue int;
|
||||
v_expected_fine numeric;
|
||||
BEGIN
|
||||
-- 获取孙七的 student_id
|
||||
SELECT student_id INTO v_student_id_sunqi FROM students WHERE stu_no = 'S2023005';
|
||||
-- 获取孙七之前插入的逾期借阅记录 (book_id=6, 追风筝的人)
|
||||
SELECT borrow_id INTO v_borrow_id_sunqi
|
||||
FROM borrow_records
|
||||
WHERE student_id = v_student_id_sunqi AND book_id = (SELECT book_id FROM books WHERE isbn='9787532767406') AND status = 'overdue';
|
||||
|
||||
RAISE NOTICE '孙七(ID:%)的借阅记录ID: % 将被更新为已归还。', v_student_id_sunqi, v_borrow_id_sunqi;
|
||||
|
||||
-- 模拟孙七归还之前逾期的书 (book_id=6, 《追风筝的人》)
|
||||
-- 这将触发 trg_calc_fine
|
||||
UPDATE borrow_records
|
||||
SET status = 'returned', return_date = current_date
|
||||
WHERE borrow_id = v_borrow_id_sunqi;
|
||||
|
||||
RAISE NOTICE '已更新孙七的借阅记录。现在检查 fines 表是否自动产生罚款记录...';
|
||||
-- trg_calc_fine 会根据 borrow_records 中的 due_date 和 return_date 计算罚款并插入 fines 表
|
||||
-- 假设罚款汇率是 0.5/天,逾期5天,罚款应为 2.50
|
||||
-- (current_date - (current_date - interval '5 days')) * 0.50
|
||||
SELECT setting_value::numeric INTO v_fine_per_day FROM system_settings WHERE setting_key = 'fine_per_day';
|
||||
SELECT (current_date - (SELECT due_date FROM borrow_records WHERE borrow_id = v_borrow_id_sunqi)) INTO v_days_overdue;
|
||||
v_expected_fine := v_days_overdue * v_fine_per_day;
|
||||
RAISE NOTICE '预期罚款金额: % * % = %', v_days_overdue, v_fine_per_day, v_expected_fine;
|
||||
|
||||
-- 检查孙七的账户状态是否因为罚款超过阈值 (默认20) 而被冻结
|
||||
-- trg_freeze_account 会在 fines 表 INSERT/UPDATE/DELETE 后触发
|
||||
RAISE NOTICE '检查孙七账户是否因罚款自动冻结...';
|
||||
-- 如果孙七的罚款 (例如 2.50) 没有超过 freeze_threshold (默认20.00),账户不会冻结。
|
||||
-- 我们再为孙七手动添加一笔大额罚款,使其总欠款超过阈值
|
||||
IF (SELECT sum(amount) FROM fines WHERE student_id = v_student_id_sunqi AND status = 'unpaid') < (SELECT setting_value::numeric FROM system_settings WHERE setting_key = 'freeze_threshold') THEN
|
||||
RAISE NOTICE '孙七当前未付罚款未达冻结阈值,为其添加一笔大额罚款...';
|
||||
INSERT INTO fines (student_id, amount, reason, status, issue_date, admin_id) VALUES
|
||||
(v_student_id_sunqi, 25.00, '严重损坏图书赔偿', 'unpaid', current_date, (SELECT admin_id FROM admins WHERE emp_no='A001'));
|
||||
RAISE NOTICE '已为孙七添加大额罚款,再次检查账户状态。';
|
||||
END IF;
|
||||
|
||||
END $$;
|
||||
42
sql/requirements.md
Normal file
@ -0,0 +1,42 @@
|
||||
1. 数据库设计
|
||||
设计一个关系型数据库,可包含以下实体及属性:
|
||||
(1). 图书信息:ISBN、书名、作者、出版社、出版日期、价格、分类号、馆藏位置、总册数、可借册数、图书状态(正常/遗失/损坏/下架等)、简介、封面图片URL
|
||||
(2). 学生信息:学号、姓名、性别、院系、专业、年级、班级、联系方式、邮箱、账户状态(正常/挂失/冻结)、最大借阅量、当前借阅量
|
||||
(3). 借阅记录:借阅ID、图书ID、学号、借出日期、应还日期、实际归还日期、续借次数、借阅状态(借出/已还/逾期/遗失等)、罚款金额
|
||||
(4). 管理员信息:工号、姓名、职位、联系方式、权限等级
|
||||
(5). 图书预约:预约ID、图书ID、学号、预约日期、预约状态(等待/可取/取消/过期)
|
||||
(6). 图书评价:评价ID、图书ID、学号、评分、评论内容、评论时间
|
||||
(7). 罚款记录:记录ID、学号、金额、产生原因、缴纳状态、产生日期、处理管理员
|
||||
2. 功能需求
|
||||
学生功能:
|
||||
(1). 多条件组合查询图书(按书名、作者、分类、ISBN等)
|
||||
(2). 查看图书详情及当前可借状态
|
||||
(3). 在线预约热门图书(当所有副本都被借出时)
|
||||
(4). 查看个人借阅历史、当前借阅情况和预约状态
|
||||
(5). 在线续借图书(有限制条件)
|
||||
(6). 查看和缴纳罚款
|
||||
(7). 对已借阅图书进行评分和评论
|
||||
(8). 查看图书推荐(如基于热门被借阅图书)
|
||||
管理员功能:
|
||||
(1). 图书信息管理(增删改查、批量导入)
|
||||
(2). 学生账户管理
|
||||
(3). 处理借阅、归还、续借请求
|
||||
(4). 管理预约队列
|
||||
(5). 处理图书遗失、损坏等异常情况
|
||||
(6). 设置和管理罚款规则
|
||||
(7). 生成各类统计报表(借阅量统计、热门图书、逾期分析等)
|
||||
(8). 系统参数设置(如最大借阅量、借阅期限等)
|
||||
3. 高级功能要求
|
||||
(1). 设计触发器实现以下功能:
|
||||
o 自动更新图书的可借数量
|
||||
o 自动计算并记录逾期罚款
|
||||
o 学生账户状态自动更新(如欠款超过阈值自动冻结)
|
||||
(2). 设计存储过程实现:
|
||||
o 批量处理学期初的学生账户初始化
|
||||
o 定期生成图书流通统计报表
|
||||
o 自动发送逾期提醒
|
||||
(3). 设计视图:
|
||||
o 当前热门图书视图(借阅量前20)
|
||||
o 各院系借阅统计视图
|
||||
o 图书逾期情况视图
|
||||
(4). 设计索引优化查询性能(选做)
|
||||
340
sql/user_query.sql
Normal file
@ -0,0 +1,340 @@
|
||||
-- 假设当前操作的学生是 张三 (学号 S2023001),我们先获取其 student_id
|
||||
-- 在实际应用中,这个 ID 通常来自登录会话
|
||||
DO $$
|
||||
DECLARE
|
||||
v_student_id bigint;
|
||||
BEGIN
|
||||
SELECT student_id INTO v_student_id FROM students WHERE stu_no = 'S2023001';
|
||||
-- 将 v_student_id 设为会话级变量,方便后续查询使用
|
||||
-- 注意: 这是一种在 psql 或特定SQL工具中设置变量的方式,
|
||||
-- 在应用代码中,你会直接使用从会话中获取的 student_id 变量。
|
||||
EXECUTE 'SET app.current_student_id = ' || v_student_id;
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
RAISE NOTICE '学生 S2023001 未找到。请确保测试数据已插入。';
|
||||
-- 或者你可以在这里直接硬编码一个存在的 student_id 用于测试
|
||||
-- EXECUTE 'SET app.current_student_id = 1';
|
||||
END $$;
|
||||
|
||||
-- 如果上面的 DO $$ 块由于环境限制无法执行 SET app.current_student_id,
|
||||
-- 后续查询中请手动替换 current_setting('app.current_student_id')::bigint 为实际的 student_id (例如 1)
|
||||
|
||||
-- (1). 多条件组合查询图书
|
||||
RAISE NOTICE '---------- (1) 多条件组合查询图书 ----------';
|
||||
|
||||
-- 按书名模糊查询 (使用 pg_trgm GIN 索引)
|
||||
RAISE NOTICE '-- 按书名模糊查询 "计算机":';
|
||||
SELECT book_id, isbn, title, authors, available_copies
|
||||
FROM books
|
||||
WHERE title LIKE '%计算机%' AND status = 'normal';
|
||||
|
||||
RAISE NOTICE '-- 按书名精确查询 (如果知道全名):';
|
||||
SELECT book_id, isbn, title, authors, available_copies
|
||||
FROM books
|
||||
WHERE title = '数学之美' AND status = 'normal';
|
||||
|
||||
-- 按作者查询 (使用 GIN 索引)
|
||||
RAISE NOTICE '-- 按作者查询包含 "吴军":';
|
||||
SELECT book_id, isbn, title, authors, available_copies
|
||||
FROM books
|
||||
WHERE authors @> ARRAY['吴军'] AND status = 'normal'; -- '@>' 表示数组包含
|
||||
|
||||
-- 按分类号查询
|
||||
RAISE NOTICE '-- 按分类号查询 "TP301":';
|
||||
SELECT book_id, isbn, title, authors, classification_no, available_copies
|
||||
FROM books
|
||||
WHERE classification_no = 'TP301' AND status = 'normal';
|
||||
|
||||
-- 按ISBN精确查询
|
||||
RAISE NOTICE '-- 按ISBN查询 "9787111624927":';
|
||||
SELECT book_id, isbn, title, authors, available_copies
|
||||
FROM books
|
||||
WHERE isbn = '9787111624927' AND status = 'normal';
|
||||
|
||||
-- 组合查询:书名包含 "编程" 且 作者包含 "Eric Matthes"
|
||||
RAISE NOTICE '-- 组合查询:书名包含 "编程" 且 作者包含 "Eric Matthes":';
|
||||
SELECT book_id, isbn, title, authors, available_copies
|
||||
FROM books
|
||||
WHERE title LIKE '%编程%' AND authors @> ARRAY['Eric Matthes'] AND status = 'normal';
|
||||
|
||||
-- (2). 查看图书详情及当前可借状态
|
||||
RAISE NOTICE '---------- (2) 查看图书详情及当前可借状态 ----------';
|
||||
-- 假设查看 ISBN 为 '9787111624927' (深入理解计算机系统) 的图书详情
|
||||
-- 首先获取 book_id
|
||||
DO $$
|
||||
DECLARE
|
||||
v_book_id bigint;
|
||||
BEGIN
|
||||
SELECT book_id INTO v_book_id FROM books WHERE isbn = '9787111624927';
|
||||
EXECUTE 'SET app.current_book_id = ' || v_book_id;
|
||||
END $$;
|
||||
|
||||
RAISE NOTICE '-- 查看图书 (ISBN: 9787111624927) 的详情:';
|
||||
SELECT
|
||||
book_id,
|
||||
isbn,
|
||||
title,
|
||||
authors,
|
||||
publisher,
|
||||
publish_date,
|
||||
price,
|
||||
classification_no,
|
||||
location,
|
||||
total_copies,
|
||||
available_copies,
|
||||
status,
|
||||
description,
|
||||
cover_url,
|
||||
CASE
|
||||
WHEN status = 'normal' AND available_copies > 0 THEN '可借阅'
|
||||
WHEN status = 'normal' AND available_copies = 0 THEN '已全部借出'
|
||||
ELSE '不可借阅 (' || status::text || ')'
|
||||
END as borrow_availability
|
||||
FROM books
|
||||
WHERE book_id = current_setting('app.current_book_id')::bigint;
|
||||
|
||||
-- (3). 在线预约热门图书(当所有副本都被借出时)
|
||||
RAISE NOTICE '---------- (3) 在线预约热门图书 ----------';
|
||||
-- 假设要预约的书是 'Java核心技术 卷I' (book_id=7),其 available_copies 在测试数据中为0
|
||||
-- 首先确认该书确实没有可借副本
|
||||
DO $$
|
||||
DECLARE
|
||||
v_target_book_id bigint := 7; -- Java核心技术 卷I
|
||||
v_available int;
|
||||
BEGIN
|
||||
SELECT available_copies INTO v_available FROM books WHERE book_id = v_target_book_id AND status = 'normal';
|
||||
IF v_available = 0 THEN
|
||||
RAISE NOTICE '-- 图书ID % (Java核心技术 卷I) 当前无可用副本,可以进行预约。';
|
||||
-- 学生张三 (student_id=1) 进行预约
|
||||
-- 检查是否已预约过
|
||||
IF NOT EXISTS (SELECT 1 FROM reservations
|
||||
WHERE book_id = v_target_book_id
|
||||
AND student_id = current_setting('app.current_student_id')::bigint
|
||||
AND status IN ('waiting', 'available'))
|
||||
THEN
|
||||
INSERT INTO reservations (book_id, student_id, reserve_date, status)
|
||||
VALUES (v_target_book_id, current_setting('app.current_student_id')::bigint, current_date, 'waiting');
|
||||
RAISE NOTICE '-- 学生ID % 已成功预约图书ID %。', current_setting('app.current_student_id')::bigint, v_target_book_id;
|
||||
ELSE
|
||||
RAISE NOTICE '-- 学生ID % 已预约过图书ID % 或预约已可取。', current_setting('app.current_student_id')::bigint, v_target_book_id;
|
||||
END IF;
|
||||
ELSE
|
||||
RAISE NOTICE '-- 图书ID % 当前有 % 本可借,无需预约。', v_target_book_id, v_available;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 查看张三的预约记录来确认
|
||||
RAISE NOTICE '-- 查看学生张三的当前预约:';
|
||||
SELECT r.reservation_id, b.title, r.reserve_date, r.status
|
||||
FROM reservations r
|
||||
JOIN books b ON r.book_id = b.book_id
|
||||
WHERE r.student_id = current_setting('app.current_student_id')::bigint AND r.status IN ('waiting', 'available');
|
||||
|
||||
|
||||
-- (4). 查看个人借阅历史、当前借阅情况和预约状态
|
||||
RAISE NOTICE '---------- (4) 查看个人借阅历史、当前借阅情况和预约状态 ----------';
|
||||
|
||||
RAISE NOTICE '-- 学生张三 (ID: %) 的当前借阅情况:';
|
||||
SELECT br.borrow_id, b.title, b.isbn, br.borrow_date, br.due_date, br.renew_times, br.status
|
||||
FROM borrow_records br
|
||||
JOIN books b ON br.book_id = b.book_id
|
||||
WHERE br.student_id = current_setting('app.current_student_id')::bigint
|
||||
AND br.status IN ('borrowed', 'overdue');
|
||||
|
||||
RAISE NOTICE '-- 学生张三 (ID: %) 的借阅历史 (已还):';
|
||||
SELECT br.borrow_id, b.title, b.isbn, br.borrow_date, br.return_date, br.status
|
||||
FROM borrow_records br
|
||||
JOIN books b ON br.book_id = b.book_id
|
||||
WHERE br.student_id = current_setting('app.current_student_id')::bigint
|
||||
AND br.status = 'returned';
|
||||
|
||||
RAISE NOTICE '-- 学生张三 (ID: %) 的当前预约状态:'; -- 重复上面的预约查询,为了完整性
|
||||
SELECT r.reservation_id, b.title, r.reserve_date, r.status
|
||||
FROM reservations r
|
||||
JOIN books b ON r.book_id = b.book_id
|
||||
WHERE r.student_id = current_setting('app.current_student_id')::bigint AND r.status IN ('waiting', 'available');
|
||||
|
||||
-- (5). 在线续借图书(有限制条件)
|
||||
RAISE NOTICE '---------- (5) 在线续借图书 ----------';
|
||||
-- 假设张三要续借他借阅的《深入理解计算机系统》(book_id=1)
|
||||
-- borrow_id 需要从他的当前借阅中找到,假设其 borrow_id 为 1 (根据你的测试数据)
|
||||
DO $$
|
||||
DECLARE
|
||||
v_borrow_id_to_renew bigint;
|
||||
v_student_id bigint := current_setting('app.current_student_id')::bigint;
|
||||
v_book_id_of_borrow bigint;
|
||||
v_max_renew_times int;
|
||||
v_borrow_duration_days int;
|
||||
v_current_renew_times int;
|
||||
v_current_due_date date;
|
||||
v_current_status borrow_status_enum;
|
||||
v_is_reserved boolean;
|
||||
BEGIN
|
||||
-- 找到张三正在借阅的《深入理解计算机系统》的 borrow_id
|
||||
SELECT br.borrow_id, br.book_id, br.renew_times, br.due_date, br.status
|
||||
INTO v_borrow_id_to_renew, v_book_id_of_borrow, v_current_renew_times, v_current_due_date, v_current_status
|
||||
FROM borrow_records br
|
||||
JOIN books b ON br.book_id = b.book_id
|
||||
WHERE br.student_id = v_student_id
|
||||
AND b.isbn = '9787111624927' -- 《深入理解计算机系统》
|
||||
AND br.status = 'borrowed'
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE NOTICE '-- 未找到学生ID % 对图书ISBN 9787111624927 的可续借记录。';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '-- 尝试为学生ID % 续借 borrow_id % (Book ID: %)', v_student_id, v_borrow_id_to_renew, v_book_id_of_borrow;
|
||||
|
||||
-- 获取系统参数
|
||||
SELECT setting_value::int INTO v_max_renew_times FROM system_settings WHERE setting_key = 'max_renew_times';
|
||||
SELECT setting_value::int INTO v_borrow_duration_days FROM system_settings WHERE setting_key = 'borrow_duration_days';
|
||||
|
||||
-- 检查条件:
|
||||
-- 1. 状态为 'borrowed' (不能是 'overdue' 或 'lost')
|
||||
IF v_current_status != 'borrowed' THEN
|
||||
RAISE NOTICE '-- 续借失败: 图书状态为 %,不是 "borrowed"。', v_current_status;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- 2. 续借次数未达上限
|
||||
IF v_current_renew_times >= v_max_renew_times THEN
|
||||
RAISE NOTICE '-- 续借失败: 已达到最大续借次数 (%)。', v_max_renew_times;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- 3. 该书没有其他人预约
|
||||
SELECT EXISTS (SELECT 1 FROM reservations WHERE book_id = v_book_id_of_borrow AND status = 'waiting')
|
||||
INTO v_is_reserved;
|
||||
IF v_is_reserved THEN
|
||||
RAISE NOTICE '-- 续借失败: 该图书已被其他用户预约。';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- 执行续借
|
||||
UPDATE borrow_records
|
||||
SET due_date = v_current_due_date + (v_borrow_duration_days || ' days')::interval, -- 从原应还日期开始计算
|
||||
renew_times = renew_times + 1
|
||||
WHERE borrow_id = v_borrow_id_to_renew;
|
||||
|
||||
RAISE NOTICE '-- 续借成功! borrow_id: %, 新的应还日期: %, 当前续借次数: %',
|
||||
v_borrow_id_to_renew,
|
||||
(SELECT due_date FROM borrow_records WHERE borrow_id = v_borrow_id_to_renew),
|
||||
(SELECT renew_times FROM borrow_records WHERE borrow_id = v_borrow_id_to_renew);
|
||||
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
RAISE NOTICE '-- 未找到相关借阅记录或系统参数。';
|
||||
WHEN OTHERS THEN
|
||||
RAISE NOTICE '续借操作发生错误: %', SQLERRM;
|
||||
END $$;
|
||||
|
||||
-- (6). 查看和缴纳罚款
|
||||
RAISE NOTICE '---------- (6) 查看和缴纳罚款 ----------';
|
||||
-- 假设学生王五 (S2022003, student_id=3) 有未缴罚款
|
||||
DO $$
|
||||
DECLARE
|
||||
v_student_id_wangwu bigint;
|
||||
v_fine_id_to_pay bigint;
|
||||
v_amount_to_pay numeric;
|
||||
BEGIN
|
||||
SELECT student_id INTO v_student_id_wangwu FROM students WHERE stu_no = 'S2022003';
|
||||
IF NOT FOUND THEN
|
||||
RAISE NOTICE '学生 S2022003 未找到。';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '-- 查看学生王五 (ID: %) 的未缴罚款:';
|
||||
SELECT fine_id, amount, reason, status, issue_date
|
||||
FROM fines
|
||||
WHERE student_id = v_student_id_wangwu AND status = 'unpaid';
|
||||
|
||||
-- 假设王五要缴纳 fine_id 为 (从上面查询结果中选取一个,例如第一个未缴罚款)
|
||||
SELECT fine_id, amount INTO v_fine_id_to_pay, v_amount_to_pay
|
||||
FROM fines
|
||||
WHERE student_id = v_student_id_wangwu AND status = 'unpaid'
|
||||
ORDER BY issue_date
|
||||
LIMIT 1;
|
||||
|
||||
IF v_fine_id_to_pay IS NOT NULL THEN
|
||||
RAISE NOTICE '-- 模拟缴纳学生王五 (ID: %) 的罚款 fine_id: %, 金额: %', v_student_id_wangwu, v_fine_id_to_pay, v_amount_to_pay;
|
||||
UPDATE fines
|
||||
SET status = 'paid'
|
||||
WHERE fine_id = v_fine_id_to_pay;
|
||||
-- 触发器 trg_freeze_account 会在此 UPDATE 后执行,可能解冻账户
|
||||
|
||||
RAISE NOTICE '-- 缴纳后,再次查看学生王五 (ID: %) 的未缴罚款:';
|
||||
SELECT fine_id, amount, reason, status, issue_date
|
||||
FROM fines
|
||||
WHERE student_id = v_student_id_wangwu AND status = 'unpaid';
|
||||
|
||||
RAISE NOTICE '-- 查看学生王五 (ID: %) 的账户状态 (可能已因罚款缴纳而解冻):';
|
||||
SELECT stu_no, name, account_status FROM students WHERE student_id = v_student_id_wangwu;
|
||||
ELSE
|
||||
RAISE NOTICE '-- 学生王五 (ID: %) 没有未缴罚款可供缴纳。';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- (7). 对已借阅图书进行评分和评论
|
||||
RAISE NOTICE '---------- (7) 对已借阅图书进行评分和评论 ----------';
|
||||
-- 假设张三 (student_id=1) 要对他借阅过的《深入理解计算机系统》(book_id=1)进行评价
|
||||
-- (假设他已经借过或者正在借阅)
|
||||
DO $$
|
||||
DECLARE
|
||||
v_student_id bigint := current_setting('app.current_student_id')::bigint;
|
||||
v_book_id_to_review bigint;
|
||||
BEGIN
|
||||
SELECT book_id INTO v_book_id_to_review FROM books WHERE isbn = '9787111624927'; -- 深入理解计算机系统
|
||||
|
||||
-- 检查是否已经评论过 (因为有 UNIQUE(book_id, student_id) 约束)
|
||||
IF EXISTS (SELECT 1 FROM reviews WHERE student_id = v_student_id AND book_id = v_book_id_to_review) THEN
|
||||
RAISE NOTICE '-- 学生ID % 已评价过图书ID %。';
|
||||
UPDATE reviews
|
||||
SET rating = 5, content = '更新评价:太经典了,常看常新!', review_time = now()
|
||||
WHERE student_id = v_student_id AND book_id = v_book_id_to_review;
|
||||
RAISE NOTICE '-- 已更新学生ID % 对图书ID % 的评价。';
|
||||
ELSE
|
||||
-- 应用层面通常会检查该学生是否实际借阅过这本书,这里我们直接插入
|
||||
INSERT INTO reviews (book_id, student_id, rating, content, review_time)
|
||||
VALUES (v_book_id_to_review, v_student_id, 5, '这本书写得太好了,深入浅出,对我帮助很大!', now());
|
||||
RAISE NOTICE '-- 学生ID % 已成功评价图书ID %。';
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '-- 查看图书ID % 的所有评价:';
|
||||
SELECT s.name as student_name, r.rating, r.content, r.review_time
|
||||
FROM reviews r
|
||||
JOIN students s ON r.student_id = s.student_id
|
||||
WHERE r.book_id = v_book_id_to_review
|
||||
ORDER BY r.review_time DESC;
|
||||
END $$;
|
||||
|
||||
-- (8). 查看图书推荐(如基于热门被借阅图书)
|
||||
RAISE NOTICE '---------- (8) 查看图书推荐(热门图书) ----------';
|
||||
RAISE NOTICE '-- 查看当前热门图书 (基于 v_hot_books 视图):';
|
||||
SELECT b.isbn, b.title, b.authors, vhb.borrow_cnt
|
||||
FROM v_hot_books vhb
|
||||
JOIN books b ON vhb.book_id = b.book_id
|
||||
ORDER BY vhb.borrow_cnt DESC;
|
||||
|
||||
-- 另一种推荐思路:基于用户所在院系的热门图书
|
||||
RAISE NOTICE '-- 查看学生张三所在院系的热门图书 (示例):';
|
||||
WITH student_department AS (
|
||||
SELECT department FROM students WHERE student_id = current_setting('app.current_student_id')::bigint
|
||||
),
|
||||
department_borrows AS (
|
||||
SELECT
|
||||
br.book_id,
|
||||
COUNT(*) as dept_borrow_count
|
||||
FROM borrow_records br
|
||||
JOIN students s ON br.student_id = s.student_id
|
||||
WHERE s.department = (SELECT department FROM student_department)
|
||||
GROUP BY br.book_id
|
||||
)
|
||||
SELECT b.title, b.authors, db.dept_borrow_count
|
||||
FROM department_borrows db
|
||||
JOIN books b ON db.book_id = b.book_id
|
||||
ORDER BY db.dept_borrow_count DESC
|
||||
LIMIT 10;
|
||||
|
||||
RAISE NOTICE '---------- 学生功能SQL示例演示完毕 ----------';
|
||||
360
src/app/admin/books/[id]/edit/page.tsx
Normal file
@ -0,0 +1,360 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Book } from '@/lib/types';
|
||||
import { ArrowLeft, Save } from 'lucide-react';
|
||||
|
||||
export default function EditBookPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const bookId = params.id as string;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
isbn: '',
|
||||
title: '',
|
||||
authors: '',
|
||||
publisher: '',
|
||||
publishDate: '',
|
||||
price: '',
|
||||
classificationNo: '',
|
||||
location: '',
|
||||
totalCopies: '1',
|
||||
availableCopies: '1',
|
||||
status: 'normal',
|
||||
description: '',
|
||||
coverUrl: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBook = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/books/${bookId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch book');
|
||||
}
|
||||
const book: Book = await response.json();
|
||||
|
||||
setFormData({
|
||||
isbn: book.isbn,
|
||||
title: book.title,
|
||||
authors: book.authors.join(', '),
|
||||
publisher: book.publisher || '',
|
||||
publishDate: book.publishDate || '',
|
||||
price: book.price || '',
|
||||
classificationNo: book.classificationNo || '',
|
||||
location: book.location || '',
|
||||
totalCopies: book.totalCopies.toString(),
|
||||
availableCopies: book.availableCopies.toString(),
|
||||
status: book.status,
|
||||
description: book.description || '',
|
||||
coverUrl: book.coverUrl || '',
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (bookId) {
|
||||
fetchBook();
|
||||
}
|
||||
}, [bookId]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
...formData,
|
||||
authors: formData.authors.split(',').map(author => author.trim()).filter(Boolean),
|
||||
price: formData.price ? parseFloat(formData.price) : null,
|
||||
totalCopies: parseInt(formData.totalCopies),
|
||||
availableCopies: parseInt(formData.availableCopies),
|
||||
publishDate: formData.publishDate || null,
|
||||
publisher: formData.publisher || null,
|
||||
classificationNo: formData.classificationNo || null,
|
||||
location: formData.location || null,
|
||||
description: formData.description || null,
|
||||
coverUrl: formData.coverUrl || null,
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/books/${bookId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updateData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
router.push(`/admin/books/${bookId}`);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
alert(`更新失败: ${errorData.error || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update error:', error);
|
||||
alert('更新失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-lg">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-red-600">错误: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">编辑图书</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
ISBN *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="isbn"
|
||||
value={formData.isbn}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
书名 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
作者 * (多个作者用逗号分隔)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="authors"
|
||||
value={formData.authors}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="张三, 李四"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
出版社
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="publisher"
|
||||
value={formData.publisher}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
出版日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="publishDate"
|
||||
value={formData.publishDate}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
价格
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="price"
|
||||
value={formData.price}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
分类号
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="classificationNo"
|
||||
value={formData.classificationNo}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
位置
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
value={formData.location}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
总册数 *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="totalCopies"
|
||||
value={formData.totalCopies}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min="1"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
可借册数 *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="availableCopies"
|
||||
value={formData.availableCopies}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
状态 *
|
||||
</label>
|
||||
<select
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="normal">正常</option>
|
||||
<option value="damaged">损坏</option>
|
||||
<option value="removed">已移除</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
封面链接
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="coverUrl"
|
||||
value={formData.coverUrl}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
<Save size={16} />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
src/app/admin/books/[id]/page.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Book } from '@/lib/types';
|
||||
import { ArrowLeft, Edit, Trash2, BookOpen, Calendar, User } from 'lucide-react';
|
||||
|
||||
export default function BookDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const bookId = params.id as string;
|
||||
const [book, setBook] = useState<Book | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBook = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/books/${bookId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch book');
|
||||
}
|
||||
const data = await response.json();
|
||||
setBook(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (bookId) {
|
||||
fetchBook();
|
||||
}
|
||||
}, [bookId]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!book || !confirm('确定要删除这本书吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/books/${bookId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
router.push('/admin/books');
|
||||
} else {
|
||||
alert('删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
alert('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-lg">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-red-600">错误: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!book) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div>未找到图书</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">图书详情</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => router.push(`/admin/books/${bookId}/edit`)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Edit size={16} />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Book Info Card */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Book Cover */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-48 h-64 bg-gray-200 rounded-lg flex items-center justify-center mb-4">
|
||||
<BookOpen size={48} className="text-gray-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`inline-block px-3 py-1 rounded-full text-sm ${
|
||||
book.status === 'normal'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: book.status === 'damaged'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{book.status === 'normal' ? '正常' : book.status === 'damaged' ? '损坏' : '已移除'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Book Details */}
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">{book.title}</h2>
|
||||
<p className="text-lg text-gray-600">{book.authors.join(', ')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">ISBN</label>
|
||||
<p className="text-gray-900">{book.isbn}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">出版社</label>
|
||||
<p className="text-gray-900">{book.publisher}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">分类号</label>
|
||||
<p className="text-gray-900">{book.classificationNo || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">位置</label>
|
||||
<p className="text-gray-900">{book.location}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">出版日期</label>
|
||||
<p className="text-gray-900">
|
||||
{book.publishDate ? new Date(book.publishDate).toLocaleDateString('zh-CN') : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">添加时间</label>
|
||||
<p className="text-gray-900">
|
||||
{new Date(book.createdAt).toLocaleDateString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{book.description && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">描述</label>
|
||||
<p className="text-gray-900 bg-gray-50 p-3 rounded-lg">{book.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Borrowing History */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 mt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Calendar size={20} />
|
||||
借阅历史
|
||||
</h3>
|
||||
<div className="text-gray-500 text-center py-8">
|
||||
暂无借阅记录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
314
src/app/admin/books/add/page.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Save } from 'lucide-react';
|
||||
|
||||
export default function AddBookPage() {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
isbn: '',
|
||||
title: '',
|
||||
authors: '',
|
||||
publisher: '',
|
||||
publishDate: '',
|
||||
price: '',
|
||||
classificationNo: '',
|
||||
location: '',
|
||||
totalCopies: '1',
|
||||
availableCopies: '1',
|
||||
status: 'normal',
|
||||
description: '',
|
||||
coverUrl: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const createData = {
|
||||
isbn: formData.isbn,
|
||||
title: formData.title,
|
||||
authors: formData.authors.split(',').map(author => author.trim()).filter(Boolean),
|
||||
publisher: formData.publisher || null,
|
||||
publishDate: formData.publishDate || null,
|
||||
price: formData.price ? parseFloat(formData.price) : null,
|
||||
classificationNo: formData.classificationNo || null,
|
||||
location: formData.location || null,
|
||||
totalCopies: parseInt(formData.totalCopies),
|
||||
availableCopies: parseInt(formData.availableCopies),
|
||||
status: formData.status,
|
||||
description: formData.description || null,
|
||||
coverUrl: formData.coverUrl || null,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/books', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(createData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const book = await response.json();
|
||||
router.push(`/admin/books/${book.bookId}`);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
alert(`添加失败: ${errorData.error || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Create error:', error);
|
||||
alert('添加失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">添加图书</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
ISBN *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="isbn"
|
||||
value={formData.isbn}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="978-7-111-12345-6"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
书名 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="请输入书名"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
作者 * (多个作者用逗号分隔)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="authors"
|
||||
value={formData.authors}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="张三, 李四"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
出版社
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="publisher"
|
||||
value={formData.publisher}
|
||||
onChange={handleChange}
|
||||
placeholder="请输入出版社"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
出版日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="publishDate"
|
||||
value={formData.publishDate}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
价格
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="price"
|
||||
value={formData.price}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
分类号
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="classificationNo"
|
||||
value={formData.classificationNo}
|
||||
onChange={handleChange}
|
||||
placeholder="TP312"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
位置
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
value={formData.location}
|
||||
onChange={handleChange}
|
||||
placeholder="A区1楼书架001"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
总册数 *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="totalCopies"
|
||||
value={formData.totalCopies}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min="1"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
可借册数 *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="availableCopies"
|
||||
value={formData.availableCopies}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
状态 *
|
||||
</label>
|
||||
<select
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="normal">正常</option>
|
||||
<option value="damaged">损坏</option>
|
||||
<option value="removed">已移除</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
封面链接
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="coverUrl"
|
||||
value={formData.coverUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://example.com/cover.jpg"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
placeholder="请输入图书描述"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
<Save size={16} />
|
||||
{saving ? '添加中...' : '添加'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
323
src/app/admin/books/page.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
BookOpen,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Book, BookSearchFilters } from '@/lib/types';
|
||||
|
||||
async function fetchBooks(filters: BookSearchFilters & { page: number; pageSize: number }) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
params.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/books?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch books');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export default function AdminBooksPage() {
|
||||
const [searchFilters, setSearchFilters] = useState<BookSearchFilters>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['admin-books', searchFilters, page, pageSize],
|
||||
queryFn: () => fetchBooks({ ...searchFilters, page, pageSize }),
|
||||
});
|
||||
|
||||
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof BookSearchFilters, value: string) => {
|
||||
setSearchFilters(prev => ({
|
||||
...prev,
|
||||
[field]: value || undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">图书管理</h1>
|
||||
<p className="text-gray-600">管理图书馆藏书信息</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/books/add"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加图书
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
书名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入书名关键词"
|
||||
value={searchFilters.title || ''}
|
||||
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
作者
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入作者姓名"
|
||||
value={searchFilters.author || ''}
|
||||
onChange={(e) => handleInputChange('author', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
ISBN
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入ISBN号"
|
||||
value={searchFilters.isbn || ''}
|
||||
onChange={(e) => handleInputChange('isbn', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={searchFilters.status || ''}
|
||||
onChange={(e) => handleInputChange('status', e.target.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="normal">正常</option>
|
||||
<option value="damaged">损坏</option>
|
||||
<option value="removed">下架</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">加载失败,请重试</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Results Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<p className="text-gray-600">
|
||||
找到 {data?.data?.total || 0} 本图书
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Books Table */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
图书信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
出版信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
库存状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{data?.data?.data?.map((book: Book) => (
|
||||
<tr key={book.bookId} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-16 w-12">
|
||||
{book.coverUrl ? (
|
||||
<img
|
||||
className="h-16 w-12 object-cover rounded"
|
||||
src={book.coverUrl}
|
||||
alt={book.title}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 w-12 bg-gray-200 rounded flex items-center justify-center">
|
||||
<BookOpen className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{book.title}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
作者: {book.authors?.join(', ')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
ISBN: {book.isbn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{book.publisher}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{book.publishDate ? new Date(book.publishDate).toLocaleDateString('zh-CN') : '未知'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
¥{book.price}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
可借: {book.availableCopies}/{book.totalCopies}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
位置: {book.location}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
book.status === 'normal' ? 'bg-green-100 text-green-800' :
|
||||
book.status === 'damaged' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{book.status === 'normal' ? '正常' :
|
||||
book.status === 'damaged' ? '损坏' : '下架'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Link
|
||||
href={`/admin/books/${book.bookId}`}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/books/${book.bookId}/edit`}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('确定要删除这本图书吗?')) {
|
||||
// TODO: Implement delete functionality
|
||||
}
|
||||
}}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.data?.totalPages > 1 && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<div className="flex gap-2">
|
||||
{page > 1 && (
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
)}
|
||||
|
||||
{[...Array(Math.min(5, data.data.totalPages))].map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setPage(pageNum)}
|
||||
className={`px-4 py-2 border rounded-md ${
|
||||
page === pageNum
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{page < data.data.totalPages && (
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
src/app/admin/borrows/page.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Calendar,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { BorrowRecordWithDetails } from '@/lib/types';
|
||||
|
||||
async function fetchBorrowRecords(params: { status?: string; studentId?: string; page: number; pageSize: number }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/borrow?${searchParams}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch borrow records');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export default function AdminBorrowsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [studentIdFilter, setStudentIdFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['admin-borrows', statusFilter, studentIdFilter, page, pageSize],
|
||||
queryFn: () => fetchBorrowRecords({
|
||||
status: statusFilter || undefined,
|
||||
studentId: studentIdFilter || undefined,
|
||||
page,
|
||||
pageSize
|
||||
}),
|
||||
});
|
||||
|
||||
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string, dueDate: string) => {
|
||||
const isOverdue = new Date(dueDate) < new Date();
|
||||
|
||||
if (status === 'returned') {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
已归还
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'overdue' || (status === 'borrowed' && isOverdue)) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
逾期
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
借阅中
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getDaysOverdue = (dueDate: string) => {
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
const diffTime = today.getTime() - due.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays > 0 ? diffDays : 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">借阅管理</h1>
|
||||
<p className="text-gray-600">管理图书借阅、归还和续借操作</p>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
借阅状态
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="borrowed">借阅中</option>
|
||||
<option value="returned">已归还</option>
|
||||
<option value="overdue">逾期</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
学生ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入学生ID"
|
||||
value={studentIdFilter}
|
||||
onChange={(e) => setStudentIdFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">加载失败,请重试</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Results Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<p className="text-gray-600">
|
||||
找到 {data?.data?.length || 0} 条借阅记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Borrow Records Table */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
图书信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
学生信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
借阅日期
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
归还日期
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{data?.data?.map((record: BorrowRecordWithDetails) => (
|
||||
<tr key={record.borrowId} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{record.book?.title}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
ISBN: {record.book?.isbn}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
作者: {record.book?.authors?.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{record.student?.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
学号: {record.student?.stuNo}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{record.student?.department}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{new Date(record.borrowDate).toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
续借: {record.renewTimes}/2 次
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
应还: {new Date(record.dueDate).toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
{record.returnDate && (
|
||||
<div className="text-sm text-gray-500">
|
||||
实还: {new Date(record.returnDate).toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
)}
|
||||
{!record.returnDate && getDaysOverdue(record.dueDate) > 0 && (
|
||||
<div className="text-sm text-red-600">
|
||||
逾期 {getDaysOverdue(record.dueDate)} 天
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(record.status, record.dueDate)}
|
||||
{record.fineAmount && parseFloat(record.fineAmount) > 0 && (
|
||||
<div className="text-sm text-red-600 mt-1">
|
||||
罚款: ¥{parseFloat(record.fineAmount).toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
{record.status === 'borrowed' && (
|
||||
<>
|
||||
{(record.renewTimes || 0) < 2 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement renew functionality
|
||||
console.log('Renew book:', record.borrowId);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900 flex items-center gap-1"
|
||||
title="续借"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement return functionality
|
||||
console.log('Return book:', record.borrowId);
|
||||
}}
|
||||
className="text-green-600 hover:text-green-900 flex items-center gap-1"
|
||||
title="归还"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* No results */}
|
||||
{data?.data?.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Calendar className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">暂无借阅记录</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
339
src/app/admin/fines/page.tsx
Normal file
@ -0,0 +1,339 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Search,
|
||||
DollarSign,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
CreditCard,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { FineWithDetails } from '@/lib/types';
|
||||
|
||||
// Mock data - replace with actual API
|
||||
async function fetchFines() {
|
||||
// Mock data for demonstration
|
||||
return {
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
fineId: 1,
|
||||
fineType: 'overdue',
|
||||
amount: '15.00',
|
||||
reason: '逾期归还:5天',
|
||||
status: 'unpaid',
|
||||
createdAt: '2025-01-10',
|
||||
student: {
|
||||
studentId: 1,
|
||||
stuNo: 'S2023001',
|
||||
name: '张三',
|
||||
department: '计算机科学与技术'
|
||||
},
|
||||
borrowRecord: {
|
||||
borrowId: 1,
|
||||
book: {
|
||||
title: '深入理解计算机系统',
|
||||
isbn: '9787111544937'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
fineId: 2,
|
||||
fineType: 'damage',
|
||||
amount: '50.00',
|
||||
reason: '图书损坏',
|
||||
status: 'paid',
|
||||
createdAt: '2025-01-08',
|
||||
paymentDate: '2025-01-09',
|
||||
paymentMethod: 'cash',
|
||||
student: {
|
||||
studentId: 2,
|
||||
stuNo: 'S2023002',
|
||||
name: '李四',
|
||||
department: '软件工程'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminFinesPage() {
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['admin-fines', statusFilter, typeFilter],
|
||||
queryFn: fetchFines,
|
||||
});
|
||||
|
||||
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
if (status === 'paid') {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
已缴费
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
未缴费
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'overdue':
|
||||
return '逾期罚款';
|
||||
case 'damage':
|
||||
return '损坏赔偿';
|
||||
case 'lost':
|
||||
return '丢失赔偿';
|
||||
default:
|
||||
return '其他';
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalUnpaidAmount = () => {
|
||||
if (!data?.data) return 0;
|
||||
return data.data
|
||||
.filter((fine: any) => fine.status === 'unpaid')
|
||||
.reduce((total: number, fine: any) => total + parseFloat(fine.amount), 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">罚款管理</h1>
|
||||
<p className="text-gray-600">管理学生罚款和缴费记录</p>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">总罚款记录</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{data?.data?.length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">未缴费金额</p>
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
¥{getTotalUnpaidAmount().toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">已缴费记录</p>
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{data?.data?.filter((fine: any) => fine.status === 'paid').length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
缴费状态
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="unpaid">未缴费</option>
|
||||
<option value="paid">已缴费</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
罚款类型
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
<option value="overdue">逾期罚款</option>
|
||||
<option value="damage">损坏赔偿</option>
|
||||
<option value="lost">丢失赔偿</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">加载失败,请重试</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Fines Table */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
学生信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
罚款信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
金额
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
缴费信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{data?.data?.map((fine: any) => (
|
||||
<tr key={fine.fineId} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{fine.student?.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
学号: {fine.student?.stuNo}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{fine.student?.department}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{getTypeLabel(fine.fineType)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{fine.reason}
|
||||
</div>
|
||||
{fine.borrowRecord && (
|
||||
<div className="text-sm text-gray-500">
|
||||
图书: {fine.borrowRecord.book?.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-lg font-bold text-red-600">
|
||||
¥{parseFloat(fine.amount).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(fine.createdAt).toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(fine.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{fine.status === 'paid' ? (
|
||||
<div>
|
||||
<div className="text-sm text-gray-900">
|
||||
{new Date(fine.paymentDate).toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{fine.paymentMethod === 'cash' ? '现金' :
|
||||
fine.paymentMethod === 'card' ? '银行卡' : '其他'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">
|
||||
未缴费
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{fine.status === 'unpaid' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement payment functionality
|
||||
console.log('Process payment for fine:', fine.fineId);
|
||||
}}
|
||||
className="text-green-600 hover:text-green-900 flex items-center gap-1"
|
||||
title="处理缴费"
|
||||
>
|
||||
<CreditCard className="h-4 w-4" />
|
||||
缴费
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* No results */}
|
||||
{data?.data?.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<DollarSign className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">暂无罚款记录</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
238
src/app/admin/page.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
BookOpen,
|
||||
Users,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Clock,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
// Mock data fetching function - replace with actual API calls
|
||||
async function fetchDashboardData() {
|
||||
// This would be replaced with actual API calls
|
||||
return {
|
||||
stats: {
|
||||
totalBooks: 1250,
|
||||
totalStudents: 3486,
|
||||
totalBorrows: 8924,
|
||||
overdueBooks: 23,
|
||||
reservations: 156,
|
||||
unpaidFines: 2340
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface DashboardStats {
|
||||
totalBooks: number;
|
||||
totalStudents: number;
|
||||
totalBorrows: number;
|
||||
overdueBooks: number;
|
||||
reservations: number;
|
||||
unpaidFines: number;
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-dashboard'],
|
||||
queryFn: fetchDashboardData,
|
||||
});
|
||||
|
||||
const stats: Partial<DashboardStats> = data?.stats || {};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">管理员控制台</h1>
|
||||
<p className="text-gray-600">图书馆管理系统概览和操作中心</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">图书总数</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{isLoading ? '...' : stats.totalBooks?.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<BookOpen className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">学生总数</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{isLoading ? '...' : stats.totalStudents?.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">借阅总数</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{isLoading ? '...' : stats.totalBorrows?.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">逾期图书</p>
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
{isLoading ? '...' : stats.overdueBooks}
|
||||
</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">预约数量</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{isLoading ? '...' : stats.reservations}
|
||||
</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">未缴罚款</p>
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
¥{isLoading ? '...' : ((stats.unpaidFines || 0) / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<Link
|
||||
href="/admin/books"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white p-6 rounded-lg shadow-md transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">图书管理</h3>
|
||||
<p className="text-blue-100 text-sm">添加、编辑、删除图书</p>
|
||||
</div>
|
||||
<BookOpen className="h-8 w-8 group-hover:scale-110 transition-transform" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/students"
|
||||
className="bg-green-600 hover:bg-green-700 text-white p-6 rounded-lg shadow-md transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">学生管理</h3>
|
||||
<p className="text-green-100 text-sm">管理学生账户</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 group-hover:scale-110 transition-transform" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/borrows"
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white p-6 rounded-lg shadow-md transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">借阅管理</h3>
|
||||
<p className="text-purple-100 text-sm">处理借阅和归还</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 group-hover:scale-110 transition-transform" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/fines"
|
||||
className="bg-orange-600 hover:bg-orange-700 text-white p-6 rounded-lg shadow-md transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">罚款管理</h3>
|
||||
<p className="text-orange-100 text-sm">处理罚款和缴费</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 group-hover:scale-110 transition-transform" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Recent Activities */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">最近借阅</h3>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((item) => (
|
||||
<div key={item} className="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
||||
<div>
|
||||
<p className="font-medium text-sm">深入理解计算机系统</p>
|
||||
<p className="text-gray-600 text-xs">学号: S2023001 - 张三</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500">2025-01-15</p>
|
||||
<p className="text-xs text-green-600">已借出</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/borrows"
|
||||
className="block text-center text-blue-600 hover:text-blue-700 text-sm mt-4 font-medium"
|
||||
>
|
||||
查看全部借阅记录
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">逾期提醒</h3>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((item) => (
|
||||
<div key={item} className="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
||||
<div>
|
||||
<p className="font-medium text-sm">Python编程实战</p>
|
||||
<p className="text-gray-600 text-xs">学号: S2023002 - 李四</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500">逾期 3 天</p>
|
||||
<p className="text-xs text-red-600">¥3.00</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/borrows?status=overdue"
|
||||
className="block text-center text-red-600 hover:text-red-700 text-sm mt-4 font-medium"
|
||||
>
|
||||
查看全部逾期记录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
328
src/app/admin/students/[id]/edit/page.tsx
Normal file
@ -0,0 +1,328 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Student } from '@/lib/types';
|
||||
import { ArrowLeft, Save } from 'lucide-react';
|
||||
|
||||
export default function EditStudentPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const studentId = params.id as string;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
stuNo: '',
|
||||
name: '',
|
||||
gender: '',
|
||||
department: '',
|
||||
major: '',
|
||||
grade: '',
|
||||
class: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
maxBorrow: '10',
|
||||
accountStatus: 'active',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStudent = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/students/${studentId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch student');
|
||||
}
|
||||
const student: Student = await response.json();
|
||||
|
||||
setFormData({
|
||||
stuNo: student.stuNo,
|
||||
name: student.name,
|
||||
gender: student.gender || '',
|
||||
department: student.department || '',
|
||||
major: student.major || '',
|
||||
grade: student.grade || '',
|
||||
class: student.class || '',
|
||||
phone: student.phone || '',
|
||||
email: student.email || '',
|
||||
maxBorrow: student.maxBorrow.toString(),
|
||||
accountStatus: student.accountStatus,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (studentId) {
|
||||
fetchStudent();
|
||||
}
|
||||
}, [studentId]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
stuNo: formData.stuNo,
|
||||
name: formData.name,
|
||||
gender: formData.gender || null,
|
||||
department: formData.department || null,
|
||||
major: formData.major || null,
|
||||
grade: formData.grade || null,
|
||||
class: formData.class || null,
|
||||
phone: formData.phone || null,
|
||||
email: formData.email || null,
|
||||
maxBorrow: parseInt(formData.maxBorrow),
|
||||
accountStatus: formData.accountStatus,
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/students/${studentId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updateData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
router.push(`/admin/students/${studentId}`);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
alert(`更新失败: ${errorData.error || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update error:', error);
|
||||
alert('更新失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-lg">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-red-600">错误: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">编辑学生</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
学号 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="stuNo"
|
||||
value={formData.stuNo}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
姓名 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
性别
|
||||
</label>
|
||||
<select
|
||||
name="gender"
|
||||
value={formData.gender}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
<option value="M">男</option>
|
||||
<option value="F">女</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
院系
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="department"
|
||||
value={formData.department}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
专业
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="major"
|
||||
value={formData.major}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
年级
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="grade"
|
||||
value={formData.grade}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
班级
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="class"
|
||||
value={formData.class}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
电话
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
邮箱
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
最大借阅数 *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="maxBorrow"
|
||||
value={formData.maxBorrow}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min="1"
|
||||
max="50"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
账户状态 *
|
||||
</label>
|
||||
<select
|
||||
name="accountStatus"
|
||||
value={formData.accountStatus}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="active">正常</option>
|
||||
<option value="frozen">冻结</option>
|
||||
<option value="reported">挂失</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
<Save size={16} />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
src/app/admin/students/[id]/page.tsx
Normal file
@ -0,0 +1,240 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Student } from '@/lib/types';
|
||||
import { ArrowLeft, Edit, Trash2, User, BookOpen } from 'lucide-react';
|
||||
|
||||
export default function StudentDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const studentId = params.id as string;
|
||||
const [student, setStudent] = useState<Student | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStudent = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/students/${studentId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch student');
|
||||
}
|
||||
const data = await response.json();
|
||||
setStudent(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (studentId) {
|
||||
fetchStudent();
|
||||
}
|
||||
}, [studentId]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!student || !confirm('确定要删除这个学生账户吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/students/${studentId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
router.push('/admin/students');
|
||||
} else {
|
||||
alert('删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
alert('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-lg">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-red-600">错误: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div>未找到学生</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">学生详情</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => router.push(`/admin/students/${studentId}/edit`)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Edit size={16} />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Student Info Card */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Avatar */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-32 h-32 bg-gray-200 rounded-full flex items-center justify-center mb-4">
|
||||
<User size={48} className="text-gray-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`inline-block px-3 py-1 rounded-full text-sm ${
|
||||
student.accountStatus === 'active'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: student.accountStatus === 'frozen'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{student.accountStatus === 'active' ? '正常' :
|
||||
student.accountStatus === 'frozen' ? '冻结' : '挂失'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Student Details */}
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">{student.name}</h2>
|
||||
<p className="text-lg text-gray-600">学号: {student.stuNo}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">性别</label>
|
||||
<p className="text-gray-900">{student.gender === 'M' ? '男' : student.gender === 'F' ? '女' : '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">院系</label>
|
||||
<p className="text-gray-900">{student.department || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">专业</label>
|
||||
<p className="text-gray-900">{student.major || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">年级</label>
|
||||
<p className="text-gray-900">{student.grade || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">班级</label>
|
||||
<p className="text-gray-900">{student.class || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">电话</label>
|
||||
<p className="text-gray-900">{student.phone || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">邮箱</label>
|
||||
<p className="text-gray-900">{student.email || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">账户状态</label>
|
||||
<p className="text-gray-900">
|
||||
{student.accountStatus === 'active' ? '正常' :
|
||||
student.accountStatus === 'frozen' ? '冻结' : '挂失'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">最大借阅数</label>
|
||||
<p className="text-gray-900">{student.maxBorrow}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">当前借阅数</label>
|
||||
<p className="text-gray-900">{student.currentBorrow}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">注册时间</label>
|
||||
<p className="text-gray-900">
|
||||
{student.createdAt ? new Date(student.createdAt).toLocaleDateString('zh-CN') : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Borrowing Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">当前借阅</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{student.currentBorrow}</p>
|
||||
</div>
|
||||
<BookOpen className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">可借阅</p>
|
||||
<p className="text-2xl font-bold text-green-600">{student.maxBorrow - student.currentBorrow}</p>
|
||||
</div>
|
||||
<BookOpen className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">历史借阅</p>
|
||||
<p className="text-2xl font-bold text-gray-600">0</p>
|
||||
</div>
|
||||
<BookOpen className="h-8 w-8 text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activities */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">最近活动</h3>
|
||||
<div className="text-gray-500 text-center py-8">
|
||||
暂无活动记录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
299
src/app/admin/students/add/page.tsx
Normal file
@ -0,0 +1,299 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Save } from 'lucide-react';
|
||||
|
||||
export default function AddStudentPage() {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
stuNo: '',
|
||||
name: '',
|
||||
gender: '',
|
||||
department: '',
|
||||
major: '',
|
||||
grade: '',
|
||||
class: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
maxBorrow: '10',
|
||||
accountStatus: 'active',
|
||||
enrollmentDate: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const createData = {
|
||||
stuNo: formData.stuNo,
|
||||
name: formData.name,
|
||||
gender: formData.gender || null,
|
||||
department: formData.department || null,
|
||||
major: formData.major || null,
|
||||
grade: formData.grade || null,
|
||||
class: formData.class || null,
|
||||
phone: formData.phone || null,
|
||||
email: formData.email || null,
|
||||
maxBorrow: parseInt(formData.maxBorrow),
|
||||
accountStatus: formData.accountStatus,
|
||||
enrollmentDate: formData.enrollmentDate || null,
|
||||
currentBorrow: 0,
|
||||
passwordHash: null, // 待实现密码设置
|
||||
};
|
||||
|
||||
const response = await fetch('/api/students', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(createData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const student = await response.json();
|
||||
router.push(`/admin/students/${student.studentId}`);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
alert(`添加失败: ${errorData.error || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Create error:', error);
|
||||
alert('添加失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">添加学生</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
学号 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="stuNo"
|
||||
value={formData.stuNo}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="请输入学号"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
姓名 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="请输入姓名"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
性别
|
||||
</label>
|
||||
<select
|
||||
name="gender"
|
||||
value={formData.gender}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
<option value="M">男</option>
|
||||
<option value="F">女</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
院系
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="department"
|
||||
value={formData.department}
|
||||
onChange={handleChange}
|
||||
placeholder="计算机学院"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
专业
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="major"
|
||||
value={formData.major}
|
||||
onChange={handleChange}
|
||||
placeholder="计算机科学与技术"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
年级
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="grade"
|
||||
value={formData.grade}
|
||||
onChange={handleChange}
|
||||
placeholder="2024"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
班级
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="class"
|
||||
value={formData.class}
|
||||
onChange={handleChange}
|
||||
placeholder="计科1班"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
电话
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="13800138000"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
邮箱
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="student@example.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
最大借阅数 *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="maxBorrow"
|
||||
value={formData.maxBorrow}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min="1"
|
||||
max="50"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
账户状态 *
|
||||
</label>
|
||||
<select
|
||||
name="accountStatus"
|
||||
value={formData.accountStatus}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="active">正常</option>
|
||||
<option value="frozen">冻结</option>
|
||||
<option value="reported">挂失</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
入学日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="enrollmentDate"
|
||||
value={formData.enrollmentDate}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
<Save size={16} />
|
||||
{saving ? '添加中...' : '添加'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
308
src/app/admin/students/page.tsx
Normal file
@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Edit,
|
||||
Eye,
|
||||
Users,
|
||||
UserCheck,
|
||||
UserX
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Student, StudentSearchFilters } from '@/lib/types';
|
||||
|
||||
async function fetchStudents(filters: StudentSearchFilters & { page: number; pageSize: number }) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
params.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/students?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch students');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export default function AdminStudentsPage() {
|
||||
const [searchFilters, setSearchFilters] = useState<StudentSearchFilters>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['admin-students', searchFilters, page, pageSize],
|
||||
queryFn: () => fetchStudents({ ...searchFilters, page, pageSize }),
|
||||
});
|
||||
|
||||
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof StudentSearchFilters, value: string) => {
|
||||
setSearchFilters(prev => ({
|
||||
...prev,
|
||||
[field]: value || undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">学生管理</h1>
|
||||
<p className="text-gray-600">管理学生账户和信息</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/students/add"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加学生
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
姓名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入学生姓名"
|
||||
value={searchFilters.name || ''}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
学号
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入学号"
|
||||
value={searchFilters.stuNo || ''}
|
||||
onChange={(e) => handleInputChange('stuNo', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
院系
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入院系名称"
|
||||
value={searchFilters.department || ''}
|
||||
onChange={(e) => handleInputChange('department', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
账户状态
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={searchFilters.accountStatus || ''}
|
||||
onChange={(e) => handleInputChange('accountStatus', e.target.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">正常</option>
|
||||
<option value="frozen">冻结</option>
|
||||
<option value="reported">挂失</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">加载失败,请重试</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Results Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<p className="text-gray-600">
|
||||
找到 {data?.data?.total || 0} 名学生
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Students Table */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
学生信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
院系专业
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
借阅状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
账户状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{data?.data?.data?.map((student: Omit<Student, 'passwordHash'>) => (
|
||||
<tr key={student.studentId} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
<div className="h-10 w-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<Users className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{student.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
学号: {student.stuNo}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{student.gender === 'M' ? '男' : student.gender === 'F' ? '女' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{student.department}</div>
|
||||
<div className="text-sm text-gray-500">{student.major}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{student.grade}级 {student.class}班
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
当前借阅: {student.currentBorrow}/{student.maxBorrow}
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${(student.currentBorrow / student.maxBorrow) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
student.accountStatus === 'active' ? 'bg-green-100 text-green-800' :
|
||||
student.accountStatus === 'frozen' ? 'bg-red-100 text-red-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{student.accountStatus === 'active' && <UserCheck className="h-3 w-3 mr-1" />}
|
||||
{student.accountStatus === 'frozen' && <UserX className="h-3 w-3 mr-1" />}
|
||||
{student.accountStatus === 'active' ? '正常' :
|
||||
student.accountStatus === 'frozen' ? '冻结' : '挂失'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Link
|
||||
href={`/admin/students/${student.studentId}`}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/students/${student.studentId}/edit`}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.data?.totalPages > 1 && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<div className="flex gap-2">
|
||||
{page > 1 && (
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
)}
|
||||
|
||||
{[...Array(Math.min(5, data.data.totalPages))].map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setPage(pageNum)}
|
||||
className={`px-4 py-2 border rounded-md ${
|
||||
page === pageNum
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{page < data.data.totalPages && (
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
src/app/api/books/[id]/route.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { books, reviews } from '@/lib/db/schema';
|
||||
import { eq, avg, count } from 'drizzle-orm';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const bookId = parseInt(id);
|
||||
|
||||
if (isNaN(bookId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid book ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取图书基本信息
|
||||
const book = await db
|
||||
.select()
|
||||
.from(books)
|
||||
.where(eq(books.bookId, bookId))
|
||||
.limit(1);
|
||||
|
||||
if (book.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Book not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取评价统计
|
||||
const reviewStats = await db
|
||||
.select({
|
||||
averageRating: avg(reviews.rating),
|
||||
reviewCount: count(reviews.reviewId),
|
||||
})
|
||||
.from(reviews)
|
||||
.where(eq(reviews.bookId, bookId));
|
||||
|
||||
const bookWithDetails = {
|
||||
...book[0],
|
||||
averageRating: reviewStats[0].averageRating || 0,
|
||||
reviewCount: reviewStats[0].reviewCount || 0,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: bookWithDetails
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching book:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch book' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const bookId = parseInt(id);
|
||||
const body = await request.json();
|
||||
|
||||
if (isNaN(bookId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid book ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const updatedBook = await db
|
||||
.update(books)
|
||||
.set({
|
||||
...body,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(books.bookId, bookId))
|
||||
.returning();
|
||||
|
||||
if (updatedBook.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Book not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: updatedBook[0],
|
||||
message: 'Book updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating book:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update book' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const bookId = parseInt(id);
|
||||
|
||||
if (isNaN(bookId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid book ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 软删除:更新状态为 removed
|
||||
const deletedBook = await db
|
||||
.update(books)
|
||||
.set({
|
||||
status: 'removed',
|
||||
availableCopies: 0,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(books.bookId, bookId))
|
||||
.returning();
|
||||
|
||||
if (deletedBook.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Book not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Book removed successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting book:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete book' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
114
src/app/api/books/route.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { books } from '@/lib/db/schema';
|
||||
import { eq, ilike, and, sql } from 'drizzle-orm';
|
||||
import { BookSearchFilters, PaginatedResponse, Book } from '@/lib/types';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// 分页参数
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '10');
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 搜索参数
|
||||
const filters: BookSearchFilters = {
|
||||
title: searchParams.get('title') || undefined,
|
||||
author: searchParams.get('author') || undefined,
|
||||
isbn: searchParams.get('isbn') || undefined,
|
||||
classificationNo: searchParams.get('classificationNo') || undefined,
|
||||
status: (searchParams.get('status') as 'normal' | 'damaged' | 'removed') || undefined,
|
||||
available: searchParams.get('available') === 'true' ? true : undefined,
|
||||
};
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [];
|
||||
|
||||
if (filters.title) {
|
||||
conditions.push(ilike(books.title, `%${filters.title}%`));
|
||||
}
|
||||
|
||||
if (filters.author) {
|
||||
conditions.push(sql`${books.authors} && ARRAY[${filters.author}]`);
|
||||
}
|
||||
|
||||
if (filters.isbn) {
|
||||
conditions.push(eq(books.isbn, filters.isbn));
|
||||
}
|
||||
|
||||
if (filters.classificationNo) {
|
||||
conditions.push(eq(books.classificationNo, filters.classificationNo));
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
conditions.push(eq(books.status, filters.status as 'normal' | 'damaged' | 'removed'));
|
||||
}
|
||||
|
||||
if (filters.available) {
|
||||
conditions.push(sql`${books.availableCopies} > 0`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
// 获取总数
|
||||
const totalResult = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(books)
|
||||
.where(whereClause);
|
||||
|
||||
const total = totalResult[0].count;
|
||||
|
||||
// 获取分页数据
|
||||
const booksData = await db
|
||||
.select()
|
||||
.from(books)
|
||||
.where(whereClause)
|
||||
.limit(pageSize)
|
||||
.offset(offset)
|
||||
.orderBy(books.createdAt);
|
||||
|
||||
const response: PaginatedResponse<Book> = {
|
||||
data: booksData,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
|
||||
return NextResponse.json({ success: true, data: response });
|
||||
} catch (error) {
|
||||
console.error('Error fetching books:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch books' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const newBook = await db
|
||||
.insert(books)
|
||||
.values({
|
||||
...body,
|
||||
availableCopies: body.totalCopies || 1,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: newBook[0],
|
||||
message: 'Book created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating book:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create book' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
90
src/app/api/borrow/renew/route.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { borrowRecords, reservations } from '@/lib/db/schema';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { addDays } from 'date-fns';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { borrowId } = await request.json();
|
||||
|
||||
if (!borrowId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Borrow ID is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// 查找借阅记录
|
||||
const borrowRecord = await tx
|
||||
.select()
|
||||
.from(borrowRecords)
|
||||
.where(eq(borrowRecords.borrowId, borrowId))
|
||||
.limit(1);
|
||||
|
||||
if (borrowRecord.length === 0) {
|
||||
throw new Error('Borrow record not found');
|
||||
}
|
||||
|
||||
if (borrowRecord[0].status !== 'borrowed') {
|
||||
throw new Error('Book is not currently borrowed');
|
||||
}
|
||||
|
||||
// 检查续借次数限制
|
||||
const maxRenewTimes = 2; // 最多续借2次,可以从系统设置中读取
|
||||
if (borrowRecord[0].renewTimes >= maxRenewTimes) {
|
||||
throw new Error(`Maximum renewal limit (${maxRenewTimes}) reached`);
|
||||
}
|
||||
|
||||
// 检查是否有其他学生预约此书
|
||||
const hasReservation = await tx
|
||||
.select()
|
||||
.from(reservations)
|
||||
.where(
|
||||
and(
|
||||
eq(reservations.bookId, borrowRecord[0].bookId),
|
||||
eq(reservations.status, 'waiting')
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (hasReservation.length > 0) {
|
||||
throw new Error('Cannot renew: book is reserved by another student');
|
||||
}
|
||||
|
||||
// 计算新的到期日期(续借30天)
|
||||
const currentDueDate = new Date(borrowRecord[0].dueDate);
|
||||
const newDueDate = addDays(currentDueDate, 30);
|
||||
|
||||
// 更新借阅记录
|
||||
const updatedBorrow = await tx
|
||||
.update(borrowRecords)
|
||||
.set({
|
||||
dueDate: newDueDate.toISOString().split('T')[0],
|
||||
renewTimes: sql`${borrowRecords.renewTimes} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(borrowRecords.borrowId, borrowId))
|
||||
.returning();
|
||||
|
||||
return updatedBorrow[0];
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: 'Book renewed successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error renewing book:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to renew book'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
112
src/app/api/borrow/return/route.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { borrowRecords, books, students, fines } from '@/lib/db/schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { borrowId } = await request.json();
|
||||
|
||||
if (!borrowId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Borrow ID is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// 查找借阅记录
|
||||
const borrowRecord = await tx
|
||||
.select()
|
||||
.from(borrowRecords)
|
||||
.where(eq(borrowRecords.borrowId, borrowId))
|
||||
.limit(1);
|
||||
|
||||
if (borrowRecord.length === 0) {
|
||||
throw new Error('Borrow record not found');
|
||||
}
|
||||
|
||||
if (borrowRecord[0].status !== 'borrowed') {
|
||||
throw new Error('Book is not currently borrowed');
|
||||
}
|
||||
|
||||
const returnDate = new Date();
|
||||
const dueDate = new Date(borrowRecord[0].dueDate);
|
||||
const overdueDays = differenceInDays(returnDate, dueDate);
|
||||
|
||||
// 计算罚款
|
||||
let fineAmount = 0;
|
||||
if (overdueDays > 0) {
|
||||
const finePerDay = 1.0; // 每天1元罚款,可以从系统设置中读取
|
||||
fineAmount = overdueDays * finePerDay;
|
||||
}
|
||||
|
||||
// 更新借阅记录
|
||||
const updatedBorrow = await tx
|
||||
.update(borrowRecords)
|
||||
.set({
|
||||
returnDate: returnDate.toISOString().split('T')[0],
|
||||
status: overdueDays > 0 ? 'overdue' : 'returned',
|
||||
fineAmount: fineAmount.toString(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(borrowRecords.borrowId, borrowId))
|
||||
.returning();
|
||||
|
||||
// 更新图书可借数量
|
||||
await tx
|
||||
.update(books)
|
||||
.set({
|
||||
availableCopies: sql`${books.availableCopies} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(books.bookId, borrowRecord[0].bookId));
|
||||
|
||||
// 更新学生当前借阅数量
|
||||
await tx
|
||||
.update(students)
|
||||
.set({
|
||||
currentBorrow: sql`${students.currentBorrow} - 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(students.studentId, borrowRecord[0].studentId));
|
||||
|
||||
// 如果有罚款,创建罚款记录
|
||||
if (fineAmount > 0) {
|
||||
await tx
|
||||
.insert(fines)
|
||||
.values({
|
||||
studentId: borrowRecord[0].studentId,
|
||||
borrowId: borrowId,
|
||||
fineType: 'overdue',
|
||||
amount: fineAmount.toString(),
|
||||
reason: `Overdue return: ${overdueDays} days late`,
|
||||
status: 'unpaid',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...updatedBorrow[0],
|
||||
overdueDays,
|
||||
fineAmount,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: 'Book returned successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error returning book:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to return book'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
187
src/app/api/borrow/route.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { borrowRecords, books, students } from '@/lib/db/schema';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { addDays } from 'date-fns';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { bookId, studentId } = await request.json();
|
||||
|
||||
if (!bookId || !studentId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Book ID and Student ID are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// 检查图书是否可借
|
||||
const book = await tx
|
||||
.select()
|
||||
.from(books)
|
||||
.where(eq(books.bookId, bookId))
|
||||
.limit(1);
|
||||
|
||||
if (book.length === 0) {
|
||||
throw new Error('Book not found');
|
||||
}
|
||||
|
||||
if (book[0].availableCopies <= 0) {
|
||||
throw new Error('Book is not available for borrowing');
|
||||
}
|
||||
|
||||
if (book[0].status !== 'normal') {
|
||||
throw new Error('Book is not in normal status');
|
||||
}
|
||||
|
||||
// 检查学生状态
|
||||
const student = await tx
|
||||
.select()
|
||||
.from(students)
|
||||
.where(eq(students.studentId, studentId))
|
||||
.limit(1);
|
||||
|
||||
if (student.length === 0) {
|
||||
throw new Error('Student not found');
|
||||
}
|
||||
|
||||
if (student[0].accountStatus !== 'active') {
|
||||
throw new Error('Student account is not active');
|
||||
}
|
||||
|
||||
if (student[0].currentBorrow >= student[0].maxBorrow) {
|
||||
throw new Error('Student has reached maximum borrow limit');
|
||||
}
|
||||
|
||||
// 检查是否已借阅此书
|
||||
const existingBorrow = await tx
|
||||
.select()
|
||||
.from(borrowRecords)
|
||||
.where(
|
||||
and(
|
||||
eq(borrowRecords.bookId, bookId),
|
||||
eq(borrowRecords.studentId, studentId),
|
||||
eq(borrowRecords.status, 'borrowed')
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingBorrow.length > 0) {
|
||||
throw new Error('Student has already borrowed this book');
|
||||
}
|
||||
|
||||
// 计算到期日期(默认30天)
|
||||
const borrowDate = new Date();
|
||||
const dueDate = addDays(borrowDate, 30);
|
||||
|
||||
// 创建借阅记录
|
||||
const newBorrow = await tx
|
||||
.insert(borrowRecords)
|
||||
.values({
|
||||
bookId,
|
||||
studentId,
|
||||
borrowDate: borrowDate.toISOString().split('T')[0],
|
||||
dueDate: dueDate.toISOString().split('T')[0],
|
||||
status: 'borrowed',
|
||||
})
|
||||
.returning();
|
||||
|
||||
// 更新图书可借数量
|
||||
await tx
|
||||
.update(books)
|
||||
.set({
|
||||
availableCopies: sql`${books.availableCopies} - 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(books.bookId, bookId));
|
||||
|
||||
// 更新学生当前借阅数量
|
||||
await tx
|
||||
.update(students)
|
||||
.set({
|
||||
currentBorrow: sql`${students.currentBorrow} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(students.studentId, studentId));
|
||||
|
||||
return newBorrow[0];
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: 'Book borrowed successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error borrowing book:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to borrow book'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const studentId = searchParams.get('studentId');
|
||||
const status = searchParams.get('status');
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [];
|
||||
|
||||
if (studentId) {
|
||||
conditions.push(eq(borrowRecords.studentId, parseInt(studentId)));
|
||||
}
|
||||
|
||||
if (status) {
|
||||
conditions.push(eq(borrowRecords.status, status as 'borrowed' | 'returned' | 'overdue'));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
// 获取借阅记录,包含图书和学生信息
|
||||
const borrows = await db
|
||||
.select({
|
||||
borrowId: borrowRecords.borrowId,
|
||||
borrowDate: borrowRecords.borrowDate,
|
||||
dueDate: borrowRecords.dueDate,
|
||||
returnDate: borrowRecords.returnDate,
|
||||
renewTimes: borrowRecords.renewTimes,
|
||||
status: borrowRecords.status,
|
||||
fineAmount: borrowRecords.fineAmount,
|
||||
notes: borrowRecords.notes,
|
||||
book: {
|
||||
bookId: books.bookId,
|
||||
isbn: books.isbn,
|
||||
title: books.title,
|
||||
authors: books.authors,
|
||||
publisher: books.publisher,
|
||||
},
|
||||
student: {
|
||||
studentId: students.studentId,
|
||||
stuNo: students.stuNo,
|
||||
name: students.name,
|
||||
department: students.department,
|
||||
},
|
||||
})
|
||||
.from(borrowRecords)
|
||||
.leftJoin(books, eq(borrowRecords.bookId, books.bookId))
|
||||
.leftJoin(students, eq(borrowRecords.studentId, students.studentId))
|
||||
.where(whereClause)
|
||||
.orderBy(borrowRecords.createdAt);
|
||||
|
||||
return NextResponse.json({ success: true, data: borrows });
|
||||
} catch (error) {
|
||||
console.error('Error fetching borrow records:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch borrow records' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
101
src/app/api/students/[id]/route.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { students } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const studentId = parseInt(params.id);
|
||||
|
||||
if (isNaN(studentId)) {
|
||||
return NextResponse.json({ error: 'Invalid student ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(students)
|
||||
.where(eq(students.studentId, studentId))
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) {
|
||||
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(result[0]);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch student:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch student' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const studentId = parseInt(params.id);
|
||||
|
||||
if (isNaN(studentId)) {
|
||||
return NextResponse.json({ error: 'Invalid student ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const result = await db
|
||||
.update(students)
|
||||
.set({
|
||||
...body,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(students.studentId, studentId))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(result[0]);
|
||||
} catch (error) {
|
||||
console.error('Failed to update student:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update student' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const studentId = parseInt(params.id);
|
||||
|
||||
if (isNaN(studentId)) {
|
||||
return NextResponse.json({ error: 'Invalid student ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.delete(students)
|
||||
.where(eq(students.studentId, studentId))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Student deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete student:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete student' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
149
src/app/api/students/route.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { students } from '@/lib/db/schema';
|
||||
import { eq, ilike, and, sql } from 'drizzle-orm';
|
||||
import { StudentSearchFilters, PaginatedResponse, Student } from '@/lib/types';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// 分页参数
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '10');
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 搜索参数
|
||||
const filters: StudentSearchFilters = {
|
||||
name: searchParams.get('name') || undefined,
|
||||
stuNo: searchParams.get('stuNo') || undefined,
|
||||
department: searchParams.get('department') || undefined,
|
||||
major: searchParams.get('major') || undefined,
|
||||
accountStatus: (searchParams.get('accountStatus') as 'active' | 'frozen' | 'reported') || undefined,
|
||||
};
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [];
|
||||
|
||||
if (filters.name) {
|
||||
conditions.push(ilike(students.name, `%${filters.name}%`));
|
||||
}
|
||||
|
||||
if (filters.stuNo) {
|
||||
conditions.push(eq(students.stuNo, filters.stuNo));
|
||||
}
|
||||
|
||||
if (filters.department) {
|
||||
conditions.push(ilike(students.department, `%${filters.department}%`));
|
||||
}
|
||||
|
||||
if (filters.major) {
|
||||
conditions.push(ilike(students.major, `%${filters.major}%`));
|
||||
}
|
||||
|
||||
if (filters.accountStatus) {
|
||||
conditions.push(eq(students.accountStatus, filters.accountStatus as 'active' | 'frozen' | 'reported'));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
// 获取总数
|
||||
const totalResult = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(students)
|
||||
.where(whereClause);
|
||||
|
||||
const total = totalResult[0].count;
|
||||
|
||||
// 获取分页数据(排除密码字段)
|
||||
const studentsData = await db
|
||||
.select({
|
||||
studentId: students.studentId,
|
||||
stuNo: students.stuNo,
|
||||
name: students.name,
|
||||
gender: students.gender,
|
||||
department: students.department,
|
||||
major: students.major,
|
||||
grade: students.grade,
|
||||
class: students.class,
|
||||
phone: students.phone,
|
||||
email: students.email,
|
||||
maxBorrow: students.maxBorrow,
|
||||
currentBorrow: students.currentBorrow,
|
||||
accountStatus: students.accountStatus,
|
||||
createdAt: students.createdAt,
|
||||
})
|
||||
.from(students)
|
||||
.where(whereClause)
|
||||
.limit(pageSize)
|
||||
.offset(offset)
|
||||
.orderBy(students.createdAt);
|
||||
|
||||
const response: PaginatedResponse<Omit<Student, 'passwordHash'>> = {
|
||||
data: studentsData,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
|
||||
return NextResponse.json({ success: true, data: response });
|
||||
} catch (error) {
|
||||
console.error('Error fetching students:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch students' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const newStudent = await db
|
||||
.insert(students)
|
||||
.values({
|
||||
stuNo: body.stuNo,
|
||||
name: body.name,
|
||||
gender: body.gender || null,
|
||||
department: body.department || null,
|
||||
major: body.major || null,
|
||||
grade: body.grade || null,
|
||||
class: body.class || null,
|
||||
phone: body.phone || null,
|
||||
email: body.email || null,
|
||||
maxBorrow: body.maxBorrow || 10,
|
||||
currentBorrow: 0,
|
||||
accountStatus: body.accountStatus || 'active',
|
||||
})
|
||||
.returning({
|
||||
studentId: students.studentId,
|
||||
stuNo: students.stuNo,
|
||||
name: students.name,
|
||||
gender: students.gender,
|
||||
department: students.department,
|
||||
major: students.major,
|
||||
grade: students.grade,
|
||||
class: students.class,
|
||||
phone: students.phone,
|
||||
email: students.email,
|
||||
maxBorrow: students.maxBorrow,
|
||||
currentBorrow: students.currentBorrow,
|
||||
accountStatus: students.accountStatus,
|
||||
createdAt: students.createdAt,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: newStudent[0],
|
||||
message: 'Student created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating student:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create student' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
36
src/app/api/test-db/route.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 测试数据库连接和 schema
|
||||
const result = await db.execute(sql`SELECT current_schema(), current_database()`);
|
||||
|
||||
// 测试查询 library schema 中的表
|
||||
const tablesResult = await db.execute(sql`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'library'
|
||||
`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
connection: result,
|
||||
tables: tablesResult,
|
||||
message: 'Database connection successful'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Database test error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Database connection failed',
|
||||
details: error
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
284
src/app/books/page.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Search, Eye, Star, BookOpen } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Book, BookSearchFilters } from '@/lib/types';
|
||||
|
||||
async function fetchBooks(filters: BookSearchFilters & { page: number; pageSize: number }) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
params.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/books?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch books');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export default function BooksPage() {
|
||||
const [searchFilters, setSearchFilters] = useState<BookSearchFilters>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 12;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['books', searchFilters, page, pageSize],
|
||||
queryFn: () => fetchBooks({ ...searchFilters, page, pageSize }),
|
||||
});
|
||||
|
||||
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof BookSearchFilters, value: string) => {
|
||||
setSearchFilters(prev => ({
|
||||
...prev,
|
||||
[field]: value || undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">图书检索</h1>
|
||||
<p className="text-gray-600">搜索和浏览图书馆藏书</p>
|
||||
</div>
|
||||
|
||||
{/* Search Filters */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
书名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入书名关键词"
|
||||
value={searchFilters.title || ''}
|
||||
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
作者
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入作者姓名"
|
||||
value={searchFilters.author || ''}
|
||||
onChange={(e) => handleInputChange('author', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
ISBN
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入ISBN号"
|
||||
value={searchFilters.isbn || ''}
|
||||
onChange={(e) => handleInputChange('isbn', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
分类号
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入分类号"
|
||||
value={searchFilters.classificationNo || ''}
|
||||
onChange={(e) => handleInputChange('classificationNo', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-end">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={searchFilters.status || ''}
|
||||
onChange={(e) => handleInputChange('status', e.target.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="normal">正常</option>
|
||||
<option value="damaged">损坏</option>
|
||||
<option value="removed">下架</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="available"
|
||||
className="mr-2"
|
||||
checked={searchFilters.available || false}
|
||||
onChange={(e) => handleInputChange('available', e.target.checked ? 'true' : '')}
|
||||
/>
|
||||
<label htmlFor="available" className="text-sm font-medium text-gray-700">
|
||||
仅显示可借图书
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">加载失败,请重试</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Results Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<p className="text-gray-600">
|
||||
找到 {data?.data?.total || 0} 本图书
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Books Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{data?.data?.data?.map((book: Book) => (
|
||||
<div key={book.bookId} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<div className="h-48 bg-gray-200 flex items-center justify-center">
|
||||
{book.coverUrl ? (
|
||||
<img
|
||||
src={`https://picsum.photos/300/300?random=${book.bookId}`}
|
||||
alt={book.title}
|
||||
width={192}
|
||||
height={192}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<BookOpen className="h-16 w-16 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-lg mb-2 line-clamp-2">
|
||||
{book.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-2">
|
||||
作者: {book.authors?.join(', ')}
|
||||
</p>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-2">
|
||||
出版社: {book.publisher}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500">
|
||||
可借: {book.availableCopies}/{book.totalCopies}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
book.status === 'normal' ? 'bg-green-100 text-green-800' :
|
||||
book.status === 'damaged' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{book.status === 'normal' ? '正常' :
|
||||
book.status === 'damaged' ? '损坏' : '下架'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center text-yellow-500">
|
||||
<Star className="h-4 w-4 fill-current" />
|
||||
<span className="text-sm ml-1">4.5</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/books/${book.bookId}`}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-sm flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
详情
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.data?.totalPages > 1 && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<div className="flex gap-2">
|
||||
{page > 1 && (
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
)}
|
||||
|
||||
{[...Array(Math.min(5, data.data.totalPages))].map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setPage(pageNum)}
|
||||
className={`px-4 py-2 border rounded-md ${
|
||||
page === pageNum
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{page < data.data.totalPages && (
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,20 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "智能图书馆管理系统",
|
||||
description: "基于Next.js和PostgreSQL的智能图书馆管理系统",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@ -23,11 +16,11 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="zh-CN">
|
||||
<body className={inter.className}>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
82
src/app/page.new.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import Link from 'next/link';
|
||||
import { BookOpen, Users, BarChart3, Settings } from 'lucide-react';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-5xl font-bold text-gray-800 mb-4">
|
||||
智能图书馆管理系统
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
基于Next.js和PostgreSQL构建的现代化图书馆管理解决方案
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 mb-16">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition-shadow">
|
||||
<BookOpen className="h-12 w-12 text-blue-600 mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">图书管理</h3>
|
||||
<p className="text-gray-600">
|
||||
全面的图书信息管理,支持批量导入、分类检索和状态跟踪
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition-shadow">
|
||||
<Users className="h-12 w-12 text-green-600 mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">用户管理</h3>
|
||||
<p className="text-gray-600">
|
||||
学生和管理员账户管理,权限控制和状态监控
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition-shadow">
|
||||
<BarChart3 className="h-12 w-12 text-purple-600 mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">数据统计</h3>
|
||||
<p className="text-gray-600">
|
||||
借阅统计、热门图书分析和数据可视化展示
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition-shadow">
|
||||
<Settings className="h-12 w-12 text-orange-600 mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">系统设置</h3>
|
||||
<p className="text-gray-600">
|
||||
灵活的系统配置,罚款规则和借阅政策管理
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-4">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-lg font-semibold transition-colors text-center"
|
||||
>
|
||||
管理员入口
|
||||
</Link>
|
||||
<Link
|
||||
href="/student"
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-8 py-3 rounded-lg font-semibold transition-colors text-center"
|
||||
>
|
||||
学生入口
|
||||
</Link>
|
||||
<Link
|
||||
href="/books"
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white px-8 py-3 rounded-lg font-semibold transition-colors text-center"
|
||||
>
|
||||
图书检索
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-16 text-gray-500">
|
||||
<p>© 2025 智能图书馆管理系统. 所有权利保留.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
src/app/page.tsx
@ -1,103 +1,82 @@
|
||||
import Image from "next/image";
|
||||
import Link from 'next/link';
|
||||
import { BookOpen, Users, BarChart3, Settings } from 'lucide-react';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-5xl font-bold text-gray-800 mb-4">
|
||||
智能图书馆管理系统
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
基于Next.js和PostgreSQL构建的现代化图书馆管理解决方案
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 mb-16">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition-shadow">
|
||||
<BookOpen className="h-12 w-12 text-blue-600 mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">图书管理</h3>
|
||||
<p className="text-gray-600">
|
||||
全面的图书信息管理,支持批量导入、分类检索和状态跟踪
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition-shadow">
|
||||
<Users className="h-12 w-12 text-green-600 mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">用户管理</h3>
|
||||
<p className="text-gray-600">
|
||||
学生和管理员账户管理,权限控制和状态监控
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition-shadow">
|
||||
<BarChart3 className="h-12 w-12 text-purple-600 mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">数据统计</h3>
|
||||
<p className="text-gray-600">
|
||||
借阅统计、热门图书分析和数据可视化展示
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition-shadow">
|
||||
<Settings className="h-12 w-12 text-orange-600 mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">系统设置</h3>
|
||||
<p className="text-gray-600">
|
||||
灵活的系统配置,罚款规则和借阅政策管理
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-4">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-lg font-semibold transition-colors text-center"
|
||||
>
|
||||
管理员入口
|
||||
</Link>
|
||||
<Link
|
||||
href="/student"
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-8 py-3 rounded-lg font-semibold transition-colors text-center"
|
||||
>
|
||||
学生入口
|
||||
</Link>
|
||||
<Link
|
||||
href="/books"
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white px-8 py-3 rounded-lg font-semibold transition-colors text-center"
|
||||
>
|
||||
图书检索
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-16 text-gray-500">
|
||||
<p>© 2025 智能图书馆管理系统. 所有权利保留.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
298
src/app/student/history/page.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Calendar,
|
||||
BookOpen,
|
||||
Search,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
|
||||
// Mock data - replace with actual API
|
||||
async function fetchBorrowHistory() {
|
||||
return {
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
borrowId: 1,
|
||||
book: {
|
||||
title: '深入理解计算机系统',
|
||||
authors: ['Randal E. Bryant'],
|
||||
isbn: '9787111544937'
|
||||
},
|
||||
borrowDate: '2024-12-01',
|
||||
dueDate: '2024-12-31',
|
||||
returnDate: '2024-12-28',
|
||||
renewTimes: 1,
|
||||
status: 'returned',
|
||||
fineAmount: '0'
|
||||
},
|
||||
{
|
||||
borrowId: 2,
|
||||
book: {
|
||||
title: 'Python编程实战',
|
||||
authors: ['Mark Lutz'],
|
||||
isbn: '9787111627295'
|
||||
},
|
||||
borrowDate: '2025-01-05',
|
||||
dueDate: '2025-02-04',
|
||||
returnDate: null,
|
||||
renewTimes: 1,
|
||||
status: 'borrowed',
|
||||
fineAmount: '0'
|
||||
},
|
||||
{
|
||||
borrowId: 3,
|
||||
book: {
|
||||
title: '数据结构与算法',
|
||||
authors: ['Thomas H. Cormen'],
|
||||
isbn: '9787111407010'
|
||||
},
|
||||
borrowDate: '2024-11-15',
|
||||
dueDate: '2024-12-15',
|
||||
returnDate: '2024-12-20',
|
||||
renewTimes: 0,
|
||||
status: 'returned',
|
||||
fineAmount: '5.00'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export default function StudentHistoryPage() {
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['student-history', statusFilter, searchTerm],
|
||||
queryFn: fetchBorrowHistory,
|
||||
});
|
||||
|
||||
const getStatusBadge = (status: string, dueDate: string, returnDate: string | null) => {
|
||||
if (status === 'returned') {
|
||||
const wasOverdue = returnDate && new Date(returnDate) > new Date(dueDate);
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
wasOverdue ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
{wasOverdue ? '逾期归还' : '正常归还'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const isOverdue = new Date(dueDate) < new Date();
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
isOverdue ? 'bg-red-100 text-red-800' : 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{isOverdue ? <AlertTriangle className="h-3 w-3 mr-1" /> : <Clock className="h-3 w-3 mr-1" />}
|
||||
{isOverdue ? '逾期' : '借阅中'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const filteredData = data?.data?.filter((record: any) => {
|
||||
const matchesStatus = !statusFilter || record.status === statusFilter;
|
||||
const matchesSearch = !searchTerm ||
|
||||
record.book.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
record.book.authors.some((author: string) =>
|
||||
author.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">借阅历史</h1>
|
||||
<p className="text-gray-600">查看您的借阅记录和归还历史</p>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">总借阅次数</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{data?.data?.length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<BookOpen className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">当前借阅</p>
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
{data?.data?.filter((r: any) => r.status === 'borrowed').length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">已归还</p>
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{data?.data?.filter((r: any) => r.status === 'returned').length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">逾期次数</p>
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
{data?.data?.filter((r: any) =>
|
||||
(r.status === 'returned' && r.returnDate && new Date(r.returnDate) > new Date(r.dueDate)) ||
|
||||
(r.status === 'borrowed' && new Date(r.dueDate) < new Date())
|
||||
).length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
搜索图书
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入书名或作者"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
借阅状态
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="borrowed">借阅中</option>
|
||||
<option value="returned">已归还</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">加载失败,请重试</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* History List */}
|
||||
<div className="space-y-4">
|
||||
{filteredData?.map((record: any) => (
|
||||
<div key={record.borrowId} className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{record.book.title}
|
||||
</h3>
|
||||
{getStatusBadge(record.status, record.dueDate, record.returnDate)}
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-2">
|
||||
作者: {record.book.authors.join(', ')}
|
||||
</p>
|
||||
|
||||
<p className="text-gray-500 text-sm mb-2">
|
||||
ISBN: {record.book.isbn}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">借阅日期:</span>
|
||||
<span className="ml-2 text-gray-600">
|
||||
{new Date(record.borrowDate).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">应还日期:</span>
|
||||
<span className="ml-2 text-gray-600">
|
||||
{new Date(record.dueDate).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{record.returnDate && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">归还日期:</span>
|
||||
<span className="ml-2 text-gray-600">
|
||||
{new Date(record.returnDate).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">续借次数:</span>
|
||||
<span className="ml-2 text-gray-600">{record.renewTimes}/2</span>
|
||||
</div>
|
||||
|
||||
{record.fineAmount && parseFloat(record.fineAmount) > 0 && (
|
||||
<div className="text-red-600">
|
||||
<span className="font-medium">罚款:</span>
|
||||
<span className="ml-2">¥{parseFloat(record.fineAmount).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* No results */}
|
||||
{filteredData?.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Calendar className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">
|
||||
{data?.data?.length === 0 ? '暂无借阅记录' : '没有找到符合条件的记录'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
src/app/student/page.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
BookOpen,
|
||||
Calendar,
|
||||
Clock,
|
||||
Star,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
// Mock student data
|
||||
const mockStudent = {
|
||||
stuNo: 'S2023001',
|
||||
name: '张三',
|
||||
department: '计算机科学与技术',
|
||||
currentBorrow: 3,
|
||||
maxBorrow: 10,
|
||||
totalFines: 5.00,
|
||||
accountStatus: 'active'
|
||||
};
|
||||
|
||||
// Mock data fetching functions
|
||||
async function fetchStudentDashboard() {
|
||||
return {
|
||||
student: mockStudent,
|
||||
currentBorrows: [
|
||||
{
|
||||
borrowId: 1,
|
||||
book: { title: '深入理解计算机系统', authors: ['Randal E. Bryant'] },
|
||||
borrowDate: '2025-01-01',
|
||||
dueDate: '2025-01-31',
|
||||
renewTimes: 0,
|
||||
status: 'borrowed'
|
||||
},
|
||||
{
|
||||
borrowId: 2,
|
||||
book: { title: 'Python编程实战', authors: ['Mark Lutz'] },
|
||||
borrowDate: '2025-01-05',
|
||||
dueDate: '2025-02-04',
|
||||
renewTimes: 1,
|
||||
status: 'borrowed'
|
||||
},
|
||||
{
|
||||
borrowId: 3,
|
||||
book: { title: '数据结构与算法', authors: ['Thomas H. Cormen'] },
|
||||
borrowDate: '2024-12-20',
|
||||
dueDate: '2025-01-19',
|
||||
renewTimes: 0,
|
||||
status: 'overdue'
|
||||
}
|
||||
],
|
||||
reservations: [
|
||||
{
|
||||
reservationId: 1,
|
||||
book: { title: '人工智能导论', authors: ['Stuart Russell'] },
|
||||
reserveDate: '2025-01-10',
|
||||
status: 'waiting'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export default function StudentDashboard() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['student-dashboard'],
|
||||
queryFn: fetchStudentDashboard,
|
||||
});
|
||||
|
||||
const student = data?.student || mockStudent;
|
||||
const currentBorrows = data?.currentBorrows || [];
|
||||
const reservations = data?.reservations || [];
|
||||
|
||||
const isOverdue = (dueDate: string) => {
|
||||
return new Date(dueDate) < new Date();
|
||||
};
|
||||
|
||||
const getDaysUntilDue = (dueDate: string) => {
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
const diffTime = due.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
欢迎回来, {student.name}
|
||||
</h1>
|
||||
<p className="text-gray-600">学号: {student.stuNo} | {student.department}</p>
|
||||
</div>
|
||||
|
||||
{/* Account Status */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">当前借阅</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{student.currentBorrow}/{student.maxBorrow}
|
||||
</p>
|
||||
</div>
|
||||
<BookOpen className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">预约数量</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{reservations.length}
|
||||
</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">未缴罚款</p>
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
¥{student.totalFines.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">账户状态</p>
|
||||
<p className={`text-2xl font-bold ${
|
||||
student.accountStatus === 'active' ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{student.accountStatus === 'active' ? '正常' : '冻结'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`h-8 w-8 rounded-full flex items-center justify-center ${
|
||||
student.accountStatus === 'active' ? 'bg-green-100' : 'bg-red-100'
|
||||
}`}>
|
||||
<div className={`h-4 w-4 rounded-full ${
|
||||
student.accountStatus === 'active' ? 'bg-green-600' : 'bg-red-600'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<Link
|
||||
href="/books"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white p-6 rounded-lg shadow-md transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">图书检索</h3>
|
||||
<p className="text-blue-100 text-sm">搜索和浏览图书</p>
|
||||
</div>
|
||||
<Search className="h-8 w-8 group-hover:scale-110 transition-transform" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/student/history"
|
||||
className="bg-green-600 hover:bg-green-700 text-white p-6 rounded-lg shadow-md transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">借阅历史</h3>
|
||||
<p className="text-green-100 text-sm">查看历史记录</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 group-hover:scale-110 transition-transform" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/student/reviews"
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white p-6 rounded-lg shadow-md transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">我的评价</h3>
|
||||
<p className="text-purple-100 text-sm">图书评价和推荐</p>
|
||||
</div>
|
||||
<Star className="h-8 w-8 group-hover:scale-110 transition-transform" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Current Borrows & Reservations */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Current Borrows */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">当前借阅</h3>
|
||||
|
||||
{currentBorrows.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">暂无借阅图书</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{currentBorrows.map((borrow: {
|
||||
borrowId: number;
|
||||
book: { title: string; authors: string[] };
|
||||
borrowDate: string;
|
||||
dueDate: string;
|
||||
renewTimes: number;
|
||||
status: string;
|
||||
}) => (
|
||||
<div key={borrow.borrowId} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h4 className="font-medium text-gray-900">{borrow.book.title}</h4>
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
borrow.status === 'overdue'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: isOverdue(borrow.dueDate)
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{borrow.status === 'overdue' ? '逾期' :
|
||||
isOverdue(borrow.dueDate) ? '即将逾期' : '正常'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
作者: {borrow.book.authors.join(', ')}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">
|
||||
借阅日期: {new Date(borrow.borrowDate).toLocaleDateString('zh-CN')}
|
||||
</p>
|
||||
<p className={`${isOverdue(borrow.dueDate) ? 'text-red-600' : 'text-gray-500'}`}>
|
||||
到期日期: {new Date(borrow.dueDate).toLocaleDateString('zh-CN')}
|
||||
{!isOverdue(borrow.dueDate) && ` (${getDaysUntilDue(borrow.dueDate)}天后)`}
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
续借次数: {borrow.renewTimes}/2
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{borrow.renewTimes < 2 && borrow.status !== 'overdue' && (
|
||||
<button className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-xs flex items-center gap-1">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
续借
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reservations */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">预约图书</h3>
|
||||
|
||||
{reservations.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">暂无预约图书</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{reservations.map((reservation: {
|
||||
reservationId: number;
|
||||
book: { title: string; authors: string[] };
|
||||
reserveDate: string;
|
||||
status: string;
|
||||
}) => (
|
||||
<div key={reservation.reservationId} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h4 className="font-medium text-gray-900">{reservation.book.title}</h4>
|
||||
<span className="px-2 py-1 rounded-full text-xs bg-orange-100 text-orange-800">
|
||||
{reservation.status === 'waiting' ? '等待中' :
|
||||
reservation.status === 'available' ? '可取书' : '已过期'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
作者: {reservation.book.authors.join(', ')}
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
预约日期: {new Date(reservation.reserveDate).toLocaleDateString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/components/providers.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
16
src/lib/db/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
import * as schema from './schema';
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
ssl: false,
|
||||
// 设置默认 schema
|
||||
options: '-c search_path=library,public',
|
||||
});
|
||||
|
||||
export const db = drizzle(pool, { schema });
|
||||
215
src/lib/db/schema.new.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import { pgTable, varchar, text, integer, numeric, timestamp, boolean, pgEnum, bigint, date, index, pgSchema } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
// 定义 library schema
|
||||
export const librarySchema = pgSchema('library');
|
||||
|
||||
// 枚举定义 - 使用 library schema
|
||||
export const genderEnum = librarySchema.enum('gender_enum', ['M', 'F']);
|
||||
export const accountStatusEnum = librarySchema.enum('account_status_enum', ['active', 'frozen', 'reported']);
|
||||
export const bookStatusEnum = librarySchema.enum('book_status_enum', ['normal', 'damaged', 'removed']);
|
||||
export const borrowStatusEnum = librarySchema.enum('borrow_status_enum', ['borrowed', 'returned', 'overdue']);
|
||||
export const reservationStatusEnum = librarySchema.enum('reservation_status_enum', ['waiting', 'available', 'expired', 'cancelled']);
|
||||
export const fineStatusEnum = librarySchema.enum('fine_status_enum', ['unpaid', 'paid']);
|
||||
|
||||
// 系统设置表
|
||||
export const systemSettings = librarySchema.table('system_settings', {
|
||||
settingKey: varchar('setting_key', { length: 50 }).primaryKey(),
|
||||
settingValue: text('setting_value').notNull(),
|
||||
description: text('description'),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// 管理员表
|
||||
export const admins = librarySchema.table('admins', {
|
||||
adminId: bigint('admin_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
empNo: varchar('emp_no', { length: 20 }).unique().notNull(),
|
||||
name: varchar('name', { length: 50 }).notNull(),
|
||||
position: varchar('position', { length: 50 }),
|
||||
phone: varchar('phone', { length: 20 }),
|
||||
email: varchar('email', { length: 100 }),
|
||||
passwordHash: varchar('password_hash', { length: 255 }),
|
||||
privilegeLv: integer('privilege_lv').default(2).notNull(),
|
||||
lastLogin: timestamp('last_login'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// 图书表
|
||||
export const books = librarySchema.table('books', {
|
||||
bookId: bigint('book_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
isbn: varchar('isbn', { length: 20 }).unique().notNull(),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
authors: text('authors').array().notNull(),
|
||||
publisher: varchar('publisher', { length: 100 }),
|
||||
publishDate: date('publish_date'),
|
||||
price: numeric('price', { precision: 10, scale: 2 }),
|
||||
classificationNo: varchar('classification_no', { length: 50 }),
|
||||
location: varchar('location', { length: 100 }),
|
||||
totalCopies: integer('total_copies').default(1).notNull(),
|
||||
availableCopies: integer('available_copies').default(1).notNull(),
|
||||
status: bookStatusEnum('status').default('normal').notNull(),
|
||||
description: text('description'),
|
||||
coverUrl: varchar('cover_url', { length: 500 }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
titleIdx: index('idx_books_title').on(table.title),
|
||||
authorsIdx: index('idx_books_authors').on(table.authors),
|
||||
isbnIdx: index('idx_books_isbn').on(table.isbn),
|
||||
}));
|
||||
|
||||
// 学生表
|
||||
export const students = librarySchema.table('students', {
|
||||
studentId: bigint('student_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
stuNo: varchar('stu_no', { length: 20 }).unique().notNull(),
|
||||
name: varchar('name', { length: 50 }).notNull(),
|
||||
gender: genderEnum('gender'),
|
||||
department: varchar('department', { length: 100 }),
|
||||
major: varchar('major', { length: 100 }),
|
||||
grade: varchar('grade', { length: 10 }),
|
||||
class: varchar('class', { length: 20 }),
|
||||
phone: varchar('phone', { length: 20 }),
|
||||
email: varchar('email', { length: 100 }),
|
||||
passwordHash: varchar('password_hash', { length: 255 }),
|
||||
maxBorrow: integer('max_borrow').default(10).notNull(),
|
||||
currentBorrow: integer('current_borrow').default(0).notNull(),
|
||||
accountStatus: accountStatusEnum('account_status').default('active').notNull(),
|
||||
enrollmentDate: date('enrollment_date'),
|
||||
lastLogin: timestamp('last_login'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
stuNoIdx: index('idx_students_stu_no').on(table.stuNo),
|
||||
nameIdx: index('idx_students_name').on(table.name),
|
||||
deptIdx: index('idx_students_department').on(table.department),
|
||||
}));
|
||||
|
||||
// 借阅记录表
|
||||
export const borrowRecords = librarySchema.table('borrow_records', {
|
||||
borrowId: bigint('borrow_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
bookId: bigint('book_id', { mode: 'number' }).references(() => books.bookId, { onDelete: 'cascade' }).notNull(),
|
||||
studentId: bigint('student_id', { mode: 'number' }).references(() => students.studentId, { onDelete: 'cascade' }).notNull(),
|
||||
borrowDate: date('borrow_date').defaultNow().notNull(),
|
||||
dueDate: date('due_date').notNull(),
|
||||
returnDate: date('return_date'),
|
||||
renewTimes: integer('renew_times').default(0).notNull(),
|
||||
status: borrowStatusEnum('status').default('borrowed').notNull(),
|
||||
fineAmount: numeric('fine_amount', { precision: 10, scale: 2 }).default('0').notNull(),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
bookIdIdx: index('idx_borrow_records_book_id').on(table.bookId),
|
||||
studentIdIdx: index('idx_borrow_records_student_id').on(table.studentId),
|
||||
statusIdx: index('idx_borrow_records_status').on(table.status),
|
||||
dueDateIdx: index('idx_borrow_records_due_date').on(table.dueDate),
|
||||
}));
|
||||
|
||||
// 预约记录表
|
||||
export const reservations = librarySchema.table('reservations', {
|
||||
reservationId: bigint('reservation_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
bookId: bigint('book_id', { mode: 'number' }).references(() => books.bookId, { onDelete: 'cascade' }).notNull(),
|
||||
studentId: bigint('student_id', { mode: 'number' }).references(() => students.studentId, { onDelete: 'cascade' }).notNull(),
|
||||
reserveDate: timestamp('reserve_date').defaultNow().notNull(),
|
||||
expiryDate: timestamp('expiry_date'),
|
||||
status: reservationStatusEnum('status').default('waiting').notNull(),
|
||||
notified: boolean('notified').default(false).notNull(),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
bookIdIdx: index('idx_reservations_book_id').on(table.bookId),
|
||||
studentIdIdx: index('idx_reservations_student_id').on(table.studentId),
|
||||
statusIdx: index('idx_reservations_status').on(table.status),
|
||||
}));
|
||||
|
||||
// 罚款记录表
|
||||
export const fines = librarySchema.table('fines', {
|
||||
fineId: bigint('fine_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
studentId: bigint('student_id', { mode: 'number' }).references(() => students.studentId, { onDelete: 'cascade' }).notNull(),
|
||||
borrowId: bigint('borrow_id', { mode: 'number' }).references(() => borrowRecords.borrowId, { onDelete: 'cascade' }),
|
||||
fineType: varchar('fine_type', { length: 50 }).default('overdue').notNull(),
|
||||
amount: numeric('amount', { precision: 10, scale: 2 }).notNull(),
|
||||
reason: text('reason'),
|
||||
status: fineStatusEnum('status').default('unpaid').notNull(),
|
||||
paymentDate: timestamp('payment_date'),
|
||||
paymentMethod: varchar('payment_method', { length: 50 }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
studentIdIdx: index('idx_fines_student_id').on(table.studentId),
|
||||
statusIdx: index('idx_fines_status').on(table.status),
|
||||
}));
|
||||
|
||||
// 评价记录表
|
||||
export const reviews = librarySchema.table('reviews', {
|
||||
reviewId: bigint('review_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
bookId: bigint('book_id', { mode: 'number' }).references(() => books.bookId, { onDelete: 'cascade' }).notNull(),
|
||||
studentId: bigint('student_id', { mode: 'number' }).references(() => students.studentId, { onDelete: 'cascade' }).notNull(),
|
||||
rating: integer('rating').notNull(),
|
||||
comment: text('comment'),
|
||||
reviewTime: timestamp('review_time').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
bookIdIdx: index('idx_reviews_book_id').on(table.bookId),
|
||||
studentIdIdx: index('idx_reviews_student_id').on(table.studentId),
|
||||
}));
|
||||
|
||||
// 关系定义
|
||||
export const booksRelations = relations(books, ({ many }) => ({
|
||||
borrowRecords: many(borrowRecords),
|
||||
reservations: many(reservations),
|
||||
reviews: many(reviews),
|
||||
}));
|
||||
|
||||
export const studentsRelations = relations(students, ({ many }) => ({
|
||||
borrowRecords: many(borrowRecords),
|
||||
reservations: many(reservations),
|
||||
fines: many(fines),
|
||||
reviews: many(reviews),
|
||||
}));
|
||||
|
||||
export const borrowRecordsRelations = relations(borrowRecords, ({ one, many }) => ({
|
||||
book: one(books, {
|
||||
fields: [borrowRecords.bookId],
|
||||
references: [books.bookId],
|
||||
}),
|
||||
student: one(students, {
|
||||
fields: [borrowRecords.studentId],
|
||||
references: [students.studentId],
|
||||
}),
|
||||
fines: many(fines),
|
||||
}));
|
||||
|
||||
export const reservationsRelations = relations(reservations, ({ one }) => ({
|
||||
book: one(books, {
|
||||
fields: [reservations.bookId],
|
||||
references: [books.bookId],
|
||||
}),
|
||||
student: one(students, {
|
||||
fields: [reservations.studentId],
|
||||
references: [students.studentId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const finesRelations = relations(fines, ({ one }) => ({
|
||||
student: one(students, {
|
||||
fields: [fines.studentId],
|
||||
references: [students.studentId],
|
||||
}),
|
||||
borrowRecord: one(borrowRecords, {
|
||||
fields: [fines.borrowId],
|
||||
references: [borrowRecords.borrowId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const reviewsRelations = relations(reviews, ({ one }) => ({
|
||||
book: one(books, {
|
||||
fields: [reviews.bookId],
|
||||
references: [books.bookId],
|
||||
}),
|
||||
student: one(students, {
|
||||
fields: [reviews.studentId],
|
||||
references: [students.studentId],
|
||||
}),
|
||||
}));
|
||||
224
src/lib/db/schema.old.ts
Normal file
@ -0,0 +1,224 @@
|
||||
import { pgTable, serial, varchar, text, integer, numeric, timestamp, boolean, pgEnum, bigint, date, index, pgSchema } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
// 定义 library schema
|
||||
export const librarySchema = pgSchema('library');
|
||||
|
||||
// 枚举定义
|
||||
export const genderEnum = pgEnum('gender_enum', ['M', 'F']);
|
||||
export const accountStatusEnum = pgEnum('account_status_enum', ['active', 'frozen', 'reported']);
|
||||
export const bookStatusEnum = pgEnum('book_status_enum', ['normal', 'damaged', 'removed']);
|
||||
export const borrowStatusEnum = pgEnum('borrow_status_enum', ['borrowed', 'returned', 'overdue']);
|
||||
export const reservationStatusEnum = pgEnum('reservation_status_enum', ['waiting', 'available', 'expired', 'cancelled']);
|
||||
export const fineStatusEnum = pgEnum('fine_status_enum', ['unpaid', 'paid']);gTable, varchar, text, integer, numeric, timestamp, boolean, pgEnum, bigint, date, index } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
// 枚举定义
|
||||
export const genderEnum = pgEnum('gender_enum', ['M', 'F']);
|
||||
export const accountStatusEnum = pgEnum('account_status_enum', ['active', 'frozen', 'reported']);
|
||||
export const bookStatusEnum = pgEnum('book_status_enum', ['normal', 'damaged', 'removed']);
|
||||
export const borrowStatusEnum = pgEnum('borrow_status_enum', ['borrowed', 'returned', 'overdue']);
|
||||
export const reservationStatusEnum = pgEnum('reservation_status_enum', ['waiting', 'available', 'expired', 'cancelled']);
|
||||
export const fineStatusEnum = pgEnum('fine_status_enum', ['unpaid', 'paid']);
|
||||
|
||||
// 系统设置表
|
||||
export const systemSettings = pgTable('system_settings', {
|
||||
settingKey: varchar('setting_key', { length: 50 }).primaryKey(),
|
||||
settingValue: text('setting_value').notNull(),
|
||||
description: text('description'),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// 管理员表
|
||||
export const admins = pgTable('admins', {
|
||||
adminId: bigint('admin_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
empNo: varchar('emp_no', { length: 20 }).unique().notNull(),
|
||||
name: varchar('name', { length: 50 }).notNull(),
|
||||
position: varchar('position', { length: 50 }),
|
||||
phone: varchar('phone', { length: 20 }),
|
||||
email: varchar('email', { length: 100 }),
|
||||
passwordHash: varchar('password_hash', { length: 255 }),
|
||||
privilegeLv: integer('privilege_lv').default(2).notNull(),
|
||||
lastLogin: timestamp('last_login'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// 图书表
|
||||
export const books = pgTable('books', {
|
||||
bookId: bigint('book_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
isbn: varchar('isbn', { length: 20 }).unique().notNull(),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
authors: text('authors').array().notNull(),
|
||||
publisher: varchar('publisher', { length: 100 }),
|
||||
publishDate: date('publish_date'),
|
||||
price: numeric('price', { precision: 10, scale: 2 }),
|
||||
classificationNo: varchar('classification_no', { length: 50 }),
|
||||
location: varchar('location', { length: 100 }),
|
||||
totalCopies: integer('total_copies').default(1).notNull(),
|
||||
availableCopies: integer('available_copies').default(1).notNull(),
|
||||
status: bookStatusEnum('status').default('normal').notNull(),
|
||||
description: text('description'),
|
||||
coverUrl: varchar('cover_url', { length: 500 }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
titleIdx: index('idx_books_title').on(table.title),
|
||||
authorsIdx: index('idx_books_authors').on(table.authors),
|
||||
isbnIdx: index('idx_books_isbn').on(table.isbn),
|
||||
}));
|
||||
|
||||
// 学生表
|
||||
export const students = pgTable('students', {
|
||||
studentId: bigint('student_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
stuNo: varchar('stu_no', { length: 20 }).unique().notNull(),
|
||||
name: varchar('name', { length: 50 }).notNull(),
|
||||
gender: genderEnum('gender'),
|
||||
department: varchar('department', { length: 100 }),
|
||||
major: varchar('major', { length: 100 }),
|
||||
grade: varchar('grade', { length: 10 }),
|
||||
class: varchar('class', { length: 20 }),
|
||||
phone: varchar('phone', { length: 20 }),
|
||||
email: varchar('email', { length: 100 }),
|
||||
passwordHash: varchar('password_hash', { length: 255 }),
|
||||
maxBorrow: integer('max_borrow').default(10).notNull(),
|
||||
currentBorrow: integer('current_borrow').default(0).notNull(),
|
||||
accountStatus: accountStatusEnum('account_status').default('active').notNull(),
|
||||
enrollmentDate: date('enrollment_date'),
|
||||
lastLogin: timestamp('last_login'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
stuNoIdx: index('idx_students_stu_no').on(table.stuNo),
|
||||
nameIdx: index('idx_students_name').on(table.name),
|
||||
deptIdx: index('idx_students_department').on(table.department),
|
||||
}));
|
||||
|
||||
// 借阅记录表
|
||||
export const borrowRecords = pgTable('borrow_records', {
|
||||
borrowId: bigint('borrow_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
bookId: bigint('book_id', { mode: 'number' }).references(() => books.bookId, { onDelete: 'cascade' }).notNull(),
|
||||
studentId: bigint('student_id', { mode: 'number' }).references(() => students.studentId, { onDelete: 'cascade' }).notNull(),
|
||||
borrowDate: date('borrow_date').defaultNow().notNull(),
|
||||
dueDate: date('due_date').notNull(),
|
||||
returnDate: date('return_date'),
|
||||
renewTimes: integer('renew_times').default(0).notNull(),
|
||||
status: borrowStatusEnum('status').default('borrowed').notNull(),
|
||||
fineAmount: numeric('fine_amount', { precision: 10, scale: 2 }).default('0').notNull(),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
bookIdIdx: index('idx_borrow_records_book_id').on(table.bookId),
|
||||
studentIdIdx: index('idx_borrow_records_student_id').on(table.studentId),
|
||||
statusIdx: index('idx_borrow_records_status').on(table.status),
|
||||
dueDateIdx: index('idx_borrow_records_due_date').on(table.dueDate),
|
||||
}));
|
||||
|
||||
// 预约记录表
|
||||
export const reservations = pgTable('reservations', {
|
||||
reservationId: bigint('reservation_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
bookId: bigint('book_id', { mode: 'number' }).references(() => books.bookId, { onDelete: 'cascade' }).notNull(),
|
||||
studentId: bigint('student_id', { mode: 'number' }).references(() => students.studentId, { onDelete: 'cascade' }).notNull(),
|
||||
reserveDate: timestamp('reserve_date').defaultNow().notNull(),
|
||||
expiryDate: timestamp('expiry_date'),
|
||||
status: reservationStatusEnum('status').default('waiting').notNull(),
|
||||
notified: boolean('notified').default(false).notNull(),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
bookIdIdx: index('idx_reservations_book_id').on(table.bookId),
|
||||
studentIdIdx: index('idx_reservations_student_id').on(table.studentId),
|
||||
statusIdx: index('idx_reservations_status').on(table.status),
|
||||
}));
|
||||
|
||||
// 罚款记录表
|
||||
export const fines = pgTable('fines', {
|
||||
fineId: bigint('fine_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
studentId: bigint('student_id', { mode: 'number' }).references(() => students.studentId, { onDelete: 'cascade' }).notNull(),
|
||||
borrowId: bigint('borrow_id', { mode: 'number' }).references(() => borrowRecords.borrowId, { onDelete: 'cascade' }),
|
||||
fineType: varchar('fine_type', { length: 50 }).default('overdue').notNull(),
|
||||
amount: numeric('amount', { precision: 10, scale: 2 }).notNull(),
|
||||
reason: text('reason'),
|
||||
status: fineStatusEnum('status').default('unpaid').notNull(),
|
||||
paymentDate: timestamp('payment_date'),
|
||||
paymentMethod: varchar('payment_method', { length: 50 }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
studentIdIdx: index('idx_fines_student_id').on(table.studentId),
|
||||
statusIdx: index('idx_fines_status').on(table.status),
|
||||
}));
|
||||
|
||||
// 评价记录表
|
||||
export const reviews = pgTable('reviews', {
|
||||
reviewId: bigint('review_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
bookId: bigint('book_id', { mode: 'number' }).references(() => books.bookId, { onDelete: 'cascade' }).notNull(),
|
||||
studentId: bigint('student_id', { mode: 'number' }).references(() => students.studentId, { onDelete: 'cascade' }).notNull(),
|
||||
rating: integer('rating').notNull(),
|
||||
comment: text('comment'),
|
||||
reviewTime: timestamp('review_time').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
bookIdIdx: index('idx_reviews_book_id').on(table.bookId),
|
||||
studentIdIdx: index('idx_reviews_student_id').on(table.studentId),
|
||||
}));
|
||||
|
||||
// 关系定义
|
||||
export const booksRelations = relations(books, ({ many }) => ({
|
||||
borrowRecords: many(borrowRecords),
|
||||
reservations: many(reservations),
|
||||
reviews: many(reviews),
|
||||
}));
|
||||
|
||||
export const studentsRelations = relations(students, ({ many }) => ({
|
||||
borrowRecords: many(borrowRecords),
|
||||
reservations: many(reservations),
|
||||
fines: many(fines),
|
||||
reviews: many(reviews),
|
||||
}));
|
||||
|
||||
export const borrowRecordsRelations = relations(borrowRecords, ({ one, many }) => ({
|
||||
book: one(books, {
|
||||
fields: [borrowRecords.bookId],
|
||||
references: [books.bookId],
|
||||
}),
|
||||
student: one(students, {
|
||||
fields: [borrowRecords.studentId],
|
||||
references: [students.studentId],
|
||||
}),
|
||||
fines: many(fines),
|
||||
}));
|
||||
|
||||
export const reservationsRelations = relations(reservations, ({ one }) => ({
|
||||
book: one(books, {
|
||||
fields: [reservations.bookId],
|
||||
references: [books.bookId],
|
||||
}),
|
||||
student: one(students, {
|
||||
fields: [reservations.studentId],
|
||||
references: [students.studentId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const finesRelations = relations(fines, ({ one }) => ({
|
||||
student: one(students, {
|
||||
fields: [fines.studentId],
|
||||
references: [students.studentId],
|
||||
}),
|
||||
borrowRecord: one(borrowRecords, {
|
||||
fields: [fines.borrowId],
|
||||
references: [borrowRecords.borrowId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const reviewsRelations = relations(reviews, ({ one }) => ({
|
||||
book: one(books, {
|
||||
fields: [reviews.bookId],
|
||||
references: [books.bookId],
|
||||
}),
|
||||
student: one(students, {
|
||||
fields: [reviews.studentId],
|
||||
references: [students.studentId],
|
||||
}),
|
||||
}));
|
||||
211
src/lib/db/schema.ts
Normal file
@ -0,0 +1,211 @@
|
||||
import { pgTable, varchar, text, integer, numeric, timestamp, boolean, pgEnum, bigint, date, index, pgSchema } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
// 定义 library schema
|
||||
export const librarySchema = pgSchema('library');
|
||||
|
||||
// 枚举定义 - 使用 library schema,匹配实际数据库
|
||||
export const genderEnum = librarySchema.enum('gender_enum', ['M', 'F', 'O']);
|
||||
export const accountStatusEnum = librarySchema.enum('acct_status_enum', ['active', 'frozen', 'reported']);
|
||||
export const bookStatusEnum = librarySchema.enum('book_status_enum', ['normal', 'lost', 'damaged', 'removed']);
|
||||
export const borrowStatusEnum = librarySchema.enum('borrow_status_enum', ['borrowed', 'returned', 'overdue', 'lost']);
|
||||
export const reservationStatusEnum = librarySchema.enum('reserve_status_enum', ['waiting', 'available', 'expired', 'cancelled']);
|
||||
export const fineStatusEnum = librarySchema.enum('fine_status_enum', ['unpaid', 'paid']);
|
||||
|
||||
// 系统设置表
|
||||
export const systemSettings = librarySchema.table('system_settings', {
|
||||
settingKey: varchar('setting_key', { length: 50 }).primaryKey(),
|
||||
settingValue: text('setting_value').notNull(),
|
||||
description: text('description'),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// 管理员表
|
||||
export const admins = librarySchema.table('admins', {
|
||||
adminId: bigint('admin_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
empNo: varchar('emp_no', { length: 20 }).unique().notNull(),
|
||||
name: varchar('name', { length: 50 }).notNull(),
|
||||
position: varchar('position', { length: 50 }),
|
||||
phone: varchar('phone', { length: 20 }),
|
||||
email: varchar('email', { length: 100 }),
|
||||
passwordHash: varchar('password_hash', { length: 255 }),
|
||||
privilegeLv: integer('privilege_lv').default(2).notNull(),
|
||||
lastLogin: timestamp('last_login'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// 图书表 - 匹配实际数据库结构
|
||||
export const books = librarySchema.table('books', {
|
||||
bookId: bigint('book_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
isbn: varchar('isbn', { length: 13 }).unique().notNull(),
|
||||
title: text('title').notNull(),
|
||||
authors: text('authors').array().notNull(),
|
||||
publisher: text('publisher'),
|
||||
publishDate: date('publish_date'),
|
||||
price: numeric('price', { precision: 10, scale: 2 }),
|
||||
classificationNo: varchar('classification_no', { length: 64 }),
|
||||
location: varchar('location', { length: 255 }),
|
||||
totalCopies: integer('total_copies').default(1).notNull(),
|
||||
availableCopies: integer('available_copies').default(1).notNull(),
|
||||
status: bookStatusEnum('status').default('normal').notNull(),
|
||||
description: text('description'),
|
||||
coverUrl: text('cover_url'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
titleIdx: index('idx_books_title').on(table.title),
|
||||
authorsIdx: index('idx_books_authors').on(table.authors),
|
||||
isbnIdx: index('idx_books_isbn').on(table.isbn),
|
||||
}));
|
||||
|
||||
// 学生表 - 匹配实际数据库结构
|
||||
export const students = librarySchema.table('students', {
|
||||
studentId: bigint('student_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
stuNo: varchar('stu_no', { length: 20 }).unique().notNull(),
|
||||
name: varchar('name', { length: 64 }).notNull(),
|
||||
gender: genderEnum('gender'),
|
||||
department: varchar('department', { length: 128 }),
|
||||
major: varchar('major', { length: 128 }),
|
||||
grade: varchar('grade', { length: 10 }),
|
||||
class: varchar('class', { length: 50 }),
|
||||
phone: varchar('phone', { length: 20 }),
|
||||
email: varchar('email', { length: 255 }),
|
||||
accountStatus: accountStatusEnum('account_status').default('active').notNull(),
|
||||
maxBorrow: integer('max_borrow').notNull(),
|
||||
currentBorrow: integer('current_borrow').default(0).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
stuNoIdx: index('idx_students_stu_no').on(table.stuNo),
|
||||
nameIdx: index('idx_students_name').on(table.name),
|
||||
deptIdx: index('idx_students_department').on(table.department),
|
||||
}));
|
||||
|
||||
// 借阅记录表
|
||||
export const borrowRecords = librarySchema.table('borrow_records', {
|
||||
borrowId: bigint('borrow_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
bookId: bigint('book_id', { mode: 'number' }).references(() => books.bookId, { onDelete: 'cascade' }).notNull(),
|
||||
studentId: bigint('student_id', { mode: 'number' }).references(() => students.studentId, { onDelete: 'cascade' }).notNull(),
|
||||
borrowDate: date('borrow_date').defaultNow().notNull(),
|
||||
dueDate: date('due_date').notNull(),
|
||||
returnDate: date('return_date'),
|
||||
renewTimes: integer('renew_times').default(0).notNull(),
|
||||
status: borrowStatusEnum('status').default('borrowed').notNull(),
|
||||
fineAmount: numeric('fine_amount', { precision: 10, scale: 2 }).default('0').notNull(),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
bookIdIdx: index('idx_borrow_records_book_id').on(table.bookId),
|
||||
studentIdIdx: index('idx_borrow_records_student_id').on(table.studentId),
|
||||
statusIdx: index('idx_borrow_records_status').on(table.status),
|
||||
dueDateIdx: index('idx_borrow_records_due_date').on(table.dueDate),
|
||||
}));
|
||||
|
||||
// 预约记录表
|
||||
export const reservations = librarySchema.table('reservations', {
|
||||
reservationId: bigint('reservation_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
bookId: bigint('book_id', { mode: 'number' }).references(() => books.bookId, { onDelete: 'cascade' }).notNull(),
|
||||
studentId: bigint('student_id', { mode: 'number' }).references(() => students.studentId, { onDelete: 'cascade' }).notNull(),
|
||||
reserveDate: timestamp('reserve_date').defaultNow().notNull(),
|
||||
expiryDate: timestamp('expiry_date'),
|
||||
status: reservationStatusEnum('status').default('waiting').notNull(),
|
||||
notified: boolean('notified').default(false).notNull(),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
bookIdIdx: index('idx_reservations_book_id').on(table.bookId),
|
||||
studentIdIdx: index('idx_reservations_student_id').on(table.studentId),
|
||||
statusIdx: index('idx_reservations_status').on(table.status),
|
||||
}));
|
||||
|
||||
// 罚款记录表
|
||||
export const fines = librarySchema.table('fines', {
|
||||
fineId: bigint('fine_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
studentId: bigint('student_id', { mode: 'number' }).references(() => students.studentId, { onDelete: 'cascade' }).notNull(),
|
||||
borrowId: bigint('borrow_id', { mode: 'number' }).references(() => borrowRecords.borrowId, { onDelete: 'cascade' }),
|
||||
fineType: varchar('fine_type', { length: 50 }).default('overdue').notNull(),
|
||||
amount: numeric('amount', { precision: 10, scale: 2 }).notNull(),
|
||||
reason: text('reason'),
|
||||
status: fineStatusEnum('status').default('unpaid').notNull(),
|
||||
paymentDate: timestamp('payment_date'),
|
||||
paymentMethod: varchar('payment_method', { length: 50 }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
studentIdIdx: index('idx_fines_student_id').on(table.studentId),
|
||||
statusIdx: index('idx_fines_status').on(table.status),
|
||||
}));
|
||||
|
||||
// 评价记录表
|
||||
export const reviews = librarySchema.table('reviews', {
|
||||
reviewId: bigint('review_id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
bookId: bigint('book_id', { mode: 'number' }).references(() => books.bookId, { onDelete: 'cascade' }).notNull(),
|
||||
studentId: bigint('student_id', { mode: 'number' }).references(() => students.studentId, { onDelete: 'cascade' }).notNull(),
|
||||
rating: integer('rating').notNull(),
|
||||
comment: text('comment'),
|
||||
reviewTime: timestamp('review_time').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
bookIdIdx: index('idx_reviews_book_id').on(table.bookId),
|
||||
studentIdIdx: index('idx_reviews_student_id').on(table.studentId),
|
||||
}));
|
||||
|
||||
// 关系定义
|
||||
export const booksRelations = relations(books, ({ many }) => ({
|
||||
borrowRecords: many(borrowRecords),
|
||||
reservations: many(reservations),
|
||||
reviews: many(reviews),
|
||||
}));
|
||||
|
||||
export const studentsRelations = relations(students, ({ many }) => ({
|
||||
borrowRecords: many(borrowRecords),
|
||||
reservations: many(reservations),
|
||||
fines: many(fines),
|
||||
reviews: many(reviews),
|
||||
}));
|
||||
|
||||
export const borrowRecordsRelations = relations(borrowRecords, ({ one, many }) => ({
|
||||
book: one(books, {
|
||||
fields: [borrowRecords.bookId],
|
||||
references: [books.bookId],
|
||||
}),
|
||||
student: one(students, {
|
||||
fields: [borrowRecords.studentId],
|
||||
references: [students.studentId],
|
||||
}),
|
||||
fines: many(fines),
|
||||
}));
|
||||
|
||||
export const reservationsRelations = relations(reservations, ({ one }) => ({
|
||||
book: one(books, {
|
||||
fields: [reservations.bookId],
|
||||
references: [books.bookId],
|
||||
}),
|
||||
student: one(students, {
|
||||
fields: [reservations.studentId],
|
||||
references: [students.studentId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const finesRelations = relations(fines, ({ one }) => ({
|
||||
student: one(students, {
|
||||
fields: [fines.studentId],
|
||||
references: [students.studentId],
|
||||
}),
|
||||
borrowRecord: one(borrowRecords, {
|
||||
fields: [fines.borrowId],
|
||||
references: [borrowRecords.borrowId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const reviewsRelations = relations(reviews, ({ one }) => ({
|
||||
book: one(books, {
|
||||
fields: [reviews.bookId],
|
||||
references: [books.bookId],
|
||||
}),
|
||||
student: one(students, {
|
||||
fields: [reviews.studentId],
|
||||
references: [students.studentId],
|
||||
}),
|
||||
}));
|
||||
112
src/lib/types/index.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { books, students, borrowRecords, reservations, fines, reviews, admins } from '@/lib/db/schema';
|
||||
|
||||
// 数据库表类型
|
||||
export type Book = typeof books.$inferSelect;
|
||||
export type NewBook = typeof books.$inferInsert;
|
||||
|
||||
export type Student = typeof students.$inferSelect;
|
||||
export type NewStudent = typeof students.$inferInsert;
|
||||
|
||||
export type BorrowRecord = typeof borrowRecords.$inferSelect;
|
||||
export type NewBorrowRecord = typeof borrowRecords.$inferInsert;
|
||||
|
||||
export type Reservation = typeof reservations.$inferSelect;
|
||||
export type NewReservation = typeof reservations.$inferInsert;
|
||||
|
||||
export type Fine = typeof fines.$inferSelect;
|
||||
export type NewFine = typeof fines.$inferInsert;
|
||||
|
||||
export type Review = typeof reviews.$inferSelect;
|
||||
export type NewReview = typeof reviews.$inferInsert;
|
||||
|
||||
export type Admin = typeof admins.$inferSelect;
|
||||
export type NewAdmin = typeof admins.$inferInsert;
|
||||
|
||||
// 扩展类型,包含关联数据
|
||||
export type BookWithDetails = Book & {
|
||||
reviews?: Review[];
|
||||
borrowCount?: number;
|
||||
averageRating?: number;
|
||||
};
|
||||
|
||||
export type StudentWithDetails = Student & {
|
||||
borrowRecords?: BorrowRecord[];
|
||||
reservations?: Reservation[];
|
||||
fines?: Fine[];
|
||||
totalFines?: number;
|
||||
};
|
||||
|
||||
export type BorrowRecordWithDetails = BorrowRecord & {
|
||||
book?: Book;
|
||||
student?: Student;
|
||||
};
|
||||
|
||||
export type ReservationWithDetails = Reservation & {
|
||||
book?: Book;
|
||||
student?: Student;
|
||||
};
|
||||
|
||||
export type FineWithDetails = Fine & {
|
||||
student?: Student;
|
||||
borrowRecord?: BorrowRecord;
|
||||
};
|
||||
|
||||
// 用户会话类型
|
||||
export interface UserSession {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
role: 'admin' | 'student';
|
||||
studentId?: number;
|
||||
adminId?: number;
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 搜索过滤器类型
|
||||
export interface BookSearchFilters {
|
||||
title?: string;
|
||||
author?: string;
|
||||
isbn?: string;
|
||||
classificationNo?: string;
|
||||
status?: 'normal' | 'damaged' | 'removed';
|
||||
available?: boolean;
|
||||
}
|
||||
|
||||
export interface StudentSearchFilters {
|
||||
name?: string;
|
||||
stuNo?: string;
|
||||
department?: string;
|
||||
major?: string;
|
||||
accountStatus?: 'active' | 'frozen' | 'reported';
|
||||
}
|
||||
|
||||
// 统计数据类型
|
||||
export interface LibraryStats {
|
||||
totalBooks: number;
|
||||
totalStudents: number;
|
||||
totalBorrows: number;
|
||||
overdueBooks: number;
|
||||
reservations: number;
|
||||
unpaidFines: number;
|
||||
}
|
||||
|
||||
// 分页类型
|
||||
export interface PaginationParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
6
src/lib/utils/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
BIN
智能图书管理数据库应用系统.assets/image-20250621142515480.png
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250621142527092.png
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250621142536941.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250621142552089.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250621142558447.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250621142605643.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250621142629990.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250621143046826.png
Normal file
|
After Width: | Height: | Size: 273 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250621143212018.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250621143310581.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250621143344465.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250621143408899.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250624105000930.png
Normal file
|
After Width: | Height: | Size: 701 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250624105028874.png
Normal file
|
After Width: | Height: | Size: 406 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250624105126923.png
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250624105136013.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250624105225708.png
Normal file
|
After Width: | Height: | Size: 301 KiB |
BIN
智能图书管理数据库应用系统.assets/image-20250624105258938.png
Normal file
|
After Width: | Height: | Size: 264 KiB |