diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 991e685..2dbc612 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -1,5 +1,6 @@ import json +from pydantic import BaseModel from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status from sqlalchemy.ext.asyncio import AsyncSession @@ -127,3 +128,28 @@ async def change_user_role( target.role = role await db.commit() return {"message": f"User role updated to {role}"} + + +class CommitteeRoleUpdate(BaseModel): + committee_role: str | None = None + + +@router.put("/{user_id}/committee-role") +async def change_committee_role( + user_id: int, + data: CommitteeRoleUpdate, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + target = await get_user_by_id(db, user_id) + if target is None: + raise HTTPException(status_code=404, detail="User not found") + + if admin.role == "class_admin" and target.class_id != admin.class_id: + raise HTTPException( + status_code=403, detail="Cannot manage users outside your class" + ) + + target.committee_role = data.committee_role + await db.commit() + return {"message": "Committee role updated"} diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 932b734..a2c794f 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -67,6 +67,7 @@ class User(Base): industry: Mapped[str | None] = mapped_column(String(100), nullable=True) company: Mapped[str | None] = mapped_column(String(100), nullable=True) position: Mapped[str | None] = mapped_column(String(100), nullable=True) + committee_role: Mapped[str | None] = mapped_column(String(50), nullable=True) skills_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array wechat_id: Mapped[str | None] = mapped_column(String(100), nullable=True) phone: Mapped[str | None] = mapped_column(String(20), nullable=True) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index eaf4184..126554a 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -15,6 +15,7 @@ class UserOut(BaseModel): industry: str | None company: str | None position: str | None + committee_role: str | None skills_tags: list[str] | None wechat_id: str | None phone: str | None @@ -43,6 +44,7 @@ class UserPublic(BaseModel): industry: str | None company: str | None position: str | None + committee_role: str | None = None wechat_id: str | None phone: str | None avatar_url: str | None @@ -60,6 +62,7 @@ class UserListItem(BaseModel): class_id: int | None industry: str | None company: str | None + committee_role: str | None = None created_at: datetime model_config = {"from_attributes": True} diff --git a/backend/app/services/directory_service.py b/backend/app/services/directory_service.py index 72b56a5..fd9c605 100644 --- a/backend/app/services/directory_service.py +++ b/backend/app/services/directory_service.py @@ -1,5 +1,5 @@ import json -from sqlalchemy import select, or_, func +from sqlalchemy import select, or_, func, case from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import User @@ -52,8 +52,16 @@ async def search_directory( total_result = await db.execute(count_query) total = total_result.scalar() or 0 + # Committee role priority: 班长(1) > 副班长(2) > other roles(3) > no role(4) + committee_order = case( + (User.committee_role == None, 4), + (User.committee_role == "班长", 1), + (User.committee_role == "副班长", 2), + else_=3, + ) + result = await db.execute( - query.order_by(User.name) + query.order_by(committee_order, User.committee_role, User.name) .offset((page - 1) * page_size) .limit(page_size) ) @@ -70,6 +78,7 @@ def user_to_public(user: User, include_contact: bool = True) -> UserPublic: industry=user.industry, company=user.company, position=user.position, + committee_role=user.committee_role, wechat_id=user.wechat_id if include_contact else None, phone=user.phone if include_contact else None, avatar_url=user.avatar_url, diff --git a/backend/seed_demo.py b/backend/seed_demo.py index 2ddb929..9d70c43 100644 --- a/backend/seed_demo.py +++ b/backend/seed_demo.py @@ -250,6 +250,7 @@ async def seed(): print(f"[+] Class Admin: {admin.name} ({admin.email})") # ── 3. Create students ─────────────────────────────────────────── + COMMITTEE_MAP = {0: "班长", 1: "副班长", 3: "学习委员", 5: "组织委员", 7: "宣传委员", 9: "文体委员"} students = [] for i, s in enumerate(STUDENTS): skills = random.sample(SKILLS_POOL, k=random.randint(2, 4)) @@ -264,6 +265,7 @@ async def seed(): industry=INDUSTRIES[i % len(INDUSTRIES)], company=COMPANIES[i % len(COMPANIES)], position=POSITIONS[i % len(POSITIONS)], + committee_role=COMMITTEE_MAP.get(i), skills_tags='["' + '", "'.join(skills) + '"]', bio=f"在{COMPANIES[i % len(COMPANIES)]}担任{POSITIONS[i % len(POSITIONS)]},拥有丰富的{INDUSTRIES[i % len(INDUSTRIES)]}行业经验。", wechat_id=f"wx_{s['student_id']}", diff --git a/frontend/src/app/(app)/admin/members/page.tsx b/frontend/src/app/(app)/admin/members/page.tsx index 92ffc6b..9f55c6f 100644 --- a/frontend/src/app/(app)/admin/members/page.tsx +++ b/frontend/src/app/(app)/admin/members/page.tsx @@ -29,7 +29,7 @@ import { import { ConfirmDialog } from "@/components/confirm-dialog"; import { toast } from "sonner"; import type { UserListItem, RosterEntry } from "@/lib/types"; -import { USER_STATUS, ROLES } from "@/lib/constants"; +import { ROLES } from "@/lib/constants"; export default function MembersPage() { const { user } = useAuth(); @@ -44,8 +44,6 @@ export default function MembersPage() { const [membersError, setMembersError] = useState(null); const [membersPage, setMembersPage] = useState(1); const [membersTotalPages, setMembersTotalPages] = useState(1); - const [filter, setFilter] = useState("all"); - // Roster state const [roster, setRoster] = useState([]); const [rosterLoading, setRosterLoading] = useState(true); @@ -116,25 +114,6 @@ export default function MembersPage() { }, [activeClassId, activeTab, membersPage, rosterPage]); // Member actions - const handleStatusChange = async ( - userId: number, - newStatus: string, - role?: string - ) => { - try { - await putAPI(`/api/users/${userId}/status`, { - status: newStatus, - role: role || undefined, - }); - toast.success( - `已更新状态为 ${USER_STATUS[newStatus as keyof typeof USER_STATUS] || newStatus}` - ); - loadMembers(); - } catch (err: any) { - toast.error(err.message || "操作失败"); - } - }; - const handleRoleChange = async (userId: number, newRole: string) => { try { await putAPI(`/api/users/${userId}/status`, { @@ -150,16 +129,16 @@ export default function MembersPage() { } }; - const getStatusBadge = (status: string) => { - const variants: Record = { - approved: "default", - disabled: "secondary", - }; - return ( - - {USER_STATUS[status as keyof typeof USER_STATUS] || status} - - ); + const handleCommitteeRoleChange = async (userId: number, newRole: string) => { + try { + await putAPI(`/api/users/${userId}/committee-role`, { + committee_role: newRole === "__none__" ? null : newRole, + }); + toast.success(newRole === "__none__" ? "已清除班委标签" : `已设置班委标签: ${newRole}`); + loadMembers(); + } catch (err: any) { + toast.error(err.message || "操作失败"); + } }; // Roster actions @@ -249,8 +228,8 @@ export default function MembersPage() { } }; - const filteredMembers = - filter === "all" ? members : members.filter((m) => m.status === filter); + const [editingCommitteeId, setEditingCommitteeId] = useState(null); + const [editingCommitteeValue, setEditingCommitteeValue] = useState(""); return (
@@ -435,23 +414,7 @@ export default function MembersPage() { {activeTab === "members" && (
-
-

已注册成员

- -
+

已注册成员

{membersLoading ? (
@@ -465,22 +428,70 @@ export default function MembersPage() {
) : membersError ? ( - ) : filteredMembers.length === 0 ? ( -
没有符合条件的成员
+ ) : members.length === 0 ? ( +
暂无已注册成员
) : (
- {filteredMembers.map((m) => ( + {members.map((m) => ( -
-

{m.name}

-

- {m.email} - {m.student_id ? ` · ${m.student_id}` : ""} - {m.company ? ` · ${m.company}` : ""} -

+
+
+

+ {m.name} + {m.committee_role && ( + + {m.committee_role} + + )} +

+

+ {m.email} + {m.student_id ? ` · ${m.student_id}` : ""} + {m.company ? ` · ${m.company}` : ""} +

+
+ {m.role !== "super_admin" && editingCommitteeId === m.id ? ( + setEditingCommitteeValue(e.target.value)} + onBlur={() => { + if (editingCommitteeValue.trim()) { + handleCommitteeRoleChange(m.id, editingCommitteeValue.trim()); + } else if (m.committee_role) { + handleCommitteeRoleChange(m.id, "__none__"); + } + setEditingCommitteeId(null); + setEditingCommitteeValue(""); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + (e.target as HTMLInputElement).blur(); + } + if (e.key === "Escape") { + setEditingCommitteeId(null); + setEditingCommitteeValue(""); + } + }} + autoFocus + /> + ) : m.role !== "super_admin" ? ( + + ) : null} {isSuperAdmin ? (