This commit is contained in:
aaron 2026-04-12 21:56:08 +08:00
parent b97e1de2a7
commit fa2ac8f712
10 changed files with 138 additions and 83 deletions

View File

@ -1,5 +1,6 @@
import json import json
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -127,3 +128,28 @@ async def change_user_role(
target.role = role target.role = role
await db.commit() await db.commit()
return {"message": f"User role updated to {role}"} 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"}

View File

@ -67,6 +67,7 @@ class User(Base):
industry: Mapped[str | None] = mapped_column(String(100), nullable=True) industry: Mapped[str | None] = mapped_column(String(100), nullable=True)
company: 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) 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 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)

View File

@ -15,6 +15,7 @@ class UserOut(BaseModel):
industry: str | None industry: str | None
company: str | None company: str | None
position: str | None position: str | None
committee_role: str | None
skills_tags: list[str] | None skills_tags: list[str] | None
wechat_id: str | None wechat_id: str | None
phone: str | None phone: str | None
@ -43,6 +44,7 @@ class UserPublic(BaseModel):
industry: str | None industry: str | None
company: str | None company: str | None
position: str | None position: str | None
committee_role: str | None = None
wechat_id: str | None wechat_id: str | None
phone: str | None phone: str | None
avatar_url: str | None avatar_url: str | None
@ -60,6 +62,7 @@ class UserListItem(BaseModel):
class_id: int | None class_id: int | None
industry: str | None industry: str | None
company: str | None company: str | None
committee_role: str | None = None
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@ -1,5 +1,5 @@
import json import json
from sqlalchemy import select, or_, func from sqlalchemy import select, or_, func, case
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import User from app.db.models import User
@ -52,8 +52,16 @@ async def search_directory(
total_result = await db.execute(count_query) total_result = await db.execute(count_query)
total = total_result.scalar() or 0 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( result = await db.execute(
query.order_by(User.name) query.order_by(committee_order, User.committee_role, User.name)
.offset((page - 1) * page_size) .offset((page - 1) * page_size)
.limit(page_size) .limit(page_size)
) )
@ -70,6 +78,7 @@ def user_to_public(user: User, include_contact: bool = True) -> UserPublic:
industry=user.industry, industry=user.industry,
company=user.company, company=user.company,
position=user.position, position=user.position,
committee_role=user.committee_role,
wechat_id=user.wechat_id if include_contact else None, wechat_id=user.wechat_id if include_contact else None,
phone=user.phone if include_contact else None, phone=user.phone if include_contact else None,
avatar_url=user.avatar_url, avatar_url=user.avatar_url,

View File

@ -250,6 +250,7 @@ async def seed():
print(f"[+] Class Admin: {admin.name} ({admin.email})") print(f"[+] Class Admin: {admin.name} ({admin.email})")
# ── 3. Create students ─────────────────────────────────────────── # ── 3. Create students ───────────────────────────────────────────
COMMITTEE_MAP = {0: "班长", 1: "副班长", 3: "学习委员", 5: "组织委员", 7: "宣传委员", 9: "文体委员"}
students = [] students = []
for i, s in enumerate(STUDENTS): for i, s in enumerate(STUDENTS):
skills = random.sample(SKILLS_POOL, k=random.randint(2, 4)) skills = random.sample(SKILLS_POOL, k=random.randint(2, 4))
@ -264,6 +265,7 @@ async def seed():
industry=INDUSTRIES[i % len(INDUSTRIES)], industry=INDUSTRIES[i % len(INDUSTRIES)],
company=COMPANIES[i % len(COMPANIES)], company=COMPANIES[i % len(COMPANIES)],
position=POSITIONS[i % len(POSITIONS)], position=POSITIONS[i % len(POSITIONS)],
committee_role=COMMITTEE_MAP.get(i),
skills_tags='["' + '", "'.join(skills) + '"]', skills_tags='["' + '", "'.join(skills) + '"]',
bio=f"{COMPANIES[i % len(COMPANIES)]}担任{POSITIONS[i % len(POSITIONS)]},拥有丰富的{INDUSTRIES[i % len(INDUSTRIES)]}行业经验。", bio=f"{COMPANIES[i % len(COMPANIES)]}担任{POSITIONS[i % len(POSITIONS)]},拥有丰富的{INDUSTRIES[i % len(INDUSTRIES)]}行业经验。",
wechat_id=f"wx_{s['student_id']}", wechat_id=f"wx_{s['student_id']}",

View File

@ -29,7 +29,7 @@ import {
import { ConfirmDialog } from "@/components/confirm-dialog"; import { ConfirmDialog } from "@/components/confirm-dialog";
import { toast } from "sonner"; import { toast } from "sonner";
import type { UserListItem, RosterEntry } from "@/lib/types"; import type { UserListItem, RosterEntry } from "@/lib/types";
import { USER_STATUS, ROLES } from "@/lib/constants"; import { ROLES } from "@/lib/constants";
export default function MembersPage() { export default function MembersPage() {
const { user } = useAuth(); const { user } = useAuth();
@ -44,8 +44,6 @@ export default function MembersPage() {
const [membersError, setMembersError] = useState<string | null>(null); const [membersError, setMembersError] = useState<string | null>(null);
const [membersPage, setMembersPage] = useState(1); const [membersPage, setMembersPage] = useState(1);
const [membersTotalPages, setMembersTotalPages] = useState(1); const [membersTotalPages, setMembersTotalPages] = useState(1);
const [filter, setFilter] = useState("all");
// Roster state // Roster state
const [roster, setRoster] = useState<RosterEntry[]>([]); const [roster, setRoster] = useState<RosterEntry[]>([]);
const [rosterLoading, setRosterLoading] = useState(true); const [rosterLoading, setRosterLoading] = useState(true);
@ -116,25 +114,6 @@ export default function MembersPage() {
}, [activeClassId, activeTab, membersPage, rosterPage]); }, [activeClassId, activeTab, membersPage, rosterPage]);
// Member actions // 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) => { const handleRoleChange = async (userId: number, newRole: string) => {
try { try {
await putAPI(`/api/users/${userId}/status`, { await putAPI(`/api/users/${userId}/status`, {
@ -150,16 +129,16 @@ export default function MembersPage() {
} }
}; };
const getStatusBadge = (status: string) => { const handleCommitteeRoleChange = async (userId: number, newRole: string) => {
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = { try {
approved: "default", await putAPI(`/api/users/${userId}/committee-role`, {
disabled: "secondary", committee_role: newRole === "__none__" ? null : newRole,
}; });
return ( toast.success(newRole === "__none__" ? "已清除班委标签" : `已设置班委标签: ${newRole}`);
<Badge variant={variants[status] || "secondary"}> loadMembers();
{USER_STATUS[status as keyof typeof USER_STATUS] || status} } catch (err: any) {
</Badge> toast.error(err.message || "操作失败");
); }
}; };
// Roster actions // Roster actions
@ -249,8 +228,8 @@ export default function MembersPage() {
} }
}; };
const filteredMembers = const [editingCommitteeId, setEditingCommitteeId] = useState<number | null>(null);
filter === "all" ? members : members.filter((m) => m.status === filter); const [editingCommitteeValue, setEditingCommitteeValue] = useState("");
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -435,23 +414,7 @@ export default function MembersPage() {
{activeTab === "members" && ( {activeTab === "members" && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <h2 className="text-lg font-semibold"></h2>
<h2 className="text-lg font-semibold"></h2>
<Select value={filter} onValueChange={(v) => v && setFilter(v)}>
<SelectTrigger className="w-32">
<SelectValue>
{filter === "all"
? "全部"
: USER_STATUS[filter as keyof typeof USER_STATUS] || filter}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="approved"></SelectItem>
<SelectItem value="disabled"></SelectItem>
</SelectContent>
</Select>
</div>
{membersLoading ? ( {membersLoading ? (
<div className="animate-pulse space-y-2"> <div className="animate-pulse space-y-2">
@ -465,22 +428,70 @@ export default function MembersPage() {
</div> </div>
) : membersError ? ( ) : membersError ? (
<ErrorState message={membersError} onRetry={loadMembers} /> <ErrorState message={membersError} onRetry={loadMembers} />
) : filteredMembers.length === 0 ? ( ) : members.length === 0 ? (
<div className="text-center py-8 text-gray-400"></div> <div className="text-center py-8 text-gray-400"></div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{filteredMembers.map((m) => ( {members.map((m) => (
<Card key={m.id}> <Card key={m.id}>
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div className="flex items-center gap-2">
<p className="font-medium">{m.name}</p> <div>
<p className="text-sm text-gray-500"> <p className="font-medium">
{m.email} {m.name}
{m.student_id ? ` · ${m.student_id}` : ""} {m.committee_role && (
{m.company ? ` · ${m.company}` : ""} <Badge className="ml-2 text-xs bg-amber-100 text-amber-800 hover:bg-amber-100 border-amber-200">
</p> {m.committee_role}
</Badge>
)}
</p>
<p className="text-sm text-gray-500">
{m.email}
{m.student_id ? ` · ${m.student_id}` : ""}
{m.company ? ` · ${m.company}` : ""}
</p>
</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{m.role !== "super_admin" && editingCommitteeId === m.id ? (
<Input
className="w-24 h-7 text-xs"
placeholder="班委名称"
value={editingCommitteeValue}
onChange={(e) => 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" ? (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => {
setEditingCommitteeId(m.id);
setEditingCommitteeValue(m.committee_role || "");
}}
>
{m.committee_role ? "修改班委" : "添加班委"}
</Button>
) : null}
{isSuperAdmin ? ( {isSuperAdmin ? (
<Select <Select
value={m.role} value={m.role}
@ -502,26 +513,6 @@ export default function MembersPage() {
{ROLES[m.role as keyof typeof ROLES] || m.role} {ROLES[m.role as keyof typeof ROLES] || m.role}
</Badge> </Badge>
)} )}
{getStatusBadge(m.status)}
{m.status === "approved" && (
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => handleStatusChange(m.id, "disabled")}
>
</Button>
)}
{m.status === "disabled" && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatusChange(m.id, "approved")}
>
</Button>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -70,6 +70,11 @@ export default function MemberDetailPage() {
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<h1 className="text-2xl font-bold">{member.name}</h1> <h1 className="text-2xl font-bold">{member.name}</h1>
{member.committee_role && (
<Badge className="mt-1 bg-amber-100 text-amber-800 hover:bg-amber-100 border-amber-200">
{member.committee_role}
</Badge>
)}
{member.student_id && ( {member.student_id && (
<p className="text-sm text-gray-500 mt-1">: {member.student_id}</p> <p className="text-sm text-gray-500 mt-1">: {member.student_id}</p>
)} )}

View File

@ -132,6 +132,11 @@ export default function DirectoryPage() {
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<p className="font-medium truncate w-full">{member.name}</p> <p className="font-medium truncate w-full">{member.name}</p>
{member.committee_role && (
<Badge className="mt-0.5 text-xs bg-amber-100 text-amber-800 hover:bg-amber-100 border-amber-200">
{member.committee_role}
</Badge>
)}
{member.company && ( {member.company && (
<p className="text-sm text-gray-500 truncate w-full mt-0.5"> <p className="text-sm text-gray-500 truncate w-full mt-0.5">
{member.company} {member.company}

View File

@ -31,3 +31,13 @@ export const INDUSTRY_OPTIONS = [
"政府/公共事业", "政府/公共事业",
"其他", "其他",
]; ];
export const COMMITTEE_ROLES = [
"班长",
"副班长",
"学习委员",
"组织委员",
"宣传委员",
"文体委员",
"生活委员",
] as const;

View File

@ -13,6 +13,7 @@ export interface AuthUser {
industry: string | null; industry: string | null;
company: string | null; company: string | null;
position: string | null; position: string | null;
committee_role: string | null;
skills_tags: string[] | null; skills_tags: string[] | null;
wechat_id: string | null; wechat_id: string | null;
phone: string | null; phone: string | null;
@ -43,6 +44,7 @@ export interface UserPublic {
industry: string | null; industry: string | null;
company: string | null; company: string | null;
position: string | null; position: string | null;
committee_role: string | null;
wechat_id: string | null; wechat_id: string | null;
phone: string | null; phone: string | null;
avatar_url: string | null; avatar_url: string | null;
@ -59,6 +61,7 @@ export interface UserListItem {
class_id: number | null; class_id: number | null;
industry: string | null; industry: string | null;
company: string | null; company: string | null;
committee_role: string | null;
created_at: string; created_at: string;
} }