add reading.

This commit is contained in:
aaron 2026-05-01 21:52:27 +08:00
parent 54a9fdadba
commit 2692ba8fff
18 changed files with 2489 additions and 10 deletions

213
AGENTS.md Normal file
View File

@ -0,0 +1,213 @@
# HKU ICB ClassHub Agent Guide
本文件用于约束后续 agent 与开发者在本项目中的工作方式。目标是让每次改动都符合项目定位、权限模型、技术栈和现有设计语言,避免临时拼接式开发。
## 1. 项目定义
本项目是 HKU ICB Graduate Class Resource Platform面向班级、教师、学生和班委的班级资源与协作系统。核心价值是围绕“班级”组织信息提供可信、清晰、轻量的日常管理能力。
主要模块包括:
- 登录、激活、账号审核与个人资料
- 班级与成员管理
- 通讯录、时间线、公告、课程日程、资源、作业、投票、班费
- 通知、上传与外部存储
开发时必须优先保护以下产品原则:
- 班级数据隔离优先,任何列表、详情、写入都要明确 class scope。
- 权限优先于界面便利,不允许只靠前端隐藏按钮来保证安全。
- 管理功能要清晰可审计,避免隐式批量修改。
- 面向真实班级使用场景,界面应安静、稳定、易扫描,不做营销页式表达。
## 2. 技术栈与目录
仓库结构:
- `backend/`FastAPI 后端SQLAlchemy async ORMAlembic 迁移SQLite 默认部署数据源。
- `frontend/`Next.js App Router 前端React 19TypeScriptTailwind CSS 4shadcn/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 等版本敏感问题时,以本仓库依赖版本和官方文档为准。
- 保持代码和文档简洁,避免过度抽象。

View File

@ -17,6 +17,9 @@ CH_COS_REGION=ap-guangzhou
CH_COS_BUCKET=your-bucket-name CH_COS_BUCKET=your-bucket-name
CH_COS_BASE_URL=https://your-bucket.cos.ap-guangzhou.myqcloud.com 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 (邮件通知) # SMTP Email (邮件通知)
CH_SMTP_HOST=smtp.example.com CH_SMTP_HOST=smtp.example.com
CH_SMTP_PORT=465 CH_SMTP_PORT=465

View File

@ -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")

341
backend/app/api/reading.py Normal file
View File

@ -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],
)

View File

@ -22,6 +22,9 @@ class Settings(BaseSettings):
cos_bucket: str = "" cos_bucket: str = ""
cos_base_url: str = "" cos_base_url: str = ""
# Book metadata
google_books_api_key: str = ""
# SMTP Email # SMTP Email
smtp_host: str = "" smtp_host: str = ""
smtp_port: int = 465 smtp_port: int = 465

View File

@ -19,6 +19,7 @@ CLASS_PERMISSIONS = {
"vote_manage", "vote_manage",
"schedule_manage", "schedule_manage",
"resource_manage", "resource_manage",
"reading_corner_manage",
"assignment_manage", "assignment_manage",
"fund_manage", "fund_manage",
"module_manage", "module_manage",
@ -33,6 +34,7 @@ TEACHER_DEFAULT_PERMISSIONS = {
"vote_manage", "vote_manage",
"schedule_manage", "schedule_manage",
"resource_manage", "resource_manage",
"reading_corner_manage",
"assignment_manage", "assignment_manage",
"module_manage", "module_manage",
} }

View File

@ -1,5 +1,5 @@
import json 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 import String, Text, Integer, DateTime, Boolean, ForeignKey, Float, Date, func, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -22,7 +22,7 @@ class Class_(Base):
) )
# All available modules # 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]: def get_enabled_modules(self) -> list[str]:
if not self.enabled_modules: if not self.enabled_modules:
@ -50,6 +50,9 @@ class Class_(Base):
resources: Mapped[list["Resource"]] = relationship( resources: Mapped[list["Resource"]] = relationship(
"Resource", back_populates="class_", cascade="all, delete-orphan" "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( assignments: Mapped[list["Assignment"]] = relationship(
"Assignment", back_populates="class_", cascade="all, delete-orphan" "Assignment", back_populates="class_", cascade="all, delete-orphan"
) )
@ -105,6 +108,12 @@ class User(Base):
created_votes: Mapped[list["Vote"]] = relationship( created_votes: Mapped[list["Vote"]] = relationship(
"Vote", back_populates="creator" "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]: def get_skills_list(self) -> list[str]:
if not self.skills_tags: if not self.skills_tags:
@ -315,6 +324,60 @@ class Notification(Base):
user: Mapped["User"] = relationship("User") 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): class TimelineLike(Base):
__tablename__ = "timeline_likes" __tablename__ = "timeline_likes"

View File

@ -5,7 +5,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings 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( logging.basicConfig(
level=logging.DEBUG if settings.debug else logging.INFO, 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(notifications.router)
app.include_router(votes.router) app.include_router(votes.router)
app.include_router(assignments.router) app.include_router(assignments.router)
app.include_router(reading.router)
app.include_router(fund.router) app.include_router(fund.router)

View File

@ -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

View File

@ -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"]))

View File

@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->

View File

@ -1 +1 @@
@AGENTS.md @../AGENTS.md

View File

@ -17,6 +17,7 @@ const ALL_MODULES = [
{ key: "votes", label: "投票", desc: "发起班级投票活动" }, { key: "votes", label: "投票", desc: "发起班级投票活动" },
{ key: "schedule", label: "排期表", desc: "查看课程和活动排期" }, { key: "schedule", label: "排期表", desc: "查看课程和活动排期" },
{ key: "resources", label: "资源库", desc: "上传和下载学习资源" }, { key: "resources", label: "资源库", desc: "上传和下载学习资源" },
{ key: "reading_corner", label: "读书角", desc: "记录阅读进度、读书笔记和排行榜" },
{ key: "fund", label: "班费管理", desc: "记录和管理班费收支" }, { key: "fund", label: "班费管理", desc: "记录和管理班费收支" },
]; ];

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,7 @@ const navItems = [
{ href: "/votes", label: "投票", icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", moduleKey: "votes" }, { href: "/votes", label: "投票", icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", moduleKey: "votes" },
{ href: "/schedule", label: "排期表", icon: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z", moduleKey: "schedule" }, { href: "/schedule", label: "排期表", icon: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z", moduleKey: "schedule" },
{ href: "/resources", label: "资源库", icon: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z", moduleKey: "resources" }, { href: "/resources", label: "资源库", icon: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z", moduleKey: "resources" },
{ href: "/reading", label: "读书角", icon: "M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253", moduleKey: "reading_corner" },
{ href: "/fund", label: "班费管理", icon: "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z", moduleKey: "fund" }, { href: "/fund", label: "班费管理", icon: "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z", moduleKey: "fund" },
{ href: "/profile", label: "个人资料", icon: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z", moduleKey: undefined }, { href: "/profile", label: "个人资料", icon: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z", moduleKey: undefined },
]; ];
@ -60,7 +61,7 @@ export function Sidebar() {
: []; : [];
// Default to all modules enabled if not loaded yet // Default to all modules enabled if not loaded yet
const defaultModules = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources", "fund"]; const defaultModules = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources", "reading_corner", "fund"];
const enabledSet = new Set(enabledModules ?? defaultModules); const enabledSet = new Set(enabledModules ?? defaultModules);
// Filter navItems based on enabled modules (items without moduleKey are always visible) // Filter navItems based on enabled modules (items without moduleKey are always visible)

View File

@ -13,6 +13,7 @@ export const CLASS_PERMISSIONS = {
vote_manage: "投票管理", vote_manage: "投票管理",
schedule_manage: "排期管理", schedule_manage: "排期管理",
resource_manage: "资源库管理", resource_manage: "资源库管理",
reading_corner_manage: "读书角管理",
assignment_manage: "作业管理", assignment_manage: "作业管理",
fund_manage: "班费管理", fund_manage: "班费管理",
module_manage: "模块管理", module_manage: "模块管理",
@ -27,6 +28,7 @@ export const TEACHER_DEFAULT_PERMISSIONS = [
"vote_manage", "vote_manage",
"schedule_manage", "schedule_manage",
"resource_manage", "resource_manage",
"reading_corner_manage",
"assignment_manage", "assignment_manage",
"module_manage", "module_manage",
] as const; ] as const;

View File

@ -28,6 +28,7 @@ export function getEffectiveClassPermissions(
"vote_manage", "vote_manage",
"schedule_manage", "schedule_manage",
"resource_manage", "resource_manage",
"reading_corner_manage",
"assignment_manage", "assignment_manage",
"fund_manage", "fund_manage",
"module_manage", "module_manage",

View File

@ -10,6 +10,7 @@ export type ClassPermission =
| "vote_manage" | "vote_manage"
| "schedule_manage" | "schedule_manage"
| "resource_manage" | "resource_manage"
| "reading_corner_manage"
| "assignment_manage" | "assignment_manage"
| "fund_manage" | "fund_manage"
| "module_manage"; | "module_manage";
@ -163,6 +164,87 @@ export interface Resource {
created_at: string; created_at: string;
} }
export type ReadingStatus = "reading" | "finished" | "paused" | "wishlist";
export type ReadingNoteVisibility = "class" | "private";
export interface ReadingBook {
id: number;
class_id: number;
owner_id: number;
owner_name: string;
title: string;
author: string | null;
cover_url: string | null;
total_pages: number;
current_page: number;
status: ReadingStatus;
started_at: string | null;
finished_at: string | null;
personal_note: string | null;
note_count: number;
public_note_count: number;
created_at: string;
updated_at: string;
}
export interface ReadingNote {
id: number;
book_id: number;
book_title: string;
book_author: string | null;
author_id: number;
author_name: string;
title: string;
content: string;
page_ref: string | null;
visibility: ReadingNoteVisibility;
created_at: string;
updated_at: string;
}
export interface ReadingSummary {
reading_count: number;
finished_count: number;
total_pages_read: number;
month_score: number;
}
export interface ReadingRankingItem {
user_id: number;
user_name: string;
score: number;
pages_read: number;
finished_books: number;
public_notes: number;
private_notes: number;
}
export interface ReadingRankingResponse {
period: "month" | "all";
items: ReadingRankingItem[];
}
export interface ReadingFeedItem {
id: string;
type: "progress" | "note" | "finished";
class_id: number;
user_id: number;
user_name: string;
book_id: number;
book_title: string;
book_author: string | null;
book_cover_url: string | null;
status: ReadingStatus;
current_page: number;
total_pages: number;
progress: number;
note_id: number | null;
note_title: string | null;
note_excerpt: string | null;
page_ref: string | null;
created_at: string;
}
export interface NotificationItem { export interface NotificationItem {
id: number; id: number;
type: string; type: string;