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