1、生成邀请码默认过期时间设置一天

2、邀请链接内容在列表没有显示完全,复制也没有生效
3、检查用户通过邀请链接访问是否未注册需要注册登陆,如果有账户直接登陆直接进行申请,如果已经登陆直接弹窗显示是否加入具体团队,避免单用户多次提交申请
4、如果团队负责人有未处理的加入申请,弹窗通知
This commit is contained in:
2026-07-07 10:53:34 +08:00
parent ab995ce3cf
commit 4375e4980c
13 changed files with 502 additions and 218 deletions
+39 -3
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import StreamingResponse
from app.config import settings
from app.dependencies import get_current_user, get_db
from app.dependencies import get_current_user, get_db, get_optional_current_user
from app.models.team import Team
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
@@ -189,7 +189,7 @@ async def join_by_code(
@router.get("/join-info", )
async def get_join_info(
code: str = Query(...),
current_user: User = Depends(get_current_user),
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
"""验证邀请码并返回团队信息(用于加入页面展示)。"""
@@ -202,16 +202,52 @@ async def get_join_info(
)
team_name = team.scalar_one_or_none() or ""
already_in_team = current_user.team_id == invitation.team_id
already_in_team = current_user and current_user.team_id == invitation.team_id
has_pending_request = False
if current_user:
from app.models.team_join_request import TeamJoinRequest
pending = await db.execute(
select(TeamJoinRequest).where(
TeamJoinRequest.user_id == current_user.id,
TeamJoinRequest.team_id == invitation.team_id,
TeamJoinRequest.status == "pending",
).limit(1)
)
has_pending = pending.scalar_one_or_none()
has_pending_request = has_pending is not None
return JoinTeamInfoOut(
team_name=team_name,
team_id=invitation.team_id,
valid=True,
already_in_team=already_in_team,
has_pending_request=has_pending_request,
)
@router.get("/join-info/public", )
async def get_join_info_public(
code: str = Query(...),
db: AsyncSession = Depends(get_db),
):
"""公开接口:验证邀请码并返回团队信息(无需登录)。"""
invitation = await team_invitation_service.get_invitation_by_code(db, code)
if not invitation:
return {"team_name": "", "team_id": "", "valid": False}
team = await db.execute(
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
)
team_name = team.scalar_one_or_none() or ""
return {
"team_name": team_name,
"team_id": invitation.team_id,
"valid": True,
}
@router.get("/join-requests", )
async def list_join_requests(
current_user: User = Depends(get_current_user),
+25
View File
@@ -70,6 +70,31 @@ async def get_current_user(
return current_user
async def get_optional_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User | None:
if not credentials:
return None
user_id = decode_access_token(credentials.credentials)
if not user_id:
return None
if user_id.startswith("captcha:"):
return None
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
if not user or not user.is_active:
return None
if user_must_set_password(user):
return None
return user
async def get_admin_user(
current_user: User = Depends(get_current_user_allow_password_pending),
) -> User:
@@ -31,3 +31,4 @@ class JoinTeamInfoOut(BaseModel):
team_id: str
valid: bool
already_in_team: bool = False
has_pending_request: bool = False
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import HTTPException
@@ -46,6 +46,8 @@ async def create_invitation(
"""创建邀请码(仅团队管理人)。"""
await _assert_is_manager(db, created_by, team_id)
code = _generate_invite_code()
if expires_at is None:
expires_at = datetime.now(timezone.utc) + timedelta(days=1)
invitation = TeamInvitation(
id=generate_id(),
team_id=team_id,
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DzBPiPEO.js"></script>
<script type="module" crossorigin src="/assets/index-BriVPrZ6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
</head>
<body>
+1 -1
View File
@@ -92,6 +92,7 @@ const App = () => {
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/join-team" element={<JoinTeamPage />} />
<Route
path="/"
element={
@@ -127,7 +128,6 @@ const App = () => {
<Route path="authacc" element={<AuthAccountPage />} />
<Route path="creativeplaza" element={<CreativePlazaPage />} />
<Route path="team-management" element={<TeamManagementPage />} />
<Route path="join-team" element={<JoinTeamPage />} />
</Route>
<Route path="*" element={<Navigate to="/projects" replace />} />
</Routes>
+5 -1
View File
@@ -843,7 +843,11 @@ export async function handleJoinRequest(requestId: string, action: 'approve' | '
}
export async function getJoinTeamInfo(code: string): Promise<any> {
return api.get(`/team/join-info?code=${encodeURIComponent(code)}`);
return api.get(`/team/join-info?code=${encodeURIComponent(code)}`, { auth: true, skipAuthRedirect: true });
}
export async function getJoinTeamInfoPublic(code: string): Promise<any> {
return api.get(`/team/join-info/public?code=${encodeURIComponent(code)}`, false);
}
export async function submitJoinRequest(code: string): Promise<void> {
+158 -46
View File
@@ -1,54 +1,76 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Result, Spin, Typography, Modal, message } from 'antd';
import { CheckCircleOutlined, TeamOutlined } from '@ant-design/icons';
import {
Button, Card, Result, Spin, Typography, Modal, message, Space,
} from 'antd';
import {
CheckCircleOutlined, TeamOutlined, LoginOutlined, UserAddOutlined,
} from '@ant-design/icons';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { getJoinTeamInfo, submitJoinRequest } from '../api';
import type { JoinTeamInfo } from '../types';
import { useAuthStore } from '../store/useAuthStore';
const JoinTeamPage: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const code = searchParams.get('code') || '';
const { user, loading: authLoading, checkAuth } = useAuthStore();
const [loading, setLoading] = useState(true);
const [infoLoading, setInfoLoading] = useState(true);
const [info, setInfo] = useState<JoinTeamInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
useEffect(() => {
checkAuth();
}, [checkAuth]);
useEffect(() => {
if (!code) {
setLoading(false);
setInfoLoading(false);
return;
}
if (authLoading) return;
setInfoLoading(true);
getJoinTeamInfo(code)
.then((data) => setInfo(data))
.then((data) => {
setInfo(data);
if (data?.valid && user && !data.alreadyInTeam && !data.hasPendingRequest) {
setConfirmModalOpen(true);
}
})
.catch(() => setInfo(null))
.finally(() => setLoading(false));
}, [code]);
.finally(() => setInfoLoading(false));
}, [code, user, authLoading]);
const handleJoin = async () => {
if (!code) return;
Modal.confirm({
title: '确认加入团队',
icon: <TeamOutlined style={{ color: '#6366f1' }} />,
content: info?.teamName ? `您确定要加入团队「${info.teamName}」吗?提交后需等待团队管理人审批。` : '您确定要加入该团队吗?',
okText: '确认加入',
cancelText: '取消',
onOk: async () => {
try {
setSubmitting(true);
await submitJoinRequest(code);
setSubmitted(true);
} catch (e: any) {
message.error(e?.message || '申请失败');
} finally {
setSubmitting(false);
}
},
});
try {
setSubmitting(true);
await submitJoinRequest(code);
setSubmitted(true);
setConfirmModalOpen(false);
message.success('申请已提交');
} catch (e: any) {
message.error(e?.message || '申请失败');
} finally {
setSubmitting(false);
}
};
if (loading) {
const handleLogin = () => {
const redirect = encodeURIComponent(window.location.pathname + window.location.search);
navigate(`/login?redirect=${redirect}`);
};
const handleRegister = () => {
const redirect = encodeURIComponent(window.location.pathname + window.location.search);
navigate(`/login?tab=register&redirect=${redirect}`);
};
if (authLoading || infoLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<Spin size="large" />
@@ -64,7 +86,14 @@ const JoinTeamPage: React.FC = () => {
icon={<CheckCircleOutlined style={{ color: '#6366f1' }} />}
title="申请已提交"
subTitle="您的加入申请已提交,请等待团队管理人审批。审批通过后将自动加入团队。"
extra={<Button type="primary" onClick={() => navigate('/projects')}></Button>}
extra={
<Space>
<Button type="primary" onClick={() => navigate('/projects')}></Button>
{user && (
<Button onClick={() => navigate('/team-management')}></Button>
)}
</Space>
}
/>
</div>
);
@@ -90,31 +119,114 @@ const JoinTeamPage: React.FC = () => {
status="info"
title="您已在此团队中"
subTitle={`您已经是「${info.teamName}」的成员了,无需再次加入。`}
extra={<Button type="primary" onClick={() => navigate('/projects')}></Button>}
extra={
<Space>
<Button type="primary" onClick={() => navigate('/projects')}></Button>
<Button onClick={() => navigate('/team-management')}></Button>
</Space>
}
/>
</div>
);
}
if (info.hasPendingRequest) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Result
status="info"
title="申请待审批"
subTitle={`您已提交加入「${info.teamName}」的申请,请等待团队管理人审批。`}
extra={
<Space>
<Button type="primary" onClick={() => navigate('/projects')}></Button>
<Button onClick={() => navigate('/team-management')}></Button>
</Space>
}
/>
</div>
);
}
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Card variant="outlined" style={{ borderRadius: 16, maxWidth: 480, width: '100%', textAlign: 'center' }}>
<TeamOutlined style={{ fontSize: 48, color: '#6366f1', marginBottom: 16 }} />
<Typography.Title level={3}></Typography.Title>
<Typography.Text style={{ fontSize: 16, color: '#475569', display: 'block', marginBottom: 8 }}>
</Typography.Text>
<Typography.Title level={4} style={{ color: '#6366f1', margin: '16px 0 24px' }}>
{info.teamName}
</Typography.Title>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
使
</Typography.Text>
<Button type="primary" size="large" block loading={submitting} onClick={handleJoin}>
</Button>
</Card>
</div>
<>
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Card
variant="outlined"
style={{ borderRadius: 16, maxWidth: 480, width: '100%', textAlign: 'center' }}
>
<TeamOutlined style={{ fontSize: 48, color: '#6366f1', marginBottom: 16 }} />
<Typography.Title level={3}></Typography.Title>
<Typography.Text style={{ fontSize: 16, color: '#475569', display: 'block', marginBottom: 8 }}>
</Typography.Text>
<Typography.Title level={4} style={{ color: '#6366f1', margin: '16px 0 24px' }}>
{info.teamName}
</Typography.Title>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
使
</Typography.Text>
{user ? (
<Button
type="primary"
size="large"
block
loading={submitting}
onClick={() => setConfirmModalOpen(true)}
>
</Button>
) : (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Button
type="primary"
size="large"
block
icon={<LoginOutlined />}
onClick={handleLogin}
>
</Button>
<Button
size="large"
block
icon={<UserAddOutlined />}
onClick={handleRegister}
>
</Button>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
</Space>
)}
</Card>
</div>
<Modal
title={
<Space>
<TeamOutlined style={{ color: '#6366f1' }} />
</Space>
}
open={confirmModalOpen}
onOk={handleJoin}
onCancel={() => setConfirmModalOpen(false)}
okText="确认加入"
cancelText="取消"
confirmLoading={submitting}
width={420}
>
<p style={{ marginBottom: 0 }}>
<strong style={{ color: '#6366f1' }}>{info.teamName}</strong>
</p>
<p style={{ marginTop: 8, color: '#64748b', fontSize: 13 }}>
</p>
</Modal>
</>
);
};
+24 -4
View File
@@ -5,7 +5,7 @@ import {
PlayCircleOutlined, BulbOutlined, HistoryOutlined,
MobileOutlined, SafetyOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '../store/useAuthStore';
import { sendSms,phonelogin, getSiteInfo, register } from '../api';
import './LoginPage.css';
@@ -17,6 +17,9 @@ const LoginPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<'password' | 'phone' | 'register'>('password');
const [tab, setTab] = useState<'password' | 'phone'>('password');
const [searchParams] = useSearchParams();
const redirect = searchParams.get('redirect');
const tabParam = searchParams.get('tab');
const [countdown, setCountdown] = useState(0);
const [regCountdown, setRegCountdown] = useState(0);
const [agreed, setAgreed] = useState(false);
@@ -62,6 +65,23 @@ const LoginPage: React.FC = () => {
}).catch(() => {});
}, []);
useEffect(() => {
if (tabParam === 'register') {
setMode('register');
}
}, [tabParam]);
const goToRedirect = () => {
if (redirect) {
try {
const decoded = decodeURIComponent(redirect);
navigate(decoded);
return;
} catch {}
}
navigate('/home');
};
const checkAgreed = (): boolean => {
if (!agreed) {
message.warning('请先阅读并同意用户协议及隐私政策');
@@ -77,7 +97,7 @@ const LoginPage: React.FC = () => {
await login(values.phone, values.password, undefined, values.rememberMe);
message.success('登录成功,欢迎回来');
await checkAuth();
navigate('/home');
goToRedirect();
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg);
@@ -97,7 +117,7 @@ const LoginPage: React.FC = () => {
await phonelogin(values.phone, values.code);
message.success('登录成功,欢迎回来');
await checkAuth();
navigate('/home');
goToRedirect();
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg);
@@ -114,7 +134,7 @@ const LoginPage: React.FC = () => {
const user = await register(values.phone, values.regCode, values.password);
message.success('注册成功');
await checkAuth();
navigate('/home');
goToRedirect();
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '注册失败';
message.error(errorMsg);
+96 -13
View File
@@ -1,11 +1,11 @@
import React, { useEffect, useState, useCallback } from 'react';
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,
} from 'antd';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
import {
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined,
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined, BellOutlined,
} from '@ant-design/icons';
const { RangePicker } = DatePicker;
@@ -159,22 +159,80 @@ const TeamManagementPage: React.FC = () => {
};
const copyInviteLink = (link: string) => {
navigator.clipboard.writeText(link).then(() => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(link).then(() => {
message.success('邀请链接已复制');
}).catch(() => {
fallbackCopy(link);
});
} else {
fallbackCopy(link);
}
};
const fallbackCopy = (text: string) => {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
message.success('邀请链接已复制');
}).catch(() => {
} catch {
message.warning('复制失败,请手动复制');
});
}
document.body.removeChild(textArea);
};
// ── Tab 3: 加入申请 ──
const [requests, setRequests] = useState<TeamJoinRequest[]>([]);
const [reqLoading, setReqLoading] = useState(false);
const [activeTab, setActiveTab] = useState('members');
const initialNoticeShownRef = useRef(false);
const lastRequestCountRef = useRef(0);
const loadRequests = useCallback(async () => {
const loadRequests = useCallback(async (isInitial = false) => {
setReqLoading(true);
try {
const data = await getPendingJoinRequests();
const currentCount = data?.length || 0;
setRequests(data || []);
if (currentCount > 0) {
if (isInitial && !initialNoticeShownRef.current) {
initialNoticeShownRef.current = true;
Modal.confirm({
title: (
<Space>
<BellOutlined style={{ color: '#f59e0b' }} />
</Space>
),
content: (
<div>
<p> <strong style={{ color: '#ef4444' }}>{currentCount}</strong> </p>
<p style={{ color: '#64748b', fontSize: 13, marginBottom: 0 }}></p>
</div>
),
okText: '立即处理',
cancelText: '稍后处理',
onOk: () => {
setActiveTab('requests');
},
});
} else if (!isInitial && currentCount > lastRequestCountRef.current) {
message.info({
content: `${currentCount - lastRequestCountRef.current} 条新的加入申请待处理`,
duration: 5,
});
}
}
lastRequestCountRef.current = currentCount;
} catch (e: any) {
message.error(e?.message || '加载申请失败');
} finally {
@@ -182,7 +240,17 @@ const TeamManagementPage: React.FC = () => {
}
}, []);
useEffect(() => { loadRequests(); }, [loadRequests]);
useEffect(() => {
loadRequests(true);
}, [loadRequests]);
useEffect(() => {
if (!team) return;
const interval = setInterval(() => {
loadRequests(false);
}, 60000);
return () => clearInterval(interval);
}, [team, loadRequests]);
const handleRequest = async (requestId: string, action: 'approve' | 'reject', note?: string) => {
try {
@@ -286,12 +354,27 @@ const TeamManagementPage: React.FC = () => {
const invColumns = [
{ title: '邀请码', dataIndex: 'code', width: 200, render: (v: string) => <Typography.Text copyable style={{ fontFamily: 'monospace' }}>{v}</Typography.Text> },
{
title: '邀请链接', dataIndex: 'inviteLink', ellipsis: true,
title: '邀请链接', dataIndex: 'inviteLink',
render: (v: string) => (
<Space>
<Typography.Text ellipsis style={{ maxWidth: 250, fontSize: 12 }}>{v}</Typography.Text>
<Space style={{ width: '100%' }}>
<Typography.Text
style={{
flex: 1,
fontSize: 12,
wordBreak: 'break-all',
fontFamily: 'monospace',
color: '#64748b',
}}
>
{v}
</Typography.Text>
<Tooltip title="复制链接">
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyInviteLink(v)} />
<Button
size="small"
type="text"
icon={<CopyOutlined />}
onClick={() => copyInviteLink(v)}
/>
</Tooltip>
</Space>
),
@@ -517,7 +600,7 @@ const TeamManagementPage: React.FC = () => {
</div>
{/* 标签页 */}
<Tabs items={tabItems} defaultActiveKey="members" size="large" />
<Tabs items={tabItems} activeKey={activeTab} onChange={setActiveTab} size="large" />
{/* 调整积分弹窗 */}
<Modal
@@ -608,7 +691,7 @@ const TeamManagementPage: React.FC = () => {
<InputNumber style={{ width: '100%' }} min={1} placeholder="留空表示不限" size="large" />
</Form.Item>
<Form.Item name="expiresAt" label="过期时间">
<Input type="datetime-local" style={{ width: '100%' }} placeholder="留空表示永不过期" size="large" />
<Input type="datetime-local" style={{ width: '100%' }} placeholder="默认24小时后过期" size="large" />
</Form.Item>
</Form>
</Modal>
+1
View File
@@ -77,6 +77,7 @@ export interface JoinTeamInfo {
teamId: string;
valid: boolean;
alreadyInTeam: boolean;
hasPendingRequest: boolean;
}
export interface CreditRecord {