diff --git a/backend/app/api/fund.py b/backend/app/api/fund.py new file mode 100644 index 0000000..701798c --- /dev/null +++ b/backend/app/api/fund.py @@ -0,0 +1,127 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import FundRecord, User +from app.schemas.fund import FundRecordCreate, FundRecordUpdate, FundRecordOut, FundStatistics +from app.schemas.common import PageResponse +from app.services.fund_service import ( + create_fund_record, update_fund_record, delete_fund_record, + get_fund_record_by_id, list_fund_records, get_fund_statistics, +) + +router = APIRouter(prefix="/api/fund", tags=["fund"]) + + +def record_to_out(record: FundRecord) -> FundRecordOut: + return FundRecordOut( + id=record.id, + class_id=record.class_id, + type=record.type, + amount=record.amount, + category=record.category, + description=record.description, + record_date=record.record_date, + recorder_id=record.recorder_id, + recorder_name=record.recorder.name if record.recorder else "Unknown", + created_at=record.created_at, + updated_at=record.updated_at, + ) + + +@router.get("/statistics", response_model=FundStatistics) +async def get_statistics( + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return FundStatistics( + total_income=0, total_expense=0, balance=0, + income_by_category=[], expense_by_category=[] + ) + return await get_fund_statistics(db, effective_class_id) + + +@router.get("/", response_model=PageResponse[FundRecordOut]) +async def get_fund_records( + page: int = 1, + page_size: int = 20, + type: str | None = None, + category: str | None = None, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + records, total = await list_fund_records(db, effective_class_id, page, page_size, type, category) + total_pages = (total + page_size - 1) // page_size + items = [record_to_out(r) for r in records] + return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages) + + +@router.post("/", response_model=FundRecordOut) +async def create_new_record( + data: FundRecordCreate, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + raise HTTPException(status_code=400, detail="No class specified") + + if data.type not in ("income", "expense"): + raise HTTPException(status_code=400, detail="Type must be 'income' or 'expense'") + if data.amount <= 0: + raise HTTPException(status_code=400, detail="Amount must be positive") + + record = await create_fund_record(db, effective_class_id, user.id, data) + # Reload with recorder relationship + record = await get_fund_record_by_id(db, record.id) + return record_to_out(record) + + +@router.put("/{record_id}", response_model=FundRecordOut) +async def update_existing_record( + record_id: int, + data: FundRecordUpdate, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + record = await get_fund_record_by_id(db, record_id) + if record is None: + raise HTTPException(status_code=404, detail="Record not found") + if user.role != "super_admin" and record.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + if data.type is not None and data.type not in ("income", "expense"): + raise HTTPException(status_code=400, detail="Type must be 'income' or 'expense'") + if data.amount is not None and data.amount <= 0: + raise HTTPException(status_code=400, detail="Amount must be positive") + + updated = await update_fund_record(db, record, data) + # Reload with recorder relationship + updated = await get_fund_record_by_id(db, updated.id) + return record_to_out(updated) + + +@router.delete("/{record_id}") +async def delete_existing_record( + record_id: int, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + record = await get_fund_record_by_id(db, record_id) + if record is None: + raise HTTPException(status_code=404, detail="Record not found") + if user.role != "super_admin" and record.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_fund_record(db, record) + return {"message": "Record deleted"} \ No newline at end of file diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 4c9de9c..572a89b 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1,7 +1,7 @@ import json from datetime import datetime -from sqlalchemy import String, Text, Integer, DateTime, Boolean, ForeignKey, func, UniqueConstraint +from sqlalchemy import String, Text, Integer, DateTime, Boolean, ForeignKey, Float, Date, func, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base @@ -22,7 +22,7 @@ class Class_(Base): ) # All available modules - ALL_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources"] + ALL_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources", "fund"] def get_enabled_modules(self) -> list[str]: if not self.enabled_modules: @@ -57,6 +57,9 @@ class Class_(Base): votes: Mapped[list["Vote"]] = relationship( "Vote", back_populates="class_", cascade="all, delete-orphan" ) + fund_records: Mapped[list["FundRecord"]] = relationship( + "FundRecord", back_populates="class_", cascade="all, delete-orphan" + ) class User(Base): @@ -430,3 +433,28 @@ class AssignmentSubmission(Base): assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="submissions") student: Mapped["User"] = relationship("User", back_populates="assignment_submissions") + + +class FundRecord(Base): + __tablename__ = "fund_records" + + 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 + ) + # type: income | expense + type: Mapped[str] = mapped_column(String(20), nullable=False) + amount: Mapped[float] = mapped_column(Float, nullable=False) + category: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + record_date: Mapped[datetime] = mapped_column(Date, nullable=False) + recorder_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), 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() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="fund_records") + recorder: Mapped["User"] = relationship("User") diff --git a/backend/app/main.py b/backend/app/main.py index 39875f8..96651d6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.config import settings from app.db.database import create_tables -from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments +from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments, fund logging.basicConfig( level=logging.DEBUG if settings.debug else logging.INFO, @@ -88,7 +88,7 @@ app = FastAPI( app.add_middleware( CORSMiddleware, - allow_origins=[settings.frontend_url, "http://localhost:3000", "http://192.168.31.172:3000"], + allow_origins=[settings.frontend_url, "http://localhost:3001", "http://192.168.31.172:3001"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -106,6 +106,7 @@ app.include_router(resources.router) app.include_router(notifications.router) app.include_router(votes.router) app.include_router(assignments.router) +app.include_router(fund.router) @app.get("/api/health") diff --git a/backend/app/schemas/fund.py b/backend/app/schemas/fund.py new file mode 100644 index 0000000..6263f0b --- /dev/null +++ b/backend/app/schemas/fund.py @@ -0,0 +1,46 @@ +from datetime import date, datetime + +from pydantic import BaseModel + + +class FundRecordCreate(BaseModel): + type: str # income | expense + amount: float + category: str + description: str | None = None + record_date: date + + +class FundRecordUpdate(BaseModel): + type: str | None = None + amount: float | None = None + category: str | None = None + description: str | None = None + record_date: date | None = None + + +class FundRecordOut(BaseModel): + id: int + class_id: int + type: str + amount: float + category: str + description: str | None + record_date: date + recorder_id: int + recorder_name: str + created_at: datetime + updated_at: datetime + + +class CategoryAmount(BaseModel): + category: str + amount: float + + +class FundStatistics(BaseModel): + total_income: float + total_expense: float + balance: float + income_by_category: list[CategoryAmount] + expense_by_category: list[CategoryAmount] \ No newline at end of file diff --git a/backend/app/services/fund_service.py b/backend/app/services/fund_service.py new file mode 100644 index 0000000..d8bfb61 --- /dev/null +++ b/backend/app/services/fund_service.py @@ -0,0 +1,127 @@ +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import FundRecord, User +from app.schemas.fund import FundRecordCreate, FundRecordUpdate, FundStatistics + + +async def create_fund_record( + db: AsyncSession, class_id: int, recorder_id: int, data: FundRecordCreate +) -> FundRecord: + record = FundRecord( + class_id=class_id, + recorder_id=recorder_id, + type=data.type, + amount=data.amount, + category=data.category, + description=data.description, + record_date=data.record_date, + ) + db.add(record) + await db.commit() + await db.refresh(record) + return record + + +async def update_fund_record( + db: AsyncSession, record: FundRecord, data: FundRecordUpdate +) -> FundRecord: + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(record, field, value) + await db.commit() + await db.refresh(record) + return record + + +async def delete_fund_record(db: AsyncSession, record: FundRecord): + await db.delete(record) + await db.commit() + + +async def get_fund_record_by_id(db: AsyncSession, record_id: int) -> FundRecord | None: + result = await db.execute( + select(FundRecord) + .options(selectinload(FundRecord.recorder)) + .where(FundRecord.id == record_id) + ) + return result.scalar_one_or_none() + + +async def list_fund_records( + db: AsyncSession, + class_id: int, + page: int = 1, + page_size: int = 20, + type: str | None = None, + category: str | None = None, +) -> tuple[list[FundRecord], int]: + query = select(FundRecord).where(FundRecord.class_id == class_id) + count_query = select(func.count(FundRecord.id)).where(FundRecord.class_id == class_id) + + if type: + query = query.where(FundRecord.type == type) + count_query = count_query.where(FundRecord.type == type) + if category: + query = query.where(FundRecord.category == category) + count_query = count_query.where(FundRecord.category == category) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + result = await db.execute( + query.options(selectinload(FundRecord.recorder)) + .order_by(FundRecord.record_date.desc(), FundRecord.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + records = list(result.scalars().all()) + return records, total + + +async def get_fund_statistics(db: AsyncSession, class_id: int) -> FundStatistics: + # Total income + income_result = await db.execute( + select(func.coalesce(func.sum(FundRecord.amount), 0)) + .where(FundRecord.class_id == class_id, FundRecord.type == "income") + ) + total_income = float(income_result.scalar() or 0) + + # Total expense + expense_result = await db.execute( + select(func.coalesce(func.sum(FundRecord.amount), 0)) + .where(FundRecord.class_id == class_id, FundRecord.type == "expense") + ) + total_expense = float(expense_result.scalar() or 0) + + balance = total_income - total_expense + + # Income by category + income_cat_result = await db.execute( + select(FundRecord.category, func.sum(FundRecord.amount)) + .where(FundRecord.class_id == class_id, FundRecord.type == "income") + .group_by(FundRecord.category) + ) + income_by_category = [ + {"category": row[0], "amount": float(row[1] or 0)} + for row in income_cat_result.fetchall() + ] + + # Expense by category + expense_cat_result = await db.execute( + select(FundRecord.category, func.sum(FundRecord.amount)) + .where(FundRecord.class_id == class_id, FundRecord.type == "expense") + .group_by(FundRecord.category) + ) + expense_by_category = [ + {"category": row[0], "amount": float(row[1] or 0)} + for row in expense_cat_result.fetchall() + ] + + return FundStatistics( + total_income=total_income, + total_expense=total_expense, + balance=balance, + income_by_category=income_by_category, + expense_by_category=expense_by_category, + ) \ No newline at end of file diff --git a/frontend/src/app/(app)/admin/modules/page.tsx b/frontend/src/app/(app)/admin/modules/page.tsx index b0c42a5..019fad4 100644 --- a/frontend/src/app/(app)/admin/modules/page.tsx +++ b/frontend/src/app/(app)/admin/modules/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useActiveClass } from "@/hooks/use-active-class"; +import { useAuth } from "@/hooks/use-auth"; import { fetchAPI, putAPI } from "@/lib/api"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; @@ -16,6 +17,7 @@ const ALL_MODULES = [ { key: "votes", label: "投票", desc: "发起班级投票活动" }, { key: "schedule", label: "排期表", desc: "查看课程和活动排期" }, { key: "resources", label: "资源库", desc: "上传和下载学习资源" }, + { key: "fund", label: "班费管理", desc: "记录和管理班费收支" }, ]; interface ModuleConfig { @@ -25,7 +27,8 @@ interface ModuleConfig { } export default function ModulesPage() { - const { activeClassId, activeClassName } = useActiveClass(); + const { activeClassId, activeClassName, refreshClasses } = useActiveClass(); + const { refreshUser } = useAuth(); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -65,6 +68,9 @@ export default function ModulesPage() { await putAPI(`/api/classes/${activeClassId}/modules`, { enabled_modules: updated, }); + // Refresh user data and classes so enabled_modules updates in sidebar + await refreshUser(); + await refreshClasses(); toast.success( enabled ? `已启用「${ALL_MODULES.find((m) => m.key === moduleKey)?.label}」` diff --git a/frontend/src/app/(app)/fund/page.tsx b/frontend/src/app/(app)/fund/page.tsx new file mode 100644 index 0000000..c07c63f --- /dev/null +++ b/frontend/src/app/(app)/fund/page.tsx @@ -0,0 +1,451 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useActiveClass } from "@/hooks/use-active-class"; +import { useAuth } from "@/hooks/use-auth"; +import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { RoleGuard } from "@/components/role-guard"; +import { ConfirmDialog } from "@/components/confirm-dialog"; +import { ErrorState } from "@/components/error-state"; +import { Pagination } from "@/components/pagination"; +import { toast } from "sonner"; +import type { FundRecord, FundStatistics } from "@/lib/types"; +import { FUND_TYPES, FUND_INCOME_CATEGORIES, FUND_EXPENSE_CATEGORIES } from "@/lib/constants"; + +export default function FundPage() { + const { user } = useAuth(); + const { activeClassId } = useActiveClass(); + const isAdmin = user?.role === "super_admin" || user?.role === "class_admin"; + + // Statistics + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(true); + + // Records list + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [typeFilter, setTypeFilter] = useState("all"); + + // Dialog state + const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [formType, setFormType] = useState("income"); + const [formAmount, setFormAmount] = useState(""); + const [formCategory, setFormCategory] = useState(""); + const [formDescription, setFormDescription] = useState(""); + const [formDate, setFormDate] = useState(""); + const [submitting, setSubmitting] = useState(false); + + // Delete state + const [deleteTarget, setDeleteTarget] = useState(null); + + const loadStats = async () => { + if (!activeClassId) { setStatsLoading(false); return; } + setStatsLoading(true); + try { + const params: Record = {}; + if (user?.role === "super_admin" && activeClassId) { + params.class_id = String(activeClassId); + } + const res = await fetchAPI( + `/api/fund/statistics`, + Object.keys(params).length > 0 ? params : undefined + ); + setStats(res); + } catch { + // ignore stats errors + } finally { + setStatsLoading(false); + } + }; + + const loadRecords = async () => { + if (!activeClassId) { setLoading(false); return; } + setLoading(true); + setError(null); + try { + const params: Record = { + page: String(page), + page_size: "20", + }; + if (typeFilter !== "all") params.type = typeFilter; + if (user?.role === "super_admin") params.class_id = String(activeClassId); + + const res = await fetchAPI(`/api/fund/`, params); + setRecords(res.items || []); + setTotalPages(res.total_pages || 1); + } catch (err: any) { + setError(err.message || "加载失败"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!activeClassId) return; + loadStats(); + loadRecords(); + }, [activeClassId, page, typeFilter]); + + const resetForm = () => { + setFormType("income"); + setFormAmount(""); + setFormCategory(""); + setFormDescription(""); + setFormDate(new Date().toISOString().slice(0, 10)); + setEditingId(null); + }; + + const openCreate = () => { + resetForm(); + setDialogOpen(true); + }; + + const openEdit = (record: FundRecord) => { + setEditingId(record.id); + setFormType(record.type); + setFormAmount(String(record.amount)); + setFormCategory(record.category); + setFormDescription(record.description || ""); + setFormDate(record.record_date); + setDialogOpen(true); + }; + + const handleSubmit = async () => { + if (!activeClassId) return; + const amount = parseFloat(formAmount); + if (!amount || amount <= 0) { + toast.error("金额必须大于0"); + return; + } + if (!formCategory.trim()) { + toast.error("请填写分类"); + return; + } + if (!formDate) { + toast.error("请选择日期"); + return; + } + + setSubmitting(true); + try { + const payload = { + type: formType, + amount, + category: formCategory.trim(), + description: formDescription.trim() || null, + record_date: formDate, + }; + + if (editingId) { + await putAPI(`/api/fund/${editingId}`, payload); + toast.success("记录已更新"); + } else { + const url = user?.role === "super_admin" && activeClassId + ? `/api/fund/?class_id=${activeClassId}` + : `/api/fund/`; + await postAPI(url, payload); + toast.success("记录已添加"); + } + setDialogOpen(false); + loadStats(); + loadRecords(); + } catch (err: any) { + toast.error(err.message || "操作失败"); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + try { + await deleteAPI(`/api/fund/${deleteTarget}`); + toast.success("记录已删除"); + setDeleteTarget(null); + loadStats(); + loadRecords(); + } catch (err: any) { + toast.error(err.message || "删除失败"); + } + }; + + const categories = formType === "income" ? FUND_INCOME_CATEGORIES : FUND_EXPENSE_CATEGORIES; + + if (!activeClassId) { + return
请先选择一个班级
; + } + + return ( +
+
+
+

班费管理

+

记录和管理班费收支

+
+ + + +
+ + {/* Statistics cards */} +
+ + +

总收入

+

+ ¥{statsLoading ? "—" : (stats?.total_income ?? 0).toFixed(2)} +

+
+
+ + +

总支出

+

+ ¥{statsLoading ? "—" : (stats?.total_expense ?? 0).toFixed(2)} +

+
+
+ + +

余额

+

= 0 ? "text-green-600" : "text-red-600"}`}> + ¥{statsLoading ? "—" : (stats?.balance ?? 0).toFixed(2)} +

+
+
+
+ + {/* Category breakdown */} + {stats && (stats.income_by_category.length > 0 || stats.expense_by_category.length > 0) && ( + + + 分类统计 + + +
+
+

收入分类

+ {stats.income_by_category.map((item) => ( +
+ {item.category} + ¥{item.amount.toFixed(2)} +
+ ))} + {stats.income_by_category.length === 0 && ( +

暂无收入记录

+ )} +
+
+

支出分类

+ {stats.expense_by_category.map((item) => ( +
+ {item.category} + ¥{item.amount.toFixed(2)} +
+ ))} + {stats.expense_by_category.length === 0 && ( +

暂无支出记录

+ )} +
+
+
+
+ )} + + {/* Filter */} +
+ + + +
+ + {/* Records list */} + {loading ? ( +
+ {[1, 2, 3].map((i) => ( + + +
+ + + ))} +
+ ) : error ? ( + + ) : records.length === 0 ? ( +
+ {typeFilter === "all" ? "暂无班费记录" : `暂无${FUND_TYPES[typeFilter as keyof typeof FUND_TYPES]}记录`} +
+ ) : ( +
+ {records.map((r) => ( + + +
+ + {FUND_TYPES[r.type as keyof typeof FUND_TYPES]} + +
+

+ + {r.type === "income" ? "+" : "-"}¥{r.amount.toFixed(2)} + + {r.category} +

+

+ {r.record_date} · {r.recorder_name} + {r.description ? ` · ${r.description}` : ""} +

+
+
+ {isAdmin && ( +
+ + +
+ )} +
+
+ ))} +
+ )} + + + + {/* Create/Edit Dialog */} + { setDialogOpen(open); if (!open) resetForm(); }}> + + + {editingId ? "编辑记录" : "添加记录"} + +
+
+ + +
+
+ + setFormAmount(e.target.value)} + /> +
+
+ + +
+
+ +