diff --git a/backend/alembic/versions/20260515_add_fund_record_images.py b/backend/alembic/versions/20260515_add_fund_record_images.py new file mode 100644 index 0000000..e226ccb --- /dev/null +++ b/backend/alembic/versions/20260515_add_fund_record_images.py @@ -0,0 +1,35 @@ +"""add fund record images + +Revision ID: 20260515_add_fund_record_images +Revises: 20260507_add_wechat_identity +Create Date: 2026-05-15 09:00:00 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260515_add_fund_record_images" +down_revision = "20260507_add_wechat_identity" +branch_labels = None +depends_on = None + + +def _has_column(inspector: sa.engine.reflection.Inspector, table_name: str, column_name: str) -> bool: + return any(column["name"] == column_name for column in inspector.get_columns(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "fund_records" in inspector.get_table_names() and not _has_column(inspector, "fund_records", "image_urls"): + op.add_column("fund_records", sa.Column("image_urls", sa.Text(), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "fund_records" in inspector.get_table_names() and _has_column(inspector, "fund_records", "image_urls"): + op.drop_column("fund_records", "image_urls") diff --git a/backend/app/api/directory.py b/backend/app/api/directory.py index e4b8002..21ed5b6 100644 --- a/backend/app/api/directory.py +++ b/backend/app/api/directory.py @@ -6,12 +6,31 @@ from app.db.database import get_db from app.db.models import User from app.schemas.user import UserPublic from app.schemas.common import PageResponse -from app.services.directory_service import search_directory, user_to_public +from app.services.directory_service import get_directory_role_counts, search_directory, user_to_public from app.services.user_service import get_user_by_id router = APIRouter(prefix="/api/directory", tags=["directory"]) +@router.get("/stats") +async def get_directory_stats( + class_id: int | None = None, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + effective_class_id = resolve_class_id_for_user(user, class_id) + if effective_class_id is None: + return {"student_count": 0, "teacher_count": 0, "total": 0} + ensure_class_access(user, effective_class_id) + await ensure_class_module_enabled(db, effective_class_id, "directory") + counts = await get_directory_role_counts(db, effective_class_id) + return { + "student_count": counts["student"], + "teacher_count": counts["teacher"], + "total": counts["total"], + } + + @router.get("/", response_model=PageResponse[UserPublic]) async def search_members( search: str | None = None, diff --git a/backend/app/api/fund.py b/backend/app/api/fund.py index b9827da..0cff749 100644 --- a/backend/app/api/fund.py +++ b/backend/app/api/fund.py @@ -22,6 +22,7 @@ def record_to_out(record: FundRecord) -> FundRecordOut: amount=record.amount, category=record.category, description=record.description, + image_urls=record.get_image_urls_list(), record_date=record.record_date, recorder_id=record.recorder_id, recorder_name=record.recorder.name if record.recorder else "Unknown", @@ -69,6 +70,20 @@ async def get_fund_records( return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages) +@router.get("/{record_id}", response_model=FundRecordOut) +async def get_fund_record_detail( + record_id: int, + user: User = Depends(require_role("super_admin", "teacher", "student")), + 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") + ensure_class_access(user, record.class_id) + await ensure_class_module_enabled(db, record.class_id, "fund") + return record_to_out(record) + + @router.post("/", response_model=FundRecordOut) async def create_new_record( data: FundRecordCreate, diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 2872d58..adf3b56 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -562,6 +562,7 @@ class FundRecord(Base): 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) + image_urls: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array record_date: Mapped[datetime] = mapped_column(Date, nullable=False) recorder_id: Mapped[int] = mapped_column( Integer, ForeignKey("users.id"), nullable=False @@ -573,3 +574,14 @@ class FundRecord(Base): class_: Mapped["Class_"] = relationship("Class_", back_populates="fund_records") recorder: Mapped["User"] = relationship("User") + + def get_image_urls_list(self) -> list[str]: + if not self.image_urls: + return [] + try: + return json.loads(self.image_urls) + except (json.JSONDecodeError, TypeError): + return [] + + def set_image_urls_list(self, urls: list[str]): + self.image_urls = json.dumps(urls) if urls else None diff --git a/backend/app/schemas/fund.py b/backend/app/schemas/fund.py index 6263f0b..24d5269 100644 --- a/backend/app/schemas/fund.py +++ b/backend/app/schemas/fund.py @@ -8,6 +8,7 @@ class FundRecordCreate(BaseModel): amount: float category: str description: str | None = None + image_urls: list[str] | None = None record_date: date @@ -16,6 +17,7 @@ class FundRecordUpdate(BaseModel): amount: float | None = None category: str | None = None description: str | None = None + image_urls: list[str] | None = None record_date: date | None = None @@ -26,6 +28,7 @@ class FundRecordOut(BaseModel): amount: float category: str description: str | None + image_urls: list[str] | None record_date: date recorder_id: int recorder_name: str @@ -43,4 +46,4 @@ class FundStatistics(BaseModel): total_expense: float balance: float income_by_category: list[CategoryAmount] - expense_by_category: list[CategoryAmount] \ No newline at end of file + expense_by_category: list[CategoryAmount] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 2639eb2..ab7a343 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -49,6 +49,7 @@ class UserPublic(BaseModel): id: int name: str student_id: str | None + membership_role: str | None = None industry: str | None company: str | None position: str | None diff --git a/backend/app/services/directory_service.py b/backend/app/services/directory_service.py index b805b62..4f2b9a1 100644 --- a/backend/app/services/directory_service.py +++ b/backend/app/services/directory_service.py @@ -78,6 +78,21 @@ async def search_directory( return users, total +async def get_directory_role_counts(db: AsyncSession, class_id: int) -> dict[str, int]: + result = await db.execute( + select(ClassMembership.membership_role, func.count(ClassMembership.id)) + .join(User) + .where(ClassMembership.class_id == class_id, User.status == "approved") + .group_by(ClassMembership.membership_role) + ) + counts = {"student": 0, "teacher": 0} + for role, count in result.all(): + if role in counts: + counts[role] = count + counts["total"] = counts["student"] + counts["teacher"] + return counts + + def user_to_public( user: User, class_id: int | None = None, include_contact: bool = True ) -> UserPublic: @@ -87,6 +102,7 @@ def user_to_public( id=user.id, name=user.name, student_id=user.student_id, + membership_role=membership.membership_role if membership else None, industry=user.industry, company=user.company, position=user.position, diff --git a/backend/app/services/fund_service.py b/backend/app/services/fund_service.py index d8bfb61..808ee26 100644 --- a/backend/app/services/fund_service.py +++ b/backend/app/services/fund_service.py @@ -16,8 +16,10 @@ async def create_fund_record( amount=data.amount, category=data.category, description=data.description, + image_urls=None, record_date=data.record_date, ) + record.set_image_urls_list(data.image_urls or []) db.add(record) await db.commit() await db.refresh(record) @@ -27,8 +29,13 @@ async def create_fund_record( async def update_fund_record( db: AsyncSession, record: FundRecord, data: FundRecordUpdate ) -> FundRecord: - for field, value in data.model_dump(exclude_unset=True).items(): + values = data.model_dump(exclude_unset=True) + has_image_urls = "image_urls" in values + image_urls = values.pop("image_urls", None) + for field, value in values.items(): setattr(record, field, value) + if has_image_urls: + record.set_image_urls_list(image_urls or []) await db.commit() await db.refresh(record) return record @@ -124,4 +131,4 @@ async def get_fund_statistics(db: AsyncSession, class_id: int) -> FundStatistics 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)/directory/[id]/page.tsx b/frontend/src/app/(app)/directory/[id]/page.tsx index 7fe5049..b4d705c 100644 --- a/frontend/src/app/(app)/directory/[id]/page.tsx +++ b/frontend/src/app/(app)/directory/[id]/page.tsx @@ -68,12 +68,17 @@ export default function MemberDetailPage() {
学号: {member.student_id}
)} {member.company && ( diff --git a/frontend/src/app/(app)/directory/page.tsx b/frontend/src/app/(app)/directory/page.tsx index f6ed95a..2f56c4a 100644 --- a/frontend/src/app/(app)/directory/page.tsx +++ b/frontend/src/app/(app)/directory/page.tsx @@ -24,6 +24,7 @@ export default function DirectoryPage() { const { activeClassId } = useActiveClass(); const [members, setMembers] = useState共 {total} 位成员,按行业、公司与研究兴趣建立连接
++ 同学 {roleCounts.student_count} 人,老师 {roleCounts.teacher_count} 人;当前筛选 {total} 条结果 +
{member.name}
+ {member.membership_role === "teacher" && ( +- - {r.type === "income" ? "+" : "-"}¥{r.amount.toFixed(2)} - - {r.category} -
-- {r.record_date} · {r.recorder_name} - {r.description ? ` · ${r.description}` : ""} -
-可上传收据、小票或转账凭证,最多 6 张。
+ {formImageUrls.length > 0 && ( +