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

View File

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

View File

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

View File

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

View File

@ -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']}",

View File

@ -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<string | null>(null);
const [membersPage, setMembersPage] = useState(1);
const [membersTotalPages, setMembersTotalPages] = useState(1);
const [filter, setFilter] = useState("all");
// Roster state
const [roster, setRoster] = useState<RosterEntry[]>([]);
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<string, "default" | "secondary" | "destructive" | "outline"> = {
approved: "default",
disabled: "secondary",
};
return (
<Badge variant={variants[status] || "secondary"}>
{USER_STATUS[status as keyof typeof USER_STATUS] || status}
</Badge>
);
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<number | null>(null);
const [editingCommitteeValue, setEditingCommitteeValue] = useState("");
return (
<div className="space-y-6">
@ -435,23 +414,7 @@ export default function MembersPage() {
{activeTab === "members" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<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>
<h2 className="text-lg font-semibold"></h2>
{membersLoading ? (
<div className="animate-pulse space-y-2">
@ -465,22 +428,70 @@ export default function MembersPage() {
</div>
) : membersError ? (
<ErrorState message={membersError} onRetry={loadMembers} />
) : filteredMembers.length === 0 ? (
<div className="text-center py-8 text-gray-400"></div>
) : members.length === 0 ? (
<div className="text-center py-8 text-gray-400"></div>
) : (
<div className="space-y-2">
{filteredMembers.map((m) => (
{members.map((m) => (
<Card key={m.id}>
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="font-medium">{m.name}</p>
<p className="text-sm text-gray-500">
{m.email}
{m.student_id ? ` · ${m.student_id}` : ""}
{m.company ? ` · ${m.company}` : ""}
</p>
<div className="flex items-center gap-2">
<div>
<p className="font-medium">
{m.name}
{m.committee_role && (
<Badge className="ml-2 text-xs bg-amber-100 text-amber-800 hover:bg-amber-100 border-amber-200">
{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 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 ? (
<Select
value={m.role}
@ -502,26 +513,6 @@ export default function MembersPage() {
{ROLES[m.role as keyof typeof ROLES] || m.role}
</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>
</CardContent>
</Card>

View File

@ -70,6 +70,11 @@ export default function MemberDetailPage() {
</AvatarFallback>
</Avatar>
<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 && (
<p className="text-sm text-gray-500 mt-1">: {member.student_id}</p>
)}

View File

@ -132,6 +132,11 @@ export default function DirectoryPage() {
</AvatarFallback>
</Avatar>
<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 && (
<p className="text-sm text-gray-500 truncate w-full mt-0.5">
{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;
company: string | null;
position: string | null;
committee_role: string | null;
skills_tags: string[] | null;
wechat_id: string | null;
phone: string | null;
@ -43,6 +44,7 @@ export interface UserPublic {
industry: string | null;
company: string | null;
position: string | null;
committee_role: string | null;
wechat_id: string | null;
phone: string | null;
avatar_url: string | null;
@ -59,6 +61,7 @@ export interface UserListItem {
class_id: number | null;
industry: string | null;
company: string | null;
committee_role: string | null;
created_at: string;
}