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() {
toggleMember(member.id)}> + {member.username} {member.is_team_admin && 管理员} diff --git a/web/src/pages/TeamAssetsPage.tsx b/web/src/pages/TeamAssetsPage.tsx index 4b4072e..e92877d 100644 --- a/web/src/pages/TeamAssetsPage.tsx +++ b/web/src/pages/TeamAssetsPage.tsx @@ -134,6 +134,11 @@ export function TeamAssetsPage() {
toggleMember(member.id)}> + {member.username} {member.is_team_admin && 管理员} diff --git a/web/src/pages/TeamMembersPage.tsx b/web/src/pages/TeamMembersPage.tsx index 3e0a035..fabe3ea 100644 --- a/web/src/pages/TeamMembersPage.tsx +++ b/web/src/pages/TeamMembersPage.tsx @@ -138,7 +138,14 @@ export function TeamMembersPage() { ) : ( members.map((m) => ( - {m.username} + + + {m.username} + {m.is_team_admin ? ( 管理员 diff --git a/web/src/pages/TeamsPage.tsx b/web/src/pages/TeamsPage.tsx index 2e97e5a..c1105f4 100644 --- a/web/src/pages/TeamsPage.tsx +++ b/web/src/pages/TeamsPage.tsx @@ -756,7 +756,14 @@ export function TeamsPage() { {detailTeam.members.map((m) => ( - {m.username} + + + {m.username} + {m.email} {m.is_team_admin ? ( diff --git a/web/src/pages/UsersPage.tsx b/web/src/pages/UsersPage.tsx index b5c62cf..d490dca 100644 --- a/web/src/pages/UsersPage.tsx +++ b/web/src/pages/UsersPage.tsx @@ -226,6 +226,11 @@ export function UsersPage() { diff --git a/web/src/store/auth.ts b/web/src/store/auth.ts index 7308fc3..2da4bcf 100644 --- a/web/src/store/auth.ts +++ b/web/src/store/auth.ts @@ -48,6 +48,14 @@ export const useAuthStore = create((set, get) => ({ }, 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('refresh_token'); set({ diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 7b2cf9d..ce0d377 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -161,6 +161,7 @@ export interface AdminUser { generations_this_month: number; spent_today: number; spent_this_month: number; + is_online?: boolean; } export interface AdminUserDetail extends AdminUser { @@ -314,6 +315,7 @@ export interface TeamMember { generations_this_month: number; spent_today: number; spent_this_month: number; + is_online?: boolean; date_joined: string; } @@ -359,6 +361,7 @@ export interface AssetMemberSummary { video_count: number; seconds_consumed: number; cost_consumed?: number; + is_online?: boolean; } export interface AssetVideo {