This commit is contained in:
aaron 2026-04-17 22:41:50 +08:00
parent 3d0b3ab8f6
commit f707e3ec5c
11 changed files with 850 additions and 7 deletions

127
backend/app/api/fund.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ModuleConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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}`

View File

@ -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<FundStatistics | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
// Records list
const [records, setRecords] = useState<FundRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [typeFilter, setTypeFilter] = useState<string>("all");
// Dialog state
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formType, setFormType] = useState<string>("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<number | null>(null);
const loadStats = async () => {
if (!activeClassId) { setStatsLoading(false); return; }
setStatsLoading(true);
try {
const params: Record<string, string> = {};
if (user?.role === "super_admin" && activeClassId) {
params.class_id = String(activeClassId);
}
const res = await fetchAPI<FundStatistics>(
`/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<string, string> = {
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<any>(`/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 <div className="text-center py-12 text-gray-400"></div>;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
<RoleGuard roles={["super_admin", "class_admin"]}>
<Button onClick={openCreate}></Button>
</RoleGuard>
</div>
{/* Statistics cards */}
<div className="grid grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<p className="text-sm text-gray-500"></p>
<p className="text-2xl font-bold text-green-600">
¥{statsLoading ? "—" : (stats?.total_income ?? 0).toFixed(2)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-gray-500"></p>
<p className="text-2xl font-bold text-red-600">
¥{statsLoading ? "—" : (stats?.total_expense ?? 0).toFixed(2)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-gray-500"></p>
<p className={`text-2xl font-bold ${(stats?.balance ?? 0) >= 0 ? "text-green-600" : "text-red-600"}`}>
¥{statsLoading ? "—" : (stats?.balance ?? 0).toFixed(2)}
</p>
</CardContent>
</Card>
</div>
{/* Category breakdown */}
{stats && (stats.income_by_category.length > 0 || stats.expense_by_category.length > 0) && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500 mb-2 font-medium"></p>
{stats.income_by_category.map((item) => (
<div key={item.category} className="flex justify-between text-sm py-1">
<span>{item.category}</span>
<span className="text-green-600">¥{item.amount.toFixed(2)}</span>
</div>
))}
{stats.income_by_category.length === 0 && (
<p className="text-xs text-gray-400"></p>
)}
</div>
<div>
<p className="text-xs text-gray-500 mb-2 font-medium"></p>
{stats.expense_by_category.map((item) => (
<div key={item.category} className="flex justify-between text-sm py-1">
<span>{item.category}</span>
<span className="text-red-600">¥{item.amount.toFixed(2)}</span>
</div>
))}
{stats.expense_by_category.length === 0 && (
<p className="text-xs text-gray-400"></p>
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* Filter */}
<div className="flex gap-2">
<Button
variant={typeFilter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setTypeFilter("all")}
>
</Button>
<Button
variant={typeFilter === "income" ? "default" : "outline"}
size="sm"
onClick={() => setTypeFilter("income")}
>
</Button>
<Button
variant={typeFilter === "expense" ? "default" : "outline"}
size="sm"
onClick={() => setTypeFilter("expense")}
>
</Button>
</div>
{/* Records list */}
{loading ? (
<div className="animate-pulse space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="h-12 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<ErrorState message={error} onRetry={loadRecords} />
) : records.length === 0 ? (
<div className="text-center py-12 text-gray-400">
{typeFilter === "all" ? "暂无班费记录" : `暂无${FUND_TYPES[typeFilter as keyof typeof FUND_TYPES]}记录`}
</div>
) : (
<div className="space-y-2">
{records.map((r) => (
<Card key={r.id}>
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge
className={r.type === "income"
? "bg-green-100 text-green-800 hover:bg-green-100 border-green-200"
: "bg-red-100 text-red-800 hover:bg-red-100 border-red-200"
}
>
{FUND_TYPES[r.type as keyof typeof FUND_TYPES]}
</Badge>
<div>
<p className="font-medium">
<span className={r.type === "income" ? "text-green-600" : "text-red-600"}>
{r.type === "income" ? "+" : "-"}¥{r.amount.toFixed(2)}
</span>
<span className="ml-2 text-gray-600">{r.category}</span>
</p>
<p className="text-sm text-gray-500">
{r.record_date} · {r.recorder_name}
{r.description ? ` · ${r.description}` : ""}
</p>
</div>
</div>
{isAdmin && (
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => setDeleteTarget(r.id)}
>
</Button>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={(open) => { setDialogOpen(open); if (!open) resetForm(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editingId ? "编辑记录" : "添加记录"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div>
<Label></Label>
<Select value={formType} onValueChange={(v) => { setFormType(v); setFormCategory(""); }}>
<SelectTrigger className="mt-1">
<SelectValue>{FUND_TYPES[formType as keyof typeof FUND_TYPES]}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="income"></SelectItem>
<SelectItem value="expense"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Input
type="number"
step="0.01"
min="0"
className="mt-1"
placeholder="0.00"
value={formAmount}
onChange={(e) => setFormAmount(e.target.value)}
/>
</div>
<div>
<Label></Label>
<Select value={formCategory} onValueChange={setFormCategory}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="选择分类">{formCategory || "选择分类"}</SelectValue>
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Textarea
className="mt-1"
placeholder="备注说明(可选)"
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
rows={2}
/>
</div>
<div>
<Label></Label>
<Input
type="date"
className="mt-1"
value={formDate}
onChange={(e) => setFormDate(e.target.value)}
/>
</div>
<Button className="w-full" onClick={handleSubmit} disabled={submitting}>
{submitting ? "提交中..." : (editingId ? "保存修改" : "添加记录")}
</Button>
</div>
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
title="删除班费记录"
description="确定删除该班费记录?此操作不可恢复。"
confirmText="删除"
variant="destructive"
onConfirm={handleDelete}
/>
</div>
);
}

View File

@ -9,7 +9,7 @@ import type { UserRole } from "@/lib/types";
import { useAuth } from "@/hooks/use-auth";
// Module keys that can be toggled
const TOGGLEABLE_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources"];
const TOGGLEABLE_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources", "fund"];
const navItems = [
{ href: "/dashboard", label: "首页", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6", moduleKey: undefined },
@ -20,6 +20,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: "/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 },
];
@ -39,7 +40,7 @@ export function Sidebar() {
: [];
// Default to all modules enabled if not loaded yet
const defaultModules = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources"];
const defaultModules = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources", "fund"];
const enabledSet = new Set(enabledModules ?? defaultModules);
// Filter navItems based on enabled modules (items without moduleKey are always visible)

View File

@ -25,6 +25,8 @@ interface ActiveClassContextValue {
setActiveClassId: (id: number) => void;
/** Enabled modules for the active class */
enabledModules: string[] | null;
/** Refresh available classes from API */
refreshClasses: () => Promise<void>;
}
const ActiveClassContext = createContext<ActiveClassContextValue>({
@ -34,6 +36,7 @@ const ActiveClassContext = createContext<ActiveClassContextValue>({
availableClasses: [],
setActiveClassId: () => {},
enabledModules: null,
refreshClasses: async () => {},
});
export function ActiveClassProvider({ children }: { children: ReactNode }) {
@ -97,6 +100,16 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
localStorage.setItem("active_class_id", String(id));
}, []);
const refreshClasses = useCallback(async () => {
try {
const res = await fetchAPI<any>("/api/classes/");
const items = res.items || [];
setAvailableClasses(items);
} catch {
// ignore
}
}, []);
return (
<ActiveClassContext.Provider
value={{
@ -106,6 +119,7 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
availableClasses,
setActiveClassId,
enabledModules,
refreshClasses,
}}
>
{children}

View File

@ -41,3 +41,23 @@ export const COMMITTEE_ROLES = [
"文体委员",
"生活委员",
] as const;
export const FUND_TYPES = {
income: "入账",
expense: "出账",
} as const;
export const FUND_INCOME_CATEGORIES = [
"班费收取",
"活动赞助",
"其他收入",
] as const;
export const FUND_EXPENSE_CATEGORIES = [
"聚餐",
"活动物资",
"场地费",
"交通费",
"礼品",
"其他支出",
] as const;

View File

@ -220,3 +220,25 @@ export interface Assignment {
updated_at: string;
submissions?: AssignmentSubmission[];
}
export interface FundRecord {
id: number;
class_id: number;
type: "income" | "expense";
amount: number;
category: string;
description: string | null;
record_date: string;
recorder_id: number;
recorder_name: string;
created_at: string;
updated_at: string;
}
export interface FundStatistics {
total_income: number;
total_expense: number;
balance: number;
income_by_category: { category: string; amount: number }[];
expense_by_category: { category: string; amount: number }[];
}