feat: v0.10.3 用户在线状态 + logout 会话清理
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m19s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m19s
- 用户管理/团队详情/内容资产页用户名前显示在线状态(绿点/灰点) - 基于 ActiveSession 表判断在线状态(Exists 子查询) - 新增 POST /auth/logout 接口,退出时清除 ActiveSession - 前端退出登录时先用 fetch 发 logout 请求再清 token,确保会话被正确删除 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b25a839d44
commit
5bb49b5940
@ -8,5 +8,6 @@ urlpatterns = [
|
|||||||
path('login', views.login_view, name='login'),
|
path('login', views.login_view, name='login'),
|
||||||
path('token/refresh', TokenRefreshView.as_view(), name='token_refresh'),
|
path('token/refresh', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
path('me', views.me_view, name='me'),
|
path('me', views.me_view, name='me'),
|
||||||
|
path('logout', views.logout_view, name='logout'),
|
||||||
path('change-password', views.change_password_view, name='change_password'),
|
path('change-password', views.change_password_view, name='change_password'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -154,6 +154,19 @@ def login_view(request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def logout_view(request):
|
||||||
|
"""POST /api/v1/auth/logout — 清除当前会话,标记用户离线。"""
|
||||||
|
session_id = getattr(request, 'session_id', None)
|
||||||
|
if session_id:
|
||||||
|
ActiveSession.objects.filter(user=request.user, session_id=session_id).delete()
|
||||||
|
else:
|
||||||
|
# fallback: 清除该用户所有会话
|
||||||
|
ActiveSession.objects.filter(user=request.user).delete()
|
||||||
|
return Response({'detail': 'ok'})
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def me_view(request):
|
def me_view(request):
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from rest_framework.response import Response
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Sum, Q, F, Count
|
from django.db.models import Sum, Q, F, Count, Exists, OuterRef
|
||||||
from django.db.models.functions import TruncDate
|
from django.db.models.functions import TruncDate
|
||||||
from django.db.utils import OperationalError as DbOperationalError
|
from django.db.utils import OperationalError as DbOperationalError
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@ -22,7 +22,7 @@ from .serializers import (
|
|||||||
TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer,
|
TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer,
|
||||||
TeamAnomalyConfigSerializer,
|
TeamAnomalyConfigSerializer,
|
||||||
)
|
)
|
||||||
from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnomalyConfig, LoginAnomaly
|
from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnomalyConfig, LoginAnomaly, ActiveSession
|
||||||
from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember
|
from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember
|
||||||
from utils.tos_client import upload_file as tos_upload
|
from utils.tos_client import upload_file as tos_upload
|
||||||
from utils.airdrama_client import create_task, query_task, extract_video_url, map_status
|
from utils.airdrama_client import create_task, query_task, extract_video_url, map_status
|
||||||
@ -850,6 +850,7 @@ def admin_team_detail_view(request, team_id):
|
|||||||
'generation_records__cost_amount',
|
'generation_records__cost_amount',
|
||||||
filter=Q(generation_records__created_at__date__gte=first_of_month),
|
filter=Q(generation_records__created_at__date__gte=first_of_month),
|
||||||
),
|
),
|
||||||
|
is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))),
|
||||||
).order_by('-date_joined')
|
).order_by('-date_joined')
|
||||||
|
|
||||||
# TeamAnomalyConfig
|
# TeamAnomalyConfig
|
||||||
@ -911,6 +912,7 @@ def admin_team_detail_view(request, team_id):
|
|||||||
'generations_this_month': m.generations_this_month or 0,
|
'generations_this_month': m.generations_this_month or 0,
|
||||||
'spent_today': float(m.spent_today or 0),
|
'spent_today': float(m.spent_today or 0),
|
||||||
'spent_this_month': float(m.spent_this_month or 0),
|
'spent_this_month': float(m.spent_this_month or 0),
|
||||||
|
'is_online': m.is_online,
|
||||||
'date_joined': m.date_joined.isoformat(),
|
'date_joined': m.date_joined.isoformat(),
|
||||||
} for m in members],
|
} for m in members],
|
||||||
})
|
})
|
||||||
@ -1121,6 +1123,7 @@ def admin_users_list_view(request):
|
|||||||
'generation_records__cost_amount',
|
'generation_records__cost_amount',
|
||||||
filter=Q(generation_records__created_at__date__gte=first_of_month),
|
filter=Q(generation_records__created_at__date__gte=first_of_month),
|
||||||
),
|
),
|
||||||
|
is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))),
|
||||||
)
|
)
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
@ -1159,6 +1162,7 @@ def admin_users_list_view(request):
|
|||||||
'generations_this_month': u.generations_this_month or 0,
|
'generations_this_month': u.generations_this_month or 0,
|
||||||
'spent_today': float(u.spent_today or 0),
|
'spent_today': float(u.spent_today or 0),
|
||||||
'spent_this_month': float(u.spent_this_month or 0),
|
'spent_this_month': float(u.spent_this_month or 0),
|
||||||
|
'is_online': u.is_online,
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
@ -1859,6 +1863,7 @@ def team_members_list_view(request):
|
|||||||
'generation_records__cost_amount',
|
'generation_records__cost_amount',
|
||||||
filter=Q(generation_records__created_at__date__gte=first_of_month),
|
filter=Q(generation_records__created_at__date__gte=first_of_month),
|
||||||
),
|
),
|
||||||
|
is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))),
|
||||||
).order_by('-date_joined')
|
).order_by('-date_joined')
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
@ -1878,6 +1883,7 @@ def team_members_list_view(request):
|
|||||||
'generations_this_month': m.generations_this_month or 0,
|
'generations_this_month': m.generations_this_month or 0,
|
||||||
'spent_today': float(m.spent_today or 0),
|
'spent_today': float(m.spent_today or 0),
|
||||||
'spent_this_month': float(m.spent_this_month or 0),
|
'spent_this_month': float(m.spent_this_month or 0),
|
||||||
|
'is_online': m.is_online,
|
||||||
'date_joined': m.date_joined.isoformat(),
|
'date_joined': m.date_joined.isoformat(),
|
||||||
} for m in members],
|
} for m in members],
|
||||||
})
|
})
|
||||||
@ -2311,6 +2317,7 @@ def admin_assets_team_members(request, team_id):
|
|||||||
'is_team_admin': member.is_team_admin,
|
'is_team_admin': member.is_team_admin,
|
||||||
'video_count': video_count,
|
'video_count': video_count,
|
||||||
'seconds_consumed': seconds_consumed,
|
'seconds_consumed': seconds_consumed,
|
||||||
|
'is_online': ActiveSession.objects.filter(user=member).exists(),
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
@ -2390,6 +2397,7 @@ def team_assets_overview(request):
|
|||||||
'is_team_admin': member.is_team_admin,
|
'is_team_admin': member.is_team_admin,
|
||||||
'video_count': video_count,
|
'video_count': video_count,
|
||||||
'seconds_consumed': seconds_consumed,
|
'seconds_consumed': seconds_consumed,
|
||||||
|
'is_online': ActiveSession.objects.filter(user=member).exists(),
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
|
|||||||
@ -171,6 +171,11 @@ export function AdminAssetsPage() {
|
|||||||
<div className={styles.memberItem} onClick={() => toggleMember(member.id)}>
|
<div className={styles.memberItem} onClick={() => toggleMember(member.id)}>
|
||||||
<Chevron open={expandedMember === member.id} />
|
<Chevron open={expandedMember === member.id} />
|
||||||
<span className={styles.memberName}>
|
<span className={styles.memberName}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: member.is_online ? '#00b894' : '#555', marginRight: 6,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}} />
|
||||||
{member.username}
|
{member.username}
|
||||||
{member.is_team_admin && <span className={styles.adminBadge}>管理员</span>}
|
{member.is_team_admin && <span className={styles.adminBadge}>管理员</span>}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -134,6 +134,11 @@ export function TeamAssetsPage() {
|
|||||||
<div className={styles.accordionHeader} onClick={() => toggleMember(member.id)}>
|
<div className={styles.accordionHeader} onClick={() => toggleMember(member.id)}>
|
||||||
<Chevron open={expandedMember === member.id} />
|
<Chevron open={expandedMember === member.id} />
|
||||||
<span className={styles.accordionName}>
|
<span className={styles.accordionName}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: member.is_online ? '#00b894' : '#555', marginRight: 6,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}} />
|
||||||
{member.username}
|
{member.username}
|
||||||
{member.is_team_admin && <span className={styles.adminBadge}>管理员</span>}
|
{member.is_team_admin && <span className={styles.adminBadge}>管理员</span>}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -138,7 +138,14 @@ export function TeamMembersPage() {
|
|||||||
) : (
|
) : (
|
||||||
members.map((m) => (
|
members.map((m) => (
|
||||||
<tr key={m.id}>
|
<tr key={m.id}>
|
||||||
<td>{m.username}</td>
|
<td>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: m.is_online ? '#00b894' : '#555', marginRight: 6,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}} />
|
||||||
|
{m.username}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{m.is_team_admin ? (
|
{m.is_team_admin ? (
|
||||||
<span className={styles.statusBadge} style={{ background: 'rgba(108, 99, 255, 0.15)', color: '#6c63ff' }}>管理员</span>
|
<span className={styles.statusBadge} style={{ background: 'rgba(108, 99, 255, 0.15)', color: '#6c63ff' }}>管理员</span>
|
||||||
|
|||||||
@ -756,7 +756,14 @@ export function TeamsPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{detailTeam.members.map((m) => (
|
{detailTeam.members.map((m) => (
|
||||||
<tr key={m.id}>
|
<tr key={m.id}>
|
||||||
<td>{m.username}</td>
|
<td>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: m.is_online ? '#00b894' : '#555', marginRight: 6,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}} />
|
||||||
|
{m.username}
|
||||||
|
</td>
|
||||||
<td>{m.email}</td>
|
<td>{m.email}</td>
|
||||||
<td>
|
<td>
|
||||||
{m.is_team_admin ? (
|
{m.is_team_admin ? (
|
||||||
|
|||||||
@ -226,6 +226,11 @@ export function UsersPage() {
|
|||||||
<tr key={u.id}>
|
<tr key={u.id}>
|
||||||
<td>
|
<td>
|
||||||
<button className={styles.usernameLink} onClick={() => openDrawer(u.id)}>
|
<button className={styles.usernameLink} onClick={() => openDrawer(u.id)}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: u.is_online ? '#00b894' : '#555', marginRight: 6,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}} />
|
||||||
{u.username}
|
{u.username}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -48,6 +48,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
|
// 先用当前 token 通知后端清除 ActiveSession,再清本地状态
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (token) {
|
||||||
|
fetch('/api/v1/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem('access_token');
|
||||||
localStorage.removeItem('refresh_token');
|
localStorage.removeItem('refresh_token');
|
||||||
set({
|
set({
|
||||||
|
|||||||
@ -161,6 +161,7 @@ export interface AdminUser {
|
|||||||
generations_this_month: number;
|
generations_this_month: number;
|
||||||
spent_today: number;
|
spent_today: number;
|
||||||
spent_this_month: number;
|
spent_this_month: number;
|
||||||
|
is_online?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUserDetail extends AdminUser {
|
export interface AdminUserDetail extends AdminUser {
|
||||||
@ -314,6 +315,7 @@ export interface TeamMember {
|
|||||||
generations_this_month: number;
|
generations_this_month: number;
|
||||||
spent_today: number;
|
spent_today: number;
|
||||||
spent_this_month: number;
|
spent_this_month: number;
|
||||||
|
is_online?: boolean;
|
||||||
date_joined: string;
|
date_joined: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -359,6 +361,7 @@ export interface AssetMemberSummary {
|
|||||||
video_count: number;
|
video_count: number;
|
||||||
seconds_consumed: number;
|
seconds_consumed: number;
|
||||||
cost_consumed?: number;
|
cost_consumed?: number;
|
||||||
|
is_online?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetVideo {
|
export interface AssetVideo {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user