diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py index 1de2e2a..a525998 100644 --- a/backend/apps/accounts/urls.py +++ b/backend/apps/accounts/urls.py @@ -8,5 +8,6 @@ urlpatterns = [ path('login', views.login_view, name='login'), path('token/refresh', TokenRefreshView.as_view(), name='token_refresh'), path('me', views.me_view, name='me'), + path('logout', views.logout_view, name='logout'), path('change-password', views.change_password_view, name='change_password'), ] diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index 085a5c8..ddeb232 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -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']) @permission_classes([IsAuthenticated]) def me_view(request): diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 57bcfc1..b47b3dd 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -8,7 +8,7 @@ from rest_framework.response import Response from django.contrib.auth import get_user_model from django.utils import timezone 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.utils import OperationalError as DbOperationalError from datetime import timedelta @@ -22,7 +22,7 @@ from .serializers import ( TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer, 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 utils.tos_client import upload_file as tos_upload 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', 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') # TeamAnomalyConfig @@ -911,6 +912,7 @@ def admin_team_detail_view(request, team_id): 'generations_this_month': m.generations_this_month or 0, 'spent_today': float(m.spent_today or 0), 'spent_this_month': float(m.spent_this_month or 0), + 'is_online': m.is_online, 'date_joined': m.date_joined.isoformat(), } for m in members], }) @@ -1121,6 +1123,7 @@ def admin_users_list_view(request): 'generation_records__cost_amount', filter=Q(generation_records__created_at__date__gte=first_of_month), ), + is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))), ) if search: @@ -1159,6 +1162,7 @@ def admin_users_list_view(request): 'generations_this_month': u.generations_this_month or 0, 'spent_today': float(u.spent_today or 0), 'spent_this_month': float(u.spent_this_month or 0), + 'is_online': u.is_online, }) return Response({ @@ -1859,6 +1863,7 @@ def team_members_list_view(request): 'generation_records__cost_amount', 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') return Response({ @@ -1878,6 +1883,7 @@ def team_members_list_view(request): 'generations_this_month': m.generations_this_month or 0, 'spent_today': float(m.spent_today or 0), 'spent_this_month': float(m.spent_this_month or 0), + 'is_online': m.is_online, 'date_joined': m.date_joined.isoformat(), } for m in members], }) @@ -2311,6 +2317,7 @@ def admin_assets_team_members(request, team_id): 'is_team_admin': member.is_team_admin, 'video_count': video_count, 'seconds_consumed': seconds_consumed, + 'is_online': ActiveSession.objects.filter(user=member).exists(), }) return Response({ @@ -2390,6 +2397,7 @@ def team_assets_overview(request): 'is_team_admin': member.is_team_admin, 'video_count': video_count, 'seconds_consumed': seconds_consumed, + 'is_online': ActiveSession.objects.filter(user=member).exists(), }) return Response({ diff --git a/web/src/pages/AdminAssetsPage.tsx b/web/src/pages/AdminAssetsPage.tsx index c17c511..7e39284 100644 --- a/web/src/pages/AdminAssetsPage.tsx +++ b/web/src/pages/AdminAssetsPage.tsx @@ -171,6 +171,11 @@ export function AdminAssetsPage() {