feat: v0.10.3 用户在线状态 + logout 会话清理
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:
seaislee1209 2026-03-21 01:30:30 +08:00
parent b25a839d44
commit 5bb49b5940
10 changed files with 66 additions and 4 deletions

View File

@ -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'),
] ]

View File

@ -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):

View File

@ -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({

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 ? (

View File

@ -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>

View File

@ -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({

View File

@ -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 {