新增 小程序端
@ -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")
|
||||||
@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut
|
from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut
|
||||||
@ -29,6 +29,7 @@ async def get_announcements(
|
|||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
ensure_class_access(user, effective_class_id)
|
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)
|
announcements, total = await list_announcements(db, effective_class_id, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // 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)
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
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)
|
ensure_class_permission(user, "announcement_manage", effective_class_id)
|
||||||
|
|
||||||
announcement = await create_announcement(db, effective_class_id, user.id, data)
|
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)
|
announcement = await get_announcement_by_id(db, announcement_id)
|
||||||
if announcement is None:
|
if announcement is None:
|
||||||
raise HTTPException(status_code=404, detail="Announcement not found")
|
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)
|
ensure_class_permission(user, "announcement_manage", announcement.class_id)
|
||||||
|
|
||||||
updated = await update_announcement(db, announcement, data)
|
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)
|
announcement = await get_announcement_by_id(db, announcement_id)
|
||||||
if announcement is None:
|
if announcement is None:
|
||||||
raise HTTPException(status_code=404, detail="Announcement not found")
|
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)
|
ensure_class_permission(user, "announcement_manage", announcement.class_id)
|
||||||
|
|
||||||
await delete_announcement(db, announcement)
|
await delete_announcement(db, announcement)
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from app.core.deps import (
|
from app.core.deps import (
|
||||||
ensure_class_access,
|
ensure_class_access,
|
||||||
|
ensure_class_module_enabled,
|
||||||
ensure_class_permission,
|
ensure_class_permission,
|
||||||
get_effective_class_permissions,
|
get_effective_class_permissions,
|
||||||
require_role,
|
require_role,
|
||||||
@ -95,6 +96,7 @@ async def get_assignments(
|
|||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
ensure_class_access(user, effective_class_id)
|
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)
|
assignments, total = await list_assignments(db, effective_class_id, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // 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)
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
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)
|
ensure_class_permission(user, "assignment_manage", effective_class_id)
|
||||||
|
|
||||||
assignment = await create_assignment(db, effective_class_id, user.id, data)
|
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)
|
assignment = await get_assignment_by_id(db, assignment_id)
|
||||||
if assignment is None:
|
if assignment is None:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
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)
|
ensure_class_permission(user, "assignment_manage", assignment.class_id)
|
||||||
|
|
||||||
urls = []
|
urls = []
|
||||||
@ -157,6 +161,7 @@ async def get_assignment_detail(
|
|||||||
if assignment is None:
|
if assignment is None:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
ensure_class_access(user, assignment.class_id)
|
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))
|
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)
|
assignment = await get_assignment_by_id(db, assignment_id)
|
||||||
if assignment is None:
|
if assignment is None:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
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)
|
ensure_class_permission(user, "assignment_manage", assignment.class_id)
|
||||||
|
|
||||||
updated = await update_assignment(db, assignment, data)
|
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)
|
assignment = await get_assignment_by_id(db, assignment_id)
|
||||||
if assignment is None:
|
if assignment is None:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
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)
|
ensure_class_permission(user, "assignment_manage", assignment.class_id)
|
||||||
|
|
||||||
await delete_assignment(db, assignment)
|
await delete_assignment(db, assignment)
|
||||||
@ -217,6 +224,7 @@ async def submit_assignment(
|
|||||||
if assignment is None:
|
if assignment is None:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
ensure_class_access(user, assignment.class_id)
|
ensure_class_access(user, assignment.class_id)
|
||||||
|
await ensure_class_module_enabled(db, assignment.class_id, "assignments")
|
||||||
|
|
||||||
# Upload file
|
# Upload file
|
||||||
file_url = None
|
file_url = None
|
||||||
@ -269,6 +277,7 @@ async def grade_assignment_submission(
|
|||||||
assignment = await get_assignment_by_id(db, submission.assignment_id)
|
assignment = await get_assignment_by_id(db, submission.assignment_id)
|
||||||
if assignment is None:
|
if assignment is None:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
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)
|
ensure_class_permission(user, "assignment_manage", assignment.class_id)
|
||||||
|
|
||||||
graded = await grade_submission(db, submission, data)
|
graded = await grade_submission(db, submission, data)
|
||||||
|
|||||||
@ -48,16 +48,25 @@ async def get_classes(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if user.role not in {"super_admin", "teacher"}:
|
if user.role not in {"super_admin", "teacher"}:
|
||||||
membership = user.get_default_membership()
|
memberships = user.memberships or []
|
||||||
if membership is None:
|
if not memberships:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
class_ = await get_class_by_id(db, membership.class_id)
|
result = []
|
||||||
if class_ is None:
|
for membership in memberships:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
class_ = await get_class_by_id(db, membership.class_id)
|
||||||
count = await get_member_count(db, class_.id)
|
if class_ is None:
|
||||||
out = ClassOut.model_validate(class_)
|
continue
|
||||||
out.member_count = count
|
count = await get_member_count(db, class_.id)
|
||||||
return PageResponse(items=[out], total=1, page=1, page_size=page_size, total_pages=1)
|
out = ClassOut.model_validate(class_)
|
||||||
|
out.member_count = count
|
||||||
|
result.append(out)
|
||||||
|
return PageResponse(
|
||||||
|
items=result,
|
||||||
|
total=len(result),
|
||||||
|
page=1,
|
||||||
|
page_size=page_size,
|
||||||
|
total_pages=1 if result else 0,
|
||||||
|
)
|
||||||
|
|
||||||
classes, total = await list_classes(db, page, page_size)
|
classes, total = await list_classes(db, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.schemas.user import UserPublic
|
from app.schemas.user import UserPublic
|
||||||
@ -27,6 +27,7 @@ async def search_members(
|
|||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
ensure_class_access(user, effective_class_id)
|
ensure_class_access(user, effective_class_id)
|
||||||
|
await ensure_class_module_enabled(db, effective_class_id, "directory")
|
||||||
|
|
||||||
users, total = await search_directory(
|
users, total = await search_directory(
|
||||||
db, effective_class_id, search, industry, company, page, page_size
|
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
|
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)
|
include_contact = bool(shared_class_ids)
|
||||||
scoped_class_id = next(iter(shared_class_ids), None)
|
scoped_class_id = next(iter(shared_class_ids), None)
|
||||||
return user_to_public(target, scoped_class_id, include_contact=include_contact)
|
return user_to_public(target, scoped_class_id, include_contact=include_contact)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.database import get_db
|
||||||
from app.db.models import FundRecord, User
|
from app.db.models import FundRecord, User
|
||||||
from app.schemas.fund import FundRecordCreate, FundRecordUpdate, FundRecordOut, FundStatistics
|
from app.schemas.fund import FundRecordCreate, FundRecordUpdate, FundRecordOut, FundStatistics
|
||||||
@ -43,6 +43,7 @@ async def get_statistics(
|
|||||||
income_by_category=[], expense_by_category=[]
|
income_by_category=[], expense_by_category=[]
|
||||||
)
|
)
|
||||||
ensure_class_access(user, effective_class_id)
|
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)
|
return await get_fund_statistics(db, effective_class_id)
|
||||||
|
|
||||||
|
|
||||||
@ -60,6 +61,7 @@ async def get_fund_records(
|
|||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
ensure_class_access(user, effective_class_id)
|
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)
|
records, total = await list_fund_records(db, effective_class_id, page, page_size, type, category)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
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)
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="No class specified")
|
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)
|
ensure_class_permission(user, "fund_manage", effective_class_id)
|
||||||
|
|
||||||
if data.type not in ("income", "expense"):
|
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)
|
record = await get_fund_record_by_id(db, record_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
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)
|
ensure_class_permission(user, "fund_manage", record.class_id)
|
||||||
|
|
||||||
if data.type is not None and data.type not in ("income", "expense"):
|
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)
|
record = await get_fund_record_by_id(db, record_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
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)
|
ensure_class_permission(user, "fund_manage", record.class_id)
|
||||||
|
|
||||||
await delete_fund_record(db, record)
|
await delete_fund_record(db, record)
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.deps import (
|
from app.core.deps import (
|
||||||
ensure_class_access,
|
ensure_class_access,
|
||||||
|
ensure_class_module_enabled,
|
||||||
get_effective_class_permissions,
|
get_effective_class_permissions,
|
||||||
require_role,
|
require_role,
|
||||||
resolve_class_id_for_user,
|
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
|
reading_count=0, finished_count=0, total_pages_read=0, month_score=0
|
||||||
)
|
)
|
||||||
ensure_class_access(user, effective_class_id)
|
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))
|
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:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
ensure_class_access(user, effective_class_id)
|
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
|
owner_id = user.id if owner == "me" else None
|
||||||
books, total = await list_books(
|
books, total = await list_books(
|
||||||
@ -142,6 +145,7 @@ async def get_feed(
|
|||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
ensure_class_access(user, effective_class_id)
|
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)
|
items, total = await list_feed_items(db, effective_class_id, page, page_size)
|
||||||
return PageResponse(
|
return PageResponse(
|
||||||
@ -164,6 +168,7 @@ async def create_new_book(
|
|||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
||||||
ensure_class_access(user, effective_class_id)
|
ensure_class_access(user, effective_class_id)
|
||||||
|
await ensure_class_module_enabled(db, effective_class_id, "reading_corner")
|
||||||
try:
|
try:
|
||||||
book = await create_book(db, effective_class_id, user.id, data)
|
book = await create_book(db, effective_class_id, user.id, data)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@ -185,6 +190,7 @@ async def update_existing_book(
|
|||||||
if book is None:
|
if book is None:
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
raise HTTPException(status_code=404, detail="Book not found")
|
||||||
ensure_class_access(user, book.class_id)
|
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):
|
if book.owner_id != user.id and not _can_manage(user, book.class_id):
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
try:
|
try:
|
||||||
@ -207,6 +213,7 @@ async def delete_existing_book(
|
|||||||
if book is None:
|
if book is None:
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
raise HTTPException(status_code=404, detail="Book not found")
|
||||||
ensure_class_access(user, book.class_id)
|
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):
|
if book.owner_id != user.id and not _can_manage(user, book.class_id):
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
await delete_book(db, book)
|
await delete_book(db, book)
|
||||||
@ -223,6 +230,7 @@ async def refresh_book_cover(
|
|||||||
if book is None:
|
if book is None:
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
raise HTTPException(status_code=404, detail="Book not found")
|
||||||
ensure_class_access(user, book.class_id)
|
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):
|
if book.owner_id != user.id and not _can_manage(user, book.class_id):
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
if book.cover_url:
|
if book.cover_url:
|
||||||
@ -248,6 +256,7 @@ async def get_notes(
|
|||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
ensure_class_access(user, effective_class_id)
|
ensure_class_access(user, effective_class_id)
|
||||||
|
await ensure_class_module_enabled(db, effective_class_id, "reading_corner")
|
||||||
|
|
||||||
notes, total = await list_notes(
|
notes, total = await list_notes(
|
||||||
db,
|
db,
|
||||||
@ -280,6 +289,7 @@ async def create_new_note(
|
|||||||
if book is None:
|
if book is None:
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
raise HTTPException(status_code=404, detail="Book not found")
|
||||||
ensure_class_access(user, book.class_id)
|
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):
|
if book.owner_id != user.id and not _can_manage(user, book.class_id):
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
note = await create_note(db, book, user.id, data)
|
note = await create_note(db, book, user.id, data)
|
||||||
@ -298,6 +308,7 @@ async def update_existing_note(
|
|||||||
if note is None:
|
if note is None:
|
||||||
raise HTTPException(status_code=404, detail="Note not found")
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
ensure_class_access(user, note.book.class_id)
|
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):
|
if note.author_id != user.id and not _can_manage(user, note.book.class_id):
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
updated = await update_note(db, note, data)
|
updated = await update_note(db, note, data)
|
||||||
@ -315,6 +326,7 @@ async def delete_existing_note(
|
|||||||
if note is None:
|
if note is None:
|
||||||
raise HTTPException(status_code=404, detail="Note not found")
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
ensure_class_access(user, note.book.class_id)
|
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):
|
if note.author_id != user.id and not _can_manage(user, note.book.class_id):
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
await delete_note(db, note)
|
await delete_note(db, note)
|
||||||
@ -334,6 +346,7 @@ async def get_rankings(
|
|||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return ReadingRankingResponse(period=period, items=[])
|
return ReadingRankingResponse(period=period, items=[])
|
||||||
ensure_class_access(user, effective_class_id)
|
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)
|
rows = await get_ranking_rows(db, effective_class_id, period=period)
|
||||||
return ReadingRankingResponse(
|
return ReadingRankingResponse(
|
||||||
period=period,
|
period=period,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.schemas.resource import ResourceCreate, ResourceOut
|
from app.schemas.resource import ResourceCreate, ResourceOut
|
||||||
@ -47,6 +47,7 @@ async def get_resources(
|
|||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
ensure_class_access(user, effective_class_id)
|
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)
|
resources, total = await list_resources(db, effective_class_id, category, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // 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)
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
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)
|
ensure_class_permission(user, "resource_manage", effective_class_id)
|
||||||
|
|
||||||
contents = await file.read()
|
contents = await file.read()
|
||||||
@ -135,6 +137,7 @@ async def download_resource(
|
|||||||
if resource is None:
|
if resource is None:
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
raise HTTPException(status_code=404, detail="Resource not found")
|
||||||
ensure_class_access(user, resource.class_id)
|
ensure_class_access(user, resource.class_id)
|
||||||
|
await ensure_class_module_enabled(db, resource.class_id, "resources")
|
||||||
|
|
||||||
await increment_download_count(db, resource)
|
await increment_download_count(db, resource)
|
||||||
return {"file_url": resource.file_url}
|
return {"file_url": resource.file_url}
|
||||||
@ -149,6 +152,7 @@ async def delete_existing_resource(
|
|||||||
resource = await get_resource_by_id(db, resource_id)
|
resource = await get_resource_by_id(db, resource_id)
|
||||||
if resource is None:
|
if resource is None:
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
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)
|
ensure_class_permission(user, "resource_manage", resource.class_id)
|
||||||
|
|
||||||
await delete_resource(db, resource)
|
await delete_resource(db, resource)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.schemas.schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut
|
from app.schemas.schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut
|
||||||
@ -29,6 +29,7 @@ async def get_upcoming(
|
|||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return []
|
return []
|
||||||
ensure_class_access(user, effective_class_id)
|
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)
|
items = await get_upcoming_schedules(db, effective_class_id, limit)
|
||||||
return [ScheduleOut.model_validate(i) for i in items]
|
return [ScheduleOut.model_validate(i) for i in items]
|
||||||
|
|
||||||
@ -46,6 +47,7 @@ async def get_schedules(
|
|||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
ensure_class_access(user, effective_class_id)
|
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)
|
items, total = await list_schedules(db, effective_class_id, type, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // 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)
|
@router.post("/", response_model=ScheduleOut)
|
||||||
async def create_new_schedule(
|
async def create_new_schedule(
|
||||||
data: ScheduleCreate,
|
data: ScheduleCreate,
|
||||||
@ -68,6 +84,7 @@ async def create_new_schedule(
|
|||||||
effective_class_id = resolve_class_id_for_user(user, class_id)
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
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)
|
ensure_class_permission(user, "schedule_manage", effective_class_id)
|
||||||
|
|
||||||
item = await create_schedule(db, effective_class_id, data)
|
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)
|
item = await get_schedule_by_id(db, schedule_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="Schedule not found")
|
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)
|
ensure_class_permission(user, "schedule_manage", item.class_id)
|
||||||
|
|
||||||
updated = await update_schedule(db, item, data)
|
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)
|
item = await get_schedule_by_id(db, schedule_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="Schedule not found")
|
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)
|
ensure_class_permission(user, "schedule_manage", item.class_id)
|
||||||
|
|
||||||
await delete_schedule(db, item)
|
await delete_schedule(db, item)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import asyncio
|
|||||||
|
|
||||||
from app.core.deps import (
|
from app.core.deps import (
|
||||||
ensure_class_access,
|
ensure_class_access,
|
||||||
|
ensure_class_module_enabled,
|
||||||
ensure_class_permission,
|
ensure_class_permission,
|
||||||
get_effective_class_permissions,
|
get_effective_class_permissions,
|
||||||
require_role,
|
require_role,
|
||||||
@ -81,6 +82,7 @@ async def get_timelines(
|
|||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
ensure_class_access(user, effective_class_id)
|
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)
|
posts, total = await list_timelines(db, effective_class_id, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // 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:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
||||||
ensure_class_access(user, effective_class_id)
|
ensure_class_access(user, effective_class_id)
|
||||||
|
await ensure_class_module_enabled(db, effective_class_id, "timeline")
|
||||||
|
|
||||||
data = TimelineCreate(title=title, content=content)
|
data = TimelineCreate(title=title, content=content)
|
||||||
post = await create_timeline(db, effective_class_id, user.id, data)
|
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)
|
post = await get_timeline_by_id(db, post_id)
|
||||||
if post is None:
|
if post is None:
|
||||||
raise HTTPException(status_code=404, detail="Timeline post not found")
|
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
|
# 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)
|
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}
|
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)
|
@router.put("/{post_id}", response_model=TimelineOut)
|
||||||
async def update_existing_timeline(
|
async def update_existing_timeline(
|
||||||
post_id: int,
|
post_id: int,
|
||||||
@ -180,6 +198,7 @@ async def update_existing_timeline(
|
|||||||
post = await get_timeline_by_id(db, post_id)
|
post = await get_timeline_by_id(db, post_id)
|
||||||
if post is None:
|
if post is None:
|
||||||
raise HTTPException(status_code=404, detail="Timeline post not found")
|
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)
|
can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id)
|
||||||
if not can_manage and post.author_id != user.id:
|
if not can_manage and post.author_id != user.id:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
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)
|
post = await get_timeline_by_id(db, post_id)
|
||||||
if post is None:
|
if post is None:
|
||||||
raise HTTPException(status_code=404, detail="Timeline post not found")
|
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)
|
can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id)
|
||||||
if not can_manage and post.author_id != user.id:
|
if not can_manage and post.author_id != user.id:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
@ -219,6 +239,7 @@ async def like_timeline_post(
|
|||||||
if post is None:
|
if post is None:
|
||||||
raise HTTPException(status_code=404, detail="Timeline post not found")
|
raise HTTPException(status_code=404, detail="Timeline post not found")
|
||||||
ensure_class_access(user, post.class_id)
|
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)
|
return await toggle_like(db, post_id, user.id)
|
||||||
|
|
||||||
|
|
||||||
@ -234,6 +255,7 @@ async def get_post_comments(
|
|||||||
if post is None:
|
if post is None:
|
||||||
raise HTTPException(status_code=404, detail="Timeline post not found")
|
raise HTTPException(status_code=404, detail="Timeline post not found")
|
||||||
ensure_class_access(user, post.class_id)
|
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)
|
comments, total = await list_comments(db, post_id, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
items = [
|
items = [
|
||||||
@ -262,6 +284,7 @@ async def add_post_comment(
|
|||||||
if post is None:
|
if post is None:
|
||||||
raise HTTPException(status_code=404, detail="Timeline post not found")
|
raise HTTPException(status_code=404, detail="Timeline post not found")
|
||||||
ensure_class_access(user, post.class_id)
|
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)
|
comment = await create_comment(db, post_id, user.id, data)
|
||||||
return TimelineCommentOut(
|
return TimelineCommentOut(
|
||||||
@ -287,6 +310,7 @@ async def delete_timeline_comment(
|
|||||||
post = await get_timeline_by_id(db, comment.post_id)
|
post = await get_timeline_by_id(db, comment.post_id)
|
||||||
if post is None:
|
if post is None:
|
||||||
raise HTTPException(status_code=404, detail="Timeline post not found")
|
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)
|
can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id)
|
||||||
if not can_manage and comment.author_id != user.id:
|
if not can_manage and comment.author_id != user.id:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.deps import (
|
from app.core.deps import (
|
||||||
ensure_class_access,
|
ensure_class_access,
|
||||||
|
ensure_class_module_enabled,
|
||||||
ensure_class_permission,
|
ensure_class_permission,
|
||||||
get_effective_class_permissions,
|
get_effective_class_permissions,
|
||||||
require_role,
|
require_role,
|
||||||
@ -83,6 +84,7 @@ async def get_votes(
|
|||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
ensure_class_access(user, effective_class_id)
|
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)
|
votes, total = await list_votes(db, effective_class_id, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // 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:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
||||||
ensure_class_access(user, effective_class_id)
|
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)
|
vote = await create_vote(db, effective_class_id, user.id, data)
|
||||||
# Reload with relationships
|
# Reload with relationships
|
||||||
@ -123,6 +126,7 @@ async def get_vote_detail(
|
|||||||
if vote is None:
|
if vote is None:
|
||||||
raise HTTPException(status_code=404, detail="Vote not found")
|
raise HTTPException(status_code=404, detail="Vote not found")
|
||||||
ensure_class_access(user, vote.class_id)
|
ensure_class_access(user, vote.class_id)
|
||||||
|
await ensure_class_module_enabled(db, vote.class_id, "votes")
|
||||||
return _build_vote_out(vote, user.id)
|
return _build_vote_out(vote, user.id)
|
||||||
|
|
||||||
|
|
||||||
@ -137,6 +141,7 @@ async def submit_vote_response(
|
|||||||
if vote is None:
|
if vote is None:
|
||||||
raise HTTPException(status_code=404, detail="Vote not found")
|
raise HTTPException(status_code=404, detail="Vote not found")
|
||||||
ensure_class_access(user, vote.class_id)
|
ensure_class_access(user, vote.class_id)
|
||||||
|
await ensure_class_module_enabled(db, vote.class_id, "votes")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await submit_vote(db, vote_id, user.id, data.option_ids)
|
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)
|
vote = await get_vote_by_id(db, vote_id)
|
||||||
if vote is None:
|
if vote is None:
|
||||||
raise HTTPException(status_code=404, detail="Vote not found")
|
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
|
# Only creator or admin can close
|
||||||
can_manage = "vote_manage" in get_effective_class_permissions(user, vote.class_id)
|
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:
|
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)
|
vote = await get_vote_by_id(db, vote_id)
|
||||||
if vote is None:
|
if vote is None:
|
||||||
raise HTTPException(status_code=404, detail="Vote not found")
|
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)
|
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:
|
if not can_manage and user.role == "student" and vote.creator_id != user.id:
|
||||||
raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票")
|
raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票")
|
||||||
|
|||||||
152
backend/app/api/wechat.py
Normal 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"])
|
||||||
@ -25,6 +25,11 @@ class Settings(BaseSettings):
|
|||||||
# Book metadata
|
# Book metadata
|
||||||
google_books_api_key: str = ""
|
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 Email
|
||||||
smtp_host: str = ""
|
smtp_host: str = ""
|
||||||
smtp_port: int = 465
|
smtp_port: int = 465
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.auth import decode_access_token
|
from app.core.auth import decode_access_token
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import ClassMembership, User
|
from app.db.models import ClassMembership, Class_, User
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
@ -166,3 +166,22 @@ def ensure_class_permission(user: User, permission: str, class_id: int | None =
|
|||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Insufficient permissions",
|
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="该功能当前未开放",
|
||||||
|
)
|
||||||
|
|||||||
@ -85,6 +85,9 @@ class User(Base):
|
|||||||
skills_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
|
skills_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
|
||||||
wechat_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
wechat_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
phone: Mapped[str | None] = mapped_column(String(20), 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)
|
avatar_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
bio: Mapped[str | None] = mapped_column(Text, nullable=True)
|
bio: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
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(
|
logging.basicConfig(
|
||||||
level=logging.DEBUG if settings.debug else logging.INFO,
|
level=logging.DEBUG if settings.debug else logging.INFO,
|
||||||
@ -58,6 +58,7 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
app.include_router(wechat.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(classes.router)
|
app.include_router(classes.router)
|
||||||
app.include_router(directory.router)
|
app.include_router(directory.router)
|
||||||
|
|||||||
@ -35,6 +35,8 @@ class UserOut(BaseModel):
|
|||||||
skills_tags: list[str] | None
|
skills_tags: list[str] | None
|
||||||
wechat_id: str | None
|
wechat_id: str | None
|
||||||
phone: str | None
|
phone: str | None
|
||||||
|
wechat_bound: bool = False
|
||||||
|
phone_verified_at: datetime | None = None
|
||||||
avatar_url: str | None
|
avatar_url: str | None
|
||||||
bio: str | None
|
bio: str | None
|
||||||
created_at: datetime
|
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(),
|
skills_tags=user.get_skills_list(),
|
||||||
wechat_id=user.wechat_id,
|
wechat_id=user.wechat_id,
|
||||||
phone=user.phone,
|
phone=user.phone,
|
||||||
|
wechat_bound=bool(user.wechat_openid),
|
||||||
|
phone_verified_at=user.phone_verified_at,
|
||||||
avatar_url=user.avatar_url,
|
avatar_url=user.avatar_url,
|
||||||
bio=user.bio,
|
bio=user.bio,
|
||||||
created_at=user.created_at,
|
created_at=user.created_at,
|
||||||
|
|||||||
30
backend/app/schemas/wechat.py
Normal 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)
|
||||||
137
backend/app/services/wechat_service.py
Normal 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),
|
||||||
|
}
|
||||||
@ -37,6 +37,8 @@ export interface AuthUser {
|
|||||||
skills_tags: string[] | null;
|
skills_tags: string[] | null;
|
||||||
wechat_id: string | null;
|
wechat_id: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
|
wechat_bound: boolean;
|
||||||
|
phone_verified_at: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
bio: string | null;
|
bio: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
30
miniprogram/README.md
Normal 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
@ -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
@ -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
@ -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%;
|
||||||
|
}
|
||||||
BIN
miniprogram/assets/tabbar/class-active.png
Normal file
|
After Width: | Height: | Size: 347 B |
4
miniprogram/assets/tabbar/class-active.svg
Normal 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 |
BIN
miniprogram/assets/tabbar/class.png
Normal file
|
After Width: | Height: | Size: 503 B |
4
miniprogram/assets/tabbar/class.svg
Normal 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 |
BIN
miniprogram/assets/tabbar/home-active.png
Normal file
|
After Width: | Height: | Size: 386 B |
3
miniprogram/assets/tabbar/home-active.svg
Normal 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 |
BIN
miniprogram/assets/tabbar/home.png
Normal file
|
After Width: | Height: | Size: 459 B |
3
miniprogram/assets/tabbar/home.svg
Normal 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 |
BIN
miniprogram/assets/tabbar/interact-active.png
Normal file
|
After Width: | Height: | Size: 266 B |
4
miniprogram/assets/tabbar/interact-active.svg
Normal 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 |
BIN
miniprogram/assets/tabbar/interact.png
Normal file
|
After Width: | Height: | Size: 408 B |
4
miniprogram/assets/tabbar/interact.svg
Normal 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 |
BIN
miniprogram/assets/tabbar/mine-active.png
Normal file
|
After Width: | Height: | Size: 319 B |
4
miniprogram/assets/tabbar/mine-active.svg
Normal 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 |
BIN
miniprogram/assets/tabbar/mine.png
Normal file
|
After Width: | Height: | Size: 409 B |
4
miniprogram/assets/tabbar/mine.svg
Normal 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 |
178
miniprogram/pages/bind/index.js
Normal 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: ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
71
miniprogram/pages/bind/index.wxml
Normal 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>
|
||||||
91
miniprogram/pages/bind/index.wxss
Normal 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;
|
||||||
|
}
|
||||||
16
miniprogram/pages/class/index.js
Normal 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}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
3
miniprogram/pages/class/index.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "班级"
|
||||||
|
}
|
||||||
21
miniprogram/pages/class/index.wxml
Normal 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>
|
||||||
1
miniprogram/pages/class/index.wxss
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
89
miniprogram/pages/home/index.js
Normal 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}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
3
miniprogram/pages/home/index.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "首页"
|
||||||
|
}
|
||||||
104
miniprogram/pages/home/index.wxml
Normal 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>
|
||||||
1
miniprogram/pages/home/index.wxss
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
16
miniprogram/pages/interact/index.js
Normal 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}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
3
miniprogram/pages/interact/index.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "互动"
|
||||||
|
}
|
||||||
21
miniprogram/pages/interact/index.wxml
Normal 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>
|
||||||
1
miniprogram/pages/interact/index.wxss
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
119
miniprogram/pages/manage/index.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
54
miniprogram/pages/manage/index.wxml
Normal 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 选项 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>
|
||||||
12
miniprogram/pages/manage/index.wxss
Normal 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;
|
||||||
|
}
|
||||||
24
miniprogram/pages/member-detail/index.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
3
miniprogram/pages/member-detail/index.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
50
miniprogram/pages/member-detail/index.wxml
Normal 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>
|
||||||
1
miniprogram/pages/member-detail/index.wxss
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
58
miniprogram/pages/mine/index.js
Normal 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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
3
miniprogram/pages/mine/index.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "我的"
|
||||||
|
}
|
||||||
55
miniprogram/pages/mine/index.wxml
Normal 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>
|
||||||
1
miniprogram/pages/mine/index.wxss
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
13
miniprogram/pages/module-unavailable/index.js
Normal 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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
7
miniprogram/pages/module-unavailable/index.wxml
Normal 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>
|
||||||
1
miniprogram/pages/module-unavailable/index.wxss
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
156
miniprogram/pages/module/index.js
Normal 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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
64
miniprogram/pages/module/index.wxml
Normal 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>
|
||||||
1
miniprogram/pages/module/index.wxss
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
52
miniprogram/pages/profile-edit/index.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
3
miniprogram/pages/profile-edit/index.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
18
miniprogram/pages/profile-edit/index.wxml
Normal 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>
|
||||||
12
miniprogram/pages/profile-edit/index.wxss
Normal 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;
|
||||||
|
}
|
||||||
24
miniprogram/pages/schedule-detail/index.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
3
miniprogram/pages/schedule-detail/index.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
41
miniprogram/pages/schedule-detail/index.wxml
Normal 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>
|
||||||
1
miniprogram/pages/schedule-detail/index.wxss
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
140
miniprogram/pages/timeline-detail/index.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
3
miniprogram/pages/timeline-detail/index.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
58
miniprogram/pages/timeline-detail/index.wxml
Normal 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>
|
||||||
127
miniprogram/pages/timeline-detail/index.wxss
Normal 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;
|
||||||
|
}
|
||||||
38
miniprogram/project.config.json
Normal 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": {}
|
||||||
|
}
|
||||||
21
miniprogram/project.private.config.json
Normal 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
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"action": "allow",
|
||||||
|
"page": "*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
116
miniprogram/utils/api.js
Normal 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
@ -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
|
||||||
|
};
|
||||||
4
miniprogram/utils/config.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
module.exports = {
|
||||||
|
// 发布前改为后端 HTTPS 域名,例如 https://classhub.example.com
|
||||||
|
apiBase: "http://127.0.0.1:8000"
|
||||||
|
};
|
||||||
40
miniprogram/utils/modules.js
Normal 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
|
||||||
|
};
|
||||||
48
miniprogram/utils/page-helpers.js
Normal 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
|
||||||
|
};
|
||||||
42
miniprogram/utils/permissions.js
Normal 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
|
||||||
|
};
|
||||||