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('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'),
|
||||
]
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -171,6 +171,11 @@ export function AdminAssetsPage() {
|
||||
<div className={styles.memberItem} onClick={() => toggleMember(member.id)}>
|
||||
<Chevron open={expandedMember === member.id} />
|
||||
<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.is_team_admin && <span className={styles.adminBadge}>管理员</span>}
|
||||
</span>
|
||||
|
||||
@ -134,6 +134,11 @@ export function TeamAssetsPage() {
|
||||
<div className={styles.accordionHeader} onClick={() => toggleMember(member.id)}>
|
||||
<Chevron open={expandedMember === member.id} />
|
||||
<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.is_team_admin && <span className={styles.adminBadge}>管理员</span>}
|
||||
</span>
|
||||
|
||||
@ -138,7 +138,14 @@ export function TeamMembersPage() {
|
||||
) : (
|
||||
members.map((m) => (
|
||||
<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.is_team_admin ? (
|
||||
<span className={styles.statusBadge} style={{ background: 'rgba(108, 99, 255, 0.15)', color: '#6c63ff' }}>管理员</span>
|
||||
|
||||
@ -756,7 +756,14 @@ export function TeamsPage() {
|
||||
<tbody>
|
||||
{detailTeam.members.map((m) => (
|
||||
<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.is_team_admin ? (
|
||||
|
||||
@ -226,6 +226,11 @@ export function UsersPage() {
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<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}
|
||||
</button>
|
||||
</td>
|
||||
|
||||
@ -48,6 +48,14 @@ export const useAuthStore = create<AuthState>((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({
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user