add reading.
This commit is contained in:
parent
54a9fdadba
commit
2692ba8fff
213
AGENTS.md
Normal file
213
AGENTS.md
Normal 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 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 等版本敏感问题时,以本仓库依赖版本和官方文档为准。
|
||||
- 保持代码和文档简洁,避免过度抽象。
|
||||
@ -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
|
||||
|
||||
91
backend/alembic/versions/20260501_add_reading_corner.py
Normal file
91
backend/alembic/versions/20260501_add_reading_corner.py
Normal 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
341
backend/app/api/reading.py
Normal 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],
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
151
backend/app/schemas/reading.py
Normal file
151
backend/app/schemas/reading.py
Normal 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
|
||||
484
backend/app/services/reading_service.py
Normal file
484
backend/app/services/reading_service.py
Normal 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"]))
|
||||
@ -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 -->
|
||||
@ -1 +1 @@
|
||||
@AGENTS.md
|
||||
@../AGENTS.md
|
||||
|
||||
@ -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: "记录和管理班费收支" },
|
||||
];
|
||||
|
||||
|
||||
1045
frontend/src/app/(app)/reading/page.tsx
Normal file
1045
frontend/src/app/(app)/reading/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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: "/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: "/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: "/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
|
||||
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);
|
||||
|
||||
// Filter navItems based on enabled modules (items without moduleKey are always visible)
|
||||
|
||||
@ -13,6 +13,7 @@ export const CLASS_PERMISSIONS = {
|
||||
vote_manage: "投票管理",
|
||||
schedule_manage: "排期管理",
|
||||
resource_manage: "资源库管理",
|
||||
reading_corner_manage: "读书角管理",
|
||||
assignment_manage: "作业管理",
|
||||
fund_manage: "班费管理",
|
||||
module_manage: "模块管理",
|
||||
@ -27,6 +28,7 @@ export const TEACHER_DEFAULT_PERMISSIONS = [
|
||||
"vote_manage",
|
||||
"schedule_manage",
|
||||
"resource_manage",
|
||||
"reading_corner_manage",
|
||||
"assignment_manage",
|
||||
"module_manage",
|
||||
] as const;
|
||||
|
||||
@ -28,6 +28,7 @@ export function getEffectiveClassPermissions(
|
||||
"vote_manage",
|
||||
"schedule_manage",
|
||||
"resource_manage",
|
||||
"reading_corner_manage",
|
||||
"assignment_manage",
|
||||
"fund_manage",
|
||||
"module_manage",
|
||||
|
||||
@ -10,6 +10,7 @@ export type ClassPermission =
|
||||
| "vote_manage"
|
||||
| "schedule_manage"
|
||||
| "resource_manage"
|
||||
| "reading_corner_manage"
|
||||
| "assignment_manage"
|
||||
| "fund_manage"
|
||||
| "module_manage";
|
||||
@ -163,6 +164,87 @@ export interface Resource {
|
||||
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 {
|
||||
id: number;
|
||||
type: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user