This commit is contained in:
2026-07-07 18:58:53 +08:00
parent 5ebb64a5c4
commit 9cef3106f3
7 changed files with 125 additions and 37 deletions
+4 -2
View File
@@ -245,13 +245,14 @@ async def get_join_info_public(
@router.get("/join-requests", )
async def list_join_requests(
status: str | None = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
requests = await team_invitation_service.get_pending_requests(db, team.id)
requests = await team_invitation_service.get_all_requests(db, team.id, status)
# 获取团队名
team_name_result = await db.execute(
@@ -269,7 +270,8 @@ async def list_join_requests(
phone=r.get("phone"),
status=r["status"],
note=r.get("note"),
created_at=r.get("created_at"),
created_at=r["created_at"],
handled_at=r.get("handled_at"),
)
for r in requests
]
+14
View File
@@ -11,5 +11,19 @@ TEAM_STATUS_LABELS = {
TeamStatus.DISABLED.value: "禁用",
}
class TeamJoinRequestStatus(str, Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
TEAM_JOIN_REQUEST_STATUS_LABELS = {
TeamJoinRequestStatus.PENDING.value: "待处理",
TeamJoinRequestStatus.APPROVED.value: "已通过",
TeamJoinRequestStatus.REJECTED.value: "已拒绝",
}
# 前端筛选“未分配团队”时使用的稳定哨兵值,不与真实团队ID混用。
TEAM_UNASSIGNED_VALUE = "__none__"
@@ -22,6 +22,7 @@ class JoinRequestOut(BaseModel):
status: str
note: str | None = None
created_at: NaiveDatetimeOptional = None
handled_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
@@ -222,6 +222,40 @@ async def get_pending_requests(db: AsyncSession, team_id: str) -> list[dict[str,
"status": req.status,
"note": req.note,
"created_at": req.created_at,
"handled_at": req.updated_at if req.status != "pending" else None,
}
for req, username, phone in rows
]
async def get_all_requests(
db: AsyncSession,
team_id: str,
status: str | None = None,
) -> list[dict[str, Any]]:
"""获取团队所有加入申请列表,支持按状态筛选。"""
where = [TeamJoinRequest.team_id == team_id]
if status:
where.append(TeamJoinRequest.status == status)
result = await db.execute(
select(TeamJoinRequest, User.username, User.phone)
.join(User, User.id == TeamJoinRequest.user_id)
.where(*where)
.order_by(TeamJoinRequest.created_at.desc())
)
rows = result.all()
return [
{
"id": req.id,
"team_id": req.team_id,
"user_id": req.user_id,
"username": username,
"phone": phone,
"status": req.status,
"note": req.note,
"created_at": req.created_at,
"handled_at": req.updated_at if req.status != "pending" else None,
}
for req, username, phone in rows
]
+3 -2
View File
@@ -922,8 +922,9 @@ export async function revokeInvitation(invitationId: string): Promise<void> {
await api.delete(`/team/invitations/${invitationId}`);
}
export async function getPendingJoinRequests(): Promise<any[]> {
return api.get('/team/join-requests');
export async function getPendingJoinRequests(status?: string): Promise<any[]> {
const url = status ? `/team/join-requests?status=${status}` : '/team/join-requests';
return api.get(url);
}
export async function handleJoinRequest(requestId: string, action: 'approve' | 'reject', note?: string): Promise<void> {
+68 -33
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import {
Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Space, Table, Tabs, Tag, Tooltip, Typography,
Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Segmented, Space, Table, Tabs, Tag, Tooltip, Typography,
} from 'antd';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
@@ -190,18 +190,20 @@ const TeamManagementPage: React.FC = () => {
// ── Tab 3: 加入申请 ──
const [requests, setRequests] = useState<TeamJoinRequest[]>([]);
const [reqLoading, setReqLoading] = useState(false);
const [reqStatusFilter, setReqStatusFilter] = useState<string>('pending');
const [activeTab, setActiveTab] = useState('members');
const initialNoticeShownRef = useRef(false);
const lastRequestCountRef = useRef(0);
const loadRequests = useCallback(async (isInitial = false) => {
const loadRequests = useCallback(async (status: string, isInitial = false) => {
setReqLoading(true);
try {
const data = await getPendingJoinRequests();
const data = await getPendingJoinRequests(status);
const currentCount = data?.length || 0;
setRequests(data || []);
if (currentCount > 0) {
// 只有待处理状态才显示弹窗通知
if (status === 'pending' && currentCount > 0) {
if (isInitial && !initialNoticeShownRef.current) {
initialNoticeShownRef.current = true;
Modal.confirm({
@@ -231,7 +233,9 @@ const TeamManagementPage: React.FC = () => {
}
}
lastRequestCountRef.current = currentCount;
if (status === 'pending') {
lastRequestCountRef.current = currentCount;
}
} catch (e: any) {
message.error(e?.message || '加载申请失败');
} finally {
@@ -240,13 +244,13 @@ const TeamManagementPage: React.FC = () => {
}, []);
useEffect(() => {
loadRequests(true);
loadRequests('pending', true);
}, [loadRequests]);
useEffect(() => {
if (!team) return;
const interval = setInterval(() => {
loadRequests(false);
loadRequests('pending', false);
}, 60000);
return () => clearInterval(interval);
}, [team, loadRequests]);
@@ -255,7 +259,7 @@ const TeamManagementPage: React.FC = () => {
try {
await handleJoinRequest(requestId, action, note);
message.success(action === 'approve' ? '已通过' : '已拒绝');
loadRequests();
loadRequests(reqStatusFilter);
loadMembers();
} catch (e: any) {
message.error(e?.message || '操作失败');
@@ -397,33 +401,49 @@ const TeamManagementPage: React.FC = () => {
},
];
const renderStatusTag = (status: string) => {
const map: Record<string, { color: string; text: string }> = {
pending: { color: 'orange', text: '待处理' },
approved: { color: 'green', text: '已通过' },
rejected: { color: 'red', text: '已拒绝' },
};
const info = map[status] || { color: 'default', text: status };
return <Tag color={info.color}>{info.text}</Tag>;
};
const reqColumns = [
{ title: '申请人', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string) => renderStatusTag(v) },
{ title: '申请时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
{ title: '处理时间', dataIndex: 'handledAt', width: 170, render: (v: string) => v ? formatDateTime(v) : '-' },
{ title: '备注', dataIndex: 'note', width: 180, ellipsis: true, render: (v: string) => v || '-' },
{
title: '操作', key: 'action', width: 160,
render: (_: any, r: TeamJoinRequest) => (
<Space size={4}>
<Button size="small" type="link" icon={<CheckOutlined />} style={{ color: '#16a34a', padding: 0 }} onClick={() => handleRequest(r.id, 'approve')}></Button>
<Button size="small" type="link" icon={<CloseOutlined />} danger style={{ padding: 0 }} onClick={() => {
Modal.confirm({
title: '拒绝申请',
content: (
<Form layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="note" label="拒绝原因(可选)">
<Input.TextArea rows={2} placeholder="选填" id="reject-note-input" />
</Form.Item>
</Form>
),
onOk: () => {
const note = (document.getElementById('reject-note-input') as HTMLTextAreaElement)?.value || undefined;
handleRequest(r.id, 'reject', note);
},
});
}}></Button>
</Space>
),
render: (_: any, r: TeamJoinRequest) => {
if (r.status !== 'pending') return <span style={{ color: '#94a3b8', fontSize: 12 }}></span>;
return (
<Space size={4}>
<Button size="small" type="link" icon={<CheckOutlined />} style={{ color: '#16a34a', padding: 0 }} onClick={() => handleRequest(r.id, 'approve')}></Button>
<Button size="small" type="link" icon={<CloseOutlined />} danger style={{ padding: 0 }} onClick={() => {
Modal.confirm({
title: '拒绝申请',
content: (
<Form layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="note" label="拒绝原因(可选)">
<Input.TextArea rows={2} placeholder="选填" id="reject-note-input" />
</Form.Item>
</Form>
),
onOk: () => {
const note = (document.getElementById('reject-note-input') as HTMLTextAreaElement)?.value || undefined;
handleRequest(r.id, 'reject', note);
},
});
}}></Button>
</Space>
);
},
},
];
@@ -568,9 +588,24 @@ const TeamManagementPage: React.FC = () => {
},
{
key: 'requests',
label: <Space><HistoryOutlined />{requests.length > 0 && <Tag color="red">{requests.length}</Tag>}</Space>,
label: <Space><HistoryOutlined />{requests.length > 0 && reqStatusFilter === 'pending' && <Tag color="red">{requests.length}</Tag>}</Space>,
children: (
<div style={tableWrapper}>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Segmented
value={reqStatusFilter}
onChange={(v) => {
setReqStatusFilter(v as string);
loadRequests(v as string);
}}
options={[
{ label: '待处理', value: 'pending' },
{ label: '已通过', value: 'approved' },
{ label: '已拒绝', value: 'rejected' },
{ label: '全部', value: '' },
]}
/>
</div>
<Table
columns={reqColumns}
dataSource={requests}
@@ -578,8 +613,8 @@ const TeamManagementPage: React.FC = () => {
loading={reqLoading}
pagination={false}
bordered={false}
scroll={{ x: 600 }}
locale={{ emptyText: <Empty description="暂无待审批申请" /> }}
scroll={{ x: 1000 }}
locale={{ emptyText: <Empty description={reqStatusFilter === 'pending' ? '暂无待审批申请' : '暂无记录'} /> }}
/>
</div>
),
@@ -599,7 +634,7 @@ const TeamManagementPage: React.FC = () => {
<Typography.Title level={4} style={{ margin: 0 }}>{team.name}</Typography.Title>
<Typography.Text type="secondary">: {team.code || '-'} : {team.memberCount} </Typography.Text>
</div>
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(); loadCreditRecords(); }}></Button>
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(reqStatusFilter); loadCreditRecords(); }}></Button>
</div>
) : (
<Typography.Text type="secondary"></Typography.Text>
+1
View File
@@ -59,6 +59,7 @@ export interface TeamJoinRequest {
status: string;
note?: string | null;
createdAt?: string | null;
handledAt?: string | null;
}
export interface ManagedTeam {