diff --git a/backend/alembic/versions/20260507_add_wechat_identity_fields.py b/backend/alembic/versions/20260507_add_wechat_identity_fields.py new file mode 100644 index 0000000..0c2f853 --- /dev/null +++ b/backend/alembic/versions/20260507_add_wechat_identity_fields.py @@ -0,0 +1,63 @@ +"""add wechat identity fields + +Revision ID: 20260507_add_wechat_identity +Revises: 20260501_add_reading_corner +Create Date: 2026-05-07 10:00:00 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260507_add_wechat_identity" +down_revision = "20260501_add_reading_corner" +branch_labels = None +depends_on = None + + +def _has_column(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: + return column_name in {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + if "users" not in tables: + return + + if not _has_column(inspector, "users", "wechat_openid"): + op.add_column("users", sa.Column("wechat_openid", sa.String(length=128), nullable=True)) + if not _has_column(inspector, "users", "wechat_unionid"): + op.add_column("users", sa.Column("wechat_unionid", sa.String(length=128), nullable=True)) + if not _has_column(inspector, "users", "phone_verified_at"): + op.add_column("users", sa.Column("phone_verified_at", sa.DateTime(), nullable=True)) + + inspector = sa.inspect(bind) + indexes = {index["name"] for index in inspector.get_indexes("users")} + if "ix_users_wechat_openid" not in indexes: + op.create_index("ix_users_wechat_openid", "users", ["wechat_openid"], unique=True) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + if "users" not in tables: + return + + indexes = {index["name"] for index in inspector.get_indexes("users")} + if "ix_users_wechat_openid" in indexes: + op.drop_index("ix_users_wechat_openid", table_name="users") + + inspector = sa.inspect(bind) + if _has_column(inspector, "users", "phone_verified_at"): + op.drop_column("users", "phone_verified_at") + inspector = sa.inspect(bind) + if _has_column(inspector, "users", "wechat_unionid"): + op.drop_column("users", "wechat_unionid") + inspector = sa.inspect(bind) + if _has_column(inspector, "users", "wechat_openid"): + op.drop_column("users", "wechat_openid") diff --git a/backend/app/api/announcements.py b/backend/app/api/announcements.py index 84a045f..0e1fdb1 100644 --- a/backend/app/api/announcements.py +++ b/backend/app/api/announcements.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import ensure_class_access, ensure_class_permission, require_role, resolve_class_id_for_user +from app.core.deps import ensure_class_access, ensure_class_module_enabled, ensure_class_permission, require_role, resolve_class_id_for_user from app.db.database import get_db from app.db.models import User from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut @@ -29,6 +29,7 @@ async def get_announcements( 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) + await ensure_class_module_enabled(db, effective_class_id, "announcements") announcements, total = await list_announcements(db, effective_class_id, page, page_size) total_pages = (total + page_size - 1) // page_size @@ -64,6 +65,7 @@ async def create_new_announcement( 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") + await ensure_class_module_enabled(db, effective_class_id, "announcements") ensure_class_permission(user, "announcement_manage", effective_class_id) announcement = await create_announcement(db, effective_class_id, user.id, data) @@ -90,6 +92,7 @@ async def update_existing_announcement( announcement = await get_announcement_by_id(db, announcement_id) if announcement is None: raise HTTPException(status_code=404, detail="Announcement not found") + await ensure_class_module_enabled(db, announcement.class_id, "announcements") ensure_class_permission(user, "announcement_manage", announcement.class_id) updated = await update_announcement(db, announcement, data) @@ -115,6 +118,7 @@ async def delete_existing_announcement( announcement = await get_announcement_by_id(db, announcement_id) if announcement is None: raise HTTPException(status_code=404, detail="Announcement not found") + await ensure_class_module_enabled(db, announcement.class_id, "announcements") ensure_class_permission(user, "announcement_manage", announcement.class_id) await delete_announcement(db, announcement) diff --git a/backend/app/api/assignments.py b/backend/app/api/assignments.py index df8db7c..f1bbc8d 100644 --- a/backend/app/api/assignments.py +++ b/backend/app/api/assignments.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import selectinload from app.core.deps import ( ensure_class_access, + ensure_class_module_enabled, ensure_class_permission, get_effective_class_permissions, require_role, @@ -95,6 +96,7 @@ async def get_assignments( 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) + await ensure_class_module_enabled(db, effective_class_id, "assignments") assignments, total = await list_assignments(db, effective_class_id, page, page_size) total_pages = (total + page_size - 1) // page_size @@ -113,6 +115,7 @@ async def create_new_assignment( 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") + await ensure_class_module_enabled(db, effective_class_id, "assignments") ensure_class_permission(user, "assignment_manage", effective_class_id) assignment = await create_assignment(db, effective_class_id, user.id, data) @@ -130,6 +133,7 @@ async def upload_assignment_attachments( assignment = await get_assignment_by_id(db, assignment_id) if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") + await ensure_class_module_enabled(db, assignment.class_id, "assignments") ensure_class_permission(user, "assignment_manage", assignment.class_id) urls = [] @@ -157,6 +161,7 @@ async def get_assignment_detail( if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") ensure_class_access(user, assignment.class_id) + await ensure_class_module_enabled(db, assignment.class_id, "assignments") base = _build_assignment_out(assignment, user.id, await _get_member_count(db, assignment.class_id)) @@ -184,6 +189,7 @@ async def update_existing_assignment( assignment = await get_assignment_by_id(db, assignment_id) if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") + await ensure_class_module_enabled(db, assignment.class_id, "assignments") ensure_class_permission(user, "assignment_manage", assignment.class_id) updated = await update_assignment(db, assignment, data) @@ -199,6 +205,7 @@ async def delete_existing_assignment( assignment = await get_assignment_by_id(db, assignment_id) if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") + await ensure_class_module_enabled(db, assignment.class_id, "assignments") ensure_class_permission(user, "assignment_manage", assignment.class_id) await delete_assignment(db, assignment) @@ -217,6 +224,7 @@ async def submit_assignment( if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") ensure_class_access(user, assignment.class_id) + await ensure_class_module_enabled(db, assignment.class_id, "assignments") # Upload file file_url = None @@ -269,6 +277,7 @@ async def grade_assignment_submission( assignment = await get_assignment_by_id(db, submission.assignment_id) if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") + await ensure_class_module_enabled(db, assignment.class_id, "assignments") ensure_class_permission(user, "assignment_manage", assignment.class_id) graded = await grade_submission(db, submission, data) diff --git a/backend/app/api/classes.py b/backend/app/api/classes.py index 0992bc7..10bcc26 100644 --- a/backend/app/api/classes.py +++ b/backend/app/api/classes.py @@ -48,16 +48,25 @@ async def get_classes( db: AsyncSession = Depends(get_db), ): if user.role not in {"super_admin", "teacher"}: - membership = user.get_default_membership() - if membership is None: + memberships = user.memberships or [] + if not memberships: return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) - class_ = await get_class_by_id(db, membership.class_id) - if class_ is None: - return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) - count = await get_member_count(db, class_.id) - out = ClassOut.model_validate(class_) - out.member_count = count - return PageResponse(items=[out], total=1, page=1, page_size=page_size, total_pages=1) + result = [] + for membership in memberships: + class_ = await get_class_by_id(db, membership.class_id) + if class_ is None: + continue + count = await get_member_count(db, class_.id) + out = ClassOut.model_validate(class_) + out.member_count = count + result.append(out) + return PageResponse( + items=result, + total=len(result), + page=1, + page_size=page_size, + total_pages=1 if result else 0, + ) classes, total = await list_classes(db, page, page_size) total_pages = (total + page_size - 1) // page_size diff --git a/backend/app/api/directory.py b/backend/app/api/directory.py index 395c3c3..e4b8002 100644 --- a/backend/app/api/directory.py +++ b/backend/app/api/directory.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import ensure_class_access, get_current_user, resolve_class_id_for_user +from app.core.deps import ensure_class_access, ensure_class_module_enabled, get_current_user, resolve_class_id_for_user from app.db.database import get_db from app.db.models import User from app.schemas.user import UserPublic @@ -27,6 +27,7 @@ async def search_members( 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) + await ensure_class_module_enabled(db, effective_class_id, "directory") users, total = await search_directory( db, effective_class_id, search, industry, company, page, page_size @@ -61,6 +62,16 @@ async def get_member_detail( } & { membership.class_id for membership in target.memberships } + module_enabled = False + for shared_class_id in shared_class_ids: + try: + await ensure_class_module_enabled(db, shared_class_id, "directory") + module_enabled = True + break + except HTTPException: + continue + if not module_enabled: + raise HTTPException(status_code=403, detail="该功能当前未开放") include_contact = bool(shared_class_ids) scoped_class_id = next(iter(shared_class_ids), None) return user_to_public(target, scoped_class_id, include_contact=include_contact) diff --git a/backend/app/api/fund.py b/backend/app/api/fund.py index 2bb444d..b9827da 100644 --- a/backend/app/api/fund.py +++ b/backend/app/api/fund.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import ensure_class_access, ensure_class_permission, require_role, resolve_class_id_for_user +from app.core.deps import ensure_class_access, ensure_class_module_enabled, ensure_class_permission, require_role, resolve_class_id_for_user from app.db.database import get_db from app.db.models import FundRecord, User from app.schemas.fund import FundRecordCreate, FundRecordUpdate, FundRecordOut, FundStatistics @@ -43,6 +43,7 @@ async def get_statistics( income_by_category=[], expense_by_category=[] ) ensure_class_access(user, effective_class_id) + await ensure_class_module_enabled(db, effective_class_id, "fund") return await get_fund_statistics(db, effective_class_id) @@ -60,6 +61,7 @@ async def get_fund_records( 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) + await ensure_class_module_enabled(db, effective_class_id, "fund") records, total = await list_fund_records(db, effective_class_id, page, page_size, type, category) total_pages = (total + page_size - 1) // page_size @@ -77,6 +79,7 @@ async def create_new_record( effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: raise HTTPException(status_code=400, detail="No class specified") + await ensure_class_module_enabled(db, effective_class_id, "fund") ensure_class_permission(user, "fund_manage", effective_class_id) if data.type not in ("income", "expense"): @@ -100,6 +103,7 @@ async def update_existing_record( record = await get_fund_record_by_id(db, record_id) if record is None: raise HTTPException(status_code=404, detail="Record not found") + await ensure_class_module_enabled(db, record.class_id, "fund") ensure_class_permission(user, "fund_manage", record.class_id) if data.type is not None and data.type not in ("income", "expense"): @@ -122,6 +126,7 @@ async def delete_existing_record( record = await get_fund_record_by_id(db, record_id) if record is None: raise HTTPException(status_code=404, detail="Record not found") + await ensure_class_module_enabled(db, record.class_id, "fund") ensure_class_permission(user, "fund_manage", record.class_id) await delete_fund_record(db, record) diff --git a/backend/app/api/reading.py b/backend/app/api/reading.py index 00f33fe..405024d 100644 --- a/backend/app/api/reading.py +++ b/backend/app/api/reading.py @@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import ( ensure_class_access, + ensure_class_module_enabled, get_effective_class_permissions, require_role, resolve_class_id_for_user, @@ -98,6 +99,7 @@ async def get_my_reading_summary( reading_count=0, finished_count=0, total_pages_read=0, month_score=0 ) ensure_class_access(user, effective_class_id) + await ensure_class_module_enabled(db, effective_class_id, "reading_corner") return ReadingSummary(**await get_summary(db, effective_class_id, user.id)) @@ -116,6 +118,7 @@ async def get_books( 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) + await ensure_class_module_enabled(db, effective_class_id, "reading_corner") owner_id = user.id if owner == "me" else None books, total = await list_books( @@ -142,6 +145,7 @@ async def get_feed( 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) + await ensure_class_module_enabled(db, effective_class_id, "reading_corner") items, total = await list_feed_items(db, effective_class_id, page, page_size) return PageResponse( @@ -164,6 +168,7 @@ async def create_new_book( 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) + await ensure_class_module_enabled(db, effective_class_id, "reading_corner") try: book = await create_book(db, effective_class_id, user.id, data) except ValueError as exc: @@ -185,6 +190,7 @@ async def update_existing_book( if book is None: raise HTTPException(status_code=404, detail="Book not found") ensure_class_access(user, book.class_id) + await ensure_class_module_enabled(db, book.class_id, "reading_corner") if book.owner_id != user.id and not _can_manage(user, book.class_id): raise HTTPException(status_code=403, detail="Access denied") try: @@ -207,6 +213,7 @@ async def delete_existing_book( if book is None: raise HTTPException(status_code=404, detail="Book not found") ensure_class_access(user, book.class_id) + await ensure_class_module_enabled(db, book.class_id, "reading_corner") 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) @@ -223,6 +230,7 @@ async def refresh_book_cover( if book is None: raise HTTPException(status_code=404, detail="Book not found") ensure_class_access(user, book.class_id) + await ensure_class_module_enabled(db, book.class_id, "reading_corner") 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: @@ -248,6 +256,7 @@ async def get_notes( 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) + await ensure_class_module_enabled(db, effective_class_id, "reading_corner") notes, total = await list_notes( db, @@ -280,6 +289,7 @@ async def create_new_note( if book is None: raise HTTPException(status_code=404, detail="Book not found") ensure_class_access(user, book.class_id) + await ensure_class_module_enabled(db, book.class_id, "reading_corner") 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) @@ -298,6 +308,7 @@ async def update_existing_note( if note is None: raise HTTPException(status_code=404, detail="Note not found") ensure_class_access(user, note.book.class_id) + await ensure_class_module_enabled(db, note.book.class_id, "reading_corner") 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) @@ -315,6 +326,7 @@ async def delete_existing_note( if note is None: raise HTTPException(status_code=404, detail="Note not found") ensure_class_access(user, note.book.class_id) + await ensure_class_module_enabled(db, note.book.class_id, "reading_corner") 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) @@ -334,6 +346,7 @@ async def get_rankings( if effective_class_id is None: return ReadingRankingResponse(period=period, items=[]) ensure_class_access(user, effective_class_id) + await ensure_class_module_enabled(db, effective_class_id, "reading_corner") rows = await get_ranking_rows(db, effective_class_id, period=period) return ReadingRankingResponse( period=period, diff --git a/backend/app/api/resources.py b/backend/app/api/resources.py index 516ce2c..d45f690 100644 --- a/backend/app/api/resources.py +++ b/backend/app/api/resources.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import ensure_class_access, ensure_class_permission, require_role, resolve_class_id_for_user +from app.core.deps import ensure_class_access, ensure_class_module_enabled, ensure_class_permission, require_role, resolve_class_id_for_user from app.db.database import get_db from app.db.models import User from app.schemas.resource import ResourceCreate, ResourceOut @@ -47,6 +47,7 @@ async def get_resources( 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) + await ensure_class_module_enabled(db, effective_class_id, "resources") resources, total = await list_resources(db, effective_class_id, category, page, page_size) total_pages = (total + page_size - 1) // page_size @@ -88,6 +89,7 @@ async def upload_new_resource( 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") + await ensure_class_module_enabled(db, effective_class_id, "resources") ensure_class_permission(user, "resource_manage", effective_class_id) contents = await file.read() @@ -135,6 +137,7 @@ async def download_resource( if resource is None: raise HTTPException(status_code=404, detail="Resource not found") ensure_class_access(user, resource.class_id) + await ensure_class_module_enabled(db, resource.class_id, "resources") await increment_download_count(db, resource) return {"file_url": resource.file_url} @@ -149,6 +152,7 @@ async def delete_existing_resource( resource = await get_resource_by_id(db, resource_id) if resource is None: raise HTTPException(status_code=404, detail="Resource not found") + await ensure_class_module_enabled(db, resource.class_id, "resources") ensure_class_permission(user, "resource_manage", resource.class_id) await delete_resource(db, resource) diff --git a/backend/app/api/schedule.py b/backend/app/api/schedule.py index 72d789c..089055d 100644 --- a/backend/app/api/schedule.py +++ b/backend/app/api/schedule.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import ensure_class_access, ensure_class_permission, require_role, resolve_class_id_for_user +from app.core.deps import ensure_class_access, ensure_class_module_enabled, ensure_class_permission, require_role, resolve_class_id_for_user from app.db.database import get_db from app.db.models import User from app.schemas.schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut @@ -29,6 +29,7 @@ async def get_upcoming( if effective_class_id is None: return [] ensure_class_access(user, effective_class_id) + await ensure_class_module_enabled(db, effective_class_id, "schedule") items = await get_upcoming_schedules(db, effective_class_id, limit) return [ScheduleOut.model_validate(i) for i in items] @@ -46,6 +47,7 @@ async def get_schedules( 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) + await ensure_class_module_enabled(db, effective_class_id, "schedule") items, total = await list_schedules(db, effective_class_id, type, page, page_size) total_pages = (total + page_size - 1) // page_size @@ -58,6 +60,20 @@ async def get_schedules( ) +@router.get("/{schedule_id}", response_model=ScheduleOut) +async def get_schedule_detail( + schedule_id: int, + user: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + item = await get_schedule_by_id(db, schedule_id) + if item is None: + raise HTTPException(status_code=404, detail="Schedule not found") + ensure_class_access(user, item.class_id) + await ensure_class_module_enabled(db, item.class_id, "schedule") + return ScheduleOut.model_validate(item) + + @router.post("/", response_model=ScheduleOut) async def create_new_schedule( data: ScheduleCreate, @@ -68,6 +84,7 @@ async def create_new_schedule( 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") + await ensure_class_module_enabled(db, effective_class_id, "schedule") ensure_class_permission(user, "schedule_manage", effective_class_id) item = await create_schedule(db, effective_class_id, data) @@ -84,6 +101,7 @@ async def update_existing_schedule( item = await get_schedule_by_id(db, schedule_id) if item is None: raise HTTPException(status_code=404, detail="Schedule not found") + await ensure_class_module_enabled(db, item.class_id, "schedule") ensure_class_permission(user, "schedule_manage", item.class_id) updated = await update_schedule(db, item, data) @@ -99,6 +117,7 @@ async def delete_existing_schedule( item = await get_schedule_by_id(db, schedule_id) if item is None: raise HTTPException(status_code=404, detail="Schedule not found") + await ensure_class_module_enabled(db, item.class_id, "schedule") ensure_class_permission(user, "schedule_manage", item.class_id) await delete_schedule(db, item) diff --git a/backend/app/api/timeline.py b/backend/app/api/timeline.py index 8555ac7..bc9debe 100644 --- a/backend/app/api/timeline.py +++ b/backend/app/api/timeline.py @@ -4,6 +4,7 @@ import asyncio from app.core.deps import ( ensure_class_access, + ensure_class_module_enabled, ensure_class_permission, get_effective_class_permissions, require_role, @@ -81,6 +82,7 @@ async def get_timelines( 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) + await ensure_class_module_enabled(db, effective_class_id, "timeline") posts, total = await list_timelines(db, effective_class_id, page, page_size) total_pages = (total + page_size - 1) // page_size @@ -105,6 +107,7 @@ async def create_new_timeline( 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) + await ensure_class_module_enabled(db, effective_class_id, "timeline") data = TimelineCreate(title=title, content=content) post = await create_timeline(db, effective_class_id, user.id, data) @@ -149,6 +152,7 @@ async def upload_timeline_images( post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") + await ensure_class_module_enabled(db, post.class_id, "timeline") # Student can only upload to own post; admin can upload to any in their class can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id) @@ -170,6 +174,20 @@ async def upload_timeline_images( return {"image_urls": urls} +@router.get("/{post_id}", response_model=TimelineOut) +async def get_timeline_detail( + post_id: int, + user: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + post = await get_timeline_by_id(db, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + ensure_class_access(user, post.class_id) + await ensure_class_module_enabled(db, post.class_id, "timeline") + return _build_timeline_out(post, user.id, include_comments=True) + + @router.put("/{post_id}", response_model=TimelineOut) async def update_existing_timeline( post_id: int, @@ -180,6 +198,7 @@ async def update_existing_timeline( post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") + await ensure_class_module_enabled(db, post.class_id, "timeline") can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id) if not can_manage and post.author_id != user.id: raise HTTPException(status_code=403, detail="Access denied") @@ -198,6 +217,7 @@ async def delete_existing_timeline( post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") + await ensure_class_module_enabled(db, post.class_id, "timeline") can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id) if not can_manage and post.author_id != user.id: raise HTTPException(status_code=403, detail="Access denied") @@ -219,6 +239,7 @@ async def like_timeline_post( if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") ensure_class_access(user, post.class_id) + await ensure_class_module_enabled(db, post.class_id, "timeline") return await toggle_like(db, post_id, user.id) @@ -234,6 +255,7 @@ async def get_post_comments( if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") ensure_class_access(user, post.class_id) + await ensure_class_module_enabled(db, post.class_id, "timeline") comments, total = await list_comments(db, post_id, page, page_size) total_pages = (total + page_size - 1) // page_size items = [ @@ -262,6 +284,7 @@ async def add_post_comment( if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") ensure_class_access(user, post.class_id) + await ensure_class_module_enabled(db, post.class_id, "timeline") comment = await create_comment(db, post_id, user.id, data) return TimelineCommentOut( @@ -287,6 +310,7 @@ async def delete_timeline_comment( post = await get_timeline_by_id(db, comment.post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") + await ensure_class_module_enabled(db, post.class_id, "timeline") can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id) if not can_manage and comment.author_id != user.id: raise HTTPException(status_code=403, detail="Access denied") diff --git a/backend/app/api/votes.py b/backend/app/api/votes.py index f6e4f78..6c74ab5 100644 --- a/backend/app/api/votes.py +++ b/backend/app/api/votes.py @@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import ( ensure_class_access, + ensure_class_module_enabled, ensure_class_permission, get_effective_class_permissions, require_role, @@ -83,6 +84,7 @@ async def get_votes( 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) + await ensure_class_module_enabled(db, effective_class_id, "votes") votes, total = await list_votes(db, effective_class_id, page, page_size) total_pages = (total + page_size - 1) // page_size @@ -106,6 +108,7 @@ async def create_new_vote( 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) + await ensure_class_module_enabled(db, effective_class_id, "votes") vote = await create_vote(db, effective_class_id, user.id, data) # Reload with relationships @@ -123,6 +126,7 @@ async def get_vote_detail( if vote is None: raise HTTPException(status_code=404, detail="Vote not found") ensure_class_access(user, vote.class_id) + await ensure_class_module_enabled(db, vote.class_id, "votes") return _build_vote_out(vote, user.id) @@ -137,6 +141,7 @@ async def submit_vote_response( if vote is None: raise HTTPException(status_code=404, detail="Vote not found") ensure_class_access(user, vote.class_id) + await ensure_class_module_enabled(db, vote.class_id, "votes") try: await submit_vote(db, vote_id, user.id, data.option_ids) @@ -155,6 +160,7 @@ async def close_vote_endpoint( vote = await get_vote_by_id(db, vote_id) if vote is None: raise HTTPException(status_code=404, detail="Vote not found") + await ensure_class_module_enabled(db, vote.class_id, "votes") # Only creator or admin can close can_manage = "vote_manage" in get_effective_class_permissions(user, vote.class_id) if not can_manage and user.role == "student" and vote.creator_id != user.id: @@ -174,6 +180,7 @@ async def delete_vote_endpoint( vote = await get_vote_by_id(db, vote_id) if vote is None: raise HTTPException(status_code=404, detail="Vote not found") + await ensure_class_module_enabled(db, vote.class_id, "votes") can_manage = "vote_manage" in get_effective_class_permissions(user, vote.class_id) if not can_manage and user.role == "student" and vote.creator_id != user.id: raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票") diff --git a/backend/app/api/wechat.py b/backend/app/api/wechat.py new file mode 100644 index 0000000..9ac2dd7 --- /dev/null +++ b/backend/app/api/wechat.py @@ -0,0 +1,152 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.deps import get_current_user +from app.db.database import get_db +from app.db.models import ClassMembership, User +from app.schemas.user import TokenResponse, build_user_out +from app.schemas.wechat import ( + WeChatBindRequest, + WeChatCurrentBindRequest, + WeChatLoginRequest, + WeChatLoginResponse, + WeChatPhoneUpdateRequest, +) +from app.services.member_activation_service import validate_registration +from app.services.wechat_service import ( + build_wechat_login_result, + create_bind_token, + decode_bind_token, + exchange_code_for_session, + exchange_phone_code, + get_user_by_openid, + mark_wechat_bound, +) + +router = APIRouter(prefix="/api/wechat", tags=["wechat"]) + + +@router.post("/login", response_model=WeChatLoginResponse) +async def wechat_login(req: WeChatLoginRequest, db: AsyncSession = Depends(get_db)): + session = await exchange_code_for_session(req.code) + openid = session["openid"] + unionid = session.get("unionid") + + user = await get_user_by_openid(db, openid) + if user is None: + return WeChatLoginResponse( + binding_required=True, + bind_token=create_bind_token(openid, unionid), + ) + if user.status == "inactive": + raise HTTPException(status_code=403, detail="账号尚未激活") + if user.status == "disabled": + raise HTTPException(status_code=403, detail="账号已被禁用") + return WeChatLoginResponse(**build_wechat_login_result(user)) + + +@router.post("/bind", response_model=TokenResponse) +async def wechat_bind(req: WeChatBindRequest, db: AsyncSession = Depends(get_db)): + openid, unionid = decode_bind_token(req.bind_token) + existing = await get_user_by_openid(db, openid) + if existing is not None and existing.status != "inactive": + raise HTTPException(status_code=400, detail="该微信已绑定账号") + + phone = await exchange_phone_code(req.phone_code) + activation_target = await validate_registration( + db, + req.invite_code.strip().upper(), + req.student_id.strip(), + ) + if activation_target is None: + raise HTTPException(status_code=400, detail="邀请码或学号无效,或账号已激活") + + user, class_id = activation_target + if existing is not None and existing.id != user.id: + raise HTTPException(status_code=400, detail="该微信已绑定其他账号") + if user.phone and user.phone != phone: + raise HTTPException(status_code=400, detail="手机号与预留手机号不一致") + + other_phone_result = await db.execute( + select(User).where(User.phone == phone, User.id != user.id, User.status != "inactive") + ) + if other_phone_result.scalar_one_or_none() is not None: + raise HTTPException(status_code=400, detail="该手机号已绑定其他账号") + + mark_wechat_bound(user, openid, unionid, phone) + await db.commit() + + result = await db.execute( + select(User) + .options( + selectinload(User.memberships), + selectinload(User.memberships).selectinload(ClassMembership.class_), + ) + .where(User.id == user.id) + ) + bound_user = result.scalar_one() + bound_user.set_active_membership(class_id) + login_result = build_wechat_login_result(bound_user, class_id) + return TokenResponse(token=login_result["token"], user=login_result["user"]) + + +@router.post("/phone", response_model=TokenResponse) +async def update_wechat_phone( + req: WeChatPhoneUpdateRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + phone = await exchange_phone_code(req.phone_code) + other_result = await db.execute( + select(User).where(User.phone == phone, User.id != user.id, User.status != "inactive") + ) + if other_result.scalar_one_or_none() is not None: + raise HTTPException(status_code=400, detail="该手机号已绑定其他账号") + user.phone = phone + from datetime import datetime, timezone + + user.phone_verified_at = datetime.now(timezone.utc).replace(tzinfo=None) + await db.commit() + await db.refresh(user) + return TokenResponse( + token=build_wechat_login_result(user)["token"], + user=build_user_out(user), + ) + + +@router.post("/bind-current", response_model=TokenResponse) +async def bind_current_user_wechat( + req: WeChatCurrentBindRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + session = await exchange_code_for_session(req.code) + openid = session["openid"] + unionid = session.get("unionid") + existing = await get_user_by_openid(db, openid) + if existing is not None and existing.id != user.id: + raise HTTPException(status_code=400, detail="该微信已绑定其他账号") + + phone = await exchange_phone_code(req.phone_code) + other_result = await db.execute( + select(User).where(User.phone == phone, User.id != user.id, User.status != "inactive") + ) + if other_result.scalar_one_or_none() is not None: + raise HTTPException(status_code=400, detail="该手机号已绑定其他账号") + + mark_wechat_bound(user, openid, unionid, phone) + await db.commit() + + result = await db.execute( + select(User) + .options( + selectinload(User.memberships), + selectinload(User.memberships).selectinload(ClassMembership.class_), + ) + .where(User.id == user.id) + ) + bound_user = result.scalar_one() + login_result = build_wechat_login_result(bound_user) + return TokenResponse(token=login_result["token"], user=login_result["user"]) diff --git a/backend/app/config.py b/backend/app/config.py index 2b5831c..bc48e79 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -25,6 +25,11 @@ class Settings(BaseSettings): # Book metadata google_books_api_key: str = "" + # WeChat Mini Program + wechat_mini_app_id: str = "wxfac912df4f9cdebe" + wechat_mini_app_secret: str = "360f537c8ebed117b2ca32d533ff2912" + wechat_api_timeout_seconds: float = 8.0 + # SMTP Email smtp_host: str = "" smtp_port: int = 465 diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index f6376c9..6c22b27 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.auth import decode_access_token from app.db.database import get_db -from app.db.models import ClassMembership, User +from app.db.models import ClassMembership, Class_, User security = HTTPBearer() @@ -166,3 +166,22 @@ def ensure_class_permission(user: User, permission: str, class_id: int | None = status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions", ) + + +async def ensure_class_module_enabled( + db: AsyncSession, class_id: int | None, module_key: str +) -> None: + if class_id is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied for this class", + ) + result = await db.execute(select(Class_).where(Class_.id == class_id)) + class_ = result.scalar_one_or_none() + if class_ is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Class not found") + if module_key not in class_.get_enabled_modules(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="该功能当前未开放", + ) diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 6f0987f..2872d58 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -85,6 +85,9 @@ class User(Base): skills_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array wechat_id: Mapped[str | None] = mapped_column(String(100), nullable=True) phone: Mapped[str | None] = mapped_column(String(20), nullable=True) + wechat_openid: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True, index=True) + wechat_unionid: Mapped[str | None] = mapped_column(String(128), nullable=True) + phone_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) avatar_url: Mapped[str | None] = mapped_column(Text, nullable=True) bio: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/main.py b/backend/app/main.py index 3949b4b..71c47a0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, reading, fund +from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments, reading, fund, wechat logging.basicConfig( level=logging.DEBUG if settings.debug else logging.INFO, @@ -58,6 +58,7 @@ app.add_middleware( ) app.include_router(auth.router) +app.include_router(wechat.router) app.include_router(users.router) app.include_router(classes.router) app.include_router(directory.router) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index b06f0fc..2639eb2 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -35,6 +35,8 @@ class UserOut(BaseModel): skills_tags: list[str] | None wechat_id: str | None phone: str | None + wechat_bound: bool = False + phone_verified_at: datetime | None = None avatar_url: str | None bio: str | None created_at: datetime @@ -157,6 +159,8 @@ def build_user_out(user: User, class_id: int | None = None) -> UserOut: skills_tags=user.get_skills_list(), wechat_id=user.wechat_id, phone=user.phone, + wechat_bound=bool(user.wechat_openid), + phone_verified_at=user.phone_verified_at, avatar_url=user.avatar_url, bio=user.bio, created_at=user.created_at, diff --git a/backend/app/schemas/wechat.py b/backend/app/schemas/wechat.py new file mode 100644 index 0000000..43d0742 --- /dev/null +++ b/backend/app/schemas/wechat.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel, Field + +from app.schemas.user import UserOut + + +class WeChatLoginRequest(BaseModel): + code: str = Field(min_length=1, max_length=512) + + +class WeChatLoginResponse(BaseModel): + binding_required: bool + bind_token: str | None = None + token: str | None = None + user: UserOut | None = None + + +class WeChatBindRequest(BaseModel): + bind_token: str = Field(min_length=1) + invite_code: str = Field(min_length=1, max_length=20) + student_id: str = Field(min_length=1, max_length=50) + phone_code: str = Field(min_length=1, max_length=512) + + +class WeChatPhoneUpdateRequest(BaseModel): + phone_code: str = Field(min_length=1, max_length=512) + + +class WeChatCurrentBindRequest(BaseModel): + code: str = Field(min_length=1, max_length=512) + phone_code: str = Field(min_length=1, max_length=512) diff --git a/backend/app/services/wechat_service.py b/backend/app/services/wechat_service.py new file mode 100644 index 0000000..581558d --- /dev/null +++ b/backend/app/services/wechat_service.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import httpx +from fastapi import HTTPException +from jose import JWTError, jwt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.config import settings +from app.core.auth import create_access_token +from app.db.models import ClassMembership, User +from app.schemas.user import build_user_out + +WECHAT_BIND_PURPOSE = "wechat_bind" +WECHAT_BIND_TOKEN_EXP_SECONDS = 15 * 60 + + +def _require_wechat_config() -> None: + if not settings.wechat_mini_app_id or not settings.wechat_mini_app_secret: + raise HTTPException(status_code=500, detail="微信小程序配置未完成") + + +async def exchange_code_for_session(code: str) -> dict: + _require_wechat_config() + async with httpx.AsyncClient(timeout=settings.wechat_api_timeout_seconds) as client: + response = await client.get( + "https://api.weixin.qq.com/sns/jscode2session", + params={ + "appid": settings.wechat_mini_app_id, + "secret": settings.wechat_mini_app_secret, + "js_code": code, + "grant_type": "authorization_code", + }, + ) + data = response.json() + if data.get("errcode"): + raise HTTPException(status_code=400, detail=data.get("errmsg") or "微信登录失败") + if not data.get("openid"): + raise HTTPException(status_code=400, detail="微信登录未返回 openid") + return data + + +async def _get_access_token() -> str: + _require_wechat_config() + async with httpx.AsyncClient(timeout=settings.wechat_api_timeout_seconds) as client: + response = await client.get( + "https://api.weixin.qq.com/cgi-bin/token", + params={ + "grant_type": "client_credential", + "appid": settings.wechat_mini_app_id, + "secret": settings.wechat_mini_app_secret, + }, + ) + data = response.json() + if data.get("errcode"): + raise HTTPException(status_code=400, detail=data.get("errmsg") or "获取微信 access_token 失败") + access_token = data.get("access_token") + if not access_token: + raise HTTPException(status_code=400, detail="微信未返回 access_token") + return access_token + + +async def exchange_phone_code(phone_code: str) -> str: + access_token = await _get_access_token() + async with httpx.AsyncClient(timeout=settings.wechat_api_timeout_seconds) as client: + response = await client.post( + "https://api.weixin.qq.com/wxa/business/getuserphonenumber", + params={"access_token": access_token}, + json={"code": phone_code}, + ) + data = response.json() + if data.get("errcode"): + raise HTTPException(status_code=400, detail=data.get("errmsg") or "获取微信手机号失败") + phone_info = data.get("phone_info") or {} + phone = phone_info.get("purePhoneNumber") or phone_info.get("phoneNumber") + if not phone: + raise HTTPException(status_code=400, detail="微信未返回手机号") + return str(phone) + + +def create_bind_token(openid: str, unionid: str | None = None) -> str: + payload = { + "purpose": WECHAT_BIND_PURPOSE, + "openid": openid, + "unionid": unionid, + "exp": datetime.now(timezone.utc) + timedelta(seconds=WECHAT_BIND_TOKEN_EXP_SECONDS), + } + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def decode_bind_token(token: str) -> tuple[str, str | None]: + try: + payload = jwt.decode( + token, + settings.jwt_secret, + algorithms=[settings.jwt_algorithm], + ) + except JWTError: + raise HTTPException(status_code=401, detail="微信绑定凭证无效或已过期") + if payload.get("purpose") != WECHAT_BIND_PURPOSE or not payload.get("openid"): + raise HTTPException(status_code=401, detail="微信绑定凭证无效") + return str(payload["openid"]), payload.get("unionid") + + +async def get_user_by_openid(db: AsyncSession, openid: str) -> User | None: + result = await db.execute( + select(User) + .options( + selectinload(User.memberships), + selectinload(User.memberships).selectinload(ClassMembership.class_), + ) + .where(User.wechat_openid == openid) + ) + return result.scalar_one_or_none() + + +def build_token_payload(user: User) -> dict: + return {"sub": str(user.id), "role": user.role} + + +def mark_wechat_bound(user: User, openid: str, unionid: str | None, phone: str) -> None: + user.wechat_openid = openid + user.wechat_unionid = unionid + user.phone = phone + user.phone_verified_at = datetime.now(timezone.utc).replace(tzinfo=None) + user.status = "approved" + + +def build_wechat_login_result(user: User, class_id: int | None = None) -> dict: + return { + "binding_required": False, + "token": create_access_token(build_token_payload(user)), + "user": build_user_out(user, class_id), + } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b776d4e..7cdddfd 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -37,6 +37,8 @@ export interface AuthUser { skills_tags: string[] | null; wechat_id: string | null; phone: string | null; + wechat_bound: boolean; + phone_verified_at: string | null; avatar_url: string | null; bio: string | null; created_at: string; diff --git a/miniprogram/README.md b/miniprogram/README.md new file mode 100644 index 0000000..346e163 --- /dev/null +++ b/miniprogram/README.md @@ -0,0 +1,30 @@ +# HKU ICB ClassHub 微信小程序 + +第一版小程序定位为同学日常入口和班委轻管理入口,暂不包含作业模块,也不包含 Web 后台管理能力。 + +## 配置 + +1. 在 `utils/config.js` 中配置后端 HTTPS 域名。 +2. 在微信开发者工具中导入 `miniprogram/`。 +3. 后端需要配置: + - `CH_WECHAT_MINI_APP_ID` + - `CH_WECHAT_MINI_APP_SECRET` + +## 模块开关 + +小程序只展示后台 `enabled_modules` 中启用的模块,并且不会展示 `assignments`。如果用户通过旧路径、通知或分享进入已关闭模块,会进入“该功能当前未开放”兜底页。 + +当前小程序模块映射: + +- `announcements`:公告 +- `schedule`:排期 +- `directory`:成员名录 +- `resources`:资源库 +- `fund`:班费 +- `timeline`:班级动态 +- `votes`:投票 +- `reading_corner`:读书角 + +## 班委轻管理 + +有对应班级权限的用户会在模块页看到轻管理入口。第一版支持新增公告、投票、排期和班费记录;成员导入、权限配置、模块启停、作业管理等仍在 Web 后台完成。 diff --git a/miniprogram/app.js b/miniprogram/app.js new file mode 100644 index 0000000..4d440d1 --- /dev/null +++ b/miniprogram/app.js @@ -0,0 +1,31 @@ +const { getStoredUser, refreshMe } = require("./utils/auth"); + +App({ + globalData: { + user: null, + activeClassId: null, + enabledModules: null + }, + + onLaunch() { + const user = getStoredUser(); + if (user) { + this.setUser(user); + refreshMe().catch(() => {}); + } + }, + + setUser(user) { + const savedClass = wx.getStorageSync("active_class") || null; + const savedClassValid = savedClass?.id && user.memberships?.some( + (membership) => membership.class_id === savedClass.id + ); + this.globalData.user = user; + this.globalData.activeClassId = savedClassValid + ? savedClass.id + : user.active_membership?.class_id || user.memberships?.[0]?.class_id || null; + this.globalData.enabledModules = savedClassValid + ? savedClass.enabled_modules + : user.enabled_modules || null; + } +}); diff --git a/miniprogram/app.json b/miniprogram/app.json new file mode 100644 index 0000000..72212e8 --- /dev/null +++ b/miniprogram/app.json @@ -0,0 +1,57 @@ +{ + "pages": [ + "pages/home/index", + "pages/class/index", + "pages/interact/index", + "pages/mine/index", + "pages/bind/index", + "pages/module/index", + "pages/manage/index", + "pages/member-detail/index", + "pages/schedule-detail/index", + "pages/timeline-detail/index", + "pages/timeline-create/index", + "pages/profile-edit/index", + "pages/module-unavailable/index" + ], + "window": { + "navigationBarTitleText": "ClassHub", + "navigationBarBackgroundColor": "#F7F0E8", + "navigationBarTextStyle": "black", + "backgroundColor": "#F7F0E8" + }, + "tabBar": { + "color": "#8C8178", + "selectedColor": "#6B1F2B", + "backgroundColor": "#FFFCF7", + "borderStyle": "white", + "list": [ + { + "pagePath": "pages/home/index", + "text": "首页", + "iconPath": "assets/tabbar/home.png", + "selectedIconPath": "assets/tabbar/home-active.png" + }, + { + "pagePath": "pages/class/index", + "text": "班级", + "iconPath": "assets/tabbar/class.png", + "selectedIconPath": "assets/tabbar/class-active.png" + }, + { + "pagePath": "pages/interact/index", + "text": "互动", + "iconPath": "assets/tabbar/interact.png", + "selectedIconPath": "assets/tabbar/interact-active.png" + }, + { + "pagePath": "pages/mine/index", + "text": "我的", + "iconPath": "assets/tabbar/mine.png", + "selectedIconPath": "assets/tabbar/mine-active.png" + } + ] + }, + "style": "v2", + "lazyCodeLoading": "requiredComponents" +} diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss new file mode 100644 index 0000000..61b0853 --- /dev/null +++ b/miniprogram/app.wxss @@ -0,0 +1,368 @@ +page { + background: #f7f0e8; + color: #2f211c; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif; +} + +.page { + min-height: 100vh; + box-sizing: border-box; + padding: 28rpx 28rpx 56rpx; + background: + linear-gradient(180deg, #f7f0e8 0%, #fffaf3 42%, #f7f0e8 100%); +} + +.hero { + position: relative; + overflow: hidden; + border: 1rpx solid rgba(107, 31, 43, 0.14); + border-radius: 40rpx; + background: linear-gradient(145deg, #6b1f2b 0%, #8a3c2a 68%, #d6a653 135%); + color: #fff8ed; + padding: 38rpx 36rpx; + box-shadow: 0 28rpx 60rpx rgba(73, 31, 24, 0.18); +} + +.hero::after { + content: ""; + position: absolute; + top: -80rpx; + right: -70rpx; + width: 260rpx; + height: 260rpx; + border: 1rpx solid rgba(255, 248, 237, 0.2); + border-radius: 999rpx; + background: rgba(255, 248, 237, 0.09); +} + +.eyebrow { + display: inline-flex; + align-items: center; + min-height: 38rpx; + padding: 0 18rpx; + border: 1rpx solid rgba(255, 248, 237, 0.24); + border-radius: 999rpx; + color: rgba(255, 248, 237, 0.78); + font-size: 20rpx; + font-weight: 600; + letter-spacing: 1rpx; +} + +.hero-title { + position: relative; + margin-top: 22rpx; + max-width: 560rpx; + font-size: 44rpx; + font-weight: 750; + line-height: 1.18; +} + +.hero-subtitle { + position: relative; + margin-top: 14rpx; + max-width: 520rpx; + color: rgba(255, 248, 237, 0.76); + font-size: 26rpx; + line-height: 1.55; +} + +.hero-metrics { + position: relative; + display: flex; + gap: 18rpx; + margin-top: 30rpx; +} + +.metric { + flex: 1; + min-height: 112rpx; + box-sizing: border-box; + padding: 18rpx; + border: 1rpx solid rgba(255, 248, 237, 0.16); + border-radius: 24rpx; + background: rgba(255, 255, 255, 0.1); +} + +.metric-number { + font-size: 36rpx; + font-weight: 760; +} + +.metric-label { + margin-top: 4rpx; + color: rgba(255, 248, 237, 0.68); + font-size: 21rpx; +} + +.section { + margin-top: 36rpx; +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 18rpx; +} + +.section-title { + color: #31211c; + font-size: 32rpx; + font-weight: 760; +} + +.section-action { + color: #8b5a36; + font-size: 24rpx; + font-weight: 600; +} + +.card { + position: relative; + overflow: hidden; + margin-bottom: 18rpx; + border: 1rpx solid rgba(121, 84, 54, 0.12); + border-radius: 28rpx; + background: rgba(255, 252, 247, 0.94); + padding: 26rpx; + box-shadow: 0 16rpx 42rpx rgba(68, 39, 27, 0.08); +} + +.menu-card { + margin-bottom: 16rpx; + border: 1rpx solid rgba(121, 84, 54, 0.1); + border-radius: 26rpx; + background: rgba(255, 252, 247, 0.96); + padding: 24rpx; +} + +.chevron { + color: #b09a86; + font-size: 48rpx; + line-height: 1; +} + +.mine-class-pill { + position: relative; + display: inline-flex; + align-items: center; + max-width: 560rpx; + min-height: 50rpx; + margin-top: 24rpx; + padding: 0 22rpx; + border: 1rpx solid rgba(255, 248, 237, 0.18); + border-radius: 999rpx; + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 248, 237, 0.9); + font-size: 24rpx; + font-weight: 650; +} + +.card-title { + color: #3a221d; + font-size: 30rpx; + font-weight: 700; + line-height: 1.35; +} + +.muted { + color: #8a7b70; + font-size: 24rpx; + line-height: 1.55; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20rpx; +} + +.module-tile { + min-height: 168rpx; + box-sizing: border-box; + border: 1rpx solid rgba(121, 84, 54, 0.12); + border-radius: 30rpx; + background: linear-gradient(180deg, #fffdf8 0%, #fff7ed 100%); + padding: 24rpx; + box-shadow: 0 18rpx 42rpx rgba(68, 39, 27, 0.07); +} + +.module-icon { + display: flex; + align-items: center; + justify-content: center; + width: 58rpx; + height: 58rpx; + margin-bottom: 18rpx; + border-radius: 20rpx; + background: #6b1f2b; + color: #fff7ea; + font-size: 24rpx; + font-weight: 760; +} + +.module-title { + color: #34211c; + font-size: 29rpx; + font-weight: 720; +} + +.module-desc { + margin-top: 10rpx; + color: #8a7b70; + font-size: 23rpx; + line-height: 1.45; +} + +.button { + margin-top: 22rpx; + border-radius: 22rpx; + background: #6b1f2b; + color: #fff8ea; + font-weight: 650; + box-shadow: 0 16rpx 34rpx rgba(107, 31, 43, 0.16); +} + +.button.secondary { + border: 1rpx solid rgba(121, 84, 54, 0.16); + background: #fffaf3; + color: #6b1f2b; + box-shadow: none; +} + +.empty { + margin-top: 120rpx; + text-align: center; +} + +.list-row { + display: flex; + align-items: center; + gap: 22rpx; +} + +.row-mark { + display: flex; + align-items: center; + justify-content: center; + width: 64rpx; + height: 64rpx; + flex: none; + border-radius: 22rpx; + background: #f1dfc4; + color: #6b1f2b; + font-size: 24rpx; + font-weight: 760; +} + +.row-body { + min-width: 0; + flex: 1; +} + +.pill { + display: inline-flex; + align-items: center; + min-height: 42rpx; + padding: 0 18rpx; + border-radius: 999rpx; + background: #f2e5d6; + color: #7a4b2b; + font-size: 22rpx; + font-weight: 650; +} + +.member-row, +.schedule-row, +.feed-head { + display: flex; + align-items: center; + gap: 22rpx; +} + +.more-dot { + flex: none; + min-width: 58rpx; + height: 44rpx; + border-radius: 999rpx; + color: #a7988b; + font-size: 30rpx; + line-height: 36rpx; + text-align: center; +} + +.avatar { + display: flex; + align-items: center; + justify-content: center; + width: 82rpx; + height: 82rpx; + flex: none; + border-radius: 30rpx; + background: linear-gradient(145deg, #6b1f2b, #a86b3b); + color: #fff8ed; + font-size: 30rpx; + font-weight: 760; +} + +.date-badge { + width: 86rpx; + height: 92rpx; + flex: none; + border-radius: 26rpx; + background: #efe0ca; + color: #6b1f2b; + text-align: center; +} + +.date-day { + margin-top: 12rpx; + font-size: 34rpx; + font-weight: 780; +} + +.date-month { + margin-top: -2rpx; + font-size: 20rpx; + font-weight: 650; +} + +.feed-card { + display: block; +} + +.feed-content { + margin-top: 20rpx; + color: #4f3930; + font-size: 27rpx; + line-height: 1.6; +} + +.feed-images { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10rpx; + margin-top: 20rpx; +} + +.feed-images image { + width: 100%; + height: 150rpx; + border-radius: 18rpx; + background: #efe0ca; +} + +.feed-actions { + display: flex; + gap: 24rpx; + margin-top: 20rpx; + color: #8a7b70; + font-size: 24rpx; +} + +input, +textarea, +picker { + box-sizing: border-box; + width: 100%; +} diff --git a/miniprogram/assets/tabbar/class-active.png b/miniprogram/assets/tabbar/class-active.png new file mode 100644 index 0000000..d592d97 Binary files /dev/null and b/miniprogram/assets/tabbar/class-active.png differ diff --git a/miniprogram/assets/tabbar/class-active.svg b/miniprogram/assets/tabbar/class-active.svg new file mode 100644 index 0000000..aa545d1 --- /dev/null +++ b/miniprogram/assets/tabbar/class-active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/assets/tabbar/class.png b/miniprogram/assets/tabbar/class.png new file mode 100644 index 0000000..161fad7 Binary files /dev/null and b/miniprogram/assets/tabbar/class.png differ diff --git a/miniprogram/assets/tabbar/class.svg b/miniprogram/assets/tabbar/class.svg new file mode 100644 index 0000000..b1ccd5e --- /dev/null +++ b/miniprogram/assets/tabbar/class.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/assets/tabbar/home-active.png b/miniprogram/assets/tabbar/home-active.png new file mode 100644 index 0000000..63c6476 Binary files /dev/null and b/miniprogram/assets/tabbar/home-active.png differ diff --git a/miniprogram/assets/tabbar/home-active.svg b/miniprogram/assets/tabbar/home-active.svg new file mode 100644 index 0000000..6b08f77 --- /dev/null +++ b/miniprogram/assets/tabbar/home-active.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniprogram/assets/tabbar/home.png b/miniprogram/assets/tabbar/home.png new file mode 100644 index 0000000..aa658b9 Binary files /dev/null and b/miniprogram/assets/tabbar/home.png differ diff --git a/miniprogram/assets/tabbar/home.svg b/miniprogram/assets/tabbar/home.svg new file mode 100644 index 0000000..759a264 --- /dev/null +++ b/miniprogram/assets/tabbar/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniprogram/assets/tabbar/interact-active.png b/miniprogram/assets/tabbar/interact-active.png new file mode 100644 index 0000000..31f81e4 Binary files /dev/null and b/miniprogram/assets/tabbar/interact-active.png differ diff --git a/miniprogram/assets/tabbar/interact-active.svg b/miniprogram/assets/tabbar/interact-active.svg new file mode 100644 index 0000000..8173a73 --- /dev/null +++ b/miniprogram/assets/tabbar/interact-active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/assets/tabbar/interact.png b/miniprogram/assets/tabbar/interact.png new file mode 100644 index 0000000..ad1392a Binary files /dev/null and b/miniprogram/assets/tabbar/interact.png differ diff --git a/miniprogram/assets/tabbar/interact.svg b/miniprogram/assets/tabbar/interact.svg new file mode 100644 index 0000000..c565155 --- /dev/null +++ b/miniprogram/assets/tabbar/interact.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/assets/tabbar/mine-active.png b/miniprogram/assets/tabbar/mine-active.png new file mode 100644 index 0000000..5ca896a Binary files /dev/null and b/miniprogram/assets/tabbar/mine-active.png differ diff --git a/miniprogram/assets/tabbar/mine-active.svg b/miniprogram/assets/tabbar/mine-active.svg new file mode 100644 index 0000000..e056bd9 --- /dev/null +++ b/miniprogram/assets/tabbar/mine-active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/assets/tabbar/mine.png b/miniprogram/assets/tabbar/mine.png new file mode 100644 index 0000000..15d6e54 Binary files /dev/null and b/miniprogram/assets/tabbar/mine.png differ diff --git a/miniprogram/assets/tabbar/mine.svg b/miniprogram/assets/tabbar/mine.svg new file mode 100644 index 0000000..72fe078 --- /dev/null +++ b/miniprogram/assets/tabbar/mine.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/pages/bind/index.js b/miniprogram/pages/bind/index.js new file mode 100644 index 0000000..bdb7c6d --- /dev/null +++ b/miniprogram/pages/bind/index.js @@ -0,0 +1,178 @@ +const { post } = require("../../utils/api"); +const { loginWithWeChat, saveSession } = require("../../utils/auth"); + +Page({ + data: { + loading: false, + mode: "login", + isLoginMode: true, + isActivateMode: false, + isBindCurrentMode: false, + loginMethod: "wechat", + isWechatLogin: true, + isEmailLogin: false, + bindingRequired: false, + currentBindingRequired: false, + bindToken: "", + inviteCode: "", + studentId: "", + email: "", + password: "" + }, + + switchLoginMethod(event) { + const method = event.currentTarget.dataset.method; + this.setData({ + loginMethod: method, + isWechatLogin: method === "wechat", + isEmailLogin: method === "email" + }); + }, + + async loginWithWechatTap() { + this.setData({ loading: true }); + try { + const res = await loginWithWeChat(); + if (!res.binding_required && res.token && res.user) { + saveSession(res.token, res.user); + wx.switchTab({ url: "/pages/home/index" }); + return; + } + this.setData({ + mode: "activate", + isLoginMode: false, + isActivateMode: true, + isBindCurrentMode: false, + bindingRequired: true, + currentBindingRequired: false, + bindToken: res.bind_token || "" + }); + } catch (error) { + wx.showToast({ title: error.message || "微信登录失败", icon: "none" }); + } finally { + this.setData({ loading: false }); + } + }, + + onEmailInput(event) { + this.setData({ email: event.detail.value.trim() }); + }, + + onPasswordInput(event) { + this.setData({ password: event.detail.value }); + }, + + async loginWithEmail() { + if (!this.data.email || !this.data.password) { + wx.showToast({ title: "请输入邮箱和密码", icon: "none" }); + return; + } + this.setData({ loading: true }); + try { + const res = await post("/api/auth/login", { + email: this.data.email, + password: this.data.password + }); + saveSession(res.token, res.user); + if (!res.user.wechat_bound || !res.user.phone) { + this.setData({ + mode: "bind-current", + isLoginMode: false, + isActivateMode: false, + isBindCurrentMode: true, + currentBindingRequired: true, + bindingRequired: false + }); + return; + } + wx.switchTab({ url: "/pages/home/index" }); + } catch (error) { + wx.showToast({ title: error.message || "邮箱登录失败", icon: "none" }); + } finally { + this.setData({ loading: false }); + } + }, + + onInviteCodeInput(event) { + this.setData({ inviteCode: event.detail.value.trim().toUpperCase() }); + }, + + onStudentIdInput(event) { + this.setData({ studentId: event.detail.value.trim() }); + }, + + async bindWithPhone(event) { + const phoneCode = event.detail?.code; + if (!phoneCode) { + wx.showToast({ title: "需要授权手机号完成绑定", icon: "none" }); + return; + } + if (!this.data.inviteCode || !this.data.studentId) { + wx.showToast({ title: "请填写邀请码和学号", icon: "none" }); + return; + } + this.setData({ loading: true }); + try { + const res = await post("/api/wechat/bind", { + bind_token: this.data.bindToken, + invite_code: this.data.inviteCode, + student_id: this.data.studentId, + phone_code: phoneCode + }); + saveSession(res.token, res.user); + wx.switchTab({ url: "/pages/home/index" }); + } catch (error) { + wx.showToast({ title: error.message || "绑定失败", icon: "none" }); + } finally { + this.setData({ loading: false }); + } + }, + + bindCurrentWithPhone(event) { + const phoneCode = event.detail?.code; + if (!phoneCode) { + wx.showToast({ title: "需要授权手机号完成绑定", icon: "none" }); + return; + } + this.setData({ loading: true }); + wx.login({ + success: async ({ code }) => { + try { + const res = await post("/api/wechat/bind-current", { + code, + phone_code: phoneCode + }); + saveSession(res.token, res.user); + wx.switchTab({ url: "/pages/home/index" }); + } catch (error) { + wx.showToast({ title: error.message || "绑定失败", icon: "none" }); + } finally { + this.setData({ loading: false }); + } + }, + fail: () => { + this.setData({ loading: false }); + wx.showToast({ title: "微信登录失败", icon: "none" }); + } + }); + }, + + skipCurrentBinding() { + wx.switchTab({ url: "/pages/home/index" }); + }, + + backToLogin() { + this.setData({ + mode: "login", + isLoginMode: true, + isActivateMode: false, + isBindCurrentMode: false, + loginMethod: "wechat", + isWechatLogin: true, + isEmailLogin: false, + bindingRequired: false, + currentBindingRequired: false, + bindToken: "" + }); + } +}); diff --git a/miniprogram/pages/bind/index.wxml b/miniprogram/pages/bind/index.wxml new file mode 100644 index 0000000..0745103 --- /dev/null +++ b/miniprogram/pages/bind/index.wxml @@ -0,0 +1,71 @@ + + + SECURE SIGN IN + 登录 ClassHub + 可以使用微信快速登录,也可以用原来的邮箱和密码登录。 + + + + + + + + + 激活账号 + 返回登录 + + + 说明 + 没有找到已绑定的微信账号 + 请用班级邀请码、学号和微信手机号激活你的班级账号。 + + + 邀请码 + + + + 学号 + + + + + + + + 绑定微信和手机号 + 邮箱登录成功。为了下次可以微信快速登录,请绑定当前微信和手机号。 + + + + + diff --git a/miniprogram/pages/bind/index.wxss b/miniprogram/pages/bind/index.wxss new file mode 100644 index 0000000..99e2733 --- /dev/null +++ b/miniprogram/pages/bind/index.wxss @@ -0,0 +1,91 @@ +input { + height: 72rpx; + font-size: 30rpx; +} + +.login-panel { + overflow: hidden; + border: 1rpx solid rgba(121, 84, 54, 0.12); + border-radius: 34rpx; + background: rgba(255, 252, 247, 0.96); + padding: 18rpx; + box-shadow: 0 18rpx 42rpx rgba(68, 39, 27, 0.1); +} + +.login-tabs { + display: flex; + gap: 8rpx; + padding: 8rpx; + border-radius: 26rpx; + background: #f1e4d4; +} + +.login-tab { + flex: 1; + height: 72rpx; + border-radius: 22rpx; + color: #8a7b70; + font-size: 27rpx; + font-weight: 700; + line-height: 72rpx; + text-align: center; +} + +.login-tab.active { + background: #fffaf3; + color: #6b1f2b; + box-shadow: 0 10rpx 24rpx rgba(68, 39, 27, 0.08); +} + +.login-method { + padding: 34rpx 12rpx 12rpx; +} + +.wechat-symbol { + display: flex; + align-items: center; + justify-content: center; + width: 92rpx; + height: 92rpx; + margin: 0 auto 24rpx; + border-radius: 32rpx; + background: #6b1f2b; + color: #fff8ed; + font-size: 34rpx; + font-weight: 780; +} + +.login-title { + color: #30211c; + font-size: 34rpx; + font-weight: 780; + text-align: center; +} + +.login-copy { + margin: 14rpx auto 28rpx; + max-width: 560rpx; + color: #8a7b70; + font-size: 25rpx; + line-height: 1.55; + text-align: center; +} + +.form-field { + margin-top: 18rpx; + border: 1rpx solid rgba(121, 84, 54, 0.1); + border-radius: 24rpx; + background: #fffaf3; + padding: 18rpx 22rpx; +} + +.field-label { + margin-bottom: 6rpx; + color: #8a7b70; + font-size: 23rpx; + font-weight: 650; +} + +.login-primary { + margin-top: 28rpx; +} diff --git a/miniprogram/pages/class/index.js b/miniprogram/pages/class/index.js new file mode 100644 index 0000000..b7c11d4 --- /dev/null +++ b/miniprogram/pages/class/index.js @@ -0,0 +1,16 @@ +const { requireLogin } = require("../../utils/auth"); +const { visibleModules } = require("../../utils/modules"); +const { getEnabledModules } = require("../../utils/page-helpers"); + +Page({ + data: { modules: [] }, + + onShow() { + if (!requireLogin()) return; + this.setData({ modules: visibleModules("class", getEnabledModules()) }); + }, + + openModule(event) { + wx.navigateTo({ url: `/pages/module/index?module=${event.currentTarget.dataset.key}` }); + } +}); diff --git a/miniprogram/pages/class/index.json b/miniprogram/pages/class/index.json new file mode 100644 index 0000000..f9b0714 --- /dev/null +++ b/miniprogram/pages/class/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "班级" +} diff --git a/miniprogram/pages/class/index.wxml b/miniprogram/pages/class/index.wxml new file mode 100644 index 0000000..c277a56 --- /dev/null +++ b/miniprogram/pages/class/index.wxml @@ -0,0 +1,21 @@ + + + CLASS SPACE + 班级事务 + 公告、排期、名录、资源和班费都按当前班级开放状态展示。 + + + 可用模块 + 已过滤关闭项 + + + + {{item.icon}} + {{item.title}} + {{item.desc}} + + + + 当前班级暂无开放的班级模块 + + diff --git a/miniprogram/pages/class/index.wxss b/miniprogram/pages/class/index.wxss new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/miniprogram/pages/class/index.wxss @@ -0,0 +1 @@ + diff --git a/miniprogram/pages/home/index.js b/miniprogram/pages/home/index.js new file mode 100644 index 0000000..83b7461 --- /dev/null +++ b/miniprogram/pages/home/index.js @@ -0,0 +1,89 @@ +const { get } = require("../../utils/api"); +const { refreshMe, requireLogin } = require("../../utils/auth"); +const { isModuleEnabled, visibleModules } = require("../../utils/modules"); +const { getActiveClassId, getActiveClassName, getEnabledModules, showError } = require("../../utils/page-helpers"); + +Page({ + data: { + className: "HKU ICB", + announcements: [], + schedules: [], + votes: [], + timelines: [], + quickModules: [], + unreadCount: 0, + loading: false + }, + + async onShow() { + if (!requireLogin()) return; + await this.load(); + }, + + async load() { + this.setData({ loading: true }); + try { + await refreshMe(); + const app = getApp(); + const user = app.globalData.user; + const classId = getActiveClassId(); + const enabledModules = getEnabledModules(); + const className = getActiveClassName(); + this.setData({ + className, + quickModules: [ + ...visibleModules("class", enabledModules), + ...visibleModules("interact", enabledModules) + ].slice(0, 4) + }); + + const tasks = [get("/api/notifications/unread-count").catch(() => ({ unread_count: 0 }))]; + const names = ["unread"]; + if (isModuleEnabled("announcements", enabledModules)) { + names.push("announcements"); + tasks.push(get("/api/announcements/", { page_size: 3, class_id: classId })); + } + if (isModuleEnabled("schedule", enabledModules)) { + names.push("schedules"); + tasks.push(get("/api/schedule/upcoming", { limit: 3, class_id: classId })); + } + if (isModuleEnabled("votes", enabledModules)) { + names.push("votes"); + tasks.push(get("/api/votes/", { page_size: 3, class_id: classId })); + } + if (isModuleEnabled("timeline", enabledModules)) { + names.push("timelines"); + tasks.push(get("/api/timeline/", { page_size: 3, class_id: classId })); + } + + const results = await Promise.all(tasks); + const next = { announcements: [], schedules: [], votes: [], timelines: [] }; + names.forEach((name, index) => { + const value = results[index]; + if (name === "unread") next.unreadCount = value.unread_count || 0; + if (name === "announcements") next.announcements = value.items || []; + if (name === "schedules") next.schedules = value || []; + if (name === "votes") next.votes = value.items || []; + if (name === "timelines") next.timelines = value.items || []; + }); + this.setData(next); + } catch (error) { + showError(error); + } finally { + this.setData({ loading: false }); + } + }, + + openModule(event) { + const key = event.currentTarget.dataset.key; + wx.navigateTo({ url: `/pages/module/index?module=${key}` }); + }, + + openSchedule(event) { + wx.navigateTo({ url: `/pages/schedule-detail/index?id=${event.currentTarget.dataset.id}` }); + }, + + openTimeline(event) { + wx.navigateTo({ url: `/pages/timeline-detail/index?id=${event.currentTarget.dataset.id}` }); + } +}); diff --git a/miniprogram/pages/home/index.json b/miniprogram/pages/home/index.json new file mode 100644 index 0000000..dac4751 --- /dev/null +++ b/miniprogram/pages/home/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "首页" +} diff --git a/miniprogram/pages/home/index.wxml b/miniprogram/pages/home/index.wxml new file mode 100644 index 0000000..cceaeb7 --- /dev/null +++ b/miniprogram/pages/home/index.wxml @@ -0,0 +1,104 @@ + + + HKU ICB CLASSHUB + {{className}} + 把公告、排期、投票和班级互动放在一个安静清晰的移动入口。 + + + {{unreadCount}} + 未读通知 + + + {{schedules.length}} + 近期安排 + + + {{votes.length}} + 班级投票 + + + + + + + 常用入口 + 按班级开放 + + + + {{item.icon}} + {{item.title}} + {{item.desc}} + + + + + + + 最新公告 + 全部 + + + + + + {{item.title}} + {{item.author_name}} + + + + + + + + 近期排期 + 日程 + + + + + + {{item.title}} + {{item.location || "地点待定"}} + + {{item.type}} + + + + + + + 班级投票 + 参与 + + + + + + {{item.title}} + {{item.has_voted ? "已参与" : "待参与"}} + + + + + + + + 班级动态 + 浏览 + + + + + + {{item.title}} + {{item.author_name}} + + + + + + + 暂无可展示内容 + + diff --git a/miniprogram/pages/home/index.wxss b/miniprogram/pages/home/index.wxss new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/miniprogram/pages/home/index.wxss @@ -0,0 +1 @@ + diff --git a/miniprogram/pages/interact/index.js b/miniprogram/pages/interact/index.js new file mode 100644 index 0000000..dcb4c86 --- /dev/null +++ b/miniprogram/pages/interact/index.js @@ -0,0 +1,16 @@ +const { requireLogin } = require("../../utils/auth"); +const { visibleModules } = require("../../utils/modules"); +const { getEnabledModules } = require("../../utils/page-helpers"); + +Page({ + data: { modules: [] }, + + onShow() { + if (!requireLogin()) return; + this.setData({ modules: visibleModules("interact", getEnabledModules()) }); + }, + + openModule(event) { + wx.navigateTo({ url: `/pages/module/index?module=${event.currentTarget.dataset.key}` }); + } +}); diff --git a/miniprogram/pages/interact/index.json b/miniprogram/pages/interact/index.json new file mode 100644 index 0000000..c54080a --- /dev/null +++ b/miniprogram/pages/interact/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "互动" +} diff --git a/miniprogram/pages/interact/index.wxml b/miniprogram/pages/interact/index.wxml new file mode 100644 index 0000000..4deee97 --- /dev/null +++ b/miniprogram/pages/interact/index.wxml @@ -0,0 +1,21 @@ + + + CLASS INTERACTION + 互动协作 + 轻量表达、投票决策和阅读分享,适合在手机上快速完成。 + + + 可用互动 + 按权限开放 + + + + {{item.icon}} + {{item.title}} + {{item.desc}} + + + + 当前班级暂无开放的互动模块 + + diff --git a/miniprogram/pages/interact/index.wxss b/miniprogram/pages/interact/index.wxss new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/miniprogram/pages/interact/index.wxss @@ -0,0 +1 @@ + diff --git a/miniprogram/pages/manage/index.js b/miniprogram/pages/manage/index.js new file mode 100644 index 0000000..4783e7e --- /dev/null +++ b/miniprogram/pages/manage/index.js @@ -0,0 +1,119 @@ +const { post } = require("../../utils/api"); +const { getModule } = require("../../utils/modules"); +const { hasManagePermission } = require("../../utils/permissions"); +const { ensureModuleOpen, getActiveClassId, showError } = require("../../utils/page-helpers"); + +const FORM_DEFAULTS = { + announcements: { title: "", content: "", is_pinned: false }, + votes: { title: "", description: "", options_text: "", vote_type: "single", is_anonymous: false }, + schedule: { title: "", type: "course", start_time: "", location: "", description: "" }, + fund: { type: "expense", amount: "", category: "", description: "", record_date: "" } +}; + +Page({ + data: { + moduleKey: "", + title: "新增", + form: {}, + isAnnouncements: false, + isVotes: false, + isSchedule: false, + isFund: false, + scheduleTypes: ["course", "deadline", "activity"], + fundTypes: ["income", "expense"], + loading: false + }, + + onLoad(options) { + const moduleKey = options.module || ""; + const module = getModule(moduleKey); + const classId = getActiveClassId(); + const user = getApp().globalData.user; + if (!module || !ensureModuleOpen(moduleKey) || !hasManagePermission(user, classId, moduleKey)) { + wx.redirectTo({ + url: `/pages/module-unavailable/index?title=${encodeURIComponent(module?.title || "功能")}` + }); + return; + } + this.setData({ + moduleKey, + title: `新增${module.title}`, + form: Object.assign({}, FORM_DEFAULTS[moduleKey] || {}), + isAnnouncements: moduleKey === "announcements", + isVotes: moduleKey === "votes", + isSchedule: moduleKey === "schedule", + isFund: moduleKey === "fund" + }); + wx.setNavigationBarTitle({ title: `新增${module.title}` }); + }, + + onInput(event) { + const field = event.currentTarget.dataset.field; + this.setData({ [`form.${field}`]: event.detail.value }); + }, + + onSwitch(event) { + const field = event.currentTarget.dataset.field; + this.setData({ [`form.${field}`]: event.detail.value }); + }, + + onPicker(event) { + const field = event.currentTarget.dataset.field; + const value = event.currentTarget.dataset.values.split(",")[Number(event.detail.value)]; + this.setData({ [`form.${field}`]: value }); + }, + + async submit() { + const classId = getActiveClassId(); + const moduleKey = this.data.moduleKey; + const form = this.data.form; + this.setData({ loading: true }); + try { + if (moduleKey === "announcements") { + await post(`/api/announcements/?class_id=${classId}`, { + title: form.title, + content: form.content || null, + is_pinned: Boolean(form.is_pinned) + }); + } + if (moduleKey === "votes") { + const options = String(form.options_text || "") + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); + await post(`/api/votes/?class_id=${classId}`, { + title: form.title, + description: form.description || null, + vote_type: form.vote_type || "single", + is_anonymous: Boolean(form.is_anonymous), + max_choices: form.vote_type === "multiple" ? Math.max(2, options.length) : 1, + options + }); + } + if (moduleKey === "schedule") { + await post(`/api/schedule/?class_id=${classId}`, { + title: form.title, + type: form.type, + start_time: form.start_time, + location: form.location || null, + description: form.description || null + }); + } + if (moduleKey === "fund") { + await post(`/api/fund/?class_id=${classId}`, { + type: form.type, + amount: Number(form.amount), + category: form.category, + description: form.description || null, + record_date: form.record_date + }); + } + wx.showToast({ title: "已保存", icon: "success" }); + setTimeout(() => wx.navigateBack(), 500); + } catch (error) { + showError(error, "保存失败"); + } finally { + this.setData({ loading: false }); + } + } +}); diff --git a/miniprogram/pages/manage/index.wxml b/miniprogram/pages/manage/index.wxml new file mode 100644 index 0000000..2bcc557 --- /dev/null +++ b/miniprogram/pages/manage/index.wxml @@ -0,0 +1,54 @@ + + {{title}} + + + 标题 + + + + + 内容 +