diff --git a/core/backend/apps/accounts/serializers.py b/core/backend/apps/accounts/serializers.py index ae4908d..b1dfc0e 100644 --- a/core/backend/apps/accounts/serializers.py +++ b/core/backend/apps/accounts/serializers.py @@ -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) diff --git a/core/backend/apps/accounts/views.py b/core/backend/apps/accounts/views.py index 2282694..a2edd29 100644 --- a/core/backend/apps/accounts/views.py +++ b/core/backend/apps/accounts/views.py @@ -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}) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 669528b..abcbe6a 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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 diff --git a/core/backend/apps/assets/serializers.py b/core/backend/apps/assets/serializers.py index 311a461..9f674c7 100644 --- a/core/backend/apps/assets/serializers.py +++ b/core/backend/apps/assets/serializers.py @@ -70,6 +70,7 @@ class AssetSerializer(serializers.ModelSerializer): "description", "metadata", "is_deleted", + "origin_task", "files", "created_at", "updated_at", diff --git a/core/backend/apps/billing/serializers.py b/core/backend/apps/billing/serializers.py index ac501aa..d26f56e 100644 --- a/core/backend/apps/billing/serializers.py +++ b/core/backend/apps/billing/serializers.py @@ -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: diff --git a/core/backend/apps/billing/services/ledger.py b/core/backend/apps/billing/services/ledger.py index d6b359e..748d4e5 100644 --- a/core/backend/apps/billing/services/ledger.py +++ b/core/backend/apps/billing/services/ledger.py @@ -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="释放未用预留额度", ) diff --git a/core/backend/apps/billing/views.py b/core/backend/apps/billing/views.py index 191a2cd..4a30cef 100644 --- a/core/backend/apps/billing/views.py +++ b/core/backend/apps/billing/views.py @@ -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"]) diff --git a/core/backend/apps/ops/views.py b/core/backend/apps/ops/views.py index 7b440b3..596cc06 100644 --- a/core/backend/apps/ops/views.py +++ b/core/backend/apps/ops/views.py @@ -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): diff --git a/core/backend/apps/projects/services/export.py b/core/backend/apps/projects/services/export.py index 890371f..948becb 100644 --- a/core/backend/apps/projects/services/export.py +++ b/core/backend/apps/projects/services/export.py @@ -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: diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index c4a0e34..fad99cc 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -107,23 +107,23 @@ export function App() { await Promise.all([ api.products(), api.projects(), - api.assets(), + api.allAssets(), api.billingSummary().catch(() => null), - api.ledgers().catch(() => []), + api.ledgers(1, 10).catch(() => ({ count: 0, page: 1, page_size: 10, results: [] as Ledger[] })), api.billingTrend().catch(() => null), api.teamMembers().catch(() => []), api.modelConfigs().catch(() => null), api.aiTasks().catch(() => null), - api.listNotifications().catch(() => null) + api.allNotifications().catch(() => null) ]); setProducts(productData.results); setProjects(projectData.results); - setAssets(assetData.results); + setAssets(assetData); setTeamMembers(memberData); setModelConfigs(modelData?.results || []); setAiTasks(taskData?.results || []); if (billingData) setBilling(billingData); - setLedgers(ledgerData); + setLedgers(ledgerData.results); setBillingTrend(trendData); if (notificationData) { setNotifications(notificationData.results); @@ -161,7 +161,7 @@ export function App() { } const reloadNotifications = useCallback(async () => { - const data = await api.listNotifications().catch(() => null); + const data = await api.allNotifications().catch(() => null); if (data) { setNotifications(data.results); setUnreadCount(data.unread_count); @@ -513,7 +513,6 @@ export function App() { case "messages": return ( ); case "assetFactory": - return ; + return ; case "imageOptimize": return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />; case "modelPhoto": diff --git a/core/frontend/src/account-page.css b/core/frontend/src/account-page.css index 426e198..d5140ae 100644 --- a/core/frontend/src/account-page.css +++ b/core/frontend/src/account-page.css @@ -97,8 +97,8 @@ .trend-chart { display: grid; grid-template-rows: 1fr auto; gap: 6px; min-height: 170px; flex: 1; padding: 6px 4px 2px; position: relative; } .trend-chart .bars { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; align-items: end; height: 100%; } - .trend-chart .bar { background: var(--background-lighter); border-radius: 2px 2px 0 0; position: relative; transition: background var(--t-base); cursor: pointer; } - .trend-chart .bar > span { display: block; width: 100%; background: var(--heat); border-radius: 2px 2px 0 0; } + .trend-chart .bar { background: var(--background-lighter); border-radius: 2px 2px 0 0; position: relative; transition: background var(--t-base); cursor: pointer; height: 100%; } + .trend-chart .bar > span { position: absolute; left: 0; bottom: 0; display: block; width: 100%; flex: none; min-height: 0; background: var(--heat); border-radius: 2px 2px 0 0; } .trend-chart .bar:hover > span { background: var(--accent-black); } .trend-chart .bar.peak > span { background: var(--accent-black); } .trend-chart .x-axis { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-32); text-align: center; letter-spacing: .02em; } @@ -127,14 +127,23 @@ .quota-rules .step .formula { font-family: var(--font-mono); font-size: 11.5px; color: var(--heat); background: var(--heat-12); padding: 0 4px; border-radius: var(--r-sm); } .billing-table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border-muted); border-radius: var(--r-md); overflow: hidden; } - .billing-table th, .billing-table td { padding: 11px 14px; text-align: left; font-size: 12.5px; border-bottom: 0; } + .billing-table th, .billing-table td { padding: 13px 16px; text-align: left; font-size: 12.5px; border-bottom: 0; } .billing-table thead th { background: var(--background-lighter); border-bottom: 1px solid var(--border-muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; } + /* 行间淡分隔线,长流水更易扫读;最后一行不画 */ + .billing-table tbody td { border-bottom: 1px solid var(--border-faint); } + .billing-table tbody tr:last-child td { border-bottom: 0; } .billing-table tbody tr:hover { background: var(--background-lighter); } .billing-table .ts { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; } .billing-table .neg { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-black); text-align: right; } .billing-table .pos { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-forest); text-align: right; } .billing-table .zero { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--black-alpha-32); text-align: right; } + /* 已用 / 月度额度:已用部分本色,额度部分弱化,整列左对齐 */ + .billing-table .quota { font-variant-numeric: tabular-nums; text-align: left; } + .billing-table .quota .used { font-weight: 500; color: var(--accent-black); } + .billing-table .quota .lim { color: var(--black-alpha-32); } .billing-table .muted { color: var(--black-alpha-56); font-size: 11.5px; } + /* 系统流水(无成员):弱化的 mono 占位,不喧宾夺主 */ + .billing-table .sys { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-32); } .billing-table .ref { color: var(--black-alpha-48); font-size: 10.5px; font-family: var(--font-mono); } .billing-table .who { display: inline-flex; align-items: center; gap: 8px; } .billing-table .who .av { width: 24px; height: 24px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: inline-grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--accent-black); } @@ -150,6 +159,15 @@ .billing-table .status-tag.ok { background: rgba(66,195,102,.12); color: var(--accent-forest); } .billing-table .status-tag.wip { background: var(--heat-12); color: var(--heat); } .billing-table .status-tag.fail { background: rgba(235,52,36,.10); color: var(--accent-crimson); } + + .bill-pager { display: flex; align-items: center; gap: 12px; margin-top: 14px; font-size: 12px; } + .bill-pager .total { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; } + .bill-pager .pages { display: inline-flex; gap: 4px; margin-left: auto; } + .bill-pager .pages button { min-width: 28px; height: 28px; padding: 0 8px; border: 1px solid var(--border-muted); border-radius: var(--r-sm); background: var(--surface); font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-56); cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); } + .bill-pager .pages button:hover:not(.active):not(:disabled) { border-color: var(--black-alpha-32); color: var(--accent-black); } + .bill-pager .pages button.active { background: var(--heat); color: var(--accent-white); border-color: var(--heat); font-weight: 600; } + .bill-pager .pages button:disabled { opacity: .4; cursor: not-allowed; } + .bill-pager .pages .ellipsis { min-width: 20px; height: 28px; display: inline-flex; align-items: flex-end; justify-content: center; padding-bottom: 4px; color: var(--black-alpha-32); font-family: var(--font-mono); font-size: 11px; user-select: none; } .billing-table .progress-mini { width: 80px; height: 4px; background: var(--background-lighter); border-radius: 2px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 8px; } .billing-table .progress-mini > span { display: block; height: 100%; background: var(--heat); } diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index 95c87e7..a2c88a2 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -158,6 +158,8 @@ font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em; margin-bottom: 6px; } +.asset-factory .task-load-more { display: flex; justify-content: center; margin-top: 18px; } +.asset-factory .task-load-more .lm-rest { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-left: 6px; } /* ============================================================ B · 图片工作室壳(.image-workbench) @@ -1529,6 +1531,11 @@ .asset-factory .history-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px; display: grid; grid-template-columns: 78px 1fr; gap: 14px; align-items: center; transition: background var(--t-base); position: relative; } .asset-factory .history-card:hover { background: var(--black-alpha-4); } .asset-factory .history-card .placeholder { width: 78px; height: 78px; } +/* 任务结果图:有真实生成图时铺满占位框,去掉斜纹底与编号 */ +.asset-factory .placeholder.has-img { overflow: hidden; position: relative; } +.asset-factory .placeholder.has-img::after { display: none; } +.asset-factory .placeholder.has-img .ph-frame { display: none; } +.asset-factory .placeholder.has-img img { width: 100%; height: 100%; object-fit: cover; display: block; } .asset-factory .history-body { min-width: 0; } .asset-factory .history-name { font-weight: 600; color: var(--accent-black); font-size: 13.5px; } .asset-factory .history-type { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; } diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 5df5104..401aac8 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -231,14 +231,26 @@ export const api = { assets() { return request>("/api/assets/"); }, + // 跟随 DRF 分页 next 取全部资产 —— 商品图/AI 素材/资产库都靠 asset.id 在这份列表里查 preview_url, + // 只取第 1 页(20 条)会让第 20 条之后的资产解析不到图、渲染成空占位。 + async allAssets(): Promise { + const out: Asset[] = []; + let path = "/api/assets/"; + for (let guard = 0; guard < 50 && path; guard += 1) { + const page: Paginated = await request>(path); + out.push(...page.results); + path = page.next ? new URL(page.next).pathname + new URL(page.next).search : ""; + } + return out; + }, uploadAsset(formData: FormData) { return request("/api/assets/upload/", { method: "POST", body: formData }); }, billingSummary() { return request("/api/billing/summary/"); }, - ledgers() { - return request("/api/billing/ledgers/"); + ledgers(page = 1, pageSize = 10) { + return request<{ count: number; page: number; page_size: number; results: Ledger[] }>(`/api/billing/ledgers/?page=${page}&page_size=${pageSize}`); }, billingTrend(range?: "day" | "week" | "month") { return request(`/api/billing/trend/${range ? `?range=${range}` : ""}`); @@ -255,13 +267,32 @@ export const api = { recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) { return request("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) }); }, - listNotifications(params?: { type?: string; unread?: boolean }) { + // 收件箱按页拉取 —— 滚动加载逐页向后端要(tab/搜索 走服务端,计数随响应回来) + listNotifications(params?: { type?: string; unread?: boolean; search?: string; page?: number; pageSize?: number }) { const query = new URLSearchParams(); if (params?.type && params.type !== "all") query.set("type", params.type); if (params?.unread) query.set("unread", "1"); + if (params?.search) query.set("search", params.search); + if (params?.page) query.set("page", String(params.page)); + if (params?.pageSize) query.set("page_size", String(params.pageSize)); const qs = query.toString(); return request(`/api/ops/notifications/${qs ? `?${qs}` : ""}`); }, + // 跟着 next 把所有消息取全(给侧边栏徽标 + 团队动态用,不参与收件箱滚动渲染),与 allAssets 同套路 + async allNotifications(): Promise { + const out: Notification[] = []; + let path = "/api/ops/notifications/?page_size=100"; + let unreadCount = 0; + let typeCounts: NotificationList["type_counts"]; + for (let guard = 0; guard < 100 && path; guard += 1) { + const page = await request(path); + out.push(...page.results); + unreadCount = page.unread_count; + typeCounts = page.type_counts ?? typeCounts; + path = page.next ? new URL(page.next).pathname + new URL(page.next).search : ""; + } + return { count: out.length, next: null, previous: null, results: out, unread_count: unreadCount, type_counts: typeCounts }; + }, markAllNotificationsRead() { return request<{ updated: number; unread_count: number }>("/api/ops/notifications/mark-all-read/", { method: "POST" diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 7c68b01..212cf2e 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -39,13 +39,13 @@ function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: ( return createPortal(
{ if (event.target === event.currentTarget) onClose(); }} >
-
- +
+ void; +}) { + useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") close(); }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, close]); + if (!open || !src) return null; + // 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏 + return createPortal( +
+ + {kind === "video" ? ( +
, + document.body, + ); +} + export function SettingRow({ title, desc, action, toggle, checked }: { title: string; desc: string; action?: string; toggle?: boolean; checked?: boolean }) { return (
@@ -20,7 +59,8 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer footer?: ReactNode; }) { if (!open) return null; - return ( + // 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏 + return createPortal(
event.stopPropagation()}> ++ @@ -28,7 +68,8 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer
{children}
{footer || }
-
+
, + document.body, ); } @@ -41,7 +82,8 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon onConfirm: () => void | Promise; }) { if (!open) return null; - return ( + // 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏 + return createPortal(
event.stopPropagation()}> ++ @@ -49,20 +91,23 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon
{detail}
-
+
, + document.body, ); } export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) { if (!open) return null; - return ( + // 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏 + return createPortal( <>
- + , + document.body, ); } diff --git a/core/frontend/src/library-page.css b/core/frontend/src/library-page.css index 144cdd4..ac93d21 100644 --- a/core/frontend/src/library-page.css +++ b/core/frontend/src/library-page.css @@ -13,6 +13,10 @@ .asset-meta { font-size: 11px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; } .asset-badge { position: absolute; top: 8px; left: 8px; font-family: var(--font-mono); font-size: 10px; letter-spacing: .04em; padding: 2px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); } .asset-card { position: relative; } + /* 视频资产缩略图上的播放角标(点击整卡弹窗播放) */ + .lib-play-badge { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 40px; height: 40px; border-radius: 50%; background: rgba(0, 0, 0, .58); color: var(--accent-white); display: grid; place-items: center; pointer-events: none; transition: background .15s; } + .lib-play-badge svg { width: 18px; height: 18px; margin-left: 2px; } + .asset-thumb:hover .lib-play-badge { background: rgba(0, 0, 0, .76); } } /* 编辑模式:开启「管理资产」后,资产卡删除按钮常显(否则全局只 hover 显) */ diff --git a/core/frontend/src/messages-page.css b/core/frontend/src/messages-page.css index ce176b2..45a0fa8 100644 --- a/core/frontend/src/messages-page.css +++ b/core/frontend/src/messages-page.css @@ -15,7 +15,10 @@ .msg-workbench { display: grid; grid-template-columns: minmax(320px, 380px) minmax(0, 1fr); - min-height: 640px; + /* 收件箱在面板内部滚动(.msg-list 已 overflow:auto)—— 把工作台收进视口高度, + 而不是让消息把整页撑高;减去 shell 头 + content 上下内边距 + 页头/页脚 */ + height: calc(100vh - 280px); + min-height: 480px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); @@ -128,6 +131,13 @@ min-height: 0; overflow-y: auto; } +.msg-load-more { + padding: 14px 16px 18px; + text-align: center; + font-size: 10.5px; + color: var(--black-alpha-48); + letter-spacing: .04em; +} .msg-item { position: relative; width: 100%; diff --git a/core/frontend/src/product-create-page.css b/core/frontend/src/product-create-page.css index 2604649..49e1a67 100644 --- a/core/frontend/src/product-create-page.css +++ b/core/frontend/src/product-create-page.css @@ -385,6 +385,10 @@ background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px); pointer-events: none; } +/* 真实示例图:图片铺满,去掉斜纹底 */ +.pc-drawer .form-card .pf-example .ex-grid .ex-thumb--img { background: var(--background-lighter); } +.pc-drawer .form-card .pf-example .ex-grid .ex-thumb--img::after { display: none; } +.pc-drawer .form-card .pf-example .ex-grid .ex-thumb--img img { width: 100%; height: 100%; object-fit: cover; display: block; } .pc-drawer .form-card .pf-example .ex-d { font-size: 12px; color: var(--black-alpha-56); line-height: 1.5; } diff --git a/core/frontend/src/routes/account.tsx b/core/frontend/src/routes/account.tsx index 8910419..7734ba2 100644 --- a/core/frontend/src/routes/account.tsx +++ b/core/frontend/src/routes/account.tsx @@ -3,6 +3,9 @@ import { api } from "../api"; import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types"; import { money } from "./stage-config"; +const ROLE_LABEL: Record = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" }; +const STATUS_LABEL: Record = { active: "活跃", invited: "待激活", disabled: "已停用" }; + type TrendRange = "day" | "week" | "month"; const RANGE_META: Record = { day: { chip: "日", sub: "// 近 14 天 · 单位 ¥", totalLabel: "14 天合计", avgLabel: "日均" }, @@ -12,6 +15,33 @@ const RANGE_META: Record = { + recharge: "充值", reserve: "预扣", release: "释放", charge: "扣费", adjustment: "调整", refund: "退款" +}; +const LEDGER_REASON_LABEL: Record = { + "reserve ai task credit": "AI 任务预扣额度", + "charge ai task credit": "AI 任务扣费", + "release reserved credit": "释放预留额度", + "release unused reserved credit": "释放未用预留额度", + "release unused credit": "释放未用额度" +}; +const ledgerTypeLabel = (t: string) => LEDGER_TYPE_LABEL[t] ?? t; +const ledgerReasonLabel = (r: string) => LEDGER_REASON_LABEL[r] ?? r; + +// 分页页码窗口:页数多时折叠成 1 … 当前±1 … 末页(≤7 页则全展开) +function pageWindow(current: number, total: number): Array { + if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1); + const items: Array = [1]; + const start = Math.max(2, current - 1); + const end = Math.min(total - 1, current + 1); + if (start > 2) items.push("ellipsis"); + for (let p = start; p <= end; p++) items.push(p); + if (end < total - 1) items.push("ellipsis"); + items.push(total); + return items; +} + const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [ { amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 }, { amt: 500, gift: "+ ¥30 赠送", bonus: true, bonusAmt: 30, ribbon: "推荐" }, @@ -38,6 +68,23 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on const [recharge, setRecharge] = useState(500); const [customAmt, setCustomAmt] = useState(""); + // 账单流水分页:服务端分页(总数随流水增长,不再写死 100),每页 10 条 + const BILLS_PER_PAGE = 10; + const [billPage, setBillPage] = useState(1); + const [ledgerRows, setLedgerRows] = useState(ledgers); + const [ledgerCount, setLedgerCount] = useState(ledgers.length); + useEffect(() => { + let alive = true; + api.ledgers(billPage, BILLS_PER_PAGE).then((data) => { + if (!alive) return; + setLedgerRows(data.results); + setLedgerCount(data.count); + }).catch(() => {}); + return () => { alive = false; }; + }, [billPage]); + const billTotalPages = Math.max(1, Math.ceil(ledgerCount / BILLS_PER_PAGE)); + const safeBillPage = Math.min(billPage, billTotalPages); + const selectedCard = RECHARGE.find((item) => item.amt === recharge); const effectiveAmount = Number(customAmt) > 0 ? Number(customAmt) : recharge; const effectiveBonus = Number(customAmt) > 0 ? 0 : selectedCard?.bonusAmt || 0; @@ -158,7 +205,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on - +
@@ -238,18 +285,34 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on - {ledgers.map((l) => ( + {ledgerRows.map((l) => ( - - - - + + + + ))}
时间项目 / 类型详情成员状态金额
{new Date(l.created_at).toLocaleString("zh-CN")}{l.ledger_type}{l.reason}OK{ledgerTypeLabel(l.ledger_type)}{ledgerReasonLabel(l.reason)}{l.user_label + ? {l.user_label.slice(0, 1).toUpperCase()}{l.user_label} + : 系统}成功 {l.amount}
+ {ledgerCount > BILLS_PER_PAGE && ( +
+ // 共 {ledgerCount} 条 · 第 {safeBillPage} / {billTotalPages} 页 +
+ + {pageWindow(safeBillPage, billTotalPages).map((p, i) => ( + p === "ellipsis" + ? + : + ))} + +
+
+ )}
@@ -270,9 +333,12 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on - {teamMembers.map((m) => ( - - ))} + {teamMembers.map((m) => { + const monthly = Number(m.monthly_credit_limit || 0); + return ( + + ); + })}
成员角色已用 / 月度额度状态
{m.user.username.slice(0, 1).toUpperCase()}{m.user.username}{m.role}{money(m.monthly_credit_limit)}{m.status}
{m.user.username.slice(0, 1).toUpperCase()}{m.user.username}{ROLE_LABEL[m.role] || m.role}{money(m.month_charged || 0)} / {monthly > 0 ? money(monthly) : "不限"}{STATUS_LABEL[m.status] || m.status}
diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index aa5d251..ff346c8 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -22,6 +22,7 @@ import { X } from "lucide-react"; import type { AITask, Asset, ModelConfig, Product } from "../types"; +import { MediaLightbox } from "../components/overlays"; import type { Page } from "./route-config"; import { statusPill } from "./stage-config"; import "../ai-tools-page.css"; @@ -46,7 +47,14 @@ const STATUS_LABEL: Record = { running: "生成中", queued: "排队中", polling: "生成中", - needs_review: "待确认" + needs_review: "待确认", + // 后端 AITask.Status 全量中文化(原先缺这些会直接透出英文) + created: "待处理", + reserved: "排队中", + submitted: "已提交", + postprocessing: "处理中", + compensating: "回滚中", + cancelled: "已取消" }; function statusText(status: string) { @@ -71,7 +79,18 @@ async function downloadImage(url: string, filename: string) { } } -export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) => void; aiTasks: AITask[] }) { +export function AssetFactoryPage({ navigate, aiTasks, assets = [] }: { navigate: (page: Page) => void; aiTasks: AITask[]; assets?: Asset[] }) { + // 任务 → 生成结果图:按 asset.origin_task 关联,取首张有预览 URL 的图片文件(脚本/视频任务无图则留占位) + const taskImage = useMemo(() => { + const map: Record = {}; + for (const asset of assets) { + const taskId = asset.origin_task; + if (!taskId || map[taskId]) continue; + const file = asset.files?.find((f) => f.preview_url && (f.content_type?.startsWith("image") ?? true)); + if (file?.preview_url) map[taskId] = file.preview_url; + } + return map; + }, [assets]); const cards = [ { page: "modelPhoto" as Page, @@ -112,8 +131,11 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) const [query, setQuery] = useState(""); const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all"); const [typeFilter, setTypeFilter] = useState(""); - const [view, setView] = useState<"grid" | "list">("list"); + const [view, setView] = useState<"grid" | "list">("grid"); const [openChip, setOpenChip] = useState<"" | "time" | "type">(""); + // 任务中心分页:每次加载 12 条,「加载更多」递增;筛选/搜索变化时重置 + const TASKS_PER_LOAD = 12; + const [shown, setShown] = useState(TASKS_PER_LOAD); useEffect(() => { if (!openChip) return; const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); }; @@ -141,6 +163,10 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) } return true; }); + // 筛选条件变化时回到第一屏(12 条) + useEffect(() => { setShown(TASKS_PER_LOAD); }, [filter, query, timeFilter, typeFilter]); + const paged = visible.slice(0, shown); + const hasMore = visible.length > paged.length; return (
@@ -246,7 +272,7 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
- // 显示 {visible.length} / {aiTasks.length} 个任务 + // 显示 {paged.length} / {visible.length} 个任务
{aiTasks.length === 0 ? ( @@ -261,12 +287,13 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
) : view === "grid" ? (
- {visible.map((task) => { + {paged.map((task) => { const pill = statusPill(task.status); const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type; + const img = taskImage[task.id]; return (
-
{task.id.slice(0, 4)}
+
{img ? {typeLabel} : {task.id.slice(0, 4)}}
{typeLabel}
// {task.task_type}
@@ -292,15 +319,16 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) - {visible.map((task) => { + {paged.map((task) => { const pill = statusPill(task.status); const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type; + const img = taskImage[task.id]; return (
-
- {task.id.slice(0, 4)} +
+ {img ? {typeLabel} : {task.id.slice(0, 4)}}
{typeLabel}
@@ -337,6 +365,14 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
)} + + {hasMore && ( +
+ +
+ )}
); } @@ -451,6 +487,8 @@ export function ImageWorkbenchPage({ const [results, setResults] = useState(null); const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null); const refInputRef = useRef(null); + // 生成结果图片放大预览 + const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); // 模特/平台 工作台头部:搜索 + 时间排序 + 模特筛选(对左侧网格真实生效) const [gridQuery, setGridQuery] = useState(""); const [gridSort, setGridSort] = useState<"recent" | "name">("recent"); @@ -502,6 +540,8 @@ export function ImageWorkbenchPage({ function renderResultGrid() { const cols = (results?.length ?? candidateCount) >= 4 ? 4 : 2; return ( + <> + setPreview(null)} />
(
{url ? ( - {`${meta.title} + {`${meta.title} setPreview({ src: url, name: `${meta.title} #${index + 1}` })} /> ) : (
@@ -538,6 +578,7 @@ export function ImageWorkbenchPage({
))}
+ ); } diff --git a/core/frontend/src/routes/library.tsx b/core/frontend/src/routes/library.tsx index 5799b8b..69b75e0 100644 --- a/core/frontend/src/routes/library.tsx +++ b/core/frontend/src/routes/library.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import type { FormEvent } from "react"; import type { Asset } from "../types"; -import { ConfirmModal, Drawer } from "../components/overlays"; +import { ConfirmModal, Drawer, MediaLightbox } from "../components/overlays"; // asset.source / asset.asset_type → 中文标签(筛选下拉用) const SOURCE_LABELS: Record = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" }; @@ -52,6 +52,8 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o const [editMode, setEditMode] = useState(false); const [metaFilter, setMetaFilter] = useState>({}); const [confirmId, setConfirmId] = useState(null); + // 资产预览灯箱(图片放大 / 视频播放) + const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null); useEffect(() => { document.body.classList.toggle("edit-mode", editMode); return () => document.body.classList.remove("edit-mode"); @@ -213,6 +215,8 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
{filtered.map((asset) => { const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || ""; + const isVideo = asset.asset_type === "video"; + const openPreview = cover ? () => setPreview({ src: cover, kind: isVideo ? "video" : "image", name: asset.name }) : undefined; return (
{editMode && onDelete && ( @@ -220,8 +224,15 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o )} -
- {cover ? {asset.name} : {asset.asset_type}} +
{ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPreview(); } } : undefined}> + {cover + ? (isVideo + ? <> +
{asset.name}
{asset.category} · {asset.source}
@@ -232,6 +243,8 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
// 当前分类暂无真实资产
)} + setPreview(null)} /> + (["task", "team", "billing", "system"]); + +// tab → 服务端查询参数(tab/未读/搜索全部走后端,滚动逐页拉) +function tabParams(tab: TabKey): { type?: string; unread?: boolean } { + if (tab === "unread") return { unread: true }; + if (TYPE_TABS.has(tab)) return { type: tab }; + return {}; +} + const PRI_LABEL: Record = { ok: "已完成", warn: "需关注", err: "风险", info: "更新" }; const ZH_TYPE: Record = { all: "全部", unread: "未读", task: "任务", team: "团队", billing: "计费", system: "系统" }; @@ -41,8 +53,7 @@ function fmtFull(iso: string): string { return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`; } -export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAllRead, navigate }: { - notifications: Notification[]; +export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate }: { unreadCount: number; onMarkRead: (id: string) => void | Promise; onMarkAllRead: () => void | Promise; @@ -50,35 +61,92 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll }) { const [tab, setTab] = useState("all"); const [query, setQuery] = useState(""); + const [debounced, setDebounced] = useState(""); const [selectedId, setSelectedId] = useState(""); - const counts = useMemo( - () => ({ - all: notifications.length, - unread: notifications.filter((n) => !n.is_read).length, - task: notifications.filter((n) => n.notification_type === "task").length, - team: notifications.filter((n) => n.notification_type === "team").length, - billing: notifications.filter((n) => n.notification_type === "billing").length, - system: notifications.filter((n) => n.notification_type === "system").length - }), - [notifications] + const [items, setItems] = useState([]); + const [counts, setCounts] = useState(() => ({ ...ZERO_COUNTS, unread: unreadCount })); + const [total, setTotal] = useState(0); // 当前筛选(tab/搜索)下的总条数,作「已加载 X / Y」的分母 + const [page, setPage] = useState(1); // 下一个要拉的页码 + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(false); + const loadingRef = useRef(false); // 防滚动重复触发追加 + const genRef = useRef(0); // 代号:tab/搜索一变就 +1,丢弃旧请求的回包 + const listRef = useRef(null); + + // 搜索去抖 300ms 再打服务端 + useEffect(() => { + const t = setTimeout(() => setDebounced(query.trim()), 300); + return () => clearTimeout(t); + }, [query]); + + const load = useCallback( + async (pageToLoad: number, replace: boolean) => { + // 追加(滚动)要防并发;重拉(replace)不阻塞,靠代号作废在途旧请求 + if (!replace && loadingRef.current) return; + const gen = replace ? (genRef.current += 1) : genRef.current; + loadingRef.current = true; + setLoading(true); + try { + const res = await api + .listNotifications({ ...tabParams(tab), search: debounced || undefined, page: pageToLoad, pageSize: PAGE_SIZE }) + .catch(() => null); + if (gen !== genRef.current) return; // tab/搜索已切换,丢弃过期结果 + if (!res) return; + setItems((prev) => (replace ? res.results : [...prev, ...res.results])); + setHasMore(Boolean(res.next)); + setPage(pageToLoad + 1); + setTotal(res.count); + if (res.type_counts) setCounts(res.type_counts); + } finally { + if (gen === genRef.current) { + loadingRef.current = false; + setLoading(false); + } + } + }, + [tab, debounced] ); - const visible = useMemo(() => { - const q = query.trim().toLowerCase(); - return notifications.filter((n) => { - if (tab === "unread" && n.is_read) return false; - if (!["all", "unread"].includes(tab) && n.notification_type !== tab) return false; - if (q && ![n.title, n.brief, n.body, n.source, n.project_name, n.stage].join(" ").toLowerCase().includes(q)) return false; - return true; - }); - }, [notifications, tab, query]); + // tab / 搜索变化 → 清空重拉第 1 页 + useEffect(() => { + setItems([]); + setSelectedId(""); + setHasMore(false); + void load(1, true); + }, [load]); - const selected = notifications.find((n) => n.id === selectedId) || visible[0] || notifications[0] || null; + // 滚到接近底部就拉下一批 + const onScroll = useCallback(() => { + const el = listRef.current; + if (!el || !hasMore || loadingRef.current) return; + if (el.scrollHeight - el.scrollTop - el.clientHeight < 120) void load(page, false); + }, [hasMore, page, load]); + + // 首批撑不满面板(没出现滚动条)却还有更多 → 自动续拉,保证可触达 + useEffect(() => { + const el = listRef.current; + if (el && hasMore && !loadingRef.current && el.scrollHeight <= el.clientHeight) void load(page, false); + }, [items, hasMore, page, load]); + + const selected = items.find((n) => n.id === selectedId) || items[0] || null; + + // 标记单条已读:同步后端/侧边栏徽标 + 本地乐观更新(列表与未读计数) + function markOne(id: string) { + void onMarkRead(id); + setItems((prev) => prev.map((x) => (x.id === id ? { ...x, is_read: true, unread: false } : x))); + setCounts((c) => ({ ...c, unread: Math.max(0, c.unread - 1) })); + } function selectItem(n: Notification) { setSelectedId(n.id); - if (!n.is_read) void onMarkRead(n.id); + if (!n.is_read) markOne(n.id); + } + + async function markAll() { + await onMarkAllRead(); + setItems((prev) => prev.map((x) => ({ ...x, is_read: true, unread: false }))); + setCounts((c) => ({ ...c, unread: 0 })); } const filters: Array<[TabKey, string, number]> = [ @@ -97,17 +165,17 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll

消息中心

-
// {counts.unread} 条未读 · {notifications.length} 条总计 任务提醒 · 团队协作 · 计费与系统公告
+
// {counts.unread} 条未读 · {counts.all} 条总计 任务提醒 · 团队协作 · 计费与系统公告
- +
-
收件箱// 显示 {visible.length} 条
+
收件箱// 已加载 {items.length} / {total} 条
{filters.map(([id, label, ct]) => (
-
- {visible.length === 0 ? ( +
+ {items.length === 0 && !loading ? (
没有符合条件的消息
) : ( - visible.map((n) => ( - - )) + + ))} + {(loading || hasMore) &&
{loading ? "// 加载中…" : "// 滚动加载更多"}
} + {!loading && !hasMore && items.length > 0 &&
// 已全部加载
} + )}
@@ -175,7 +247,7 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
- {!selected.is_read && } + {!selected.is_read && }
diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 4ec0173..24c99f8 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -5,6 +5,7 @@ import type { Asset, BillingSummary, ExportPoll, Product, Project, Team, Timelin import type { Notice, Page } from "./route-config"; import { money, stageOrder, statusPill } from "./stage-config"; import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell"; +import { MediaLightbox } from "../components/overlays"; import { IconKitSvg } from "../components/IconKitSvg"; // 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角) @@ -158,6 +159,8 @@ export function PipelinePage(props: { const activeDot = navigated ? viewStage : projectStage; const completed = Math.max(projectStage - 1, activeDot - 1); const [chatText, setChatText] = useState(""); + // 媒体预览灯箱(视频片段播放 / 故事板分镜放大) + const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null); const [chatMode, setChatMode] = useState<"ai" | "theme" | "manual">("ai"); const [chatAttachments, setChatAttachments] = useState>([]); const chatTextareaRef = useRef(null); @@ -796,7 +799,7 @@ export function PipelinePage(props: { {(() => { const url = frameUrl(sbActiveFrame); return ( -
+
setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }); } } : undefined}> {sbActiveFrame ? `场 ${sbSelected + 1}` : "// 故事板未生成"}
); @@ -899,7 +902,7 @@ export function PipelinePage(props: { const busy = ["running", "queued"].includes(seg.status); return (
-
+
setPreview({ src: url, kind: "video", name: `场 ${seg.sort_order + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "video", name: `场 ${seg.sort_order + 1}` }); } } : undefined}> {url ?
+ setPreview(null)} />
); } diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index b47f20e..24eeb6d 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState } from "react"; import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react"; +import { createPortal } from "react-dom"; import { ArrowLeft } from "lucide-react"; -import { ConfirmModal } from "../components/overlays"; +import { ConfirmModal, MediaLightbox } from "../components/overlays"; import type { Asset, Product, Project } from "../types"; import type { Page } from "./route-config"; import "../product-create-page.css"; @@ -74,6 +75,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o const [imagePreview, setImagePreview] = useState(""); const imgInputRef = useRef(null); const [showGuide, setShowGuide] = useState(false); + const [titleError, setTitleError] = useState(false); function pickImage(event: ChangeEvent) { const file = event.target.files?.[0]; if (file) setImagePreview(URL.createObjectURL(file)); @@ -131,8 +133,17 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o setBullets((list) => list.filter((_, position) => position !== index)); } + function resetForm() { + setTitle(""); + setCategory(""); + setTarget(""); + setBullets([]); + setBulletDraft(""); + setImagePreview(""); + setTitleError(false); + } function submit() { - if (!title.trim()) return; + if (!title.trim()) { setTitleError(true); return; } // 空名:给必填校验提示(不再静默无反应) onCreate({ title: title.trim(), category: category || PC_CAT_OPTIONS[0], @@ -140,11 +151,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o selling_points: bullets.map((item, index) => ({ title: item, detail: item, sort_order: index })) }); setDrawer(false); - setTitle(""); - setCategory(""); - setTarget(""); - setBullets([]); - setBulletDraft(""); + resetForm(); } return ( @@ -159,7 +166,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o {editMode ? "完成" : "管理商品"} - @@ -249,7 +256,9 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o onConfirm={doDelete} /> - {/* 新建商品 · 右侧 Drawer · 在商品库页面原地打开(转写自 products.html #pc-drawer) */} + {/* 新建商品 · 右侧 Drawer · portal 到 body,脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏(转写自 products.html #pc-drawer) */} + {createPortal( + <>
setDrawer(false)} /> + , + document.body, + )} ); } @@ -590,6 +603,8 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat const [assetSortDesc, setAssetSortDesc] = useState(true); const [assetLimit, setAssetLimit] = useState(12); const [videoSortDesc, setVideoSortDesc] = useState(true); + // 图片预览灯箱 + const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); useEffect(() => { if (!openFilter) return; @@ -709,7 +724,7 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
// 三视图预览 · {triGenerating ? "生成中…" : triUrl ? "已生成" : "待生成"}
-
+
setPreview({ src: triUrl, name: "三视图" }) : undefined} onKeyDown={triUrl ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: triUrl, name: "三视图" }); } } : undefined}> {triUrl ? 三视图 : {triGenerating ? "// 生成中,请稍候…" : "// 尚未生成 · 点击下方按钮开始"}}
@@ -793,9 +808,15 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
{productImages.map((image) => ( -
- {image.url ? {realName} : 1:1} -
+ image.url ? ( +
setPreview({ src: image.url, name: realName })} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: image.url, name: realName }); } }}> + {realName} +
+ ) : ( +
+ 1:1 +
+ ) ))}
imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}> {uploading ? ( @@ -896,7 +917,7 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat const status: "pass" | "fail" | "archive" = "pass"; return (
-
+
setPreview({ src: url, name: asset.name }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, name: asset.name }); } } : undefined}> {url ? {asset.name} : null} {pdAssetTypeLabel(asset)} {url ? null : 3:4} @@ -934,6 +955,8 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
+ setPreview(null)} /> + ); } diff --git a/core/frontend/src/routes/settings.tsx b/core/frontend/src/routes/settings.tsx index 24c8705..eb49925 100644 --- a/core/frontend/src/routes/settings.tsx +++ b/core/frontend/src/routes/settings.tsx @@ -188,6 +188,7 @@ export function SettingsPage({ setName(user.username || ""); setEmail(user.email || ""); setPhone(""); + onNotify?.("已恢复为已保存的资料"); } async function handleSaveProfile() { diff --git a/core/frontend/src/routes/team.tsx b/core/frontend/src/routes/team.tsx index d645d9c..55952b3 100644 --- a/core/frontend/src/routes/team.tsx +++ b/core/frontend/src/routes/team.tsx @@ -254,18 +254,27 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav {list.map((member) => { - const name = member.user.username || member.user.email || "成员"; + const rawName = (member.user.username || "").trim(); + const email = (member.user.email || "").trim(); + // 用户名是邮箱时取 @ 前作为显示名,完整邮箱作副行,避免名字与邮箱重复显示 + const displayName = rawName && !rawName.includes("@") ? rawName : (email ? email.split("@")[0] : (rawName || "成员")); + const showEmail = !!email && email.toLowerCase() !== displayName.toLowerCase(); const role = roleUi(member.role); const monthly = Number(member.monthly_credit_limit || 0); - const memberPct = monthly > 0 ? Math.min(100, (0 / monthly) * 100) : 0; + const memberUsed = Number(member.month_charged || 0); + // 月度不限时按团队月限额/余额作分母给参考进度;有消费即显可见细条,避免「用了钱进度条却空白」 + const quotaDenom = monthly > 0 ? monthly : limit; + const memberPct = quotaDenom > 0 ? Math.min(100, (memberUsed / quotaDenom) * 100) : 0; + const barWidth = memberUsed > 0 ? Math.max(memberPct, 3) : 0; + const barClass = memberPct >= 80 ? "warn" : "ok"; const isOwner = member.role === "owner"; return ( - {name.slice(0, 1).toUpperCase()}{name}{member.user.email || ""} + {displayName.slice(0, 1).toUpperCase()}{displayName}{showEmail && {email}} {role.label} 不限 {monthly > 0 ? money(monthly) : "不限"} -
¥0.00 / {memberPct.toFixed(0)}%
+
{money(memberUsed)} / {monthly > 0 ? `${memberPct.toFixed(0)}%` : "不限"}
{isOwner ? 不可编辑 : <> diff --git a/core/frontend/src/team-page.css b/core/frontend/src/team-page.css index 7a9b89b..8782021 100644 --- a/core/frontend/src/team-page.css +++ b/core/frontend/src/team-page.css @@ -78,10 +78,13 @@ .pane h3 .spacer { margin-left: auto; } /* ─── 成员表 ─── */ - .members-table .av { width: 32px; height: 32px; border-radius: 50%; background: var(--background-lighter); display: inline-grid; place-items: center; font-weight: 600; font-size: 13px; color: var(--accent-black); border: 1px solid var(--border-faint); } + .members-table th { white-space: nowrap; } + .members-table .av { flex: 0 0 32px; width: 32px; height: 32px; border-radius: 50%; background: var(--background-lighter); display: inline-grid; place-items: center; font-weight: 600; font-size: 13px; color: var(--accent-black); border: 1px solid var(--border-faint); } .members-table .who { display: flex; align-items: center; gap: 10px; } - .members-table .nm { font-weight: 500; font-size: 13.5px; line-height: 1.2; } - .members-table .em { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); } + .members-table .member-cell { display: flex; align-items: center; gap: 10px; min-width: 0; } + .members-table .member-cell .member-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; } + .members-table .nm { font-weight: 500; font-size: 13.5px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; } + .members-table .em { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; } .members-table .role-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 11px; font-weight: 500; } .members-table .role-pill .dot { width: 6px; height: 6px; border-radius: 50%; } .members-table .role-super { background: var(--heat-12); color: var(--heat); } diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index b6ef49c..30fc482 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -16,6 +16,7 @@ export type TeamMember = { role: string; status: string; monthly_credit_limit: string; + month_charged?: string; user: User; }; @@ -56,6 +57,7 @@ export type Asset = { category: string; description: string; metadata?: Record; + origin_task?: string | null; files?: Array<{ id: string; object_key: string; @@ -190,6 +192,7 @@ export type Ledger = { balance_after: string; reason: string; created_at: string; + user_label?: string; }; export type UserPreference = { @@ -260,7 +263,20 @@ export type Notification = { updated_at: string; }; -export type NotificationList = Paginated & { unread_count: number }; +export type NotificationTypeCounts = { + all: number; + unread: number; + task: number; + team: number; + billing: number; + system: number; +}; + +export type NotificationList = Paginated & { + unread_count: number; + // 分类 chip 的绝对总数(后端按收件人全量算,不受分页/搜索影响);旧响应可能没有 + type_counts?: NotificationTypeCounts; +}; export type RechargeResult = { account: BillingSummary["account"]; diff --git a/core/qa/visual-parity/compare-page.mjs b/core/qa/visual-parity/compare-page.mjs index 0337b09..570330e 100644 --- a/core/qa/visual-parity/compare-page.mjs +++ b/core/qa/visual-parity/compare-page.mjs @@ -35,6 +35,13 @@ function readPng(file) { } async function preparePage(page, url, shouldClearStorage, token) { + // 先到同源根页注入 token,再进目标路由 —— 否则目标路由在「无 token 首跳」时 + // 会跑一遍登出/重定向 boot,某些路由(如 /model-photo)即便随后 reload 也回不来, + // 导致截图停在登录页(假性 ~27% diff)。 + if (token) { + await page.goto(new URL(url).origin + "/", { waitUntil: "domcontentloaded" }); + await page.evaluate((value) => localStorage.setItem("airshelf_token", value), token); + } await page.goto(url, { waitUntil: "networkidle" }); if (shouldClearStorage) { await page.evaluate(() => {