diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f68c36e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,213 @@ +# HKU ICB ClassHub Agent Guide + +本文件用于约束后续 agent 与开发者在本项目中的工作方式。目标是让每次改动都符合项目定位、权限模型、技术栈和现有设计语言,避免临时拼接式开发。 + +## 1. 项目定义 + +本项目是 HKU ICB Graduate Class Resource Platform,面向班级、教师、学生和班委的班级资源与协作系统。核心价值是围绕“班级”组织信息,提供可信、清晰、轻量的日常管理能力。 + +主要模块包括: + +- 登录、激活、账号审核与个人资料 +- 班级与成员管理 +- 通讯录、时间线、公告、课程日程、资源、作业、投票、班费 +- 通知、上传与外部存储 + +开发时必须优先保护以下产品原则: + +- 班级数据隔离优先,任何列表、详情、写入都要明确 class scope。 +- 权限优先于界面便利,不允许只靠前端隐藏按钮来保证安全。 +- 管理功能要清晰可审计,避免隐式批量修改。 +- 面向真实班级使用场景,界面应安静、稳定、易扫描,不做营销页式表达。 + +## 2. 技术栈与目录 + +仓库结构: + +- `backend/`:FastAPI 后端,SQLAlchemy async ORM,Alembic 迁移,SQLite 默认部署数据源。 +- `frontend/`:Next.js App Router 前端,React 19,TypeScript,Tailwind CSS 4,shadcn/base-ui 风格组件,lucide-react 图标。 +- `nginx/`:反向代理配置。 +- `docker-compose.yml`:本地或服务器编排,前端端口映射为 `3008:3000`,后端为 `8000`。 + +关键命令: + +- 前端开发:`cd frontend && npm run dev` +- 前端构建:`cd frontend && npm run build` +- 前端检查:`cd frontend && npm run lint` +- 后端运行:`cd backend && uvicorn app.main:app --reload` +- 后端迁移:`cd backend && alembic upgrade head` +- 整体容器:`docker compose up --build` + +注意:`frontend/AGENTS.md` 已明确提示 Next.js 版本存在重要变化。修改 Next.js 相关 API、路由、缓存、服务端/客户端组件边界前,应以本仓库安装版本与本地文档为准,不要凭旧版本记忆实现。 + +## 3. 架构约束 + +### 后端分层 + +后端代码按以下边界组织: + +- `app/api/`:HTTP 路由层,只处理请求参数、依赖注入、状态码与响应。 +- `app/services/`:业务逻辑层,承载跨模型操作、校验、查询组合和副作用。 +- `app/schemas/`:Pydantic 输入输出模型。 +- `app/db/models.py`:SQLAlchemy 数据模型。 +- `app/core/`:鉴权、依赖、权限与通用基础能力。 +- `app/config.py`:环境配置,统一使用 `CH_` 前缀。 + +开发规则: + +- 新业务优先放入 service,不要把复杂逻辑堆在 route handler。 +- 数据库访问使用 async SQLAlchemy,不引入同步 session。 +- 查询关系时优先使用 `selectinload` 等显式加载策略,避免隐式懒加载导致异步上下文问题。 +- 新增或修改表结构必须提供 Alembic migration,不允许只改 model。 +- 错误信息面向用户时要清晰,面向系统时要保留日志;不要把密钥、token、内部堆栈暴露给前端。 + +### 前端分层 + +前端代码按以下边界组织: + +- `src/app/`:页面与布局,App Router 路由入口。 +- `src/components/`:跨页面业务组件。 +- `src/components/ui/`:基础 UI 组件,尽量保持通用和低业务耦合。 +- `src/hooks/`:认证、通知、当前班级、侧边栏等状态逻辑。 +- `src/lib/`:API 客户端、类型、权限、常量、工具函数。 + +开发规则: + +- 复用 `src/lib/api.ts` 中的 `fetchAPI`、`postAPI`、`putAPI`、`deleteAPI`、`uploadAPI`,不要在页面中散落裸 `fetch`。 +- 复用 `src/lib/permissions.ts` 判断前端可见性,但后端仍必须做真实权限校验。 +- 能用已有 UI 组件就不要新写一套样式系统。 +- 表单提交、加载、空状态、错误状态、成功反馈都要完整处理。 +- 客户端组件只在需要浏览器状态、事件、localStorage、交互状态时使用。 + +## 4. 权限与数据边界 + +系统存在两层权限: + +- 全局角色:`super_admin`、`teacher`、`student` +- 班级内角色与权限:由 `ClassMembership` 的 `membership_role`、`committee_role`、`class_permissions` 控制 + +后端权限入口主要在 `app/core/deps.py`: + +- `get_current_user` +- `require_role` +- `resolve_class_id_for_user` +- `ensure_class_access` +- `ensure_class_permission` + +权限开发必须遵守: + +- 任何班级资源 API 都必须解析并校验 `class_id`。 +- student 只能访问自己所属班级的数据。 +- teacher 与 super_admin 的跨班级访问能力必须通过后端依赖或 service 显式表达。 +- 新增班级模块时,要同步更新后端 `CLASS_PERMISSIONS`、`TEACHER_DEFAULT_PERMISSIONS`,前端权限类型、常量、导航与模块启用逻辑。 +- 前端隐藏按钮只是体验优化,不是安全边界。 + +## 5. 设计规范 + +本项目不是营销网站,而是班级运营工具。界面应更像可靠的工作台:信息密度适中、层级清楚、操作明确。 + +视觉方向: + +- 采用现有 Tailwind 4 token 与 `globals.css` 中的主题变量。 +- 保持温暖、克制、学术感的视觉语气,不要突然引入高饱和大渐变或强装饰背景。 +- 图标优先使用 `lucide-react`。 +- 卡片、弹窗、表格、表单、按钮优先复用 `src/components/ui/`。 +- 中文界面文案要简洁、具体,避免“智能化”“一站式”等空泛营销词。 + +布局规则: + +- 后台页面优先服务浏览、筛选、创建、编辑、审核等真实任务。 +- 列表页必须考虑空状态、加载态、错误态和分页或数据量增长。 +- 管理页的危险操作必须有确认弹窗。 +- 移动端至少保证主要阅读、筛选和提交流程可用。 +- 不在工具型页面中加入大 hero、宣传区、装饰性卡片堆叠。 + +交互规则: + +- 成功、失败、权限不足、登录过期要给出明确反馈。 +- 上传、提交、删除、批量操作要有 pending 状态,避免重复提交。 +- 需要选择班级的页面,应与 `use-active-class` 等现有机制保持一致。 + +## 6. API 与数据契约 + +后端响应模型应由 `app/schemas/` 定义,前端类型应在 `src/lib/types.ts` 中保持同步。 + +约定: + +- API 路径统一放在 `/api/...` 下。 +- 新增字段时同时检查 model、schema、service、API、前端 type、表单、列表展示。 +- 日期时间字段使用明确语义,例如 `created_at`、`updated_at`、`start_time`、`end_time`。 +- JSON 字符串字段已有历史用法时,可沿用模型中的 helper 方法;新增复杂结构时优先评估是否需要独立表。 +- 不把数据库内部字段、密码 hash、验证码 hash、密钥配置返回给前端。 + +## 7. 数据库与迁移 + +数据库相关改动必须遵守: + +- 修改 `app/db/models.py` 后必须新增 Alembic migration。 +- migration 文件命名要包含日期或清晰语义。 +- migration 要能从旧库升级,不假设空库。 +- 对生产已有数据的字段变更要考虑 backfill、默认值和 nullable 策略。 +- 不直接删除历史字段或表,除非确认没有线上依赖,并在 migration 中清楚表达。 + +SQLite 是当前部署默认数据库,避免使用 SQLite 不支持或行为差异明显的 SQL 特性,除非同时处理兼容方案。 + +## 8. 安全规范 + +- 密钥、SMTP、COS、JWT 等配置只通过 `.env` 和环境变量提供,不提交真实值。 +- JWT secret 生产环境必须覆盖默认值。 +- 上传能力必须限制文件类型、大小与访问路径,避免任意文件执行或路径穿越。 +- 邮箱验证码、密码、token 等敏感值只存 hash 或安全派生值。 +- 日志中不得打印密码、验证码、完整 token、COS secret。 +- CORS 配置应随部署域名收敛,不为方便调试长期开放任意来源。 + +## 9. 开发工作流 + +开始修改前: + +- 先读相关 route、service、schema、model、前端 page/component/type。 +- 确认是否已有相同模式,优先复用。 +- 用 `rg` 搜索模块名、权限名、API 路径和类型定义,避免漏改。 + +实现时: + +- 小步提交代码,保持每次改动有明确目的。 +- 不做与需求无关的大重构。 +- 不删除用户已有改动。 +- 不引入新依赖,除非能明显降低复杂度,并更新安装与构建说明。 +- Python 代码遵循当前项目风格和类型标注;TypeScript 避免 `any`,优先定义明确类型。 + +交付前至少检查: + +- 前端:`npm run lint`,重要 UI 改动再跑 `npm run build`。 +- 后端:能启动应用;涉及数据库时跑迁移;核心 service 改动要用脚本或测试验证主路径。 +- API 契约:前后端字段、错误处理、权限处理一致。 +- 浏览器体验:关键页面没有明显布局破损、重复提交、空白状态。 + +## 10. 新功能 Checklist + +新增一个业务模块时,按顺序检查: + +- 数据模型是否需要新增表或字段。 +- Alembic migration 是否完整。 +- Pydantic schema 是否覆盖创建、更新、读取。 +- service 是否包含权限、班级边界、业务校验。 +- API router 是否挂载到 `app/main.py`。 +- 前端 type 是否同步。 +- API client 调用是否使用 `src/lib/api.ts`。 +- 页面是否接入侧边栏、权限可见性、模块启用状态。 +- 是否有加载、空、错、成功状态。 +- 是否需要通知、上传、分页或搜索。 +- 是否更新常量、权限集合和文案。 + +## 11. Agent 行为准则 + +后续 agent 在本仓库工作时,应遵守: + +- 用中文与项目维护者沟通,技术名词可保留英文。 +- 先理解项目边界,再动手改代码。 +- 发现需求与权限、安全、数据隔离冲突时,主动指出并给出更安全的实现。 +- 能直接完成的任务直接实现,不只给建议。 +- 修改前说明将改哪些区域;修改后说明改了什么、如何验证、还剩什么风险。 +- 遇到 Next.js、React、FastAPI、SQLAlchemy 等版本敏感问题时,以本仓库依赖版本和官方文档为准。 +- 保持代码和文档简洁,避免过度抽象。 diff --git a/backend/.env.example b/backend/.env.example index 367fb5c..a028485 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,6 +17,9 @@ CH_COS_REGION=ap-guangzhou CH_COS_BUCKET=your-bucket-name CH_COS_BASE_URL=https://your-bucket.cos.ap-guangzhou.myqcloud.com +# Book metadata (Google Books API) +CH_GOOGLE_BOOKS_API_KEY=your-google-books-api-key + # SMTP Email (邮件通知) CH_SMTP_HOST=smtp.example.com CH_SMTP_PORT=465 diff --git a/backend/alembic/versions/20260501_add_reading_corner.py b/backend/alembic/versions/20260501_add_reading_corner.py new file mode 100644 index 0000000..037bab2 --- /dev/null +++ b/backend/alembic/versions/20260501_add_reading_corner.py @@ -0,0 +1,91 @@ +"""add reading corner + +Revision ID: 20260501_add_reading_corner +Revises: 20260427_add_email_codes +Create Date: 2026-05-01 12:00:00 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260501_add_reading_corner" +down_revision = "20260427_add_email_codes" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + + if "reading_books" not in tables: + op.create_table( + "reading_books", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("class_id", sa.Integer(), nullable=False), + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("author", sa.String(length=200), nullable=True), + sa.Column("cover_url", sa.Text(), nullable=True), + sa.Column("total_pages", sa.Integer(), nullable=False, server_default="0"), + sa.Column("current_page", sa.Integer(), nullable=False, server_default="0"), + sa.Column("status", sa.String(length=20), nullable=False, server_default="reading"), + sa.Column("started_at", sa.Date(), nullable=True), + sa.Column("finished_at", sa.Date(), nullable=True), + sa.Column("personal_note", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["class_id"], ["classes.id"]), + sa.ForeignKeyConstraint(["owner_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_reading_books_class_id", "reading_books", ["class_id"]) + op.create_index("ix_reading_books_owner_id", "reading_books", ["owner_id"]) + + tables = set(sa.inspect(bind).get_table_names()) + if "reading_notes" not in tables: + op.create_table( + "reading_notes", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("book_id", sa.Integer(), nullable=False), + sa.Column("author_id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("page_ref", sa.String(length=100), nullable=True), + sa.Column("visibility", sa.String(length=20), nullable=False, server_default="class"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["author_id"], ["users.id"]), + sa.ForeignKeyConstraint(["book_id"], ["reading_books.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_reading_notes_author_id", "reading_notes", ["author_id"]) + op.create_index("ix_reading_notes_book_id", "reading_notes", ["book_id"]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + + if "reading_notes" in tables: + indexes = {index["name"] for index in inspector.get_indexes("reading_notes")} + if "ix_reading_notes_author_id" in indexes: + op.drop_index("ix_reading_notes_author_id", table_name="reading_notes") + if "ix_reading_notes_book_id" in indexes: + op.drop_index("ix_reading_notes_book_id", table_name="reading_notes") + op.drop_table("reading_notes") + + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + if "reading_books" in tables: + indexes = {index["name"] for index in inspector.get_indexes("reading_books")} + if "ix_reading_books_owner_id" in indexes: + op.drop_index("ix_reading_books_owner_id", table_name="reading_books") + if "ix_reading_books_class_id" in indexes: + op.drop_index("ix_reading_books_class_id", table_name="reading_books") + op.drop_table("reading_books") diff --git a/backend/app/api/reading.py b/backend/app/api/reading.py new file mode 100644 index 0000000..00f33fe --- /dev/null +++ b/backend/app/api/reading.py @@ -0,0 +1,341 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import ( + ensure_class_access, + get_effective_class_permissions, + require_role, + resolve_class_id_for_user, +) +from app.db.database import get_db +from app.db.models import ReadingBook, ReadingNote, User +from app.schemas.common import PageResponse +from app.schemas.reading import ( + ReadingBookCreate, + ReadingBookOut, + ReadingBookUpdate, + ReadingFeedItem, + ReadingNoteCreate, + ReadingNoteOut, + ReadingNoteUpdate, + ReadingRankingItem, + ReadingRankingResponse, + ReadingSummary, +) +from app.services.reading_service import ( + create_book, + create_note, + delete_book, + delete_note, + get_book_by_id, + get_note_by_id, + get_ranking_rows, + get_summary, + schedule_cover_fetch, + list_feed_items, + list_books, + list_notes, + update_book, + update_note, +) + +router = APIRouter(prefix="/api/reading", tags=["reading"]) + + +def _can_manage(user: User, class_id: int) -> bool: + return "reading_corner_manage" in get_effective_class_permissions(user, class_id) + + +def _book_to_out(book: ReadingBook) -> ReadingBookOut: + notes = book.notes or [] + return ReadingBookOut( + id=book.id, + class_id=book.class_id, + owner_id=book.owner_id, + owner_name=book.owner.name if book.owner else "Unknown", + title=book.title, + author=book.author, + cover_url=book.cover_url, + total_pages=book.total_pages, + current_page=book.current_page, + status=book.status, + started_at=book.started_at, + finished_at=book.finished_at, + personal_note=book.personal_note, + note_count=len(notes), + public_note_count=len([note for note in notes if note.visibility == "class"]), + created_at=book.created_at, + updated_at=book.updated_at, + ) + + +def _note_to_out(note: ReadingNote) -> ReadingNoteOut: + return ReadingNoteOut( + id=note.id, + book_id=note.book_id, + book_title=note.book.title if note.book else "Unknown", + book_author=note.book.author if note.book else None, + author_id=note.author_id, + author_name=note.author.name if note.author else "Unknown", + title=note.title, + content=note.content, + page_ref=note.page_ref, + visibility=note.visibility, + created_at=note.created_at, + updated_at=note.updated_at, + ) + + +@router.get("/summary", response_model=ReadingSummary) +async def get_my_reading_summary( + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = resolve_class_id_for_user(user, class_id) + if effective_class_id is None: + return ReadingSummary( + reading_count=0, finished_count=0, total_pages_read=0, month_score=0 + ) + ensure_class_access(user, effective_class_id) + return ReadingSummary(**await get_summary(db, effective_class_id, user.id)) + + +@router.get("/books", response_model=PageResponse[ReadingBookOut]) +async def get_books( + page: int = 1, + page_size: int = 20, + status: str | None = None, + owner: str | None = None, + search: str | None = None, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = resolve_class_id_for_user(user, class_id) + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + ensure_class_access(user, effective_class_id) + + owner_id = user.id if owner == "me" else None + books, total = await list_books( + db, effective_class_id, page, page_size, status, owner_id, search + ) + return PageResponse( + items=[_book_to_out(book) for book in books], + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size, + ) + + +@router.get("/feed", response_model=PageResponse[ReadingFeedItem]) +async def get_feed( + page: int = 1, + page_size: int = 20, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = resolve_class_id_for_user(user, class_id) + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + ensure_class_access(user, effective_class_id) + + items, total = await list_feed_items(db, effective_class_id, page, page_size) + return PageResponse( + items=[ReadingFeedItem(**item) for item in items], + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size, + ) + + +@router.post("/books", response_model=ReadingBookOut) +async def create_new_book( + data: ReadingBookCreate, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = resolve_class_id_for_user(user, class_id) + if effective_class_id is None: + raise HTTPException(status_code=400, detail="You are not assigned to a class") + ensure_class_access(user, effective_class_id) + try: + book = await create_book(db, effective_class_id, user.id, data) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + loaded = await get_book_by_id(db, book.id) + if not (loaded or book).cover_url: + schedule_cover_fetch(book.id) + return _book_to_out(loaded or book) + + +@router.put("/books/{book_id}", response_model=ReadingBookOut) +async def update_existing_book( + book_id: int, + data: ReadingBookUpdate, + user: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + book = await get_book_by_id(db, book_id) + if book is None: + raise HTTPException(status_code=404, detail="Book not found") + ensure_class_access(user, book.class_id) + if book.owner_id != user.id and not _can_manage(user, book.class_id): + raise HTTPException(status_code=403, detail="Access denied") + try: + updated = await update_book(db, book, data) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + loaded = await get_book_by_id(db, updated.id) + if loaded and not loaded.cover_url: + schedule_cover_fetch(loaded.id) + return _book_to_out(loaded or updated) + + +@router.delete("/books/{book_id}") +async def delete_existing_book( + book_id: int, + user: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + book = await get_book_by_id(db, book_id) + if book is None: + raise HTTPException(status_code=404, detail="Book not found") + ensure_class_access(user, book.class_id) + if book.owner_id != user.id and not _can_manage(user, book.class_id): + raise HTTPException(status_code=403, detail="Access denied") + await delete_book(db, book) + return {"message": "Book deleted"} + + +@router.post("/books/{book_id}/cover/refresh") +async def refresh_book_cover( + book_id: int, + user: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + book = await get_book_by_id(db, book_id) + if book is None: + raise HTTPException(status_code=404, detail="Book not found") + ensure_class_access(user, book.class_id) + if book.owner_id != user.id and not _can_manage(user, book.class_id): + raise HTTPException(status_code=403, detail="Access denied") + if book.cover_url: + return {"message": "Book already has a cover", "cover_url": book.cover_url} + + schedule_cover_fetch(book.id) + return {"message": "Cover refresh scheduled"} + + +@router.get("/notes", response_model=PageResponse[ReadingNoteOut]) +async def get_notes( + page: int = 1, + page_size: int = 20, + book_id: int | None = None, + author_id: int | None = None, + include_private: bool = False, + search: str | None = None, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = resolve_class_id_for_user(user, class_id) + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + ensure_class_access(user, effective_class_id) + + notes, total = await list_notes( + db, + effective_class_id, + user.id, + page, + page_size, + book_id, + author_id, + include_private, + search, + ) + return PageResponse( + items=[_note_to_out(note) for note in notes], + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size, + ) + + +@router.post("/books/{book_id}/notes", response_model=ReadingNoteOut) +async def create_new_note( + book_id: int, + data: ReadingNoteCreate, + user: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + book = await get_book_by_id(db, book_id) + if book is None: + raise HTTPException(status_code=404, detail="Book not found") + ensure_class_access(user, book.class_id) + if book.owner_id != user.id and not _can_manage(user, book.class_id): + raise HTTPException(status_code=403, detail="Access denied") + note = await create_note(db, book, user.id, data) + loaded = await get_note_by_id(db, note.id) + return _note_to_out(loaded or note) + + +@router.put("/notes/{note_id}", response_model=ReadingNoteOut) +async def update_existing_note( + note_id: int, + data: ReadingNoteUpdate, + user: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + note = await get_note_by_id(db, note_id) + if note is None: + raise HTTPException(status_code=404, detail="Note not found") + ensure_class_access(user, note.book.class_id) + if note.author_id != user.id and not _can_manage(user, note.book.class_id): + raise HTTPException(status_code=403, detail="Access denied") + updated = await update_note(db, note, data) + loaded = await get_note_by_id(db, updated.id) + return _note_to_out(loaded or updated) + + +@router.delete("/notes/{note_id}") +async def delete_existing_note( + note_id: int, + user: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + note = await get_note_by_id(db, note_id) + if note is None: + raise HTTPException(status_code=404, detail="Note not found") + ensure_class_access(user, note.book.class_id) + if note.author_id != user.id and not _can_manage(user, note.book.class_id): + raise HTTPException(status_code=403, detail="Access denied") + await delete_note(db, note) + return {"message": "Note deleted"} + + +@router.get("/rankings", response_model=ReadingRankingResponse) +async def get_rankings( + period: str = "month", + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + if period not in {"month", "all"}: + raise HTTPException(status_code=400, detail="Period must be month or all") + effective_class_id = resolve_class_id_for_user(user, class_id) + if effective_class_id is None: + return ReadingRankingResponse(period=period, items=[]) + ensure_class_access(user, effective_class_id) + rows = await get_ranking_rows(db, effective_class_id, period=period) + return ReadingRankingResponse( + period=period, + items=[ReadingRankingItem(**row) for row in rows], + ) diff --git a/backend/app/config.py b/backend/app/config.py index dca2318..2b5831c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -22,6 +22,9 @@ class Settings(BaseSettings): cos_bucket: str = "" cos_base_url: str = "" + # Book metadata + google_books_api_key: str = "" + # SMTP Email smtp_host: str = "" smtp_port: int = 465 diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index 764b356..f6376c9 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -19,6 +19,7 @@ CLASS_PERMISSIONS = { "vote_manage", "schedule_manage", "resource_manage", + "reading_corner_manage", "assignment_manage", "fund_manage", "module_manage", @@ -33,6 +34,7 @@ TEACHER_DEFAULT_PERMISSIONS = { "vote_manage", "schedule_manage", "resource_manage", + "reading_corner_manage", "assignment_manage", "module_manage", } diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 97e8768..6f0987f 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1,5 +1,5 @@ import json -from datetime import datetime +from datetime import date, datetime from sqlalchemy import String, Text, Integer, DateTime, Boolean, ForeignKey, Float, Date, func, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -22,7 +22,7 @@ class Class_(Base): ) # All available modules - ALL_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources", "fund"] + ALL_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources", "reading_corner", "fund"] def get_enabled_modules(self) -> list[str]: if not self.enabled_modules: @@ -50,6 +50,9 @@ class Class_(Base): resources: Mapped[list["Resource"]] = relationship( "Resource", back_populates="class_", cascade="all, delete-orphan" ) + reading_books: Mapped[list["ReadingBook"]] = relationship( + "ReadingBook", back_populates="class_", cascade="all, delete-orphan" + ) assignments: Mapped[list["Assignment"]] = relationship( "Assignment", back_populates="class_", cascade="all, delete-orphan" ) @@ -105,6 +108,12 @@ class User(Base): created_votes: Mapped[list["Vote"]] = relationship( "Vote", back_populates="creator" ) + reading_books: Mapped[list["ReadingBook"]] = relationship( + "ReadingBook", back_populates="owner" + ) + reading_notes: Mapped[list["ReadingNote"]] = relationship( + "ReadingNote", back_populates="author" + ) def get_skills_list(self) -> list[str]: if not self.skills_tags: @@ -315,6 +324,60 @@ class Notification(Base): user: Mapped["User"] = relationship("User") +class ReadingBook(Base): + __tablename__ = "reading_books" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + owner_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False, index=True + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + author: Mapped[str | None] = mapped_column(String(200), nullable=True) + cover_url: Mapped[str | None] = mapped_column(Text, nullable=True) + total_pages: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + current_page: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + status: Mapped[str] = mapped_column(String(20), default="reading", nullable=False) + started_at: Mapped[date | None] = mapped_column(Date, nullable=True) + finished_at: Mapped[date | None] = mapped_column(Date, nullable=True) + personal_note: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="reading_books") + owner: Mapped["User"] = relationship("User", back_populates="reading_books") + notes: Mapped[list["ReadingNote"]] = relationship( + "ReadingNote", back_populates="book", cascade="all, delete-orphan" + ) + + +class ReadingNote(Base): + __tablename__ = "reading_notes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + book_id: Mapped[int] = mapped_column( + Integer, ForeignKey("reading_books.id"), nullable=False, index=True + ) + author_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False, index=True + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + page_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + visibility: Mapped[str] = mapped_column(String(20), default="class", nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + book: Mapped["ReadingBook"] = relationship("ReadingBook", back_populates="notes") + author: Mapped["User"] = relationship("User", back_populates="reading_notes") + + class TimelineLike(Base): __tablename__ = "timeline_likes" diff --git a/backend/app/main.py b/backend/app/main.py index 0a3a9b7..3949b4b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments, fund +from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments, reading, fund logging.basicConfig( level=logging.DEBUG if settings.debug else logging.INFO, @@ -69,6 +69,7 @@ app.include_router(resources.router) app.include_router(notifications.router) app.include_router(votes.router) app.include_router(assignments.router) +app.include_router(reading.router) app.include_router(fund.router) diff --git a/backend/app/schemas/reading.py b/backend/app/schemas/reading.py new file mode 100644 index 0000000..152ef7a --- /dev/null +++ b/backend/app/schemas/reading.py @@ -0,0 +1,151 @@ +from datetime import date, datetime + +from pydantic import BaseModel, field_validator + + +READING_STATUSES = {"reading", "finished", "paused", "wishlist"} +NOTE_VISIBILITIES = {"class", "private"} + + +class ReadingBookCreate(BaseModel): + title: str + author: str | None = None + cover_url: str | None = None + total_pages: int = 0 + current_page: int = 0 + status: str = "reading" + started_at: date | None = None + finished_at: date | None = None + personal_note: str | None = None + + @field_validator("status") + @classmethod + def validate_status(cls, value: str) -> str: + if value not in READING_STATUSES: + raise ValueError("Invalid reading status") + return value + + +class ReadingBookUpdate(BaseModel): + title: str | None = None + author: str | None = None + cover_url: str | None = None + total_pages: int | None = None + current_page: int | None = None + status: str | None = None + started_at: date | None = None + finished_at: date | None = None + personal_note: str | None = None + + @field_validator("status") + @classmethod + def validate_status(cls, value: str | None) -> str | None: + if value is not None and value not in READING_STATUSES: + raise ValueError("Invalid reading status") + return value + + +class ReadingBookOut(BaseModel): + id: int + class_id: int + owner_id: int + owner_name: str + title: str + author: str | None + cover_url: str | None + total_pages: int + current_page: int + status: str + started_at: date | None + finished_at: date | None + personal_note: str | None + note_count: int + public_note_count: int + created_at: datetime + updated_at: datetime + + +class ReadingNoteCreate(BaseModel): + title: str + content: str + page_ref: str | None = None + visibility: str = "class" + + @field_validator("visibility") + @classmethod + def validate_visibility(cls, value: str) -> str: + if value not in NOTE_VISIBILITIES: + raise ValueError("Invalid note visibility") + return value + + +class ReadingNoteUpdate(BaseModel): + title: str | None = None + content: str | None = None + page_ref: str | None = None + visibility: str | None = None + + @field_validator("visibility") + @classmethod + def validate_visibility(cls, value: str | None) -> str | None: + if value is not None and value not in NOTE_VISIBILITIES: + raise ValueError("Invalid note visibility") + return value + + +class ReadingNoteOut(BaseModel): + id: int + book_id: int + book_title: str + book_author: str | None + author_id: int + author_name: str + title: str + content: str + page_ref: str | None + visibility: str + created_at: datetime + updated_at: datetime + + +class ReadingSummary(BaseModel): + reading_count: int + finished_count: int + total_pages_read: int + month_score: int + + +class ReadingRankingItem(BaseModel): + user_id: int + user_name: str + score: int + pages_read: int + finished_books: int + public_notes: int + private_notes: int + + +class ReadingRankingResponse(BaseModel): + period: str + items: list[ReadingRankingItem] + + +class ReadingFeedItem(BaseModel): + id: str + type: str + class_id: int + user_id: int + user_name: str + book_id: int + book_title: str + book_author: str | None + book_cover_url: str | None + status: str + current_page: int + total_pages: int + progress: int + note_id: int | None = None + note_title: str | None = None + note_excerpt: str | None = None + page_ref: str | None = None + created_at: datetime diff --git a/backend/app/services/reading_service.py b/backend/app/services/reading_service.py new file mode 100644 index 0000000..903ed93 --- /dev/null +++ b/backend/app/services/reading_service.py @@ -0,0 +1,484 @@ +import asyncio +import logging +from datetime import date, datetime + +import httpx +from sqlalchemy import case, func, or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.config import settings +from app.db.database import async_session +from app.db.models import ClassMembership, ReadingBook, ReadingNote, User +from app.schemas.reading import ReadingBookCreate, ReadingBookUpdate, ReadingNoteCreate, ReadingNoteUpdate +from app.services.cos_service import upload_image + +logger = logging.getLogger(__name__) + +GOOGLE_BOOKS_API = "https://www.googleapis.com/books/v1/volumes" +GOOGLE_BOOKS_HEADERS = {"User-Agent": "HKU-ICB-ClassHub/1.0"} + + +def _month_start() -> datetime: + today = date.today() + return datetime(today.year, today.month, 1) + + +def validate_book_pages(total_pages: int, current_page: int): + if total_pages < 0: + raise ValueError("Total pages cannot be negative") + if current_page < 0: + raise ValueError("Current page cannot be negative") + if total_pages and current_page > total_pages: + raise ValueError("Current page cannot exceed total pages") + + +async def create_book( + db: AsyncSession, class_id: int, owner_id: int, data: ReadingBookCreate +) -> ReadingBook: + validate_book_pages(data.total_pages, data.current_page) + book = ReadingBook(class_id=class_id, owner_id=owner_id, **data.model_dump()) + db.add(book) + await db.commit() + await db.refresh(book) + return book + + +async def update_book(db: AsyncSession, book: ReadingBook, data: ReadingBookUpdate) -> ReadingBook: + values = data.model_dump(exclude_unset=True) + total_pages = values.get("total_pages", book.total_pages) + current_page = values.get("current_page", book.current_page) + if "current_page" in values and current_page < book.current_page: + raise ValueError("Current page can only increase") + validate_book_pages(total_pages, current_page) + + for field, value in values.items(): + setattr(book, field, value) + await db.commit() + await db.refresh(book) + return book + + +def schedule_cover_fetch(book_id: int): + asyncio.create_task(_safe_fetch_and_store_cover(book_id)) + + +async def _safe_fetch_and_store_cover(book_id: int): + try: + await fetch_and_store_cover(book_id) + except Exception as exc: + logger.warning("Failed to fetch reading book cover for %s: %s", book_id, exc) + + +async def fetch_and_store_cover(book_id: int): + if not (settings.cos_bucket and settings.cos_base_url): + logger.info("Skip reading cover fetch because COS is not configured") + return + + async with async_session() as db: + book = await get_book_by_id(db, book_id) + if book is None or book.cover_url: + return + + source_url = await find_cover_url(book.title, book.author) + if not source_url: + return + + data, content_type = await download_cover(source_url) + cos_url = await asyncio.to_thread( + upload_image, + f"reading-covers/{book.class_id}/{book.id}", + "cover.jpg", + data, + content_type, + ) + await db.execute( + update(ReadingBook) + .where( + ReadingBook.id == book.id, + or_(ReadingBook.cover_url == None, ReadingBook.cover_url == ""), + ) + .values(cover_url=cos_url) + ) + await db.commit() + + +async def find_cover_url(title: str, author: str | None = None) -> str | None: + query_parts = [f"intitle:{title}"] + if author: + query_parts.append(f"inauthor:{author}") + + async with httpx.AsyncClient(timeout=12, follow_redirects=True) as client: + params = { + "q": " ".join(query_parts), + "maxResults": "5", + "printType": "books", + } + if settings.google_books_api_key: + params["key"] = settings.google_books_api_key + try: + response = await client.get( + GOOGLE_BOOKS_API, + params=params, + headers=GOOGLE_BOOKS_HEADERS, + ) + response.raise_for_status() + except httpx.HTTPError as exc: + logger.info("Google Books cover lookup failed for %s: %s", title, exc) + return None + data = response.json() + + for item in data.get("items", []): + image_links = item.get("volumeInfo", {}).get("imageLinks", {}) + cover_url = image_links.get("thumbnail") or image_links.get("smallThumbnail") + if cover_url: + return str(cover_url).replace("http://", "https://") + return None + + +async def download_cover(url: str) -> tuple[bytes, str]: + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + response = await client.get(url, headers=GOOGLE_BOOKS_HEADERS) + response.raise_for_status() + content_type = response.headers.get("content-type", "image/jpeg").split(";")[0] + if content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}: + content_type = "image/jpeg" + return response.content, content_type + + +async def delete_book(db: AsyncSession, book: ReadingBook): + await db.delete(book) + await db.commit() + + +async def get_book_by_id(db: AsyncSession, book_id: int) -> ReadingBook | None: + result = await db.execute( + select(ReadingBook) + .options( + selectinload(ReadingBook.owner), + selectinload(ReadingBook.notes), + ) + .where(ReadingBook.id == book_id) + ) + return result.scalar_one_or_none() + + +async def list_books( + db: AsyncSession, + class_id: int, + page: int = 1, + page_size: int = 20, + status: str | None = None, + owner_id: int | None = None, + search: str | None = None, +) -> tuple[list[ReadingBook], int]: + filters = [ReadingBook.class_id == class_id] + if status and status != "all": + filters.append(ReadingBook.status == status) + if owner_id: + filters.append(ReadingBook.owner_id == owner_id) + if search: + pattern = f"%{search}%" + filters.append(or_(ReadingBook.title.ilike(pattern), ReadingBook.author.ilike(pattern))) + + count_result = await db.execute(select(func.count(ReadingBook.id)).where(*filters)) + total = count_result.scalar() or 0 + + result = await db.execute( + select(ReadingBook) + .options( + selectinload(ReadingBook.owner), + selectinload(ReadingBook.notes), + ) + .where(*filters) + .order_by(ReadingBook.updated_at.desc(), ReadingBook.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().all()), total + + +async def create_note( + db: AsyncSession, book: ReadingBook, author_id: int, data: ReadingNoteCreate +) -> ReadingNote: + note = ReadingNote(book_id=book.id, author_id=author_id, **data.model_dump()) + db.add(note) + await db.commit() + await db.refresh(note) + return note + + +async def update_note(db: AsyncSession, note: ReadingNote, data: ReadingNoteUpdate) -> ReadingNote: + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(note, field, value) + await db.commit() + await db.refresh(note) + return note + + +async def delete_note(db: AsyncSession, note: ReadingNote): + await db.delete(note) + await db.commit() + + +async def get_note_by_id(db: AsyncSession, note_id: int) -> ReadingNote | None: + result = await db.execute( + select(ReadingNote) + .options( + selectinload(ReadingNote.author), + selectinload(ReadingNote.book).selectinload(ReadingBook.owner), + ) + .where(ReadingNote.id == note_id) + ) + return result.scalar_one_or_none() + + +async def list_notes( + db: AsyncSession, + class_id: int, + viewer_id: int, + page: int = 1, + page_size: int = 20, + book_id: int | None = None, + author_id: int | None = None, + include_private: bool = False, + search: str | None = None, +) -> tuple[list[ReadingNote], int]: + filters = [ReadingBook.class_id == class_id] + if include_private: + filters.append(or_(ReadingNote.visibility == "class", ReadingNote.author_id == viewer_id)) + else: + filters.append(ReadingNote.visibility == "class") + if book_id: + filters.append(ReadingNote.book_id == book_id) + if author_id: + filters.append(ReadingNote.author_id == author_id) + if search: + pattern = f"%{search}%" + filters.append(or_(ReadingBook.title.ilike(pattern), ReadingNote.title.ilike(pattern))) + + base = select(ReadingNote).join(ReadingBook).where(*filters) + count_result = await db.execute( + select(func.count(ReadingNote.id)).join(ReadingBook).where(*filters) + ) + total = count_result.scalar() or 0 + + result = await db.execute( + base.options( + selectinload(ReadingNote.author), + selectinload(ReadingNote.book), + ) + .order_by(ReadingNote.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().all()), total + + +async def get_summary(db: AsyncSession, class_id: int, user_id: int) -> dict: + books_result = await db.execute( + select( + func.coalesce(func.sum(ReadingBook.current_page), 0), + func.sum(case((ReadingBook.status == "reading", 1), else_=0)), + func.sum(case((ReadingBook.status == "finished", 1), else_=0)), + ).where(ReadingBook.class_id == class_id, ReadingBook.owner_id == user_id) + ) + total_pages, reading_count, finished_count = books_result.one() + score_rows = await get_ranking_rows(db, class_id, period="month", user_id=user_id) + month_score = score_rows[0]["score"] if score_rows else 0 + return { + "reading_count": int(reading_count or 0), + "finished_count": int(finished_count or 0), + "total_pages_read": int(total_pages or 0), + "month_score": int(month_score or 0), + } + + +def _book_progress(book: ReadingBook) -> int: + if not book.total_pages: + return 100 if book.current_page > 0 else 0 + return min(100, round((book.current_page / book.total_pages) * 100)) + + +def _note_excerpt(content: str, limit: int = 140) -> str: + normalized = " ".join(content.split()) + return normalized if len(normalized) <= limit else f"{normalized[:limit]}..." + + +async def list_feed_items( + db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20 +) -> tuple[list[dict], int]: + book_result = await db.execute( + select(ReadingBook) + .options(selectinload(ReadingBook.owner)) + .where(ReadingBook.class_id == class_id) + .order_by(ReadingBook.updated_at.desc()) + ) + note_result = await db.execute( + select(ReadingNote) + .join(ReadingBook) + .options( + selectinload(ReadingNote.author), + selectinload(ReadingNote.book), + ) + .where( + ReadingBook.class_id == class_id, + ReadingNote.visibility == "class", + ) + .order_by(ReadingNote.created_at.desc()) + ) + + items: list[dict] = [] + for book in book_result.scalars().all(): + event_type = "finished" if book.status == "finished" else "progress" + items.append( + { + "id": f"{event_type}:{book.id}", + "type": event_type, + "class_id": book.class_id, + "user_id": book.owner_id, + "user_name": book.owner.name if book.owner else "Unknown", + "book_id": book.id, + "book_title": book.title, + "book_author": book.author, + "book_cover_url": book.cover_url, + "status": book.status, + "current_page": book.current_page, + "total_pages": book.total_pages, + "progress": _book_progress(book), + "note_id": None, + "note_title": None, + "note_excerpt": None, + "page_ref": None, + "created_at": book.updated_at, + } + ) + + for note in note_result.scalars().all(): + book = note.book + items.append( + { + "id": f"note:{note.id}", + "type": "note", + "class_id": book.class_id, + "user_id": note.author_id, + "user_name": note.author.name if note.author else "Unknown", + "book_id": note.book_id, + "book_title": book.title, + "book_author": book.author, + "book_cover_url": book.cover_url, + "status": book.status, + "current_page": book.current_page, + "total_pages": book.total_pages, + "progress": _book_progress(book), + "note_id": note.id, + "note_title": note.title, + "note_excerpt": _note_excerpt(note.content), + "page_ref": note.page_ref, + "created_at": note.created_at, + } + ) + + items.sort(key=lambda item: item["created_at"], reverse=True) + total = len(items) + start = (page - 1) * page_size + end = start + page_size + return items[start:end], total + + +async def get_ranking_rows( + db: AsyncSession, class_id: int, period: str = "month", user_id: int | None = None +) -> list[dict]: + book_filters = [ReadingBook.class_id == class_id] + note_filters = [ReadingBook.class_id == class_id] + if period == "month": + since = _month_start() + book_filters.append(ReadingBook.updated_at >= since) + note_filters.append(ReadingNote.created_at >= since) + if user_id: + book_filters.append(ReadingBook.owner_id == user_id) + note_filters.append(ReadingNote.author_id == user_id) + + book_result = await db.execute( + select( + ReadingBook.owner_id.label("user_id"), + func.coalesce(func.sum(ReadingBook.current_page), 0).label("pages_read"), + func.sum(case((ReadingBook.status == "finished", 1), else_=0)).label("finished_books"), + ) + .where(*book_filters) + .group_by(ReadingBook.owner_id) + ) + note_result = await db.execute( + select( + ReadingNote.author_id.label("user_id"), + func.sum(case((ReadingNote.visibility == "class", 1), else_=0)).label("public_notes"), + func.sum(case((ReadingNote.visibility == "private", 1), else_=0)).label("private_notes"), + ) + .join(ReadingBook) + .where(*note_filters) + .group_by(ReadingNote.author_id) + ) + member_result = await db.execute( + select(User.id, User.name) + .join(ClassMembership, ClassMembership.user_id == User.id) + .where(ClassMembership.class_id == class_id, User.status == "approved") + ) + + rows: dict[int, dict] = {} + for member_id, member_name in member_result.all(): + if user_id and member_id != user_id: + continue + rows[member_id] = { + "user_id": member_id, + "user_name": member_name, + "pages_read": 0, + "finished_books": 0, + "public_notes": 0, + "private_notes": 0, + } + + for row in book_result.mappings(): + item = rows.setdefault(row["user_id"], { + "user_id": row["user_id"], + "user_name": "Unknown", + "pages_read": 0, + "finished_books": 0, + "public_notes": 0, + "private_notes": 0, + }) + item["pages_read"] = int(row["pages_read"] or 0) + item["finished_books"] = int(row["finished_books"] or 0) + + for row in note_result.mappings(): + item = rows.setdefault(row["user_id"], { + "user_id": row["user_id"], + "user_name": "Unknown", + "pages_read": 0, + "finished_books": 0, + "public_notes": 0, + "private_notes": 0, + }) + item["public_notes"] = int(row["public_notes"] or 0) + item["private_notes"] = int(row["private_notes"] or 0) + + missing_name_ids = [ + item["user_id"] for item in rows.values() if item["user_name"] == "Unknown" + ] + if missing_name_ids: + user_result = await db.execute( + select(User.id, User.name).where(User.id.in_(missing_name_ids)) + ) + name_by_id = {user_id: name for user_id, name in user_result.all()} + for item in rows.values(): + if item["user_name"] == "Unknown": + item["user_name"] = name_by_id.get(item["user_id"], f"用户{item['user_id']}") + + for item in rows.values(): + item["score"] = ( + item["pages_read"] + + item["finished_books"] * 50 + + item["public_notes"] * 20 + + item["private_notes"] * 10 + ) + + return sorted(rows.values(), key=lambda x: (-x["score"], -x["pages_read"], x["user_name"])) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md deleted file mode 100644 index 8bd0e39..0000000 --- a/frontend/AGENTS.md +++ /dev/null @@ -1,5 +0,0 @@ - -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. - diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 43c994c..dba71e9 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -1 +1 @@ -@AGENTS.md +@../AGENTS.md diff --git a/frontend/src/app/(app)/admin/modules/page.tsx b/frontend/src/app/(app)/admin/modules/page.tsx index b61b904..656189c 100644 --- a/frontend/src/app/(app)/admin/modules/page.tsx +++ b/frontend/src/app/(app)/admin/modules/page.tsx @@ -17,6 +17,7 @@ const ALL_MODULES = [ { key: "votes", label: "投票", desc: "发起班级投票活动" }, { key: "schedule", label: "排期表", desc: "查看课程和活动排期" }, { key: "resources", label: "资源库", desc: "上传和下载学习资源" }, + { key: "reading_corner", label: "读书角", desc: "记录阅读进度、读书笔记和排行榜" }, { key: "fund", label: "班费管理", desc: "记录和管理班费收支" }, ]; diff --git a/frontend/src/app/(app)/reading/page.tsx b/frontend/src/app/(app)/reading/page.tsx new file mode 100644 index 0000000..c2304cd --- /dev/null +++ b/frontend/src/app/(app)/reading/page.tsx @@ -0,0 +1,1045 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + BookOpen, + CheckCircle2, + Clock3, + NotebookPen, + Pencil, + Plus, + Search, + Trash2, + Trophy, +} from "lucide-react"; +import { useActiveClass } from "@/hooks/use-active-class"; +import { useAuth } from "@/hooks/use-auth"; +import { deleteAPI, fetchAPI, getErrorMessage, postAPI, putAPI } from "@/lib/api"; +import { hasClassPermission } from "@/lib/permissions"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ErrorState } from "@/components/error-state"; +import { Pagination } from "@/components/pagination"; +import { ConfirmDialog } from "@/components/confirm-dialog"; +import { toast } from "sonner"; +import type { + PageResponse, + ReadingBook, + ReadingFeedItem, + ReadingNote, + ReadingRankingItem, + ReadingRankingResponse, + ReadingStatus, + ReadingSummary, +} from "@/lib/types"; + +type ActiveTab = "feed" | "mine" | "shelf" | "notes"; + +const STATUS_LABELS: Record = { + all: "全部", + reading: "在读", + finished: "已读完", + paused: "暂停", + wishlist: "想读", +}; + +const STATUS_STYLES: Record = { + reading: "border-[#d7a348] bg-[#fff3d8] text-[#7b4d12]", + finished: "border-[#91c9a6] bg-[#edf9f0] text-[#2d7042]", + paused: "border-[#d8c6ad] bg-[#f7efe5] text-[#765a4d]", + wishlist: "border-[#b9c7df] bg-[#eef4ff] text-[#365982]", +}; + +const emptySummary: ReadingSummary = { + reading_count: 0, + finished_count: 0, + total_pages_read: 0, + month_score: 0, +}; + +const emptyBookForm = { + title: "", + author: "", + total_pages: "", + current_page: "0", + status: "reading" as ReadingStatus, + personal_note: "", +}; + +const emptyNoteForm = { + book_id: "", + title: "", + content: "", + page_ref: "", + visibility: "class" as "class" | "private", +}; + +const emptyProgressForm = { + book_id: "", + current_page: "", +}; + +function formatDate(value: string | null): string { + if (!value) return "未记录"; + return new Date(value).toLocaleDateString("zh-CN", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function progressOf(book: ReadingBook): number { + if (!book.total_pages) return book.current_page > 0 ? 100 : 0; + return Math.min(100, Math.round((book.current_page / book.total_pages) * 100)); +} + +function compactText(value: string, max = 96): string { + return value.length > max ? `${value.slice(0, max)}...` : value; +} + +function BookCard({ + book, + canEdit, + onEdit, + onDelete, + onWriteNote, + onUpdateProgress, +}: { + book: ReadingBook; + canEdit: boolean; + onEdit: (book: ReadingBook) => void; + onDelete: (book: ReadingBook) => void; + onWriteNote: (book: ReadingBook) => void; + onUpdateProgress: (book: ReadingBook) => void; +}) { + const progress = progressOf(book); + return ( + + +
+
+ {book.cover_url ? ( + // eslint-disable-next-line @next/next/no-img-element + {book.title} + ) : ( +
+ +
+ )} +
+
+
+
+
+

{book.title}

+ {STATUS_LABELS[book.status]} +
+

+ {book.author || "作者未填写"} · {book.owner_name} +

+
+ {canEdit && ( +
+ + + + +
+ )} +
+ +
+
+ {book.current_page} / {book.total_pages || "?"} 页 + {progress}% +
+
+
+
+
+ + {book.personal_note && ( +

+ {compactText(book.personal_note)} +

+ )} + +
+ 公开笔记 {book.public_note_count} + 全部笔记 {book.note_count} + 更新于 {formatDate(book.updated_at)} +
+
+
+ + + ); +} + +function FeedCard({ item }: { item: ReadingFeedItem }) { + const isFinished = item.type === "finished"; + const isNote = item.type === "note"; + const actionText = isNote ? "写了读书笔记" : isFinished ? "读完了" : "更新了阅读进度"; + const Icon = isNote ? NotebookPen : isFinished ? CheckCircle2 : Clock3; + + return ( + + +
+
+ {item.book_cover_url ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.book_title} + ) : ( +
+ +
+ )} +
+
+
+
+
+ {item.user_name} + {actionText} + 《{item.book_title}》 +
+

+ {item.book_author ? `${item.book_author} · ` : ""} + {formatDate(item.created_at)} +

+
+
+ + {isNote ? "笔记" : isFinished ? "完成" : "进度"} +
+
+ + {isNote ? ( +
+
+

{item.note_title}

+ {item.page_ref && {item.page_ref}} +
+ {item.note_excerpt && ( +

{item.note_excerpt}

+ )} +
+ ) : ( +
+
+ {item.current_page} / {item.total_pages || "?"} 页 + {item.progress}% +
+
+
+
+
+ )} +
+
+ + + ); +} + +function ReadingSidebar({ + summary, + rankings, + canUpdateProgress, + onUpdateProgress, + onAddBook, + onWriteNote, +}: { + summary: ReadingSummary; + rankings: ReadingRankingItem[]; + canUpdateProgress: boolean; + onUpdateProgress: () => void; + onAddBook: () => void; + onWriteNote: () => void; +}) { + return ( +
+ + +
+

我的阅读概览

+

{summary.month_score}

+

本月积分

+
+
+
+

{summary.reading_count}

+

在读

+
+
+

{summary.finished_count}

+

已读

+
+
+

{summary.total_pages_read}

+

页数

+
+
+
+ + + +
+
+
+ + + +
+

本月读书榜

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

暂无排行数据

+ ) : ( +
+ {rankings.slice(0, 5).map((item, index) => ( +
+ + {index + 1} + +
+

{item.user_name}

+

{item.pages_read} 页 · {item.public_notes + item.private_notes} 篇笔记

+
+ {item.score} +
+ ))} +
+ )} +
+
+
+ ); +} + +export default function ReadingPage() { + const { activeClassId } = useActiveClass(); + const { user } = useAuth(); + const [activeTab, setActiveTab] = useState("feed"); + const [summary, setSummary] = useState(emptySummary); + const [feedItems, setFeedItems] = useState([]); + const [books, setBooks] = useState([]); + const [myBooks, setMyBooks] = useState([]); + const [notes, setNotes] = useState([]); + const [rankings, setRankings] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [status, setStatus] = useState("all"); + const [search, setSearch] = useState(""); + const [noteSearch, setNoteSearch] = useState(""); + const [page, setPage] = useState(1); + const [notePage, setNotePage] = useState(1); + const [feedPage, setFeedPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [noteTotalPages, setNoteTotalPages] = useState(1); + const [feedTotalPages, setFeedTotalPages] = useState(1); + const [bookDialogOpen, setBookDialogOpen] = useState(false); + const [noteDialogOpen, setNoteDialogOpen] = useState(false); + const [progressDialogOpen, setProgressDialogOpen] = useState(false); + const [editingBook, setEditingBook] = useState(null); + const [bookForm, setBookForm] = useState(emptyBookForm); + const [noteForm, setNoteForm] = useState(emptyNoteForm); + const [progressForm, setProgressForm] = useState(emptyProgressForm); + const [submitting, setSubmitting] = useState(false); + const [deleteBook, setDeleteBook] = useState(null); + const [deleteNote, setDeleteNote] = useState(null); + + const canManageReading = hasClassPermission(user, "reading_corner_manage", activeClassId); + + const loadSummary = useCallback(async () => { + if (!activeClassId) { + setSummary(emptySummary); + return; + } + const res = await fetchAPI("/api/reading/summary", { + class_id: String(activeClassId), + }); + setSummary(res); + }, [activeClassId]); + + const loadFeed = useCallback(async () => { + if (!activeClassId) { + setFeedItems([]); + setFeedTotalPages(1); + return; + } + const res = await fetchAPI>("/api/reading/feed", { + class_id: String(activeClassId), + page: String(feedPage), + page_size: "12", + }); + setFeedItems(res.items ?? []); + setFeedTotalPages(res.total_pages || 1); + }, [activeClassId, feedPage]); + + const loadBooks = useCallback(async () => { + if (!activeClassId) { + setBooks([]); + setMyBooks([]); + setTotalPages(1); + return; + } + const params: Record = { + class_id: String(activeClassId), + page: String(page), + page_size: "12", + }; + if (status !== "all") params.status = status; + if (search.trim()) params.search = search.trim(); + + const [shelfRes, mineRes] = await Promise.all([ + fetchAPI>("/api/reading/books", params), + fetchAPI>("/api/reading/books", { + class_id: String(activeClassId), + owner: "me", + page_size: "100", + }), + ]); + setBooks(shelfRes.items ?? []); + setTotalPages(shelfRes.total_pages || 1); + setMyBooks(mineRes.items ?? []); + }, [activeClassId, page, search, status]); + + const loadNotes = useCallback(async () => { + if (!activeClassId) { + setNotes([]); + setNoteTotalPages(1); + return; + } + const params: Record = { + class_id: String(activeClassId), + page: String(notePage), + page_size: "12", + }; + if (noteSearch.trim()) params.search = noteSearch.trim(); + const res = await fetchAPI>("/api/reading/notes", params); + setNotes(res.items ?? []); + setNoteTotalPages(res.total_pages || 1); + }, [activeClassId, notePage, noteSearch]); + + const loadRankings = useCallback(async () => { + if (!activeClassId) { + setRankings([]); + return; + } + const res = await fetchAPI("/api/reading/rankings", { + class_id: String(activeClassId), + period: "month", + }); + setRankings(res.items ?? []); + }, [activeClassId]); + + const loadAll = useCallback(async () => { + setError(null); + setLoading(true); + try { + await Promise.all([loadSummary(), loadFeed(), loadBooks(), loadNotes(), loadRankings()]); + } catch (err: unknown) { + setError(getErrorMessage(err, "加载读书角失败")); + } finally { + setLoading(false); + } + }, [loadBooks, loadFeed, loadNotes, loadRankings, loadSummary]); + + useEffect(() => { + void loadAll(); + }, [loadAll]); + + const bookOptions = useMemo( + () => myBooks.map((book) => ({ value: String(book.id), label: book.title })), + [myBooks] + ); + + const selectedProgressBook = useMemo( + () => myBooks.find((book) => String(book.id) === progressForm.book_id) ?? null, + [myBooks, progressForm.book_id] + ); + + const resetBookForm = () => { + setEditingBook(null); + setBookForm(emptyBookForm); + }; + + const openCreateBook = () => { + resetBookForm(); + setBookDialogOpen(true); + }; + + const openCreateNote = () => { + setNoteForm(emptyNoteForm); + setNoteDialogOpen(true); + }; + + const openProgressDialog = () => { + const firstReadingBook = + myBooks.find((book) => book.status === "reading") ?? myBooks[0] ?? null; + setProgressForm( + firstReadingBook + ? { + book_id: String(firstReadingBook.id), + current_page: String(firstReadingBook.current_page), + } + : emptyProgressForm + ); + setProgressDialogOpen(true); + }; + + const openProgressForBook = (book: ReadingBook) => { + setProgressForm({ + book_id: String(book.id), + current_page: String(book.current_page), + }); + setProgressDialogOpen(true); + }; + + const openEditBook = (book: ReadingBook) => { + setEditingBook(book); + setBookForm({ + title: book.title, + author: book.author ?? "", + total_pages: book.total_pages ? String(book.total_pages) : "", + current_page: String(book.current_page), + status: book.status, + personal_note: book.personal_note ?? "", + }); + setBookDialogOpen(true); + }; + + const openNoteForBook = (book: ReadingBook) => { + setNoteForm({ ...emptyNoteForm, book_id: String(book.id) }); + setNoteDialogOpen(true); + }; + + const handleSaveBook = async () => { + if (!activeClassId || !bookForm.title.trim()) return; + setSubmitting(true); + const payload = { + title: bookForm.title.trim(), + author: bookForm.author.trim() || null, + total_pages: Number(bookForm.total_pages || 0), + current_page: Number(bookForm.current_page || 0), + status: bookForm.status, + personal_note: bookForm.personal_note.trim() || null, + }; + try { + if (editingBook) { + await putAPI(`/api/reading/books/${editingBook.id}`, payload); + toast.success("书目已更新"); + } else { + await postAPI(`/api/reading/books?class_id=${activeClassId}`, payload); + toast.success("书目已创建"); + } + setBookDialogOpen(false); + resetBookForm(); + await loadAll(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "保存失败")); + } finally { + setSubmitting(false); + } + }; + + const handleSaveNote = async () => { + if (!noteForm.book_id || !noteForm.title.trim() || !noteForm.content.trim()) return; + setSubmitting(true); + try { + await postAPI(`/api/reading/books/${noteForm.book_id}/notes`, { + title: noteForm.title.trim(), + content: noteForm.content.trim(), + page_ref: noteForm.page_ref.trim() || null, + visibility: noteForm.visibility, + }); + const pageMatch = noteForm.page_ref.trim().match(/^\d+$/); + if (pageMatch) { + const pageValue = Number(pageMatch[0]); + const book = myBooks.find((item) => String(item.id) === noteForm.book_id); + if (book && pageValue > book.current_page) { + try { + await putAPI(`/api/reading/books/${book.id}`, { + current_page: pageValue, + status: book.total_pages > 0 && pageValue >= book.total_pages ? "finished" : book.status, + }); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "笔记已保存,但同步进度失败")); + } + } + } + toast.success("读书笔记已保存"); + setNoteDialogOpen(false); + setNoteForm(emptyNoteForm); + await loadAll(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "保存笔记失败")); + } finally { + setSubmitting(false); + } + }; + + const handleSaveProgress = async () => { + if (!progressForm.book_id || !selectedProgressBook) return; + const nextPage = Number(progressForm.current_page || 0); + if (!Number.isFinite(nextPage)) return; + setSubmitting(true); + try { + await putAPI(`/api/reading/books/${selectedProgressBook.id}`, { + current_page: nextPage, + status: + selectedProgressBook.total_pages > 0 && nextPage >= selectedProgressBook.total_pages + ? "finished" + : selectedProgressBook.status === "wishlist" + ? "reading" + : selectedProgressBook.status, + }); + toast.success("阅读进度已更新"); + setProgressDialogOpen(false); + setProgressForm(emptyProgressForm); + await loadAll(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "更新进度失败")); + } finally { + setSubmitting(false); + } + }; + + const handleDeleteBook = async () => { + if (!deleteBook) return; + setSubmitting(true); + try { + await deleteAPI(`/api/reading/books/${deleteBook.id}`); + toast.success("书目已删除"); + setDeleteBook(null); + await loadAll(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "删除失败")); + } finally { + setSubmitting(false); + } + }; + + const handleDeleteNote = async () => { + if (!deleteNote) return; + setSubmitting(true); + try { + await deleteAPI(`/api/reading/notes/${deleteNote.id}`); + toast.success("笔记已删除"); + setDeleteNote(null); + await loadAll(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "删除失败")); + } finally { + setSubmitting(false); + } + }; + + if (!activeClassId) { + return
请先选择一个班级
; + } + + return ( +
+
+
+
Reading Corner
+

读书角

+

记录阅读进度、沉淀笔记,也给彼此一点温柔的推动力

+
+
+ { + setBookDialogOpen(open); + if (!open) resetBookForm(); + }}> + + + + + + + {editingBook ? "编辑书目" : "添加书目"} + + +
+ setBookForm({ ...bookForm, title: e.target.value })} /> + setBookForm({ ...bookForm, author: e.target.value })} /> + + setBookForm({ ...bookForm, total_pages: e.target.value })} /> + setBookForm({ ...bookForm, current_page: e.target.value })} /> +