新增 小程序端

This commit is contained in:
aaron 2026-05-12 23:10:05 +08:00
parent 3b5ce47aed
commit 1d8b815f95
93 changed files with 3145 additions and 16 deletions

View File

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

View File

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

View File

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

View File

@ -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)
result = []
for membership in memberships:
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)
continue
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.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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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="只有创建者或管理员可以删除投票")

152
backend/app/api/wechat.py Normal file
View File

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

View File

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

View File

@ -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="该功能当前未开放",
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

30
miniprogram/README.md Normal file
View File

@ -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 后台完成。

31
miniprogram/app.js Normal file
View File

@ -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;
}
});

57
miniprogram/app.json Normal file
View File

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

368
miniprogram/app.wxss Normal file
View File

@ -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%;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<rect x="18" y="18" width="60" height="60" rx="10" fill="#6B1F2B"/>
<path d="M32 36h32M32 50h32M32 64h20" stroke="#FFF7EA" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<rect x="18" y="18" width="60" height="60" rx="10" fill="none" stroke="#8C8178" stroke-width="6"/>
<path d="M32 36h32M32 50h32M32 64h20" stroke="#8C8178" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 291 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<path d="M20 45.5 48 22l28 23.5V76a4 4 0 0 1-4 4H58V58H38v22H24a4 4 0 0 1-4-4V45.5Z" fill="#6B1F2B"/>
</svg>

After

Width:  |  Height:  |  Size: 195 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<path d="M20 45.5 48 22l28 23.5V76a4 4 0 0 1-4 4H58V58H38v22H24a4 4 0 0 1-4-4V45.5Z" fill="none" stroke="#8C8178" stroke-width="6" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<path d="M24 28h48a8 8 0 0 1 8 8v18a8 8 0 0 1-8 8H47L30 76V62h-6a8 8 0 0 1-8-8V36a8 8 0 0 1 8-8Z" fill="#6B1F2B"/>
<path d="M34 45h28" stroke="#FFF7EA" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<path d="M24 28h48a8 8 0 0 1 8 8v18a8 8 0 0 1-8 8H47L30 76V62h-6a8 8 0 0 1-8-8V36a8 8 0 0 1 8-8Z" fill="none" stroke="#8C8178" stroke-width="6" stroke-linejoin="round"/>
<path d="M34 45h28" stroke="#8C8178" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<circle cx="48" cy="34" r="14" fill="#6B1F2B"/>
<path d="M22 78c4-16 14-24 26-24s22 8 26 24" fill="#6B1F2B"/>
</svg>

After

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<circle cx="48" cy="34" r="14" fill="none" stroke="#8C8178" stroke-width="6"/>
<path d="M22 78c4-16 14-24 26-24s22 8 26 24" fill="none" stroke="#8C8178" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

View File

@ -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: ""
});
}
});

View File

@ -0,0 +1,71 @@
<view class="page">
<view class="hero">
<view class="eyebrow">SECURE SIGN IN</view>
<view class="hero-title">登录 ClassHub</view>
<view class="hero-subtitle">可以使用微信快速登录,也可以用原来的邮箱和密码登录。</view>
</view>
<view wx:if="{{isLoginMode}}" class="section">
<view class="login-panel">
<view class="login-tabs">
<view class="login-tab {{isWechatLogin ? 'active' : ''}}" data-method="wechat" bindtap="switchLoginMethod">微信</view>
<view class="login-tab {{isEmailLogin ? 'active' : ''}}" data-method="email" bindtap="switchLoginMethod">邮箱</view>
</view>
<view wx:if="{{isWechatLogin}}" class="login-method">
<view class="wechat-symbol">微</view>
<view class="login-title">微信快捷登录</view>
<view class="login-copy">已绑定微信的同学可直接进入班级空间;未绑定账号会进入激活流程。</view>
<button class="button login-primary" loading="{{loading}}" bindtap="loginWithWechatTap">使用微信登录</button>
</view>
<view wx:if="{{isEmailLogin}}" class="login-method">
<view class="login-title">邮箱密码登录</view>
<view class="login-copy">继续使用 Web 端账号登录,登录后可绑定微信以便下次快捷进入。</view>
<view class="form-field">
<view class="field-label">邮箱</view>
<input value="{{email}}" bindinput="onEmailInput" placeholder="请输入邮箱" />
</view>
<view class="form-field">
<view class="field-label">密码</view>
<input password="{{true}}" value="{{password}}" bindinput="onPasswordInput" placeholder="请输入密码" />
</view>
<button class="button login-primary" loading="{{loading}}" bindtap="loginWithEmail">登录</button>
</view>
</view>
</view>
<view wx:elif="{{isActivateMode}}" class="section">
<view class="section-head">
<view class="section-title">激活账号</view>
<view class="section-action" bindtap="backToLogin">返回登录</view>
</view>
<view class="card">
<view class="muted">说明</view>
<view class="card-title">没有找到已绑定的微信账号</view>
<view class="muted">请用班级邀请码、学号和微信手机号激活你的班级账号。</view>
</view>
<view class="card">
<view class="muted">邀请码</view>
<input value="{{inviteCode}}" bindinput="onInviteCodeInput" placeholder="请输入班级邀请码" />
</view>
<view class="card">
<view class="muted">学号</view>
<input value="{{studentId}}" bindinput="onStudentIdInput" placeholder="请输入学号" />
</view>
<button class="button" loading="{{loading}}" open-type="getPhoneNumber" bindgetphonenumber="bindWithPhone">
授权手机号并绑定
</button>
</view>
<view wx:elif="{{isBindCurrentMode}}" class="section">
<view class="card">
<view class="card-title">绑定微信和手机号</view>
<view class="muted">邮箱登录成功。为了下次可以微信快速登录,请绑定当前微信和手机号。</view>
</view>
<button class="button" loading="{{loading}}" open-type="getPhoneNumber" bindgetphonenumber="bindCurrentWithPhone">
授权手机号并绑定微信
</button>
<button class="button secondary" bindtap="skipCurrentBinding">暂时跳过</button>
</view>
</view>

View File

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

View File

@ -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}` });
}
});

View File

@ -0,0 +1,3 @@
{
"navigationBarTitleText": "班级"
}

View File

@ -0,0 +1,21 @@
<view class="page">
<view class="hero">
<view class="eyebrow">CLASS SPACE</view>
<view class="hero-title">班级事务</view>
<view class="hero-subtitle">公告、排期、名录、资源和班费都按当前班级开放状态展示。</view>
</view>
<view class="section-head section">
<view class="section-title">可用模块</view>
<view class="section-action">已过滤关闭项</view>
</view>
<view class="grid">
<view wx:for="{{modules}}" wx:key="key" class="module-tile" data-key="{{item.key}}" bindtap="openModule">
<view class="module-icon">{{item.icon}}</view>
<view class="module-title">{{item.title}}</view>
<view class="module-desc">{{item.desc}}</view>
</view>
</view>
<view wx:if="{{!modules.length}}" class="empty">
<view class="muted">当前班级暂无开放的班级模块</view>
</view>
</view>

View File

@ -0,0 +1 @@

View File

@ -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}` });
}
});

View File

@ -0,0 +1,3 @@
{
"navigationBarTitleText": "首页"
}

View File

@ -0,0 +1,104 @@
<view class="page">
<view class="hero">
<view class="eyebrow">HKU ICB CLASSHUB</view>
<view class="hero-title">{{className}}</view>
<view class="hero-subtitle">把公告、排期、投票和班级互动放在一个安静清晰的移动入口。</view>
<view class="hero-metrics">
<view class="metric">
<view class="metric-number">{{unreadCount}}</view>
<view class="metric-label">未读通知</view>
</view>
<view class="metric">
<view class="metric-number">{{schedules.length}}</view>
<view class="metric-label">近期安排</view>
</view>
<view class="metric">
<view class="metric-number">{{votes.length}}</view>
<view class="metric-label">班级投票</view>
</view>
</view>
</view>
<view wx:if="{{quickModules.length}}" class="section">
<view class="section-head">
<view class="section-title">常用入口</view>
<view class="section-action">按班级开放</view>
</view>
<view class="grid">
<view wx:for="{{quickModules}}" wx:key="key" class="module-tile" bindtap="openModule" data-key="{{item.key}}">
<view class="module-icon">{{item.icon}}</view>
<view class="module-title">{{item.title}}</view>
<view class="module-desc">{{item.desc}}</view>
</view>
</view>
</view>
<view wx:if="{{announcements.length}}" class="section">
<view class="section-head">
<view class="section-title">最新公告</view>
<view class="section-action" bindtap="openModule" data-key="announcements">全部</view>
</view>
<view wx:for="{{announcements}}" wx:key="id" class="card" bindtap="openModule" data-key="announcements">
<view class="list-row">
<view class="row-mark">告</view>
<view class="row-body">
<view class="card-title">{{item.title}}</view>
<view class="muted">{{item.author_name}}</view>
</view>
</view>
</view>
</view>
<view wx:if="{{schedules.length}}" class="section">
<view class="section-head">
<view class="section-title">近期排期</view>
<view class="section-action" bindtap="openModule" data-key="schedule">日程</view>
</view>
<view wx:for="{{schedules}}" wx:key="id" class="card" bindtap="openSchedule" data-id="{{item.id}}">
<view class="list-row">
<view class="row-mark">日</view>
<view class="row-body">
<view class="card-title">{{item.title}}</view>
<view class="muted">{{item.location || "地点待定"}}</view>
</view>
<view class="pill">{{item.type}}</view>
</view>
</view>
</view>
<view wx:if="{{votes.length}}" class="section">
<view class="section-head">
<view class="section-title">班级投票</view>
<view class="section-action" bindtap="openModule" data-key="votes">参与</view>
</view>
<view wx:for="{{votes}}" wx:key="id" class="card" bindtap="openModule" data-key="votes">
<view class="list-row">
<view class="row-mark">选</view>
<view class="row-body">
<view class="card-title">{{item.title}}</view>
<view class="muted">{{item.has_voted ? "已参与" : "待参与"}}</view>
</view>
</view>
</view>
</view>
<view wx:if="{{timelines.length}}" class="section">
<view class="section-head">
<view class="section-title">班级动态</view>
<view class="section-action" bindtap="openModule" data-key="timeline">浏览</view>
</view>
<view wx:for="{{timelines}}" wx:key="id" class="card" bindtap="openTimeline" data-id="{{item.id}}">
<view class="list-row">
<view class="row-mark">动</view>
<view class="row-body">
<view class="card-title">{{item.title}}</view>
<view class="muted">{{item.author_name}}</view>
</view>
</view>
</view>
</view>
<view wx:if="{{!loading && !announcements.length && !schedules.length && !votes.length && !timelines.length}}" class="empty">
<view class="muted">暂无可展示内容</view>
</view>
</view>

View File

@ -0,0 +1 @@

View File

@ -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}` });
}
});

View File

@ -0,0 +1,3 @@
{
"navigationBarTitleText": "互动"
}

View File

@ -0,0 +1,21 @@
<view class="page">
<view class="hero">
<view class="eyebrow">CLASS INTERACTION</view>
<view class="hero-title">互动协作</view>
<view class="hero-subtitle">轻量表达、投票决策和阅读分享,适合在手机上快速完成。</view>
</view>
<view class="section-head section">
<view class="section-title">可用互动</view>
<view class="section-action">按权限开放</view>
</view>
<view class="grid">
<view wx:for="{{modules}}" wx:key="key" class="module-tile" data-key="{{item.key}}" bindtap="openModule">
<view class="module-icon">{{item.icon}}</view>
<view class="module-title">{{item.title}}</view>
<view class="module-desc">{{item.desc}}</view>
</view>
</view>
<view wx:if="{{!modules.length}}" class="empty">
<view class="muted">当前班级暂无开放的互动模块</view>
</view>
</view>

View File

@ -0,0 +1 @@

View File

@ -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 });
}
}
});

View File

@ -0,0 +1,54 @@
<view class="page">
<view class="section-title">{{title}}</view>
<view class="card">
<view class="muted">标题</view>
<input value="{{form.title}}" data-field="title" bindinput="onInput" placeholder="请输入标题" />
</view>
<view wx:if="{{isAnnouncements}}" class="card">
<view class="muted">内容</view>
<textarea value="{{form.content}}" data-field="content" bindinput="onInput" placeholder="请输入公告内容" />
<view class="muted">置顶</view>
<switch checked="{{form.is_pinned}}" data-field="is_pinned" bindchange="onSwitch" />
</view>
<view wx:if="{{isVotes}}" class="card">
<view class="muted">说明</view>
<textarea value="{{form.description}}" data-field="description" bindinput="onInput" placeholder="请输入投票说明" />
<view class="muted">选项,每行一个</view>
<textarea value="{{form.options_text}}" data-field="options_text" bindinput="onInput" placeholder="选项 A&#10;选项 B" />
<view class="muted">匿名</view>
<switch checked="{{form.is_anonymous}}" data-field="is_anonymous" bindchange="onSwitch" />
</view>
<view wx:if="{{isSchedule}}" class="card">
<view class="muted">类型</view>
<picker mode="selector" range="{{scheduleTypes}}" data-field="type" data-values="course,deadline,activity" bindchange="onPicker">
<view>{{form.type}}</view>
</picker>
<view class="muted">开始时间格式2026-05-07T09:00:00</view>
<input value="{{form.start_time}}" data-field="start_time" bindinput="onInput" placeholder="2026-05-07T09:00:00" />
<view class="muted">地点</view>
<input value="{{form.location}}" data-field="location" bindinput="onInput" placeholder="地点" />
<view class="muted">说明</view>
<textarea value="{{form.description}}" data-field="description" bindinput="onInput" placeholder="说明" />
</view>
<view wx:if="{{isFund}}" class="card">
<view class="muted">类型</view>
<picker mode="selector" range="{{fundTypes}}" data-field="type" data-values="income,expense" bindchange="onPicker">
<view>{{form.type}}</view>
</picker>
<view class="muted">金额</view>
<input type="digit" value="{{form.amount}}" data-field="amount" bindinput="onInput" placeholder="0.00" />
<view class="muted">分类</view>
<input value="{{form.category}}" data-field="category" bindinput="onInput" placeholder="例如:聚餐" />
<view class="muted">日期格式2026-05-07</view>
<input value="{{form.record_date}}" data-field="record_date" bindinput="onInput" placeholder="2026-05-07" />
<view class="muted">说明</view>
<textarea value="{{form.description}}" data-field="description" bindinput="onInput" placeholder="说明" />
</view>
<button class="button" loading="{{loading}}" bindtap="submit">保存</button>
</view>

View File

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

View File

@ -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 });
}
}
});

View File

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@ -0,0 +1,50 @@
<view class="page" wx:if="{{member}}">
<view class="hero">
<view class="eyebrow">CLASSMATE</view>
<view class="hero-title">{{member.name}}</view>
<view class="hero-subtitle">{{member.company || "公司未填写"}} · {{member.position || "职位未填写"}}</view>
</view>
<view class="section">
<view class="card">
<view class="list-row">
<view class="row-mark">业</view>
<view class="row-body">
<view class="card-title">行业</view>
<view class="muted">{{member.industry || "未填写"}}</view>
</view>
</view>
</view>
<view class="card">
<view class="list-row">
<view class="row-mark">班</view>
<view class="row-body">
<view class="card-title">班级角色</view>
<view class="muted">{{member.committee_role || "同学"}}</view>
</view>
</view>
</view>
<view class="card">
<view class="list-row">
<view class="row-mark">微</view>
<view class="row-body">
<view class="card-title">微信</view>
<view class="muted">{{member.wechat_id || "未填写"}}</view>
</view>
</view>
</view>
<view class="card">
<view class="list-row">
<view class="row-mark">电</view>
<view class="row-body">
<view class="card-title">电话</view>
<view class="muted">{{member.phone || "未填写"}}</view>
</view>
</view>
</view>
<view class="card">
<view class="card-title">简介</view>
<view class="muted">{{member.bio || "暂无简介"}}</view>
</view>
</view>
</view>

View File

@ -0,0 +1 @@

View File

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

View File

@ -0,0 +1,3 @@
{
"navigationBarTitleText": "我的"
}

View File

@ -0,0 +1,55 @@
<view class="page">
<view class="hero">
<view class="eyebrow">MY CLASSHUB</view>
<view class="hero-title">{{user.name || "我的"}}</view>
<view class="hero-subtitle">学号:{{user.student_id || "未填写"}}</view>
<view class="mine-class-pill">{{activeClassName}}</view>
</view>
<view class="section">
<view class="menu-card" bindtap="openProfile">
<view class="list-row">
<view class="row-mark">人</view>
<view class="row-body">
<view class="card-title">个人资料</view>
<view class="muted">{{user.company || "公司未填写"}} · {{user.position || "职位未填写"}}</view>
</view>
<view class="chevron"></view>
</view>
</view>
<view class="menu-card">
<view class="list-row">
<view class="row-mark">号</view>
<view class="row-body">
<view class="card-title">手机号</view>
<view class="muted">{{user.phone || "未绑定"}}</view>
</view>
</view>
</view>
<view class="menu-card">
<view class="list-row">
<view class="row-mark">微</view>
<view class="row-body">
<view class="card-title">微信绑定</view>
<view class="muted">{{user.wechat_bound ? "已绑定微信" : "未绑定微信"}}</view>
</view>
</view>
</view>
</view>
<view wx:if="{{classes.length > 1}}" class="section">
<view class="section-title">我的班级</view>
<view wx:for="{{classes}}" wx:key="id" class="menu-card" data-id="{{item.id}}" bindtap="switchClass">
<view class="list-row">
<view class="row-mark">班</view>
<view class="row-body">
<view class="card-title">{{item.name}}</view>
<view class="muted">{{item.is_active ? "当前班级" : "点击切换"}} · {{item.cohort_year}}</view>
</view>
<view wx:if="{{item.is_active}}" class="pill">当前</view>
</view>
</view>
</view>
<button class="button secondary" bindtap="logout">退出登录</button>
</view>

View File

@ -0,0 +1 @@

View File

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

View File

@ -0,0 +1,7 @@
<view class="page">
<view class="empty">
<view class="section-title">{{title}}</view>
<view class="muted">该功能当前未开放</view>
<button class="button" bindtap="backHome">返回首页</button>
</view>
</view>

View File

@ -0,0 +1 @@

View File

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

View File

@ -0,0 +1,64 @@
<view class="page">
<view class="hero">
<view class="eyebrow">MODULE</view>
<view class="hero-title">{{title}}</view>
<view class="hero-subtitle">当前内容来自已开放的班级模块,关闭后会自动隐藏入口。</view>
</view>
<button wx:if="{{canPostTimeline}}" class="button" bindtap="openTimelineCreate">发布动态</button>
<button wx:if="{{canManage}}" class="button" bindtap="openManage">新增{{title}}</button>
<view class="section">
<view wx:for="{{items}}" wx:key="id" class="card" data-id="{{item.id || item.user_id}}" bindtap="openItem">
<view wx:if="{{isTimeline}}" class="feed-card">
<view class="feed-head">
<view class="row-mark">动</view>
<view class="row-body">
<view class="card-title">{{item.title}}</view>
<view class="muted">{{item.author_name}} · {{item.created_at}}</view>
</view>
<view wx:if="{{item.can_delete}}" class="more-dot" data-id="{{item.id}}" catchtap="openTimelineActions">···</view>
</view>
<view wx:if="{{item.content}}" class="feed-content">{{item.content}}</view>
<view wx:if="{{item.image_urls && item.image_urls.length}}" class="feed-images">
<image wx:for="{{item.image_urls}}" wx:for-item="img" wx:key="*this" src="{{img}}" mode="aspectFill" data-src="{{img}}" data-post-id="{{item.id}}" catchtap="previewImage" />
</view>
<view class="feed-actions">
<view>赞 {{item.like_count}}</view>
<view>评论 {{item.comment_count}}</view>
</view>
</view>
<view wx:elif="{{isDirectory}}" class="member-row">
<view class="avatar">{{item.initial}}</view>
<view class="row-body">
<view class="card-title">{{item.name}}</view>
<view class="muted">{{item.company || "公司未填写"}} · {{item.position || "职位未填写"}}</view>
<view class="muted">{{item.industry || "行业未填写"}}{{item.committee_text}}</view>
</view>
</view>
<view wx:elif="{{isSchedule}}" class="schedule-row">
<view class="date-badge">
<view class="date-day">{{item.schedule_day}}</view>
<view class="date-month">{{item.schedule_month}}</view>
</view>
<view class="row-body">
<view class="card-title">{{item.title}}</view>
<view class="muted">{{item.location || "地点待定"}}</view>
<view class="muted">{{item.start_time}}</view>
</view>
<view class="pill">{{item.type}}</view>
</view>
<view wx:else class="list-row">
<view class="row-mark">{{moduleIcon}}</view>
<view class="row-body">
<view class="card-title">{{item.title || item.name || item.book_title || item.category}}</view>
<view class="muted">{{item.description || item.content || item.author_name || item.recorder_name || item.location || ""}}</view>
</view>
</view>
</view>
</view>
<view wx:if="{{!loading && !items.length}}" class="empty">
<view class="muted">暂无内容</view>
</view>
</view>

View File

@ -0,0 +1 @@

View File

@ -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 });
}
}
});

View File

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@ -0,0 +1,18 @@
<view class="page">
<view class="hero">
<view class="eyebrow">PROFILE</view>
<view class="hero-title">编辑个人资料</view>
<view class="hero-subtitle">这些信息会在成员名录中展示给同班同学。</view>
</view>
<view class="section">
<view class="card"><view class="muted">姓名</view><input value="{{form.name}}" data-field="name" bindinput="onInput" /></view>
<view class="card"><view class="muted">行业</view><input value="{{form.industry}}" data-field="industry" bindinput="onInput" placeholder="例如:金融" /></view>
<view class="card"><view class="muted">公司</view><input value="{{form.company}}" data-field="company" bindinput="onInput" /></view>
<view class="card"><view class="muted">职位</view><input value="{{form.position}}" data-field="position" bindinput="onInput" /></view>
<view class="card"><view class="muted">微信号</view><input value="{{form.wechat_id}}" data-field="wechat_id" bindinput="onInput" /></view>
<view class="card"><view class="muted">简介</view><textarea value="{{form.bio}}" data-field="bio" bindinput="onInput" placeholder="简单介绍一下自己" /></view>
</view>
<button class="button" loading="{{loading}}" bindtap="save">保存资料</button>
</view>

View File

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

View File

@ -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 });
}
}
});

View File

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@ -0,0 +1,41 @@
<view class="page" wx:if="{{item}}">
<view class="hero">
<view class="eyebrow">SCHEDULE</view>
<view class="hero-title">{{item.title}}</view>
<view class="hero-subtitle">{{item.location || "地点待定"}}</view>
</view>
<view class="section">
<view class="card">
<view class="list-row">
<view class="row-mark">类</view>
<view class="row-body">
<view class="card-title">类型</view>
<view class="muted">{{item.type}}</view>
</view>
</view>
</view>
<view class="card">
<view class="list-row">
<view class="row-mark">始</view>
<view class="row-body">
<view class="card-title">开始时间</view>
<view class="muted">{{item.start_time}}</view>
</view>
</view>
</view>
<view class="card" wx:if="{{item.end_time}}">
<view class="list-row">
<view class="row-mark">止</view>
<view class="row-body">
<view class="card-title">结束时间</view>
<view class="muted">{{item.end_time}}</view>
</view>
</view>
</view>
<view class="card">
<view class="card-title">说明</view>
<view class="muted">{{item.description || "暂无说明"}}</view>
</view>
</view>
</view>

View File

@ -0,0 +1 @@

View File

@ -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 });
}
}
});

View File

@ -0,0 +1,3 @@
{
"enablePullDownRefresh": true
}

View File

@ -0,0 +1,58 @@
<view class="page" wx:if="{{post}}">
<view class="hero">
<view class="eyebrow">CLASS FEED</view>
<view wx:if="{{canDelete}}" class="detail-more" bindtap="openActions">···</view>
<view class="hero-title">{{post.title}}</view>
<view class="hero-subtitle">{{post.author_name}} · {{post.created_at}}</view>
</view>
<view class="section">
<view class="card">
<view class="feed-content">{{post.content || "暂无正文"}}</view>
<view wx:if="{{post.image_urls && post.image_urls.length}}" class="feed-images detail-images">
<image wx:for="{{post.image_urls}}" wx:for-item="img" wx:key="*this" src="{{img}}" mode="aspectFill" data-src="{{img}}" bindtap="previewImage" />
</view>
<view class="detail-action-bar">
<view class="action-chip {{post.has_liked ? 'active' : ''}}" bindtap="toggleLike">
<text class="action-icon">赞</text>
<text>{{post.has_liked ? "已点赞" : "点赞"}} · {{post.like_count}}</text>
</view>
<view class="action-chip">
<text class="action-icon">评</text>
<text>评论 · {{comments.length}}</text>
</view>
</view>
</view>
</view>
<view class="section">
<view class="section-head">
<view class="section-title">评论</view>
<view class="section-action">{{comments.length}} 条</view>
</view>
<view wx:for="{{comments}}" wx:key="id" class="comment-card">
<view class="list-row">
<view class="avatar">{{item.initial}}</view>
<view class="row-body">
<view class="comment-head">
<view class="card-title">{{item.author_name}}</view>
<view class="reply-link" data-name="{{item.author_name}}" bindtap="replyComment">回复</view>
</view>
<view class="comment-content">{{item.content}}</view>
</view>
</view>
</view>
<view wx:if="{{!comments.length}}" class="card">
<view class="muted">还没有评论</view>
</view>
</view>
<view wx:if="{{replyTo}}" class="replying-bar">
<view>正在回复 {{replyTo}}</view>
<view bindtap="cancelReply">取消</view>
</view>
<view class="comment-box">
<input value="{{commentText}}" bindinput="onCommentInput" placeholder="{{inputPlaceholder}}" />
<button loading="{{submitting}}" bindtap="submitComment">发送</button>
</view>
</view>

View File

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

View File

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

View File

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

8
miniprogram/sitemap.json Normal file
View File

@ -0,0 +1,8 @@
{
"rules": [
{
"action": "allow",
"page": "*"
}
]
}

116
miniprogram/utils/api.js Normal file
View File

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

52
miniprogram/utils/auth.js Normal file
View File

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

View File

@ -0,0 +1,4 @@
module.exports = {
// 发布前改为后端 HTTPS 域名,例如 https://classhub.example.com
apiBase: "http://127.0.0.1:8000"
};

View File

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

View File

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

View File

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