1
This commit is contained in:
parent
b97e1de2a7
commit
fa2ac8f712
@ -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"}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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']}",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -31,3 +31,13 @@ export const INDUSTRY_OPTIONS = [
|
||||
"政府/公共事业",
|
||||
"其他",
|
||||
];
|
||||
|
||||
export const COMMITTEE_ROLES = [
|
||||
"班长",
|
||||
"副班长",
|
||||
"学习委员",
|
||||
"组织委员",
|
||||
"宣传委员",
|
||||
"文体委员",
|
||||
"生活委员",
|
||||
] as const;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user