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:
zyc
2026-06-10 09:37:41 +08:00
co-authored by Claude Opus 4.8
parent aa4bdeac83
commit 3fac38c5ef
29 changed files with 724 additions and 150 deletions
+7 -1
View File
@@ -41,12 +41,18 @@ class TeamSerializer(serializers.ModelSerializer):
class TeamMemberSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
# 本月已消费(自然月,按 CreditLedger CHARGE 流水按人聚合);由 view 经 context 注入 charged_map
month_charged = serializers.SerializerMethodField()
class Meta:
model = TeamMember
fields = ["id", "team", "user", "role", "status", "monthly_credit_limit"]
fields = ["id", "team", "user", "role", "status", "monthly_credit_limit", "month_charged"]
read_only_fields = ["id", "team", "user", "status"]
def get_month_charged(self, obj):
charged_map = self.context.get("charged_map") or {}
return str(charged_map.get(obj.user_id, 0))
class RegisterSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150)
+63 -13
View File
@@ -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})
+48
View File
@@ -1,7 +1,11 @@
import re
import subprocess
import tempfile
import uuid
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
from pathlib import Path
from django.db import transaction
from django.utils import timezone
@@ -184,6 +188,34 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id
raise
def _generate_video_poster(*, video_bytes: bytes, team, project, asset_id) -> "StoredObject | None":
"""用 ffmpeg 抽视频首帧作为封面(poster)并上传 TOS。best-effort:任何失败都返回 None,不影响视频资产落地。"""
if not video_bytes:
return None
try:
with tempfile.TemporaryDirectory(prefix="airshelf-poster-") as tmp:
tmp_dir = Path(tmp)
video_path = tmp_dir / "in.mp4"
poster_path = tmp_dir / "poster.jpg"
video_path.write_bytes(video_bytes)
proc = subprocess.run(
["ffmpeg", "-y", "-ss", "0", "-i", str(video_path), "-frames:v", "1", "-q:v", "3", str(poster_path)],
capture_output=True,
timeout=60,
)
if proc.returncode != 0 or not poster_path.exists():
return None
poster_bytes = poster_path.read_bytes()
if not poster_bytes:
return None
object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}-poster.jpg"
return TosStorage().upload_fileobj(
fileobj=BytesIO(poster_bytes), object_key=object_key, content_type="image/jpeg"
)
except Exception: # noqa: BLE001 — poster 仅用于展示,失败不阻断
return None
def _store_generated_media(*, team, user, project, task, media: str, name: str, category: str, asset_type: str) -> Asset:
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
suffix = ".png"
@@ -214,6 +246,22 @@ def _store_generated_media(*, team, user, project, task, media: str, name: str,
size_bytes=stored.size_bytes,
is_primary=True,
)
# 视频资产:额外抽首帧作为封面图,挂成同一 Asset 下的 image 文件,供任务中心/列表显示缩略图
if "video" in content_type:
try:
video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b""
except Exception: # noqa: BLE001
video_bytes = b""
poster = _generate_video_poster(video_bytes=video_bytes, team=team, project=project, asset_id=asset_id)
if poster:
AssetFile.objects.create(
asset=asset,
object_key=poster.object_key,
bucket=poster.bucket,
content_type=poster.content_type,
size_bytes=poster.size_bytes,
is_primary=False,
)
return asset
+1
View File
@@ -70,6 +70,7 @@ class AssetSerializer(serializers.ModelSerializer):
"description",
"metadata",
"is_deleted",
"origin_task",
"files",
"created_at",
"updated_at",
+10
View File
@@ -11,11 +11,15 @@ class CreditAccountSerializer(serializers.ModelSerializer):
class CreditLedgerSerializer(serializers.ModelSerializer):
# 成员展示名:优先真实姓名 → 用户名 → 邮箱;系统流水(无 user)留空
user_label = serializers.SerializerMethodField()
class Meta:
model = CreditLedger
fields = [
"id",
"user",
"user_label",
"project",
"task",
"ledger_type",
@@ -27,6 +31,12 @@ class CreditLedgerSerializer(serializers.ModelSerializer):
]
read_only_fields = fields
def get_user_label(self, obj):
user = obj.user
if user is None:
return ""
return user.first_name or user.username or user.email or ""
class CreditReservationSerializer(serializers.ModelSerializer):
class Meta:
+4 -4
View File
@@ -29,7 +29,7 @@ def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
ledger_type=CreditLedger.Type.RESERVE,
amount=amount,
balance_after=account.balance,
reason="reserve ai task credit",
reason="AI 任务预扣额度",
)
return reservation
@@ -52,7 +52,7 @@ def release_credit(*, reservation: CreditReservation, reason: str = "") -> None:
ledger_type=CreditLedger.Type.RELEASE,
amount=reservation.amount,
balance_after=account.balance,
reason=reason or "release reserved credit",
reason=reason or "释放预留额度",
)
@@ -77,7 +77,7 @@ def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Dec
ledger_type=CreditLedger.Type.CHARGE,
amount=actual_amount,
balance_after=account.balance,
reason="charge ai task credit",
reason="AI 任务扣费",
)
if reservation.amount > actual_amount:
CreditLedger.objects.create(
@@ -88,6 +88,6 @@ def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Dec
ledger_type=CreditLedger.Type.RELEASE,
amount=reservation.amount - actual_amount,
balance_after=account.balance,
reason="release unused reserved credit",
reason="释放未用预留额度",
)
+21 -1
View File
@@ -56,7 +56,27 @@ def ledgers(request):
queryset = queryset.filter(project_id=project_id)
if user_id:
queryset = queryset.filter(user_id=user_id)
return Response(CreditLedgerSerializer(queryset[:100], many=True).data)
# 服务端分页:总数随流水增长(原先写死 [:100] 导致永远 100 条)
try:
page = max(1, int(request.query_params.get("page", 1)))
except (TypeError, ValueError):
page = 1
try:
page_size = int(request.query_params.get("page_size", 10))
except (TypeError, ValueError):
page_size = 10
page_size = max(1, min(page_size, 100))
total = queryset.count()
start = (page - 1) * page_size
rows = queryset[start:start + page_size]
return Response(
{
"count": total,
"page": page,
"page_size": page_size,
"results": CreditLedgerSerializer(rows, many=True).data,
}
)
@api_view(["POST"])
+26 -3
View File
@@ -2,9 +2,17 @@ from django.db.models import Q
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
class NotificationPagination(PageNumberPagination):
# 收件箱滚动加载:每批 10 条,前端可用 ?page_size= 覆盖(上限 100)
page_size = 10
page_size_query_param = "page_size"
max_page_size = 100
from apps.assets.models import Asset
from apps.billing.models import CreditAccount
from apps.common.api import TeamScopedViewSetMixin
@@ -109,14 +117,19 @@ def ensure_team_notifications(team, user):
class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
serializer_class = NotificationSerializer
queryset = Notification.objects.select_related("team", "recipient", "project").all()
pagination_class = NotificationPagination
search_fields = ["title", "brief", "body", "source", "stage"]
ordering_fields = ["created_at", "updated_at"]
ordering = ["-created_at"]
def get_queryset(self):
# 团队 + 收件人 + 未归档:分类计数的基准集(不含 tab/未读/搜索过滤)
def _recipient_scope(self):
queryset = super().get_queryset().filter(archived_at__isnull=True)
user = self.request.user
queryset = queryset.filter(Q(recipient=user) | Q(recipient__isnull=True))
return queryset.filter(Q(recipient=user) | Q(recipient__isnull=True))
def get_queryset(self):
queryset = self._recipient_scope()
notification_type = self.request.query_params.get("type")
if notification_type and notification_type not in {"all", "unread"}:
queryset = queryset.filter(notification_type=notification_type)
@@ -128,9 +141,19 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
ensure_team_notifications(self.get_team(), request.user)
response = super().list(request, *args, **kwargs)
data = response.data
unread_count = self.get_queryset().filter(is_read=False).count()
if isinstance(data, dict):
# 分类 chip 计数取绝对总数(忽略当前 tab/搜索),与设计稿一致
base = self._recipient_scope()
unread_count = base.filter(is_read=False).count()
data["unread_count"] = unread_count
data["type_counts"] = {
"all": base.count(),
"unread": unread_count,
"task": base.filter(notification_type="task").count(),
"team": base.filter(notification_type="team").count(),
"billing": base.filter(notification_type="billing").count(),
"system": base.filter(notification_type="system").count(),
}
return response
def perform_create(self, serializer):
+49 -6
View File
@@ -44,6 +44,19 @@ def _download_asset_primary_file(asset, target_path: Path) -> None:
target_path.write_bytes(response.content)
def _has_audio_stream(path: Path) -> bool:
"""探测视频文件是否含音频流(决定是否保留该片段的原声/人声)。ffprobe 失败时保守按无声处理。"""
try:
proc = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries",
"stream=index", "-of", "csv=p=0", str(path)],
capture_output=True, timeout=60,
)
return bool(proc.stdout.strip())
except Exception: # noqa: BLE001
return False
def _load_font(size: int):
from PIL import ImageFont
@@ -123,9 +136,14 @@ def _output_starts(specs: list[dict], xfade: float) -> tuple[list[float], float]
return starts, max(0.1, total)
_AFMT = "aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo"
def _build_export_command(*, n: int, specs: list[dict], starts: list[float], total: float,
transition: str, sub_overlays: list[tuple[str, float, float]],
bgm_name: str | None, bgm_volume: float) -> list[str]:
bgm_name: str | None, bgm_volume: float,
has_audio: list[bool] | None = None) -> list[str]:
has_audio = has_audio or [False] * n
parts: list[str] = []
for i, s in enumerate(specs):
parts.append(
@@ -153,8 +171,30 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-150:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
)
vlabel = out
if bgm_name:
parts.append(f"[{n}:a]volume={bgm_volume:.3f},atrim=0:{total:.3f},asetpts=PTS-STARTPTS[aout]")
# 音频:片段自带的人声/原声必须保留(有声片段取原音轨,无声片段补等长静音,否则 concat 会缺流);
# 若另挂了 BGM,则把 BGM 混到原声之上(amix,normalize=0 不自动衰减原声音量)。
# 三种片段全无声且无 BGM 时,保持旧行为=纯视频不带音轨。
want_audio = any(has_audio) or bool(bgm_name)
audio_label: str | None = None
if want_audio:
for i, s in enumerate(specs):
if has_audio[i]:
parts.append(
f"[{i}:a]atrim=start={s['ts']:.3f}:end={s['te']:.3f},asetpts=PTS-STARTPTS,{_AFMT}[a{i}]"
)
else:
parts.append(
f"anullsrc=channel_layout=stereo:sample_rate=44100,atrim=0:{s['dur']:.3f},"
f"asetpts=PTS-STARTPTS,{_AFMT}[a{i}]"
)
parts.append("".join(f"[a{i}]" for i in range(n)) + f"concat=n={n}:v=0:a=1[avoice0]")
parts.append(f"[avoice0]atrim=0:{total:.3f},asetpts=PTS-STARTPTS[avoice]")
audio_label = "avoice"
if bgm_name:
parts.append(f"[{n}:a]volume={bgm_volume:.3f},atrim=0:{total:.3f},asetpts=PTS-STARTPTS,{_AFMT}[abgm]")
parts.append("[avoice][abgm]amix=inputs=2:duration=longest:dropout_transition=0:normalize=0[aout]")
audio_label = "aout"
cmd = ["ffmpeg", "-y"]
for i in range(n):
@@ -164,10 +204,10 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
for png, _s, _e in sub_overlays:
cmd += ["-loop", "1", "-i", png]
cmd += ["-filter_complex", ";".join(parts), "-map", f"[{vlabel}]"]
if bgm_name:
cmd += ["-map", "[aout]"]
if audio_label:
cmd += ["-map", f"[{audio_label}]"]
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", "-preset", "veryfast"]
if bgm_name:
if audio_label:
cmd += ["-c:a", "aac", "-b:a", "192k"]
cmd += ["-t", f"{total:.3f}", "-movflags", "+faststart", "output.mp4"]
return cmd
@@ -237,6 +277,8 @@ def run_export_job(export_job_id: str) -> ExportJob:
tmp = Path(tmp_dir)
for index, clip in enumerate(clips):
_download_asset_primary_file(clip.asset, tmp / f"clip{index}.mp4")
# 逐片段探测是否自带音轨:有声→保留原声,无声→补静音(见 _build_export_command)
has_audio = [_has_audio_stream(tmp / f"clip{index}.mp4") for index in range(len(clips))]
bgm_name = None
if bgm_track is not None and bgm_track.asset_id:
@@ -258,6 +300,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
command = _build_export_command(
n=len(clips), specs=specs, starts=starts, total=total, transition=transition,
sub_overlays=sub_overlays, bgm_name=bgm_name, bgm_volume=(bgm_track.volume / 100.0) if bgm_track else 1.0,
has_audio=has_audio,
)
proc = subprocess.run(command, cwd=str(tmp), capture_output=True)
if proc.returncode != 0: