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
+ 可以使用微信快速登录,也可以用原来的邮箱和密码登录。
+
+
+
+
+
+ 微信
+ 邮箱
+
+
+
+ 微
+ 微信快捷登录
+ 已绑定微信的同学可直接进入班级空间;未绑定账号会进入激活流程。
+
+
+
+
+ 邮箱密码登录
+ 继续使用 Web 端账号登录,登录后可绑定微信以便下次快捷进入。
+
+ 邮箱
+
+
+
+ 密码
+
+
+
+
+
+
+
+
+
+ 激活账号
+ 返回登录
+
+
+ 说明
+ 没有找到已绑定的微信账号
+ 请用班级邀请码、学号和微信手机号激活你的班级账号。
+
+
+ 邀请码
+
+
+
+ 学号
+
+
+
+
+
+
+
+ 绑定微信和手机号
+ 邮箱登录成功。为了下次可以微信快速登录,请绑定当前微信和手机号。
+
+
+
+
+
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}}
+
+
+ 标题
+
+
+
+
+ 内容
+
+ 置顶
+
+
+
+
+ 说明
+
+ 选项,每行一个
+
+ 匿名
+
+
+
+
+ 类型
+
+ {{form.type}}
+
+ 开始时间,格式:2026-05-07T09:00:00
+
+ 地点
+
+ 说明
+
+
+
+
+ 类型
+
+ {{form.type}}
+
+ 金额
+
+ 分类
+
+ 日期,格式:2026-05-07
+
+ 说明
+
+
+
+
+
diff --git a/miniprogram/pages/manage/index.wxss b/miniprogram/pages/manage/index.wxss
new file mode 100644
index 0000000..090a59f
--- /dev/null
+++ b/miniprogram/pages/manage/index.wxss
@@ -0,0 +1,12 @@
+input {
+ margin-top: 12rpx;
+ height: 72rpx;
+ font-size: 30rpx;
+}
+
+textarea {
+ width: 100%;
+ min-height: 160rpx;
+ margin-top: 12rpx;
+ font-size: 28rpx;
+}
diff --git a/miniprogram/pages/member-detail/index.js b/miniprogram/pages/member-detail/index.js
new file mode 100644
index 0000000..d20d9f0
--- /dev/null
+++ b/miniprogram/pages/member-detail/index.js
@@ -0,0 +1,24 @@
+const { get } = require("../../utils/api");
+const { showError } = require("../../utils/page-helpers");
+
+Page({
+ data: { member: null, loading: false },
+
+ onLoad(options) {
+ wx.setNavigationBarTitle({ title: "同学资料" });
+ this.load(options.id);
+ },
+
+ async load(id) {
+ if (!id) return;
+ this.setData({ loading: true });
+ try {
+ const member = await get(`/api/directory/${id}`);
+ this.setData({ member });
+ } catch (error) {
+ showError(error, "加载资料失败");
+ } finally {
+ this.setData({ loading: false });
+ }
+ }
+});
diff --git a/miniprogram/pages/member-detail/index.json b/miniprogram/pages/member-detail/index.json
new file mode 100644
index 0000000..8835af0
--- /dev/null
+++ b/miniprogram/pages/member-detail/index.json
@@ -0,0 +1,3 @@
+{
+ "usingComponents": {}
+}
\ No newline at end of file
diff --git a/miniprogram/pages/member-detail/index.wxml b/miniprogram/pages/member-detail/index.wxml
new file mode 100644
index 0000000..00f1402
--- /dev/null
+++ b/miniprogram/pages/member-detail/index.wxml
@@ -0,0 +1,50 @@
+
+
+ CLASSMATE
+ {{member.name}}
+ {{member.company || "公司未填写"}} · {{member.position || "职位未填写"}}
+
+
+
+
+
+ 业
+
+ 行业
+ {{member.industry || "未填写"}}
+
+
+
+
+
+ 班
+
+ 班级角色
+ {{member.committee_role || "同学"}}
+
+
+
+
+
+ 微
+
+ 微信
+ {{member.wechat_id || "未填写"}}
+
+
+
+
+
+ 电
+
+ 电话
+ {{member.phone || "未填写"}}
+
+
+
+
+ 简介
+ {{member.bio || "暂无简介"}}
+
+
+
diff --git a/miniprogram/pages/member-detail/index.wxss b/miniprogram/pages/member-detail/index.wxss
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/miniprogram/pages/member-detail/index.wxss
@@ -0,0 +1 @@
+
diff --git a/miniprogram/pages/mine/index.js b/miniprogram/pages/mine/index.js
new file mode 100644
index 0000000..62418c8
--- /dev/null
+++ b/miniprogram/pages/mine/index.js
@@ -0,0 +1,58 @@
+const { clearSession, refreshMe, requireLogin } = require("../../utils/auth");
+const { get } = require("../../utils/api");
+const { showError } = require("../../utils/page-helpers");
+
+Page({
+ data: { user: null, classes: [], activeClassId: null, activeClassName: "" },
+
+ async onShow() {
+ if (!requireLogin()) return;
+ try {
+ const user = await refreshMe();
+ const classRes = await get("/api/classes/");
+ const app = getApp();
+ const activeClassId = app.globalData.activeClassId;
+ const activeMembership = user.memberships?.find((item) => item.class_id === activeClassId) || user.active_membership;
+ this.setData({
+ user,
+ activeClassName: activeMembership?.class_name || "",
+ classes: (classRes.items || []).map((item) => ({
+ ...item,
+ is_active: item.id === activeClassId
+ })),
+ activeClassId
+ });
+ } catch (error) {
+ showError(error);
+ }
+ },
+
+ openProfile() {
+ wx.navigateTo({ url: "/pages/profile-edit/index" });
+ },
+
+ logout() {
+ clearSession();
+ wx.removeStorageSync("active_class");
+ wx.navigateTo({ url: "/pages/bind/index" });
+ },
+
+ switchClass(event) {
+ const classId = Number(event.currentTarget.dataset.id);
+ const classItem = this.data.classes.find((item) => item.id === classId);
+ if (!classItem) return;
+ wx.setStorageSync("active_class", classItem);
+ const app = getApp();
+ app.globalData.activeClassId = classItem.id;
+ app.globalData.enabledModules = classItem.enabled_modules || null;
+ this.setData({
+ activeClassId: classItem.id,
+ activeClassName: classItem.name,
+ classes: this.data.classes.map((item) => ({
+ ...item,
+ is_active: item.id === classItem.id
+ }))
+ });
+ wx.showToast({ title: "已切换班级", icon: "success" });
+ }
+});
diff --git a/miniprogram/pages/mine/index.json b/miniprogram/pages/mine/index.json
new file mode 100644
index 0000000..4ec55be
--- /dev/null
+++ b/miniprogram/pages/mine/index.json
@@ -0,0 +1,3 @@
+{
+ "navigationBarTitleText": "我的"
+}
diff --git a/miniprogram/pages/mine/index.wxml b/miniprogram/pages/mine/index.wxml
new file mode 100644
index 0000000..082bb87
--- /dev/null
+++ b/miniprogram/pages/mine/index.wxml
@@ -0,0 +1,55 @@
+
+
+ MY CLASSHUB
+ {{user.name || "我的"}}
+ 学号:{{user.student_id || "未填写"}}
+ {{activeClassName}}
+
+
+
+
+
+
+
+
+
+ 我的班级
+
+
+
+
+
diff --git a/miniprogram/pages/mine/index.wxss b/miniprogram/pages/mine/index.wxss
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/miniprogram/pages/mine/index.wxss
@@ -0,0 +1 @@
+
diff --git a/miniprogram/pages/module-unavailable/index.js b/miniprogram/pages/module-unavailable/index.js
new file mode 100644
index 0000000..6bff513
--- /dev/null
+++ b/miniprogram/pages/module-unavailable/index.js
@@ -0,0 +1,13 @@
+Page({
+ data: { title: "功能" },
+
+ onLoad(options) {
+ const title = decodeURIComponent(options.title || "功能");
+ this.setData({ title });
+ wx.setNavigationBarTitle({ title: "功能未开放" });
+ },
+
+ backHome() {
+ wx.switchTab({ url: "/pages/home/index" });
+ }
+});
diff --git a/miniprogram/pages/module-unavailable/index.wxml b/miniprogram/pages/module-unavailable/index.wxml
new file mode 100644
index 0000000..62a8c32
--- /dev/null
+++ b/miniprogram/pages/module-unavailable/index.wxml
@@ -0,0 +1,7 @@
+
+
+ {{title}}
+ 该功能当前未开放
+
+
+
diff --git a/miniprogram/pages/module-unavailable/index.wxss b/miniprogram/pages/module-unavailable/index.wxss
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/miniprogram/pages/module-unavailable/index.wxss
@@ -0,0 +1 @@
+
diff --git a/miniprogram/pages/module/index.js b/miniprogram/pages/module/index.js
new file mode 100644
index 0000000..6639d38
--- /dev/null
+++ b/miniprogram/pages/module/index.js
@@ -0,0 +1,156 @@
+const { del, get } = require("../../utils/api");
+const { getModule } = require("../../utils/modules");
+const { hasManagePermission } = require("../../utils/permissions");
+const { ensureModuleOpen, getActiveClassId, showError } = require("../../utils/page-helpers");
+
+const ENDPOINTS = {
+ announcements: "/api/announcements/",
+ directory: "/api/directory/",
+ timeline: "/api/timeline/",
+ votes: "/api/votes/",
+ schedule: "/api/schedule/",
+ resources: "/api/resources/",
+ reading_corner: "/api/reading/feed",
+ fund: "/api/fund/"
+};
+
+Page({
+ data: {
+ moduleKey: "",
+ title: "功能",
+ moduleIcon: "项",
+ items: [],
+ canManage: false,
+ canPostTimeline: false,
+ isTimeline: false,
+ isDirectory: false,
+ isSchedule: false,
+ needsRefresh: false,
+ loading: false
+ },
+
+ onLoad(options) {
+ const moduleKey = options.module || "";
+ const module = getModule(moduleKey);
+ const app = getApp();
+ const classId = getActiveClassId();
+ this.setData({
+ moduleKey,
+ title: module?.title || "功能",
+ moduleIcon: module?.icon || "项",
+ isTimeline: moduleKey === "timeline",
+ isDirectory: moduleKey === "directory",
+ isSchedule: moduleKey === "schedule",
+ canPostTimeline: moduleKey === "timeline",
+ canManage: ["announcements", "votes", "schedule", "fund"].includes(moduleKey) &&
+ hasManagePermission(app.globalData.user, classId, moduleKey)
+ });
+ wx.setNavigationBarTitle({ title: module?.title || "功能" });
+ if (!module || !ensureModuleOpen(moduleKey)) return;
+ this.load();
+ },
+
+ onShow() {
+ if (this.data.moduleKey) {
+ this.setData({ needsRefresh: false });
+ this.load();
+ }
+ },
+
+ async onPullDownRefresh() {
+ await this.load();
+ wx.stopPullDownRefresh();
+ },
+
+ async load() {
+ const classId = getActiveClassId();
+ const endpoint = ENDPOINTS[this.data.moduleKey];
+ if (!endpoint) return;
+ this.setData({ loading: true });
+ try {
+ const res = await get(endpoint, { page_size: 20, class_id: classId });
+ const rawItems = Array.isArray(res) ? res : res.items || [];
+ const currentUserId = getApp().globalData.user?.id;
+ const items = rawItems.map((item) => ({
+ ...item,
+ can_delete: this.data.moduleKey === "timeline" && item.author_id === currentUserId,
+ initial: String(item.name || item.author_name || this.data.title || "项").slice(0, 1),
+ schedule_day: item.start_time ? String(item.start_time).slice(8, 10) : "",
+ schedule_month: item.start_time ? `${String(item.start_time).slice(5, 7)}月` : "",
+ committee_text: item.committee_role ? ` · ${item.committee_role}` : ""
+ }));
+ this.setData({ items });
+ } catch (error) {
+ if (error.message === "该功能当前未开放") {
+ wx.redirectTo({
+ url: `/pages/module-unavailable/index?title=${encodeURIComponent(this.data.title)}`
+ });
+ return;
+ }
+ showError(error);
+ } finally {
+ this.setData({ loading: false });
+ }
+ },
+
+ openItem(event) {
+ const id = event.currentTarget.dataset.id;
+ const key = this.data.moduleKey;
+ if (!id) return;
+ if (key === "directory") {
+ wx.navigateTo({ url: `/pages/member-detail/index?id=${id}` });
+ return;
+ }
+ if (key === "schedule") {
+ wx.navigateTo({ url: `/pages/schedule-detail/index?id=${id}` });
+ return;
+ }
+ if (key === "timeline") {
+ wx.navigateTo({ url: `/pages/timeline-detail/index?id=${id}` });
+ return;
+ }
+ },
+
+ previewImage(event) {
+ const current = event.currentTarget.dataset.src;
+ const postId = Number(event.currentTarget.dataset.postId);
+ const post = this.data.items.find((item) => item.id === postId);
+ const urls = post?.image_urls || [];
+ if (!current || !urls.length) return;
+ wx.previewImage({ current, urls });
+ },
+
+ openTimelineActions(event) {
+ const id = event.currentTarget.dataset.id;
+ wx.showActionSheet({
+ itemList: ["删除动态"],
+ itemColor: "#b42318",
+ success: () => {
+ wx.showModal({
+ title: "删除动态",
+ content: "删除后无法恢复,确认删除这条动态?",
+ confirmText: "删除",
+ confirmColor: "#b42318",
+ success: async (res) => {
+ if (!res.confirm) return;
+ try {
+ await del(`/api/timeline/${id}`);
+ wx.showToast({ title: "已删除", icon: "success" });
+ this.load();
+ } catch (error) {
+ showError(error, "删除失败");
+ }
+ }
+ });
+ }
+ });
+ },
+
+ openManage() {
+ wx.navigateTo({ url: `/pages/manage/index?module=${this.data.moduleKey}` });
+ },
+
+ openTimelineCreate() {
+ wx.navigateTo({ url: "/pages/timeline-create/index" });
+ }
+});
diff --git a/miniprogram/pages/module/index.wxml b/miniprogram/pages/module/index.wxml
new file mode 100644
index 0000000..c6baf13
--- /dev/null
+++ b/miniprogram/pages/module/index.wxml
@@ -0,0 +1,64 @@
+
+
+ MODULE
+ {{title}}
+ 当前内容来自已开放的班级模块,关闭后会自动隐藏入口。
+
+
+
+
+
+
+
+ 动
+
+ {{item.title}}
+ {{item.author_name}} · {{item.created_at}}
+
+ ···
+
+ {{item.content}}
+
+
+
+
+ 赞 {{item.like_count}}
+ 评论 {{item.comment_count}}
+
+
+
+
+ {{item.initial}}
+
+ {{item.name}}
+ {{item.company || "公司未填写"}} · {{item.position || "职位未填写"}}
+ {{item.industry || "行业未填写"}}{{item.committee_text}}
+
+
+
+
+
+ {{item.schedule_day}}
+ {{item.schedule_month}}
+
+
+ {{item.title}}
+ {{item.location || "地点待定"}}
+ {{item.start_time}}
+
+ {{item.type}}
+
+
+
+ {{moduleIcon}}
+
+ {{item.title || item.name || item.book_title || item.category}}
+ {{item.description || item.content || item.author_name || item.recorder_name || item.location || ""}}
+
+
+
+
+
+ 暂无内容
+
+
diff --git a/miniprogram/pages/module/index.wxss b/miniprogram/pages/module/index.wxss
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/miniprogram/pages/module/index.wxss
@@ -0,0 +1 @@
+
diff --git a/miniprogram/pages/profile-edit/index.js b/miniprogram/pages/profile-edit/index.js
new file mode 100644
index 0000000..8a29ea8
--- /dev/null
+++ b/miniprogram/pages/profile-edit/index.js
@@ -0,0 +1,52 @@
+const { put } = require("../../utils/api");
+const { refreshMe, saveSession } = require("../../utils/auth");
+const { showError } = require("../../utils/page-helpers");
+
+Page({
+ data: {
+ form: {
+ name: "",
+ industry: "",
+ company: "",
+ position: "",
+ wechat_id: "",
+ bio: ""
+ },
+ loading: false
+ },
+
+ async onLoad() {
+ wx.setNavigationBarTitle({ title: "编辑资料" });
+ const user = await refreshMe();
+ this.setData({
+ form: {
+ name: user.name || "",
+ industry: user.industry || "",
+ company: user.company || "",
+ position: user.position || "",
+ wechat_id: user.wechat_id || "",
+ bio: user.bio || ""
+ }
+ });
+ },
+
+ onInput(event) {
+ const field = event.currentTarget.dataset.field;
+ this.setData({ [`form.${field}`]: event.detail.value });
+ },
+
+ async save() {
+ this.setData({ loading: true });
+ try {
+ const user = await put("/api/users/me", this.data.form);
+ const token = wx.getStorageSync("auth_token");
+ saveSession(token, user);
+ wx.showToast({ title: "已保存", icon: "success" });
+ setTimeout(() => wx.navigateBack(), 500);
+ } catch (error) {
+ showError(error, "保存失败");
+ } finally {
+ this.setData({ loading: false });
+ }
+ }
+});
diff --git a/miniprogram/pages/profile-edit/index.json b/miniprogram/pages/profile-edit/index.json
new file mode 100644
index 0000000..8835af0
--- /dev/null
+++ b/miniprogram/pages/profile-edit/index.json
@@ -0,0 +1,3 @@
+{
+ "usingComponents": {}
+}
\ No newline at end of file
diff --git a/miniprogram/pages/profile-edit/index.wxml b/miniprogram/pages/profile-edit/index.wxml
new file mode 100644
index 0000000..dac3b07
--- /dev/null
+++ b/miniprogram/pages/profile-edit/index.wxml
@@ -0,0 +1,18 @@
+
+
+ PROFILE
+ 编辑个人资料
+ 这些信息会在成员名录中展示给同班同学。
+
+
+
+ 姓名
+ 行业
+ 公司
+ 职位
+ 微信号
+ 简介
+
+
+
+
diff --git a/miniprogram/pages/profile-edit/index.wxss b/miniprogram/pages/profile-edit/index.wxss
new file mode 100644
index 0000000..02c4b82
--- /dev/null
+++ b/miniprogram/pages/profile-edit/index.wxss
@@ -0,0 +1,12 @@
+input {
+ margin-top: 12rpx;
+ height: 72rpx;
+ font-size: 30rpx;
+}
+
+textarea {
+ width: 100%;
+ min-height: 180rpx;
+ margin-top: 12rpx;
+ font-size: 28rpx;
+}
diff --git a/miniprogram/pages/schedule-detail/index.js b/miniprogram/pages/schedule-detail/index.js
new file mode 100644
index 0000000..3f253e8
--- /dev/null
+++ b/miniprogram/pages/schedule-detail/index.js
@@ -0,0 +1,24 @@
+const { get } = require("../../utils/api");
+const { showError } = require("../../utils/page-helpers");
+
+Page({
+ data: { item: null, loading: false },
+
+ onLoad(options) {
+ wx.setNavigationBarTitle({ title: "排期详情" });
+ this.load(options.id);
+ },
+
+ async load(id) {
+ if (!id) return;
+ this.setData({ loading: true });
+ try {
+ const item = await get(`/api/schedule/${id}`);
+ this.setData({ item });
+ } catch (error) {
+ showError(error, "加载排期失败");
+ } finally {
+ this.setData({ loading: false });
+ }
+ }
+});
diff --git a/miniprogram/pages/schedule-detail/index.json b/miniprogram/pages/schedule-detail/index.json
new file mode 100644
index 0000000..8835af0
--- /dev/null
+++ b/miniprogram/pages/schedule-detail/index.json
@@ -0,0 +1,3 @@
+{
+ "usingComponents": {}
+}
\ No newline at end of file
diff --git a/miniprogram/pages/schedule-detail/index.wxml b/miniprogram/pages/schedule-detail/index.wxml
new file mode 100644
index 0000000..49dfc2a
--- /dev/null
+++ b/miniprogram/pages/schedule-detail/index.wxml
@@ -0,0 +1,41 @@
+
+
+ SCHEDULE
+ {{item.title}}
+ {{item.location || "地点待定"}}
+
+
+
+
+
+ 类
+
+ 类型
+ {{item.type}}
+
+
+
+
+
+ 始
+
+ 开始时间
+ {{item.start_time}}
+
+
+
+
+
+ 止
+
+ 结束时间
+ {{item.end_time}}
+
+
+
+
+ 说明
+ {{item.description || "暂无说明"}}
+
+
+
diff --git a/miniprogram/pages/schedule-detail/index.wxss b/miniprogram/pages/schedule-detail/index.wxss
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/miniprogram/pages/schedule-detail/index.wxss
@@ -0,0 +1 @@
+
diff --git a/miniprogram/pages/timeline-detail/index.js b/miniprogram/pages/timeline-detail/index.js
new file mode 100644
index 0000000..f1681a1
--- /dev/null
+++ b/miniprogram/pages/timeline-detail/index.js
@@ -0,0 +1,140 @@
+const { del, get, post } = require("../../utils/api");
+const { showError } = require("../../utils/page-helpers");
+
+Page({
+ data: {
+ id: null,
+ post: null,
+ canDelete: false,
+ comments: [],
+ commentText: "",
+ replyTo: "",
+ inputPlaceholder: "写下评论",
+ loading: false,
+ submitting: false
+ },
+
+ onLoad(options) {
+ wx.setNavigationBarTitle({ title: "动态详情" });
+ this.setData({ id: options.id });
+ this.load();
+ },
+
+ async onPullDownRefresh() {
+ await this.load();
+ wx.stopPullDownRefresh();
+ },
+
+ async load() {
+ if (!this.data.id) return;
+ this.setData({ loading: true });
+ try {
+ const [postDetail, commentsRes] = await Promise.all([
+ get(`/api/timeline/${this.data.id}`),
+ get(`/api/timeline/${this.data.id}/comments`, { page_size: 50 })
+ ]);
+ this.setData({
+ post: postDetail,
+ canDelete: postDetail.author_id === getApp().globalData.user?.id,
+ comments: (commentsRes.items || []).map((item) => ({
+ ...item,
+ initial: String(item.author_name || "评").slice(0, 1)
+ }))
+ });
+ } catch (error) {
+ showError(error, "加载动态失败");
+ } finally {
+ this.setData({ loading: false });
+ }
+ },
+
+ async toggleLike() {
+ if (!this.data.id || this.data.submitting) return;
+ this.setData({ submitting: true });
+ try {
+ await post(`/api/timeline/${this.data.id}/like`);
+ await this.load();
+ } catch (error) {
+ showError(error, "操作失败");
+ } finally {
+ this.setData({ submitting: false });
+ }
+ },
+
+ onCommentInput(event) {
+ this.setData({ commentText: event.detail.value });
+ },
+
+ previewImage(event) {
+ const current = event.currentTarget.dataset.src;
+ const urls = this.data.post?.image_urls || [];
+ if (!current || !urls.length) return;
+ wx.previewImage({ current, urls });
+ },
+
+ openActions() {
+ wx.showActionSheet({
+ itemList: ["删除动态"],
+ itemColor: "#b42318",
+ success: () => {
+ wx.showModal({
+ title: "删除动态",
+ content: "删除后无法恢复,确认删除这条动态?",
+ confirmText: "删除",
+ confirmColor: "#b42318",
+ success: async (res) => {
+ if (!res.confirm) return;
+ try {
+ await del(`/api/timeline/${this.data.id}`);
+ wx.showToast({ title: "已删除", icon: "success" });
+ const pages = getCurrentPages();
+ const previousPage = pages[pages.length - 2];
+ if (previousPage?.setData) previousPage.setData({ needsRefresh: true });
+ setTimeout(() => wx.navigateBack(), 500);
+ } catch (error) {
+ showError(error, "删除失败");
+ }
+ }
+ });
+ }
+ });
+ },
+
+ replyComment(event) {
+ const name = event.currentTarget.dataset.name || "";
+ this.setData({
+ replyTo: name,
+ inputPlaceholder: name ? `回复 ${name}` : "写下评论"
+ });
+ },
+
+ cancelReply() {
+ this.setData({
+ replyTo: "",
+ inputPlaceholder: "写下评论"
+ });
+ },
+
+ async submitComment() {
+ const text = this.data.commentText.trim();
+ if (!text) {
+ wx.showToast({ title: "请输入评论", icon: "none" });
+ return;
+ }
+ const content = this.data.replyTo ? `回复 @${this.data.replyTo}:${text}` : text;
+ this.setData({ submitting: true });
+ try {
+ await post(`/api/timeline/${this.data.id}/comments`, { content });
+ this.setData({
+ commentText: "",
+ replyTo: "",
+ inputPlaceholder: "写下评论"
+ });
+ await this.load();
+ } catch (error) {
+ showError(error, "评论失败");
+ } finally {
+ this.setData({ submitting: false });
+ }
+ }
+});
diff --git a/miniprogram/pages/timeline-detail/index.json b/miniprogram/pages/timeline-detail/index.json
new file mode 100644
index 0000000..98a6de9
--- /dev/null
+++ b/miniprogram/pages/timeline-detail/index.json
@@ -0,0 +1,3 @@
+{
+ "enablePullDownRefresh": true
+}
diff --git a/miniprogram/pages/timeline-detail/index.wxml b/miniprogram/pages/timeline-detail/index.wxml
new file mode 100644
index 0000000..7cb7d76
--- /dev/null
+++ b/miniprogram/pages/timeline-detail/index.wxml
@@ -0,0 +1,58 @@
+
+
+ CLASS FEED
+ ···
+ {{post.title}}
+ {{post.author_name}} · {{post.created_at}}
+
+
+
+
+ {{post.content || "暂无正文"}}
+
+
+
+
+
+ 赞
+ {{post.has_liked ? "已点赞" : "点赞"}} · {{post.like_count}}
+
+
+ 评
+ 评论 · {{comments.length}}
+
+
+
+
+
+
+
+ 评论
+ {{comments.length}} 条
+
+
+
+ 还没有评论
+
+
+
+
+ 正在回复 {{replyTo}}
+ 取消
+
+
+
diff --git a/miniprogram/pages/timeline-detail/index.wxss b/miniprogram/pages/timeline-detail/index.wxss
new file mode 100644
index 0000000..3e816e6
--- /dev/null
+++ b/miniprogram/pages/timeline-detail/index.wxss
@@ -0,0 +1,127 @@
+.detail-images image {
+ height: 210rpx;
+}
+
+.detail-more {
+ position: absolute;
+ top: 28rpx;
+ right: 28rpx;
+ z-index: 2;
+ min-width: 64rpx;
+ height: 46rpx;
+ border-radius: 999rpx;
+ background: rgba(255, 255, 255, 0.12);
+ color: rgba(255, 248, 237, 0.72);
+ font-size: 32rpx;
+ line-height: 38rpx;
+ text-align: center;
+}
+
+.detail-action-bar {
+ display: flex;
+ gap: 16rpx;
+ margin-top: 24rpx;
+}
+
+.action-chip {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8rpx;
+ min-height: 64rpx;
+ flex: 1;
+ border-radius: 22rpx;
+ background: #f4eadc;
+ color: #725d4d;
+ font-size: 24rpx;
+ font-weight: 650;
+}
+
+.action-chip.active {
+ background: #6b1f2b;
+ color: #fff8ed;
+}
+
+.action-icon {
+ font-size: 22rpx;
+ font-weight: 780;
+}
+
+.comment-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;
+}
+
+.comment-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18rpx;
+}
+
+.reply-link {
+ flex: none;
+ color: #8b5a36;
+ font-size: 24rpx;
+ font-weight: 650;
+}
+
+.comment-content {
+ margin-top: 8rpx;
+ color: #59463d;
+ font-size: 26rpx;
+ line-height: 1.55;
+}
+
+.replying-bar {
+ position: sticky;
+ bottom: 116rpx;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 24rpx;
+ padding: 16rpx 24rpx;
+ border-radius: 22rpx;
+ background: #f1e4d4;
+ color: #76533e;
+ font-size: 24rpx;
+}
+
+.comment-box {
+ position: sticky;
+ bottom: 20rpx;
+ display: flex;
+ align-items: center;
+ gap: 14rpx;
+ margin-top: 32rpx;
+ padding: 14rpx;
+ border: 1rpx solid rgba(121, 84, 54, 0.12);
+ border-radius: 28rpx;
+ background: rgba(255, 252, 247, 0.96);
+ box-shadow: 0 18rpx 42rpx rgba(68, 39, 27, 0.1);
+}
+
+.comment-box input {
+ flex: 1;
+ min-width: 0;
+ height: 72rpx;
+ padding: 0 22rpx;
+ border-radius: 20rpx;
+ background: #f7f0e8;
+}
+
+.comment-box button {
+ width: 156rpx;
+ flex: none;
+ height: 72rpx;
+ margin: 0;
+ padding: 0;
+ border-radius: 20rpx;
+ background: #6b1f2b;
+ color: #fff7ea;
+ font-size: 26rpx;
+ line-height: 72rpx;
+}
diff --git a/miniprogram/project.config.json b/miniprogram/project.config.json
new file mode 100644
index 0000000..6d6aa3b
--- /dev/null
+++ b/miniprogram/project.config.json
@@ -0,0 +1,38 @@
+{
+ "description": "HKU ICB ClassHub Mini Program",
+ "miniprogramRoot": "./",
+ "projectname": "hku-icb-classhub",
+ "setting": {
+ "urlCheck": true,
+ "es6": true,
+ "enhance": true,
+ "postcss": true,
+ "minified": true,
+ "compileWorklet": false,
+ "uglifyFileName": false,
+ "uploadWithSourceMap": true,
+ "packNpmManually": false,
+ "packNpmRelationList": [],
+ "minifyWXSS": true,
+ "minifyWXML": true,
+ "localPlugins": false,
+ "disableUseStrict": false,
+ "useCompilerPlugins": false,
+ "condition": false,
+ "swc": false,
+ "disableSWC": true,
+ "babelSetting": {
+ "ignore": [],
+ "disablePlugins": [],
+ "outputPath": ""
+ }
+ },
+ "compileType": "miniprogram",
+ "appid": "wxfac912df4f9cdebe",
+ "simulatorPluginLibVersion": {},
+ "packOptions": {
+ "ignore": [],
+ "include": []
+ },
+ "editorSetting": {}
+}
\ No newline at end of file
diff --git a/miniprogram/project.private.config.json b/miniprogram/project.private.config.json
new file mode 100644
index 0000000..f7e9607
--- /dev/null
+++ b/miniprogram/project.private.config.json
@@ -0,0 +1,21 @@
+{
+ "libVersion": "3.15.2",
+ "projectname": "hku-icb-class",
+ "setting": {
+ "urlCheck": false,
+ "coverView": false,
+ "lazyloadPlaceholderEnable": false,
+ "skylineRenderEnable": false,
+ "preloadBackgroundData": false,
+ "autoAudits": false,
+ "useApiHook": true,
+ "showShadowRootInWxmlPanel": false,
+ "useStaticServer": false,
+ "useLanDebug": false,
+ "showES6CompileOption": false,
+ "compileHotReLoad": true,
+ "checkInvalidKey": true,
+ "ignoreDevUnusedFiles": true,
+ "bigPackageSizeSupport": false
+ }
+}
\ No newline at end of file
diff --git a/miniprogram/sitemap.json b/miniprogram/sitemap.json
new file mode 100644
index 0000000..1de189d
--- /dev/null
+++ b/miniprogram/sitemap.json
@@ -0,0 +1,8 @@
+{
+ "rules": [
+ {
+ "action": "allow",
+ "page": "*"
+ }
+ ]
+}
diff --git a/miniprogram/utils/api.js b/miniprogram/utils/api.js
new file mode 100644
index 0000000..3b19100
--- /dev/null
+++ b/miniprogram/utils/api.js
@@ -0,0 +1,116 @@
+const { apiBase } = require("./config");
+
+function getToken() {
+ return wx.getStorageSync("auth_token") || "";
+}
+
+function clearSession() {
+ wx.removeStorageSync("auth_token");
+ wx.removeStorageSync("auth_user");
+}
+
+function request(path, options = {}) {
+ const token = getToken();
+ const headers = Object.assign({}, options.headers || {});
+ if (token) headers.Authorization = `Bearer ${token}`;
+
+ return new Promise((resolve, reject) => {
+ wx.request({
+ url: `${apiBase}${path}`,
+ method: options.method || "GET",
+ data: options.data,
+ header: headers,
+ success(res) {
+ if (res.statusCode === 401) {
+ clearSession();
+ wx.navigateTo({ url: "/pages/bind/index" });
+ reject(new Error("登录已过期"));
+ return;
+ }
+ if (res.statusCode >= 400) {
+ const message = res.data?.detail || res.data?.message || "操作失败";
+ reject(new Error(message));
+ return;
+ }
+ resolve(res.data);
+ },
+ fail() {
+ reject(new Error("网络连接失败"));
+ }
+ });
+ });
+}
+
+function get(path, data) {
+ return request(path, { data });
+}
+
+function post(path, data) {
+ return request(path, {
+ method: "POST",
+ data,
+ headers: { "Content-Type": "application/json" }
+ });
+}
+
+function postForm(path, data) {
+ return request(path, {
+ method: "POST",
+ data,
+ headers: { "Content-Type": "application/x-www-form-urlencoded" }
+ });
+}
+
+function put(path, data) {
+ return request(path, {
+ method: "PUT",
+ data,
+ headers: { "Content-Type": "application/json" }
+ });
+}
+
+function del(path) {
+ return request(path, { method: "DELETE" });
+}
+
+function uploadFile(path, filePath, formData = {}, fieldName = "files") {
+ const token = getToken();
+ return new Promise((resolve, reject) => {
+ wx.uploadFile({
+ url: `${apiBase}${path}`,
+ filePath,
+ name: fieldName,
+ formData,
+ header: token ? { Authorization: `Bearer ${token}` } : {},
+ success(res) {
+ if (res.statusCode >= 400) {
+ let message = "上传失败";
+ try {
+ const data = JSON.parse(res.data || "{}");
+ message = data.detail || data.message || message;
+ } catch {}
+ reject(new Error(message));
+ return;
+ }
+ try {
+ resolve(JSON.parse(res.data || "{}"));
+ } catch {
+ resolve({});
+ }
+ },
+ fail: () => reject(new Error("上传失败"))
+ });
+ });
+}
+
+module.exports = {
+ clearSession,
+ del,
+ get,
+ getToken,
+ post,
+ postForm,
+ put,
+ uploadFile,
+ request
+};
diff --git a/miniprogram/utils/auth.js b/miniprogram/utils/auth.js
new file mode 100644
index 0000000..873c9b4
--- /dev/null
+++ b/miniprogram/utils/auth.js
@@ -0,0 +1,52 @@
+const { get, post, clearSession } = require("./api");
+
+function getStoredUser() {
+ return wx.getStorageSync("auth_user") || null;
+}
+
+function saveSession(token, user) {
+ wx.setStorageSync("auth_token", token);
+ wx.setStorageSync("auth_user", user);
+ const app = getApp();
+ if (app?.setUser) app.setUser(user);
+}
+
+function requireLogin() {
+ if (!wx.getStorageSync("auth_token")) {
+ wx.navigateTo({ url: "/pages/bind/index" });
+ return false;
+ }
+ return true;
+}
+
+async function refreshMe() {
+ const user = await get("/api/auth/me");
+ wx.setStorageSync("auth_user", user);
+ const app = getApp();
+ if (app?.setUser) app.setUser(user);
+ return user;
+}
+
+function loginWithWeChat() {
+ return new Promise((resolve, reject) => {
+ wx.login({
+ success: async ({ code }) => {
+ try {
+ resolve(await post("/api/wechat/login", { code }));
+ } catch (error) {
+ reject(error);
+ }
+ },
+ fail: () => reject(new Error("微信登录失败"))
+ });
+ });
+}
+
+module.exports = {
+ clearSession,
+ getStoredUser,
+ loginWithWeChat,
+ refreshMe,
+ requireLogin,
+ saveSession
+};
diff --git a/miniprogram/utils/config.js b/miniprogram/utils/config.js
new file mode 100644
index 0000000..7c7fd97
--- /dev/null
+++ b/miniprogram/utils/config.js
@@ -0,0 +1,4 @@
+module.exports = {
+ // 发布前改为后端 HTTPS 域名,例如 https://classhub.example.com
+ apiBase: "http://127.0.0.1:8000"
+};
diff --git a/miniprogram/utils/modules.js b/miniprogram/utils/modules.js
new file mode 100644
index 0000000..fea266f
--- /dev/null
+++ b/miniprogram/utils/modules.js
@@ -0,0 +1,40 @@
+const MODULES = {
+ announcements: { key: "announcements", title: "公告", desc: "查看班级重要通知", group: "class", icon: "告" },
+ schedule: { key: "schedule", title: "排期", desc: "课程、活动与截止日", group: "class", icon: "日" },
+ directory: { key: "directory", title: "成员名录", desc: "查找同学与班委", group: "class", icon: "友" },
+ resources: { key: "resources", title: "资源库", desc: "浏览和下载班级文件", group: "class", icon: "档" },
+ fund: { key: "fund", title: "班费", desc: "查看公开收支账本", group: "class", icon: "账" },
+ timeline: { key: "timeline", title: "班级动态", desc: "分享近况与评论互动", group: "interact", icon: "动" },
+ votes: { key: "votes", title: "投票", desc: "参与班级决策", group: "interact", icon: "选" },
+ reading_corner: { key: "reading_corner", title: "读书角", desc: "记录阅读与笔记", group: "interact", icon: "书" }
+};
+
+const MINI_PROGRAM_MODULE_KEYS = Object.keys(MODULES);
+
+function enabledSet(enabledModules) {
+ const source = Array.isArray(enabledModules) ? enabledModules : MINI_PROGRAM_MODULE_KEYS;
+ return new Set(source.filter((key) => MINI_PROGRAM_MODULE_KEYS.includes(key)));
+}
+
+function isModuleEnabled(key, enabledModules) {
+ return enabledSet(enabledModules).has(key);
+}
+
+function visibleModules(group, enabledModules) {
+ const enabled = enabledSet(enabledModules);
+ return MINI_PROGRAM_MODULE_KEYS
+ .map((key) => MODULES[key])
+ .filter((item) => item.group === group && enabled.has(item.key));
+}
+
+function getModule(key) {
+ return MODULES[key] || null;
+}
+
+module.exports = {
+ MODULES,
+ MINI_PROGRAM_MODULE_KEYS,
+ getModule,
+ isModuleEnabled,
+ visibleModules
+};
diff --git a/miniprogram/utils/page-helpers.js b/miniprogram/utils/page-helpers.js
new file mode 100644
index 0000000..3ce53a0
--- /dev/null
+++ b/miniprogram/utils/page-helpers.js
@@ -0,0 +1,48 @@
+const { getModule, isModuleEnabled } = require("./modules");
+
+function getActiveClassId() {
+ const app = getApp();
+ const user = app.globalData.user || wx.getStorageSync("auth_user");
+ return app.globalData.activeClassId || user?.active_membership?.class_id || user?.memberships?.[0]?.class_id || null;
+}
+
+function getEnabledModules() {
+ const app = getApp();
+ const user = app.globalData.user || wx.getStorageSync("auth_user");
+ return app.globalData.enabledModules || user?.enabled_modules || null;
+}
+
+function getActiveClassName() {
+ const app = getApp();
+ const user = app.globalData.user || wx.getStorageSync("auth_user");
+ const classId = getActiveClassId();
+ const savedClass = wx.getStorageSync("active_class") || null;
+ if (savedClass?.id === classId && savedClass?.name) return savedClass.name;
+ const membership = user?.memberships?.find((item) => item.class_id === classId);
+ return membership?.class_name || user?.active_membership?.class_name || "HKU ICB";
+}
+
+function ensureModuleOpen(moduleKey) {
+ const enabledModules = getEnabledModules();
+ if (isModuleEnabled(moduleKey, enabledModules)) return true;
+ const module = getModule(moduleKey);
+ wx.redirectTo({
+ url: `/pages/module-unavailable/index?title=${encodeURIComponent(module?.title || "功能")}`
+ });
+ return false;
+}
+
+function showError(error, fallback = "加载失败") {
+ wx.showToast({
+ title: error?.message || fallback,
+ icon: "none"
+ });
+}
+
+module.exports = {
+ ensureModuleOpen,
+ getActiveClassId,
+ getActiveClassName,
+ getEnabledModules,
+ showError
+};
diff --git a/miniprogram/utils/permissions.js b/miniprogram/utils/permissions.js
new file mode 100644
index 0000000..dd17d11
--- /dev/null
+++ b/miniprogram/utils/permissions.js
@@ -0,0 +1,42 @@
+const MODULE_MANAGE_PERMISSIONS = {
+ announcements: "announcement_manage",
+ schedule: "schedule_manage",
+ resources: "resource_manage",
+ fund: "fund_manage",
+ timeline: "timeline_manage",
+ votes: "vote_manage",
+ reading_corner: "reading_corner_manage"
+};
+
+const TEACHER_DEFAULT_PERMISSIONS = new Set([
+ "member_view",
+ "member_manage",
+ "committee_manage",
+ "announcement_manage",
+ "timeline_manage",
+ "vote_manage",
+ "schedule_manage",
+ "resource_manage",
+ "reading_corner_manage",
+ "assignment_manage",
+ "module_manage"
+]);
+
+function activeMembership(user, classId) {
+ return user?.memberships?.find((item) => item.class_id === classId) || user?.active_membership || null;
+}
+
+function hasManagePermission(user, classId, moduleKey) {
+ const permission = MODULE_MANAGE_PERMISSIONS[moduleKey];
+ if (!permission || !user) return false;
+ if (user.role === "super_admin") return true;
+ const membershipPermissions = activeMembership(user, classId)?.class_permissions || [];
+ if (user.role === "teacher") {
+ return TEACHER_DEFAULT_PERMISSIONS.has(permission) || membershipPermissions.includes(permission);
+ }
+ return membershipPermissions.includes(permission);
+}
+
+module.exports = {
+ hasManagePermission
+};