feat(core): notification inbox infinite scroll + command palette fix (+ pending WIP)
消息中心:全量渲染 → 真·后端分页滚动加载 - backend(ops/views): NotificationPagination(10/页,page_size 可覆盖)+ 响应回 type_counts(按收件人绝对计数,不受分页/搜索影响) - frontend(messages): 自管分页,滚到底加载下一批;tab/搜索走服务端并重置到第1页; 代号作废在途旧请求防切换卡空白;乐观标已读;「已加载 X / Y」分母用当前筛选总数 - api/App/types: listNotifications 支持 page/page_size/search;allNotifications 携带 type_counts 命令面板(侧边栏搜索):修复点开后 UI 错位 - app-shell: 遮罩 className 漏了基类 shell-command-bg(只有 .show)致无定位塌到左下; 补回基类 + header 类名对齐 .shell-command-h - messages-page.css: 工作台收进视口高度,收件箱在面板内滚动 本次提交一并带入此前若干未提交 WIP(account/ai-tools/library/pipeline/products/settings + accounts/ai/assets/billing/projects 后端),按用户要求整体推 dev。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -40,13 +40,22 @@ def _client_ip(request):
|
||||
|
||||
|
||||
def record_login_session(request, user):
|
||||
"""登录成功后记录一条会话(设备 UA / IP),供设置页「在用设备」展示。"""
|
||||
"""登录成功后记录设备会话(UA / IP)。去重:同一台电脑(UA)+ 同一 IP 视为同一台设备,
|
||||
已存在未下线的同设备会话则只刷新 last_seen_at,不再新增一行(避免「在用设备」列表里同设备重复堆叠)。"""
|
||||
try:
|
||||
LoginSession.objects.create(
|
||||
user=user,
|
||||
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:400],
|
||||
ip_address=_client_ip(request),
|
||||
user_agent = (request.META.get("HTTP_USER_AGENT") or "")[:400]
|
||||
ip_address = _client_ip(request)
|
||||
existing = (
|
||||
LoginSession.objects.filter(
|
||||
user=user, user_agent=user_agent, ip_address=ip_address, revoked_at__isnull=True
|
||||
)
|
||||
.order_by("-last_seen_at")
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
existing.save(update_fields=["last_seen_at"]) # auto_now 刷新最近活跃时间
|
||||
else:
|
||||
LoginSession.objects.create(user=user, user_agent=user_agent, ip_address=ip_address)
|
||||
except Exception: # noqa: BLE001 — 会话记录失败不应阻断登录
|
||||
pass
|
||||
|
||||
@@ -170,13 +179,36 @@ def can_manage_team(user, team):
|
||||
return bool(member and member.role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN})
|
||||
|
||||
|
||||
def _month_charged_by_user(team):
|
||||
"""本团队当前自然月每个成员的消费(CHARGE 流水)合计:{user_id: Decimal}。"""
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.billing.models import CreditLedger
|
||||
|
||||
month_start = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
rows = (
|
||||
CreditLedger.objects.filter(
|
||||
team=team,
|
||||
ledger_type=CreditLedger.Type.CHARGE,
|
||||
created_at__gte=month_start,
|
||||
)
|
||||
.values("user_id")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
return {row["user_id"]: row["total"] for row in rows if row["user_id"] is not None}
|
||||
|
||||
|
||||
@api_view(["GET", "POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def team_members(request):
|
||||
team = get_current_team(request.user)
|
||||
if request.method == "GET":
|
||||
members = TeamMember.objects.filter(team=team).select_related("user").order_by("created_at")
|
||||
return Response(TeamMemberSerializer(members, many=True).data)
|
||||
charged_map = _month_charged_by_user(team)
|
||||
return Response(
|
||||
TeamMemberSerializer(members, many=True, context={"charged_map": charged_map}).data
|
||||
)
|
||||
|
||||
if not can_manage_team(request.user, team):
|
||||
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
|
||||
@@ -277,23 +309,41 @@ def preferences(request):
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def login_sessions(request):
|
||||
"""在用设备:返回未下线的登录会话(最近 20 条)。"""
|
||||
sessions = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True)[:20]
|
||||
"""在用设备:返回未下线的登录会话(去重后最近 20 台)。
|
||||
去重规则:同一台电脑(UA)+ 同一 IP 只算一台,取该设备最近一次会话展示(兼容历史已堆叠的重复行)。"""
|
||||
queryset = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True).order_by("-last_seen_at")
|
||||
seen: set = set()
|
||||
unique = []
|
||||
for session in queryset:
|
||||
key = (session.user_agent, session.ip_address)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(session)
|
||||
if len(unique) >= 20:
|
||||
break
|
||||
current_ip = _client_ip(request)
|
||||
current_ua = (request.META.get("HTTP_USER_AGENT") or "")[:400]
|
||||
data = LoginSessionSerializer(sessions, many=True, context={"current_ip": current_ip, "current_ua": current_ua}).data
|
||||
data = LoginSessionSerializer(unique, many=True, context={"current_ip": current_ip, "current_ua": current_ua}).data
|
||||
return Response(data)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def revoke_login_session(request, session_id):
|
||||
"""下线单个设备会话。"""
|
||||
"""下线单个设备:把同一台设备(UA + IP)下的所有未下线会话一并下线,
|
||||
否则去重展示的一台设备点「下线」后,底层其它重复会话仍存活会再次冒出来。"""
|
||||
from django.utils import timezone
|
||||
|
||||
updated = LoginSession.objects.filter(user=request.user, id=session_id, revoked_at__isnull=True).update(
|
||||
revoked_at=timezone.now()
|
||||
)
|
||||
target = LoginSession.objects.filter(user=request.user, id=session_id).first()
|
||||
if not target:
|
||||
return Response({"revoked": 0})
|
||||
updated = LoginSession.objects.filter(
|
||||
user=request.user,
|
||||
user_agent=target.user_agent,
|
||||
ip_address=target.ip_address,
|
||||
revoked_at__isnull=True,
|
||||
).update(revoked_at=timezone.now())
|
||||
return Response({"revoked": updated})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user