feat(core): notification inbox infinite scroll + command palette fix (+ pending WIP)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m37s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m37s
消息中心:全量渲染 → 真·后端分页滚动加载 - 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:
parent
aa4bdeac83
commit
3fac38c5ef
@ -41,12 +41,18 @@ class TeamSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class TeamMemberSerializer(serializers.ModelSerializer):
|
class TeamMemberSerializer(serializers.ModelSerializer):
|
||||||
user = UserSerializer(read_only=True)
|
user = UserSerializer(read_only=True)
|
||||||
|
# 本月已消费(自然月,按 CreditLedger CHARGE 流水按人聚合);由 view 经 context 注入 charged_map
|
||||||
|
month_charged = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TeamMember
|
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"]
|
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):
|
class RegisterSerializer(serializers.Serializer):
|
||||||
username = serializers.CharField(max_length=150)
|
username = serializers.CharField(max_length=150)
|
||||||
|
|||||||
@ -40,13 +40,22 @@ def _client_ip(request):
|
|||||||
|
|
||||||
|
|
||||||
def record_login_session(request, user):
|
def record_login_session(request, user):
|
||||||
"""登录成功后记录一条会话(设备 UA / IP),供设置页「在用设备」展示。"""
|
"""登录成功后记录设备会话(UA / IP)。去重:同一台电脑(UA)+ 同一 IP 视为同一台设备,
|
||||||
|
已存在未下线的同设备会话则只刷新 last_seen_at,不再新增一行(避免「在用设备」列表里同设备重复堆叠)。"""
|
||||||
try:
|
try:
|
||||||
LoginSession.objects.create(
|
user_agent = (request.META.get("HTTP_USER_AGENT") or "")[:400]
|
||||||
user=user,
|
ip_address = _client_ip(request)
|
||||||
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:400],
|
existing = (
|
||||||
ip_address=_client_ip(request),
|
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 — 会话记录失败不应阻断登录
|
except Exception: # noqa: BLE001 — 会话记录失败不应阻断登录
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -170,13 +179,36 @@ def can_manage_team(user, team):
|
|||||||
return bool(member and member.role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN})
|
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"])
|
@api_view(["GET", "POST"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def team_members(request):
|
def team_members(request):
|
||||||
team = get_current_team(request.user)
|
team = get_current_team(request.user)
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
members = TeamMember.objects.filter(team=team).select_related("user").order_by("created_at")
|
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):
|
if not can_manage_team(request.user, team):
|
||||||
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
|
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
|
||||||
@ -277,23 +309,41 @@ def preferences(request):
|
|||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def login_sessions(request):
|
def login_sessions(request):
|
||||||
"""在用设备:返回未下线的登录会话(最近 20 条)。"""
|
"""在用设备:返回未下线的登录会话(去重后最近 20 台)。
|
||||||
sessions = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True)[: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_ip = _client_ip(request)
|
||||||
current_ua = (request.META.get("HTTP_USER_AGENT") or "")[:400]
|
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)
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
@api_view(["POST"])
|
@api_view(["POST"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def revoke_login_session(request, session_id):
|
def revoke_login_session(request, session_id):
|
||||||
"""下线单个设备会话。"""
|
"""下线单个设备:把同一台设备(UA + IP)下的所有未下线会话一并下线,
|
||||||
|
否则去重展示的一台设备点「下线」后,底层其它重复会话仍存活会再次冒出来。"""
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
updated = LoginSession.objects.filter(user=request.user, id=session_id, revoked_at__isnull=True).update(
|
target = LoginSession.objects.filter(user=request.user, id=session_id).first()
|
||||||
revoked_at=timezone.now()
|
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})
|
return Response({"revoked": updated})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@ -184,6 +188,34 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id
|
|||||||
raise
|
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:
|
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)
|
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
|
||||||
suffix = ".png"
|
suffix = ".png"
|
||||||
@ -214,6 +246,22 @@ def _store_generated_media(*, team, user, project, task, media: str, name: str,
|
|||||||
size_bytes=stored.size_bytes,
|
size_bytes=stored.size_bytes,
|
||||||
is_primary=True,
|
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
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -70,6 +70,7 @@ class AssetSerializer(serializers.ModelSerializer):
|
|||||||
"description",
|
"description",
|
||||||
"metadata",
|
"metadata",
|
||||||
"is_deleted",
|
"is_deleted",
|
||||||
|
"origin_task",
|
||||||
"files",
|
"files",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
|
|||||||
@ -11,11 +11,15 @@ class CreditAccountSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class CreditLedgerSerializer(serializers.ModelSerializer):
|
class CreditLedgerSerializer(serializers.ModelSerializer):
|
||||||
|
# 成员展示名:优先真实姓名 → 用户名 → 邮箱;系统流水(无 user)留空
|
||||||
|
user_label = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CreditLedger
|
model = CreditLedger
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"user",
|
"user",
|
||||||
|
"user_label",
|
||||||
"project",
|
"project",
|
||||||
"task",
|
"task",
|
||||||
"ledger_type",
|
"ledger_type",
|
||||||
@ -27,6 +31,12 @@ class CreditLedgerSerializer(serializers.ModelSerializer):
|
|||||||
]
|
]
|
||||||
read_only_fields = fields
|
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 CreditReservationSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@ -29,7 +29,7 @@ def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
|
|||||||
ledger_type=CreditLedger.Type.RESERVE,
|
ledger_type=CreditLedger.Type.RESERVE,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
balance_after=account.balance,
|
balance_after=account.balance,
|
||||||
reason="reserve ai task credit",
|
reason="AI 任务预扣额度",
|
||||||
)
|
)
|
||||||
return reservation
|
return reservation
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ def release_credit(*, reservation: CreditReservation, reason: str = "") -> None:
|
|||||||
ledger_type=CreditLedger.Type.RELEASE,
|
ledger_type=CreditLedger.Type.RELEASE,
|
||||||
amount=reservation.amount,
|
amount=reservation.amount,
|
||||||
balance_after=account.balance,
|
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,
|
ledger_type=CreditLedger.Type.CHARGE,
|
||||||
amount=actual_amount,
|
amount=actual_amount,
|
||||||
balance_after=account.balance,
|
balance_after=account.balance,
|
||||||
reason="charge ai task credit",
|
reason="AI 任务扣费",
|
||||||
)
|
)
|
||||||
if reservation.amount > actual_amount:
|
if reservation.amount > actual_amount:
|
||||||
CreditLedger.objects.create(
|
CreditLedger.objects.create(
|
||||||
@ -88,6 +88,6 @@ def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Dec
|
|||||||
ledger_type=CreditLedger.Type.RELEASE,
|
ledger_type=CreditLedger.Type.RELEASE,
|
||||||
amount=reservation.amount - actual_amount,
|
amount=reservation.amount - actual_amount,
|
||||||
balance_after=account.balance,
|
balance_after=account.balance,
|
||||||
reason="release unused reserved credit",
|
reason="释放未用预留额度",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -56,7 +56,27 @@ def ledgers(request):
|
|||||||
queryset = queryset.filter(project_id=project_id)
|
queryset = queryset.filter(project_id=project_id)
|
||||||
if user_id:
|
if user_id:
|
||||||
queryset = queryset.filter(user_id=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"])
|
@api_view(["POST"])
|
||||||
|
|||||||
@ -2,9 +2,17 @@ from django.db.models import Q
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
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.assets.models import Asset
|
||||||
from apps.billing.models import CreditAccount
|
from apps.billing.models import CreditAccount
|
||||||
from apps.common.api import TeamScopedViewSetMixin
|
from apps.common.api import TeamScopedViewSetMixin
|
||||||
@ -109,14 +117,19 @@ def ensure_team_notifications(team, user):
|
|||||||
class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||||
serializer_class = NotificationSerializer
|
serializer_class = NotificationSerializer
|
||||||
queryset = Notification.objects.select_related("team", "recipient", "project").all()
|
queryset = Notification.objects.select_related("team", "recipient", "project").all()
|
||||||
|
pagination_class = NotificationPagination
|
||||||
search_fields = ["title", "brief", "body", "source", "stage"]
|
search_fields = ["title", "brief", "body", "source", "stage"]
|
||||||
ordering_fields = ["created_at", "updated_at"]
|
ordering_fields = ["created_at", "updated_at"]
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
def get_queryset(self):
|
# 团队 + 收件人 + 未归档:分类计数的基准集(不含 tab/未读/搜索过滤)
|
||||||
|
def _recipient_scope(self):
|
||||||
queryset = super().get_queryset().filter(archived_at__isnull=True)
|
queryset = super().get_queryset().filter(archived_at__isnull=True)
|
||||||
user = self.request.user
|
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")
|
notification_type = self.request.query_params.get("type")
|
||||||
if notification_type and notification_type not in {"all", "unread"}:
|
if notification_type and notification_type not in {"all", "unread"}:
|
||||||
queryset = queryset.filter(notification_type=notification_type)
|
queryset = queryset.filter(notification_type=notification_type)
|
||||||
@ -128,9 +141,19 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
ensure_team_notifications(self.get_team(), request.user)
|
ensure_team_notifications(self.get_team(), request.user)
|
||||||
response = super().list(request, *args, **kwargs)
|
response = super().list(request, *args, **kwargs)
|
||||||
data = response.data
|
data = response.data
|
||||||
unread_count = self.get_queryset().filter(is_read=False).count()
|
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
|
# 分类 chip 计数取绝对总数(忽略当前 tab/搜索),与设计稿一致
|
||||||
|
base = self._recipient_scope()
|
||||||
|
unread_count = base.filter(is_read=False).count()
|
||||||
data["unread_count"] = unread_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
|
return response
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
|
|||||||
@ -44,6 +44,19 @@ def _download_asset_primary_file(asset, target_path: Path) -> None:
|
|||||||
target_path.write_bytes(response.content)
|
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):
|
def _load_font(size: int):
|
||||||
from PIL import ImageFont
|
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)
|
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,
|
def _build_export_command(*, n: int, specs: list[dict], starts: list[float], total: float,
|
||||||
transition: str, sub_overlays: list[tuple[str, float, 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] = []
|
parts: list[str] = []
|
||||||
for i, s in enumerate(specs):
|
for i, s in enumerate(specs):
|
||||||
parts.append(
|
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}]"
|
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-150:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
|
||||||
)
|
)
|
||||||
vlabel = 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"]
|
cmd = ["ffmpeg", "-y"]
|
||||||
for i in range(n):
|
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:
|
for png, _s, _e in sub_overlays:
|
||||||
cmd += ["-loop", "1", "-i", png]
|
cmd += ["-loop", "1", "-i", png]
|
||||||
cmd += ["-filter_complex", ";".join(parts), "-map", f"[{vlabel}]"]
|
cmd += ["-filter_complex", ";".join(parts), "-map", f"[{vlabel}]"]
|
||||||
if bgm_name:
|
if audio_label:
|
||||||
cmd += ["-map", "[aout]"]
|
cmd += ["-map", f"[{audio_label}]"]
|
||||||
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", "-preset", "veryfast"]
|
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 += ["-c:a", "aac", "-b:a", "192k"]
|
||||||
cmd += ["-t", f"{total:.3f}", "-movflags", "+faststart", "output.mp4"]
|
cmd += ["-t", f"{total:.3f}", "-movflags", "+faststart", "output.mp4"]
|
||||||
return cmd
|
return cmd
|
||||||
@ -237,6 +277,8 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
|||||||
tmp = Path(tmp_dir)
|
tmp = Path(tmp_dir)
|
||||||
for index, clip in enumerate(clips):
|
for index, clip in enumerate(clips):
|
||||||
_download_asset_primary_file(clip.asset, tmp / f"clip{index}.mp4")
|
_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
|
bgm_name = None
|
||||||
if bgm_track is not None and bgm_track.asset_id:
|
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(
|
command = _build_export_command(
|
||||||
n=len(clips), specs=specs, starts=starts, total=total, transition=transition,
|
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,
|
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)
|
proc = subprocess.run(command, cwd=str(tmp), capture_output=True)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
|
|||||||
@ -107,23 +107,23 @@ export function App() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
api.products(),
|
api.products(),
|
||||||
api.projects(),
|
api.projects(),
|
||||||
api.assets(),
|
api.allAssets(),
|
||||||
api.billingSummary().catch(() => null),
|
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.billingTrend().catch(() => null),
|
||||||
api.teamMembers().catch(() => []),
|
api.teamMembers().catch(() => []),
|
||||||
api.modelConfigs().catch(() => null),
|
api.modelConfigs().catch(() => null),
|
||||||
api.aiTasks().catch(() => null),
|
api.aiTasks().catch(() => null),
|
||||||
api.listNotifications().catch(() => null)
|
api.allNotifications().catch(() => null)
|
||||||
]);
|
]);
|
||||||
setProducts(productData.results);
|
setProducts(productData.results);
|
||||||
setProjects(projectData.results);
|
setProjects(projectData.results);
|
||||||
setAssets(assetData.results);
|
setAssets(assetData);
|
||||||
setTeamMembers(memberData);
|
setTeamMembers(memberData);
|
||||||
setModelConfigs(modelData?.results || []);
|
setModelConfigs(modelData?.results || []);
|
||||||
setAiTasks(taskData?.results || []);
|
setAiTasks(taskData?.results || []);
|
||||||
if (billingData) setBilling(billingData);
|
if (billingData) setBilling(billingData);
|
||||||
setLedgers(ledgerData);
|
setLedgers(ledgerData.results);
|
||||||
setBillingTrend(trendData);
|
setBillingTrend(trendData);
|
||||||
if (notificationData) {
|
if (notificationData) {
|
||||||
setNotifications(notificationData.results);
|
setNotifications(notificationData.results);
|
||||||
@ -161,7 +161,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reloadNotifications = useCallback(async () => {
|
const reloadNotifications = useCallback(async () => {
|
||||||
const data = await api.listNotifications().catch(() => null);
|
const data = await api.allNotifications().catch(() => null);
|
||||||
if (data) {
|
if (data) {
|
||||||
setNotifications(data.results);
|
setNotifications(data.results);
|
||||||
setUnreadCount(data.unread_count);
|
setUnreadCount(data.unread_count);
|
||||||
@ -513,7 +513,6 @@ export function App() {
|
|||||||
case "messages":
|
case "messages":
|
||||||
return (
|
return (
|
||||||
<MessagesPage
|
<MessagesPage
|
||||||
notifications={notifications}
|
|
||||||
unreadCount={unreadCount}
|
unreadCount={unreadCount}
|
||||||
onMarkRead={markNotificationRead}
|
onMarkRead={markNotificationRead}
|
||||||
onMarkAllRead={markAllNotificationsRead}
|
onMarkAllRead={markAllNotificationsRead}
|
||||||
@ -521,7 +520,7 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "assetFactory":
|
case "assetFactory":
|
||||||
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} />;
|
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} assets={assets} />;
|
||||||
case "imageOptimize":
|
case "imageOptimize":
|
||||||
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
|
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
|
||||||
case "modelPhoto":
|
case "modelPhoto":
|
||||||
|
|||||||
@ -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 { 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 .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 { 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 { display: block; width: 100%; background: var(--heat); border-radius: 2px 2px 0 0; }
|
.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:hover > span { background: var(--accent-black); }
|
||||||
.trend-chart .bar.peak > 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; }
|
.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); }
|
.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 { 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 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 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 .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 .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 .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 .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; }
|
.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 .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 { 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); }
|
.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.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.wip { background: var(--heat-12); color: var(--heat); }
|
||||||
.billing-table .status-tag.fail { background: rgba(235,52,36,.10); color: var(--accent-crimson); }
|
.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 { 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); }
|
.billing-table .progress-mini > span { display: block; height: 100%; background: var(--heat); }
|
||||||
|
|
||||||
|
|||||||
@ -158,6 +158,8 @@
|
|||||||
font-family: var(--font-mono); font-size: 11px;
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
letter-spacing: .04em; margin-bottom: 6px;
|
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)
|
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 { 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:hover { background: var(--black-alpha-4); }
|
||||||
.asset-factory .history-card .placeholder { width: 78px; height: 78px; }
|
.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-body { min-width: 0; }
|
||||||
.asset-factory .history-name { font-weight: 600; color: var(--accent-black); font-size: 13.5px; }
|
.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; }
|
.asset-factory .history-type { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
|
||||||
|
|||||||
@ -231,14 +231,26 @@ export const api = {
|
|||||||
assets() {
|
assets() {
|
||||||
return request<Paginated<Asset>>("/api/assets/");
|
return request<Paginated<Asset>>("/api/assets/");
|
||||||
},
|
},
|
||||||
|
// 跟随 DRF 分页 next 取全部资产 —— 商品图/AI 素材/资产库都靠 asset.id 在这份列表里查 preview_url,
|
||||||
|
// 只取第 1 页(20 条)会让第 20 条之后的资产解析不到图、渲染成空占位。
|
||||||
|
async allAssets(): Promise<Asset[]> {
|
||||||
|
const out: Asset[] = [];
|
||||||
|
let path = "/api/assets/";
|
||||||
|
for (let guard = 0; guard < 50 && path; guard += 1) {
|
||||||
|
const page: Paginated<Asset> = await request<Paginated<Asset>>(path);
|
||||||
|
out.push(...page.results);
|
||||||
|
path = page.next ? new URL(page.next).pathname + new URL(page.next).search : "";
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
uploadAsset(formData: FormData) {
|
uploadAsset(formData: FormData) {
|
||||||
return request<Asset>("/api/assets/upload/", { method: "POST", body: formData });
|
return request<Asset>("/api/assets/upload/", { method: "POST", body: formData });
|
||||||
},
|
},
|
||||||
billingSummary() {
|
billingSummary() {
|
||||||
return request<BillingSummary>("/api/billing/summary/");
|
return request<BillingSummary>("/api/billing/summary/");
|
||||||
},
|
},
|
||||||
ledgers() {
|
ledgers(page = 1, pageSize = 10) {
|
||||||
return request<Ledger[]>("/api/billing/ledgers/");
|
return request<{ count: number; page: number; page_size: number; results: Ledger[] }>(`/api/billing/ledgers/?page=${page}&page_size=${pageSize}`);
|
||||||
},
|
},
|
||||||
billingTrend(range?: "day" | "week" | "month") {
|
billingTrend(range?: "day" | "week" | "month") {
|
||||||
return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`);
|
return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`);
|
||||||
@ -255,13 +267,32 @@ export const api = {
|
|||||||
recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) {
|
recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) {
|
||||||
return request<RechargeResult>("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) });
|
return request<RechargeResult>("/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();
|
const query = new URLSearchParams();
|
||||||
if (params?.type && params.type !== "all") query.set("type", params.type);
|
if (params?.type && params.type !== "all") query.set("type", params.type);
|
||||||
if (params?.unread) query.set("unread", "1");
|
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();
|
const qs = query.toString();
|
||||||
return request<NotificationList>(`/api/ops/notifications/${qs ? `?${qs}` : ""}`);
|
return request<NotificationList>(`/api/ops/notifications/${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
|
// 跟着 next 把所有消息取全(给侧边栏徽标 + 团队动态用,不参与收件箱滚动渲染),与 allAssets 同套路
|
||||||
|
async allNotifications(): Promise<NotificationList> {
|
||||||
|
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<NotificationList>(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() {
|
markAllNotificationsRead() {
|
||||||
return request<{ updated: number; unread_count: number }>("/api/ops/notifications/mark-all-read/", {
|
return request<{ updated: number; unread_count: number }>("/api/ops/notifications/mark-all-read/", {
|
||||||
method: "POST"
|
method: "POST"
|
||||||
|
|||||||
@ -39,13 +39,13 @@ function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: (
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
id="shell-command-bg"
|
id="shell-command-bg"
|
||||||
className="show"
|
className="shell-command-bg show"
|
||||||
aria-hidden="false"
|
aria-hidden="false"
|
||||||
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
|
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div className="shell-command" role="dialog" aria-modal="true" aria-label="命令面板">
|
<div className="shell-command" role="dialog" aria-modal="true" aria-label="命令面板">
|
||||||
<div className="shell-command-head">
|
<div className="shell-command-h">
|
||||||
<IconKitSvg name="search" />
|
<span className="ic"><IconKitSvg name="search" /></span>
|
||||||
<input
|
<input
|
||||||
id="shell-command-input"
|
id="shell-command-input"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|||||||
@ -1,6 +1,45 @@
|
|||||||
import type { ReactNode } from "react";
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { Shield, X } from "lucide-react";
|
import { Shield, X } from "lucide-react";
|
||||||
|
|
||||||
|
// 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放(复用 .np-lightbox 样式)
|
||||||
|
// 背景点击 / Esc / 关闭键都可关闭;点媒体本身不关闭。
|
||||||
|
export function MediaLightbox({ open, src, kind, name, close }: {
|
||||||
|
open: boolean;
|
||||||
|
src: string;
|
||||||
|
kind?: "image" | "video";
|
||||||
|
name?: string;
|
||||||
|
close: () => 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(
|
||||||
|
<div className="np-lightbox show" onClick={close}>
|
||||||
|
<button className="lb-x" type="button" aria-label="关闭" onClick={close}><X /></button>
|
||||||
|
{kind === "video" ? (
|
||||||
|
<video
|
||||||
|
src={src}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
style={{ maxWidth: "90vw", maxHeight: "88vh", borderRadius: "var(--r-md)", boxShadow: "0 20px 60px rgba(0,0,0,.5)", background: "#000", cursor: "default" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img src={src} alt={name || "预览"} onClick={(event) => event.stopPropagation()} style={{ cursor: "default" }} />
|
||||||
|
)}
|
||||||
|
{name && <div className="lb-name">{name}</div>}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingRow({ title, desc, action, toggle, checked }: { title: string; desc: string; action?: string; toggle?: boolean; checked?: boolean }) {
|
export function SettingRow({ title, desc, action, toggle, checked }: { title: string; desc: string; action?: string; toggle?: boolean; checked?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="setting-row">
|
<div className="setting-row">
|
||||||
@ -20,7 +59,8 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer
|
|||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return (
|
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
|
||||||
|
return createPortal(
|
||||||
<div className="modal-bg show" onClick={close}>
|
<div className="modal-bg show" onClick={close}>
|
||||||
<div className="modal invite-modal" onClick={(event) => event.stopPropagation()}>
|
<div className="modal invite-modal" onClick={(event) => event.stopPropagation()}>
|
||||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||||
@ -28,7 +68,8 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer
|
|||||||
<div className="modal-b">{children}</div>
|
<div className="modal-b">{children}</div>
|
||||||
<div className="modal-f"><button className="btn" type="button" onClick={close}>取消</button>{footer || <button className="btn btn-primary" type="button" onClick={close}>保存</button>}</div>
|
<div className="modal-f"><button className="btn" type="button" onClick={close}>取消</button>{footer || <button className="btn btn-primary" type="button" onClick={close}>保存</button>}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,7 +82,8 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon
|
|||||||
onConfirm: () => void | Promise<unknown>;
|
onConfirm: () => void | Promise<unknown>;
|
||||||
}) {
|
}) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return (
|
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
|
||||||
|
return createPortal(
|
||||||
<div className="modal-bg show" onClick={onCancel}>
|
<div className="modal-bg show" onClick={onCancel}>
|
||||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||||
@ -49,20 +91,23 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon
|
|||||||
<div className="modal-b">{detail}</div>
|
<div className="modal-b">{detail}</div>
|
||||||
<div className="modal-f"><button className="btn" type="button" onClick={onCancel}>取消</button><button className="btn btn-primary" type="button" onClick={() => void onConfirm()}>{confirmText}</button></div>
|
<div className="modal-f"><button className="btn" type="button" onClick={onCancel}>取消</button><button className="btn btn-primary" type="button" onClick={() => void onConfirm()}>{confirmText}</button></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) {
|
export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return (
|
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
|
||||||
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="drawer-bg show" onClick={close} />
|
<div className="drawer-bg show" onClick={close} />
|
||||||
<aside className="drawer show">
|
<aside className="drawer show">
|
||||||
<div className="drawer-h"><h3>{title}</h3><button className="x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
|
<div className="drawer-h"><h3>{title}</h3><button className="x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
|
||||||
<div className="drawer-b">{children}</div>
|
<div className="drawer-b">{children}</div>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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-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-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; }
|
.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 显) */
|
/* 编辑模式:开启「管理资产」后,资产卡删除按钮常显(否则全局只 hover 显) */
|
||||||
|
|||||||
@ -15,7 +15,10 @@
|
|||||||
.msg-workbench {
|
.msg-workbench {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
|
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);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border-faint);
|
border: 1px solid var(--border-faint);
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-md);
|
||||||
@ -128,6 +131,13 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
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 {
|
.msg-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@ -385,6 +385,10 @@
|
|||||||
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
|
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
|
||||||
pointer-events: none;
|
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 {
|
.pc-drawer .form-card .pf-example .ex-d {
|
||||||
font-size: 12px; color: var(--black-alpha-56); line-height: 1.5;
|
font-size: 12px; color: var(--black-alpha-56); line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,9 @@ import { api } from "../api";
|
|||||||
import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types";
|
import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types";
|
||||||
import { money } from "./stage-config";
|
import { money } from "./stage-config";
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<string, string> = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" };
|
||||||
|
const STATUS_LABEL: Record<string, string> = { active: "活跃", invited: "待激活", disabled: "已停用" };
|
||||||
|
|
||||||
type TrendRange = "day" | "week" | "month";
|
type TrendRange = "day" | "week" | "month";
|
||||||
const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: string; avgLabel: string }> = {
|
const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: string; avgLabel: string }> = {
|
||||||
day: { chip: "日", sub: "// 近 14 天 · 单位 ¥", totalLabel: "14 天合计", avgLabel: "日均" },
|
day: { chip: "日", sub: "// 近 14 天 · 单位 ¥", totalLabel: "14 天合计", avgLabel: "日均" },
|
||||||
@ -12,6 +15,33 @@ const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: st
|
|||||||
|
|
||||||
type Tab = "overview" | "by-project" | "by-member" | "bills";
|
type Tab = "overview" | "by-project" | "by-member" | "bills";
|
||||||
|
|
||||||
|
// 账单类型 / 详情 中文化(后端历史英文流水也一并映射;未知值原样透出)
|
||||||
|
const LEDGER_TYPE_LABEL: Record<string, string> = {
|
||||||
|
recharge: "充值", reserve: "预扣", release: "释放", charge: "扣费", adjustment: "调整", refund: "退款"
|
||||||
|
};
|
||||||
|
const LEDGER_REASON_LABEL: Record<string, string> = {
|
||||||
|
"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<number | "ellipsis"> {
|
||||||
|
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||||
|
const items: Array<number | "ellipsis"> = [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 }> = [
|
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [
|
||||||
{ amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 },
|
{ amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 },
|
||||||
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, bonusAmt: 30, ribbon: "推荐" },
|
{ 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 [recharge, setRecharge] = useState(500);
|
||||||
const [customAmt, setCustomAmt] = useState("");
|
const [customAmt, setCustomAmt] = useState("");
|
||||||
|
|
||||||
|
// 账单流水分页:服务端分页(总数随流水增长,不再写死 100),每页 10 条
|
||||||
|
const BILLS_PER_PAGE = 10;
|
||||||
|
const [billPage, setBillPage] = useState(1);
|
||||||
|
const [ledgerRows, setLedgerRows] = useState<Ledger[]>(ledgers);
|
||||||
|
const [ledgerCount, setLedgerCount] = useState<number>(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 selectedCard = RECHARGE.find((item) => item.amt === recharge);
|
||||||
const effectiveAmount = Number(customAmt) > 0 ? Number(customAmt) : recharge;
|
const effectiveAmount = Number(customAmt) > 0 ? Number(customAmt) : recharge;
|
||||||
const effectiveBonus = Number(customAmt) > 0 ? 0 : selectedCard?.bonusAmt || 0;
|
const effectiveBonus = Number(customAmt) > 0 ? 0 : selectedCard?.bonusAmt || 0;
|
||||||
@ -158,7 +205,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
|
|||||||
<button className={`tab ${tab === "overview" ? "active" : ""}`} type="button" onClick={() => setTab("overview")}>总览</button>
|
<button className={`tab ${tab === "overview" ? "active" : ""}`} type="button" onClick={() => setTab("overview")}>总览</button>
|
||||||
<button className={`tab ${tab === "by-project" ? "active" : ""}`} type="button" onClick={() => setTab("by-project")}>项目 <span className="count">{projects.length}</span></button>
|
<button className={`tab ${tab === "by-project" ? "active" : ""}`} type="button" onClick={() => setTab("by-project")}>项目 <span className="count">{projects.length}</span></button>
|
||||||
<button className={`tab ${tab === "by-member" ? "active" : ""}`} type="button" onClick={() => setTab("by-member")}>成员 <span className="count">{teamMembers.length}</span></button>
|
<button className={`tab ${tab === "by-member" ? "active" : ""}`} type="button" onClick={() => setTab("by-member")}>成员 <span className="count">{teamMembers.length}</span></button>
|
||||||
<button className={`tab ${tab === "bills" ? "active" : ""}`} type="button" onClick={() => setTab("bills")}>账单流水 <span className="count">{ledgers.length}</span></button>
|
<button className={`tab ${tab === "bills" ? "active" : ""}`} type="button" onClick={() => setTab("bills")}>账单流水 <span className="count">{ledgerCount}</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`tab-panel ${tab === "overview" ? "active" : ""}`}>
|
<div className={`tab-panel ${tab === "overview" ? "active" : ""}`}>
|
||||||
@ -238,18 +285,34 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
|
|||||||
<table className="billing-table">
|
<table className="billing-table">
|
||||||
<thead><tr><th>时间</th><th>项目 / 类型</th><th>详情</th><th>成员</th><th>状态</th><th style={{ textAlign: "right" }}>金额</th></tr></thead>
|
<thead><tr><th>时间</th><th>项目 / 类型</th><th>详情</th><th>成员</th><th>状态</th><th style={{ textAlign: "right" }}>金额</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ledgers.map((l) => (
|
{ledgerRows.map((l) => (
|
||||||
<tr key={l.id}>
|
<tr key={l.id}>
|
||||||
<td className="ts">{new Date(l.created_at).toLocaleString("zh-CN")}</td>
|
<td className="ts">{new Date(l.created_at).toLocaleString("zh-CN")}</td>
|
||||||
<td>{l.ledger_type}</td>
|
<td>{ledgerTypeLabel(l.ledger_type)}</td>
|
||||||
<td className="muted">{l.reason}</td>
|
<td className="muted">{ledgerReasonLabel(l.reason)}</td>
|
||||||
<td></td>
|
<td>{l.user_label
|
||||||
<td><span className="status-tag ok">OK</span></td>
|
? <span className="who"><span className="av">{l.user_label.slice(0, 1).toUpperCase()}</span>{l.user_label}</span>
|
||||||
|
: <span className="sys">系统</span>}</td>
|
||||||
|
<td><span className="status-tag ok">成功</span></td>
|
||||||
<td className="neg">{l.amount}</td>
|
<td className="neg">{l.amount}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{ledgerCount > BILLS_PER_PAGE && (
|
||||||
|
<div className="bill-pager">
|
||||||
|
<span className="total">// 共 {ledgerCount} 条 · 第 {safeBillPage} / {billTotalPages} 页</span>
|
||||||
|
<div className="pages">
|
||||||
|
<button type="button" disabled={safeBillPage <= 1} onClick={() => setBillPage(safeBillPage - 1)}>上一页</button>
|
||||||
|
{pageWindow(safeBillPage, billTotalPages).map((p, i) => (
|
||||||
|
p === "ellipsis"
|
||||||
|
? <span key={`e${i}`} className="ellipsis">…</span>
|
||||||
|
: <button key={p} className={p === safeBillPage ? "active" : ""} type="button" onClick={() => setBillPage(p)}>{p}</button>
|
||||||
|
))}
|
||||||
|
<button type="button" disabled={safeBillPage >= billTotalPages} onClick={() => setBillPage(safeBillPage + 1)}>下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
|
<div className={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
|
||||||
@ -270,9 +333,12 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
|
|||||||
<table className="billing-table">
|
<table className="billing-table">
|
||||||
<thead><tr><th>成员</th><th>角色</th><th>已用 / 月度额度</th><th>状态</th></tr></thead>
|
<thead><tr><th>成员</th><th>角色</th><th>已用 / 月度额度</th><th>状态</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{teamMembers.map((m) => (
|
{teamMembers.map((m) => {
|
||||||
<tr key={m.id}><td className="who"><span className="av">{m.user.username.slice(0, 1).toUpperCase()}</span>{m.user.username}</td><td>{m.role}</td><td className="zero">{money(m.monthly_credit_limit)}</td><td>{m.status}</td></tr>
|
const monthly = Number(m.monthly_credit_limit || 0);
|
||||||
))}
|
return (
|
||||||
|
<tr key={m.id}><td className="who"><span className="av">{m.user.username.slice(0, 1).toUpperCase()}</span>{m.user.username}</td><td>{ROLE_LABEL[m.role] || m.role}</td><td className="quota"><span className="used">{money(m.month_charged || 0)}</span> <span className="lim">/ {monthly > 0 ? money(monthly) : "不限"}</span></td><td>{STATUS_LABEL[m.status] || m.status}</td></tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import {
|
|||||||
X
|
X
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AITask, Asset, ModelConfig, Product } from "../types";
|
import type { AITask, Asset, ModelConfig, Product } from "../types";
|
||||||
|
import { MediaLightbox } from "../components/overlays";
|
||||||
import type { Page } from "./route-config";
|
import type { Page } from "./route-config";
|
||||||
import { statusPill } from "./stage-config";
|
import { statusPill } from "./stage-config";
|
||||||
import "../ai-tools-page.css";
|
import "../ai-tools-page.css";
|
||||||
@ -46,7 +47,14 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
running: "生成中",
|
running: "生成中",
|
||||||
queued: "排队中",
|
queued: "排队中",
|
||||||
polling: "生成中",
|
polling: "生成中",
|
||||||
needs_review: "待确认"
|
needs_review: "待确认",
|
||||||
|
// 后端 AITask.Status 全量中文化(原先缺这些会直接透出英文)
|
||||||
|
created: "待处理",
|
||||||
|
reserved: "排队中",
|
||||||
|
submitted: "已提交",
|
||||||
|
postprocessing: "处理中",
|
||||||
|
compensating: "回滚中",
|
||||||
|
cancelled: "已取消"
|
||||||
};
|
};
|
||||||
|
|
||||||
function statusText(status: string) {
|
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<string, string> = {};
|
||||||
|
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 = [
|
const cards = [
|
||||||
{
|
{
|
||||||
page: "modelPhoto" as Page,
|
page: "modelPhoto" as Page,
|
||||||
@ -112,8 +131,11 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all");
|
const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all");
|
||||||
const [typeFilter, setTypeFilter] = useState("");
|
const [typeFilter, setTypeFilter] = useState("");
|
||||||
const [view, setView] = useState<"grid" | "list">("list");
|
const [view, setView] = useState<"grid" | "list">("grid");
|
||||||
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
|
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
|
||||||
|
// 任务中心分页:每次加载 12 条,「加载更多」递增;筛选/搜索变化时重置
|
||||||
|
const TASKS_PER_LOAD = 12;
|
||||||
|
const [shown, setShown] = useState(TASKS_PER_LOAD);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openChip) return;
|
if (!openChip) return;
|
||||||
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); };
|
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;
|
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 (
|
return (
|
||||||
<div className="asset-factory">
|
<div className="asset-factory">
|
||||||
@ -246,7 +272,7 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="result-meta">
|
<div className="result-meta">
|
||||||
// 显示 {visible.length} / {aiTasks.length} 个任务
|
// 显示 {paged.length} / {visible.length} 个任务
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{aiTasks.length === 0 ? (
|
{aiTasks.length === 0 ? (
|
||||||
@ -261,12 +287,13 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
|
|||||||
</div>
|
</div>
|
||||||
) : view === "grid" ? (
|
) : view === "grid" ? (
|
||||||
<div className="history-grid">
|
<div className="history-grid">
|
||||||
{visible.map((task) => {
|
{paged.map((task) => {
|
||||||
const pill = statusPill(task.status);
|
const pill = statusPill(task.status);
|
||||||
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
|
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
|
||||||
|
const img = taskImage[task.id];
|
||||||
return (
|
return (
|
||||||
<article className="task-card history-card" key={task.id}>
|
<article className="task-card history-card" key={task.id}>
|
||||||
<div className="placeholder"><span className="ph-frame">{task.id.slice(0, 4)}</span></div>
|
<div className={`placeholder${img ? " has-img" : ""}`}>{img ? <img src={img} alt={typeLabel} /> : <span className="ph-frame">{task.id.slice(0, 4)}</span>}</div>
|
||||||
<div className="history-body">
|
<div className="history-body">
|
||||||
<div className="history-name">{typeLabel}</div>
|
<div className="history-name">{typeLabel}</div>
|
||||||
<div className="history-type">// {task.task_type}</div>
|
<div className="history-type">// {task.task_type}</div>
|
||||||
@ -292,15 +319,16 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visible.map((task) => {
|
{paged.map((task) => {
|
||||||
const pill = statusPill(task.status);
|
const pill = statusPill(task.status);
|
||||||
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
|
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
|
||||||
|
const img = taskImage[task.id];
|
||||||
return (
|
return (
|
||||||
<tr key={task.id}>
|
<tr key={task.id}>
|
||||||
<td>
|
<td>
|
||||||
<div className="task-name-cell">
|
<div className="task-name-cell">
|
||||||
<div className="placeholder task-thumb">
|
<div className={`placeholder task-thumb${img ? " has-img" : ""}`}>
|
||||||
<span className="ph-frame">{task.id.slice(0, 4)}</span>
|
{img ? <img src={img} alt={typeLabel} /> : <span className="ph-frame">{task.id.slice(0, 4)}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="task-name">{typeLabel}</div>
|
<div className="task-name">{typeLabel}</div>
|
||||||
@ -337,6 +365,14 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<div className="task-load-more">
|
||||||
|
<button className="btn" type="button" onClick={() => setShown((n) => n + TASKS_PER_LOAD)}>
|
||||||
|
加载更多 <span className="lm-rest">还有 {visible.length - paged.length} 个</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -451,6 +487,8 @@ export function ImageWorkbenchPage({
|
|||||||
const [results, setResults] = useState<Asset[] | null>(null);
|
const [results, setResults] = useState<Asset[] | null>(null);
|
||||||
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
|
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
|
||||||
const refInputRef = useRef<HTMLInputElement | null>(null);
|
const refInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
// 生成结果图片放大预览
|
||||||
|
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||||
// 模特/平台 工作台头部:搜索 + 时间排序 + 模特筛选(对左侧网格真实生效)
|
// 模特/平台 工作台头部:搜索 + 时间排序 + 模特筛选(对左侧网格真实生效)
|
||||||
const [gridQuery, setGridQuery] = useState("");
|
const [gridQuery, setGridQuery] = useState("");
|
||||||
const [gridSort, setGridSort] = useState<"recent" | "name">("recent");
|
const [gridSort, setGridSort] = useState<"recent" | "name">("recent");
|
||||||
@ -502,6 +540,8 @@ export function ImageWorkbenchPage({
|
|||||||
function renderResultGrid() {
|
function renderResultGrid() {
|
||||||
const cols = (results?.length ?? candidateCount) >= 4 ? 4 : 2;
|
const cols = (results?.length ?? candidateCount) >= 4 ? 4 : 2;
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||||
<div
|
<div
|
||||||
className="gen-images"
|
className="gen-images"
|
||||||
style={{ "--cols": cols, "--ratio": ratioVar } as React.CSSProperties}
|
style={{ "--cols": cols, "--ratio": ratioVar } as React.CSSProperties}
|
||||||
@ -512,7 +552,7 @@ export function ImageWorkbenchPage({
|
|||||||
).map(({ key, index, url }) => (
|
).map(({ key, index, url }) => (
|
||||||
<div className={`gen-image ${generating && !url ? "gen" : ""}`} key={key}>
|
<div className={`gen-image ${generating && !url ? "gen" : ""}`} key={key}>
|
||||||
{url ? (
|
{url ? (
|
||||||
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" />
|
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
|
||||||
) : (
|
) : (
|
||||||
<div className="placeholder">
|
<div className="placeholder">
|
||||||
<span className="ph-frame">
|
<span className="ph-frame">
|
||||||
@ -538,6 +578,7 @@ export function ImageWorkbenchPage({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import type { Asset } from "../types";
|
import type { Asset } from "../types";
|
||||||
import { ConfirmModal, Drawer } from "../components/overlays";
|
import { ConfirmModal, Drawer, MediaLightbox } from "../components/overlays";
|
||||||
|
|
||||||
// asset.source / asset.asset_type → 中文标签(筛选下拉用)
|
// asset.source / asset.asset_type → 中文标签(筛选下拉用)
|
||||||
const SOURCE_LABELS: Record<string, string> = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" };
|
const SOURCE_LABELS: Record<string, string> = { 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 [editMode, setEditMode] = useState(false);
|
||||||
const [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
|
const [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
|
||||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||||
|
// 资产预览灯箱(图片放大 / 视频播放)
|
||||||
|
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.toggle("edit-mode", editMode);
|
document.body.classList.toggle("edit-mode", editMode);
|
||||||
return () => document.body.classList.remove("edit-mode");
|
return () => document.body.classList.remove("edit-mode");
|
||||||
@ -213,6 +215,8 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
|||||||
<div className="asset-grid" id="asset-grid">
|
<div className="asset-grid" id="asset-grid">
|
||||||
{filtered.map((asset) => {
|
{filtered.map((asset) => {
|
||||||
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
|
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 (
|
return (
|
||||||
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
|
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
|
||||||
{editMode && onDelete && (
|
{editMode && onDelete && (
|
||||||
@ -220,8 +224,15 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="placeholder asset-thumb">
|
<div className="placeholder asset-thumb" role={openPreview ? "button" : undefined} tabIndex={openPreview ? 0 : undefined} title={openPreview ? (isVideo ? "点击播放" : "点击放大") : undefined} style={openPreview ? { cursor: isVideo ? "pointer" : "zoom-in", position: "relative" } : undefined} onClick={openPreview} onKeyDown={openPreview ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPreview(); } } : undefined}>
|
||||||
{cover ? <img src={cover} alt={asset.name} loading="lazy" /> : <span className="ph-frame">{asset.asset_type}</span>}
|
{cover
|
||||||
|
? (isVideo
|
||||||
|
? <>
|
||||||
|
<video src={cover} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
|
||||||
|
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
|
||||||
|
</>
|
||||||
|
: <img src={cover} alt={asset.name} loading="lazy" />)
|
||||||
|
: <span className="ph-frame">{asset.asset_type}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{asset.category} · {asset.source}</div></div>
|
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{asset.category} · {asset.source}</div></div>
|
||||||
</article>
|
</article>
|
||||||
@ -232,6 +243,8 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
|||||||
<div className="empty-filter">// 当前分类暂无真实资产</div>
|
<div className="empty-filter">// 当前分类暂无真实资产</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={Boolean(confirmId)}
|
open={Boolean(confirmId)}
|
||||||
title="删除资产"
|
title="删除资产"
|
||||||
|
|||||||
@ -1,11 +1,23 @@
|
|||||||
import { useMemo, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { Bell, Clapperboard, CreditCard, Info, Search, Users } from "lucide-react";
|
import { Bell, Clapperboard, CreditCard, Info, Search, Users } from "lucide-react";
|
||||||
import type { Notification } from "../types";
|
import { api } from "../api";
|
||||||
|
import type { Notification, NotificationTypeCounts } from "../types";
|
||||||
import type { Page } from "./route-config";
|
import type { Page } from "./route-config";
|
||||||
import { routeLabels } from "./route-config";
|
import { routeLabels } from "./route-config";
|
||||||
|
|
||||||
type TabKey = "all" | "unread" | "task" | "team" | "billing" | "system";
|
type TabKey = "all" | "unread" | "task" | "team" | "billing" | "system";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10; // 每次滚到底加载一批
|
||||||
|
const ZERO_COUNTS: NotificationTypeCounts = { all: 0, unread: 0, task: 0, team: 0, billing: 0, system: 0 };
|
||||||
|
const TYPE_TABS = new Set<TabKey>(["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<string, string> = { ok: "已完成", warn: "需关注", err: "风险", info: "更新" };
|
const PRI_LABEL: Record<string, string> = { ok: "已完成", warn: "需关注", err: "风险", info: "更新" };
|
||||||
const ZH_TYPE: Record<string, string> = { all: "全部", unread: "未读", task: "任务", team: "团队", billing: "计费", system: "系统" };
|
const ZH_TYPE: Record<string, string> = { 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())}`;
|
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAllRead, navigate }: {
|
export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate }: {
|
||||||
notifications: Notification[];
|
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
onMarkRead: (id: string) => void | Promise<unknown>;
|
onMarkRead: (id: string) => void | Promise<unknown>;
|
||||||
onMarkAllRead: () => void | Promise<unknown>;
|
onMarkAllRead: () => void | Promise<unknown>;
|
||||||
@ -50,35 +61,92 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
|
|||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<TabKey>("all");
|
const [tab, setTab] = useState<TabKey>("all");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [debounced, setDebounced] = useState("");
|
||||||
const [selectedId, setSelectedId] = useState("");
|
const [selectedId, setSelectedId] = useState("");
|
||||||
|
|
||||||
const counts = useMemo(
|
const [items, setItems] = useState<Notification[]>([]);
|
||||||
() => ({
|
const [counts, setCounts] = useState<NotificationTypeCounts>(() => ({ ...ZERO_COUNTS, unread: unreadCount }));
|
||||||
all: notifications.length,
|
const [total, setTotal] = useState(0); // 当前筛选(tab/搜索)下的总条数,作「已加载 X / Y」的分母
|
||||||
unread: notifications.filter((n) => !n.is_read).length,
|
const [page, setPage] = useState(1); // 下一个要拉的页码
|
||||||
task: notifications.filter((n) => n.notification_type === "task").length,
|
const [hasMore, setHasMore] = useState(false);
|
||||||
team: notifications.filter((n) => n.notification_type === "team").length,
|
const [loading, setLoading] = useState(false);
|
||||||
billing: notifications.filter((n) => n.notification_type === "billing").length,
|
const loadingRef = useRef(false); // 防滚动重复触发追加
|
||||||
system: notifications.filter((n) => n.notification_type === "system").length
|
const genRef = useRef(0); // 代号:tab/搜索一变就 +1,丢弃旧请求的回包
|
||||||
}),
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
[notifications]
|
|
||||||
|
// 搜索去抖 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(() => {
|
// tab / 搜索变化 → 清空重拉第 1 页
|
||||||
const q = query.trim().toLowerCase();
|
useEffect(() => {
|
||||||
return notifications.filter((n) => {
|
setItems([]);
|
||||||
if (tab === "unread" && n.is_read) return false;
|
setSelectedId("");
|
||||||
if (!["all", "unread"].includes(tab) && n.notification_type !== tab) return false;
|
setHasMore(false);
|
||||||
if (q && ![n.title, n.brief, n.body, n.source, n.project_name, n.stage].join(" ").toLowerCase().includes(q)) return false;
|
void load(1, true);
|
||||||
return true;
|
}, [load]);
|
||||||
});
|
|
||||||
}, [notifications, tab, query]);
|
|
||||||
|
|
||||||
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) {
|
function selectItem(n: Notification) {
|
||||||
setSelectedId(n.id);
|
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]> = [
|
const filters: Array<[TabKey, string, number]> = [
|
||||||
@ -97,17 +165,17 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
|
|||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>消息中心</h1>
|
<h1>消息中心</h1>
|
||||||
<div className="sub"><span className="mono">// {counts.unread} 条未读 · {notifications.length} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div>
|
<div className="sub"><span className="mono">// {counts.unread} 条未读 · {counts.all} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="msg-head-actions">
|
<div className="msg-head-actions">
|
||||||
<button className="btn" type="button" onClick={() => void onMarkAllRead()} disabled={unreadCount === 0}>全部标已读</button>
|
<button className="btn" type="button" onClick={() => void markAll()} disabled={counts.unread === 0}>全部标已读</button>
|
||||||
<button className="btn" type="button" onClick={() => navigate("settingsNotify")}>通知设置</button>
|
<button className="btn" type="button" onClick={() => navigate("settingsNotify")}>通知设置</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="msg-workbench">
|
<div className="msg-workbench">
|
||||||
<section className="msg-panel msg-inbox">
|
<section className="msg-panel msg-inbox">
|
||||||
<div className="msg-panel-h"><span className="ti">收件箱</span><span className="mono">// 显示 {visible.length} 条</span></div>
|
<div className="msg-panel-h"><span className="ti">收件箱</span><span className="mono">// 已加载 {items.length} / {total} 条</span></div>
|
||||||
<div className="msg-filters">
|
<div className="msg-filters">
|
||||||
{filters.map(([id, label, ct]) => (
|
{filters.map(([id, label, ct]) => (
|
||||||
<button key={id} className={`msg-filter ${tab === id ? "active" : ""}`} type="button" onClick={() => setTab(id)}>
|
<button key={id} className={`msg-filter ${tab === id ? "active" : ""}`} type="button" onClick={() => setTab(id)}>
|
||||||
@ -119,27 +187,31 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
|
|||||||
<Search />
|
<Search />
|
||||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
|
||||||
</div>
|
</div>
|
||||||
<div className="msg-list">
|
<div className="msg-list" ref={listRef} onScroll={onScroll}>
|
||||||
{visible.length === 0 ? (
|
{items.length === 0 && !loading ? (
|
||||||
<div className="msg-empty"><Search /><span>没有符合条件的消息</span></div>
|
<div className="msg-empty"><Search /><span>没有符合条件的消息</span></div>
|
||||||
) : (
|
) : (
|
||||||
visible.map((n) => (
|
<>
|
||||||
<button key={n.id} className={`msg-item ${selected?.id === n.id ? "active" : ""} ${n.is_read ? "read" : ""}`} type="button" onClick={() => selectItem(n)}>
|
{items.map((n) => (
|
||||||
<span className={`msg-type-ic ${n.notification_type}`}>{typeIcon(n.notification_type)}</span>
|
<button key={n.id} className={`msg-item ${selected?.id === n.id ? "active" : ""} ${n.is_read ? "read" : ""}`} type="button" onClick={() => selectItem(n)}>
|
||||||
<span className="msg-item-main">
|
<span className={`msg-type-ic ${n.notification_type}`}>{typeIcon(n.notification_type)}</span>
|
||||||
<span className="msg-item-row">
|
<span className="msg-item-main">
|
||||||
<span className="msg-dot"></span>
|
<span className="msg-item-row">
|
||||||
<span className="msg-item-title">{n.title}</span>
|
<span className="msg-dot"></span>
|
||||||
<span className="msg-time">{fmtTime(n.created_at)}</span>
|
<span className="msg-item-title">{n.title}</span>
|
||||||
|
<span className="msg-time">{fmtTime(n.created_at)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="msg-brief">{n.brief}</span>
|
||||||
|
<span className="msg-item-foot">
|
||||||
|
<span className={`msg-priority ${n.priority}`}>{PRI_LABEL[n.priority] || "更新"}</span>
|
||||||
|
{n.project_name ? <span className="msg-priority">{n.project_name}</span> : null}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="msg-brief">{n.brief}</span>
|
</button>
|
||||||
<span className="msg-item-foot">
|
))}
|
||||||
<span className={`msg-priority ${n.priority}`}>{PRI_LABEL[n.priority] || "更新"}</span>
|
{(loading || hasMore) && <div className="msg-load-more mono">{loading ? "// 加载中…" : "// 滚动加载更多"}</div>}
|
||||||
{n.project_name ? <span className="msg-priority">{n.project_name}</span> : null}
|
{!loading && !hasMore && items.length > 0 && <div className="msg-load-more mono">// 已全部加载</div>}
|
||||||
</span>
|
</>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -175,7 +247,7 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="msg-detail-f">
|
<div className="msg-detail-f">
|
||||||
{!selected.is_read && <button className="btn btn-ghost" type="button" onClick={() => void onMarkRead(selected.id)}>标为已读</button>}
|
{!selected.is_read && <button className="btn btn-ghost" type="button" onClick={() => markOne(selected.id)}>标为已读</button>}
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-primary" type="button" onClick={() => navigate(target)}>进入{routeLabels[target]}</button>
|
<button className="btn btn-primary" type="button" onClick={() => navigate(target)}>进入{routeLabels[target]}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { Asset, BillingSummary, ExportPoll, Product, Project, Team, Timelin
|
|||||||
import type { Notice, Page } from "./route-config";
|
import type { Notice, Page } from "./route-config";
|
||||||
import { money, stageOrder, statusPill } from "./stage-config";
|
import { money, stageOrder, statusPill } from "./stage-config";
|
||||||
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
|
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
|
||||||
|
import { MediaLightbox } from "../components/overlays";
|
||||||
import { IconKitSvg } from "../components/IconKitSvg";
|
import { IconKitSvg } from "../components/IconKitSvg";
|
||||||
|
|
||||||
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
|
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
|
||||||
@ -158,6 +159,8 @@ export function PipelinePage(props: {
|
|||||||
const activeDot = navigated ? viewStage : projectStage;
|
const activeDot = navigated ? viewStage : projectStage;
|
||||||
const completed = Math.max(projectStage - 1, activeDot - 1);
|
const completed = Math.max(projectStage - 1, activeDot - 1);
|
||||||
const [chatText, setChatText] = useState("");
|
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 [chatMode, setChatMode] = useState<"ai" | "theme" | "manual">("ai");
|
||||||
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
|
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
|
||||||
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
@ -796,7 +799,7 @@ export function PipelinePage(props: {
|
|||||||
{(() => {
|
{(() => {
|
||||||
const url = frameUrl(sbActiveFrame);
|
const url = frameUrl(sbActiveFrame);
|
||||||
return (
|
return (
|
||||||
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? mediaStyle(url) : undefined}>
|
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? { ...mediaStyle(url), cursor: "zoom-in" } : undefined} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} onClick={url ? () => 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}>
|
||||||
<span className="ph-frame">{sbActiveFrame ? `场 ${sbSelected + 1}` : "// 故事板未生成"}</span>
|
<span className="ph-frame">{sbActiveFrame ? `场 ${sbSelected + 1}` : "// 故事板未生成"}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -899,7 +902,7 @@ export function PipelinePage(props: {
|
|||||||
const busy = ["running", "queued"].includes(seg.status);
|
const busy = ["running", "queued"].includes(seg.status);
|
||||||
return (
|
return (
|
||||||
<div className="video-card" key={seg.id} data-video-id={seg.id}>
|
<div className="video-card" key={seg.id} data-video-id={seg.id}>
|
||||||
<div className="placeholder video-thumb" style={{ position: "relative", overflow: "hidden" }}>
|
<div className="placeholder video-thumb" style={{ position: "relative", overflow: "hidden" }} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击播放" : undefined} onClick={url ? () => 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
|
{url
|
||||||
? <video src={url} muted playsInline preload="metadata" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
|
? <video src={url} muted playsInline preload="metadata" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
|
||||||
: <span className="ph-frame">场 {seg.sort_order + 1}</span>}
|
: <span className="ph-frame">场 {seg.sort_order + 1}</span>}
|
||||||
@ -1199,6 +1202,7 @@ export function PipelinePage(props: {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
|
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
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 { Asset, Product, Project } from "../types";
|
||||||
import type { Page } from "./route-config";
|
import type { Page } from "./route-config";
|
||||||
import "../product-create-page.css";
|
import "../product-create-page.css";
|
||||||
@ -74,6 +75,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
|||||||
const [imagePreview, setImagePreview] = useState<string>("");
|
const [imagePreview, setImagePreview] = useState<string>("");
|
||||||
const imgInputRef = useRef<HTMLInputElement | null>(null);
|
const imgInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [showGuide, setShowGuide] = useState(false);
|
const [showGuide, setShowGuide] = useState(false);
|
||||||
|
const [titleError, setTitleError] = useState(false);
|
||||||
function pickImage(event: ChangeEvent<HTMLInputElement>) {
|
function pickImage(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (file) setImagePreview(URL.createObjectURL(file));
|
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));
|
setBullets((list) => list.filter((_, position) => position !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
setTitle("");
|
||||||
|
setCategory("");
|
||||||
|
setTarget("");
|
||||||
|
setBullets([]);
|
||||||
|
setBulletDraft("");
|
||||||
|
setImagePreview("");
|
||||||
|
setTitleError(false);
|
||||||
|
}
|
||||||
function submit() {
|
function submit() {
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) { setTitleError(true); return; } // 空名:给必填校验提示(不再静默无反应)
|
||||||
onCreate({
|
onCreate({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
category: category || PC_CAT_OPTIONS[0],
|
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 }))
|
selling_points: bullets.map((item, index) => ({ title: item, detail: item, sort_order: index }))
|
||||||
});
|
});
|
||||||
setDrawer(false);
|
setDrawer(false);
|
||||||
setTitle("");
|
resetForm();
|
||||||
setCategory("");
|
|
||||||
setTarget("");
|
|
||||||
setBullets([]);
|
|
||||||
setBulletDraft("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -159,7 +166,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
|
||||||
<span className="btn-edit-label">{editMode ? "完成" : "管理商品"}</span>
|
<span className="btn-edit-label">{editMode ? "完成" : "管理商品"}</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary btn-create" type="button" id="open-new-product" onClick={() => setDrawer(true)}>
|
<button className="btn btn-primary btn-create" type="button" id="open-new-product" onClick={() => { resetForm(); setDrawer(true); }}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22V12" /><path d="M16 17h6" /><path d="M19 14v6" /><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="m7.5 4.3 9 5.1" /></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22V12" /><path d="M16 17h6" /><path d="M19 14v6" /><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="m7.5 4.3 9 5.1" /></svg>
|
||||||
新建商品
|
新建商品
|
||||||
</button>
|
</button>
|
||||||
@ -249,7 +256,9 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
|||||||
onConfirm={doDelete}
|
onConfirm={doDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 新建商品 · 右侧 Drawer · 在商品库页面原地打开(转写自 products.html #pc-drawer) */}
|
{/* 新建商品 · 右侧 Drawer · portal 到 body,脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏(转写自 products.html #pc-drawer) */}
|
||||||
|
{createPortal(
|
||||||
|
<>
|
||||||
<div className={`drawer-bg${drawer ? " show" : ""}`} onClick={() => setDrawer(false)} />
|
<div className={`drawer-bg${drawer ? " show" : ""}`} onClick={() => setDrawer(false)} />
|
||||||
<aside className={`drawer pc-drawer${drawer ? " show" : ""}`} role="dialog" aria-label="新建商品" aria-hidden={!drawer}>
|
<aside className={`drawer pc-drawer${drawer ? " show" : ""}`} role="dialog" aria-label="新建商品" aria-hidden={!drawer}>
|
||||||
<div className="drawer-h">
|
<div className="drawer-h">
|
||||||
@ -263,7 +272,8 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
|||||||
<div className="form-card">
|
<div className="form-card">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="field-label">商品名称<span className="req">*</span></label>
|
<label className="field-label">商品名称<span className="req">*</span></label>
|
||||||
<input className="input" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="请输入商品名称(必填)" maxLength={100} />
|
<input className="input" value={title} onChange={(event) => { setTitle(event.target.value); if (titleError) setTitleError(false); }} placeholder="请输入商品名称(必填)" maxLength={100} aria-invalid={titleError} style={titleError ? { borderColor: "var(--accent-crimson, #c43d3d)" } : undefined} />
|
||||||
|
{titleError && <div style={{ color: "var(--accent-crimson, #c43d3d)", fontSize: 12, marginTop: 4 }}>请先填写商品名称</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
@ -299,9 +309,9 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
|||||||
<div className="pf-example">
|
<div className="pf-example">
|
||||||
<div className="ex-h">示例图</div>
|
<div className="ex-h">示例图</div>
|
||||||
<div className="ex-grid">
|
<div className="ex-grid">
|
||||||
<div className="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M7 4h10l1 4v12H6V8l1-4z" /><path d="M9 4v3M15 4v3M9 11h6M9 14h6" /></svg></div>
|
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-earbuds.png" alt="示例:蓝牙耳机" loading="lazy" /></div>
|
||||||
<div className="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="5" width="12" height="15" rx="2" /><path d="M9 9h6M9 12h6M9 15h4" /></svg></div>
|
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-mask.png" alt="示例:面膜" loading="lazy" /></div>
|
||||||
<div className="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3h8l1 5v12H7V8l1-5z" /><circle cx="12" cy="13" r="2.5" /></svg></div>
|
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-air-fryer.png" alt="示例:空气炸锅" loading="lazy" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ex-d">优质的商品图有助于生成更好的素材效果</div>
|
<div className="ex-d">优质的商品图有助于生成更好的素材效果</div>
|
||||||
</div>
|
</div>
|
||||||
@ -350,6 +360,9 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -590,6 +603,8 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
|
|||||||
const [assetSortDesc, setAssetSortDesc] = useState(true);
|
const [assetSortDesc, setAssetSortDesc] = useState(true);
|
||||||
const [assetLimit, setAssetLimit] = useState(12);
|
const [assetLimit, setAssetLimit] = useState(12);
|
||||||
const [videoSortDesc, setVideoSortDesc] = useState(true);
|
const [videoSortDesc, setVideoSortDesc] = useState(true);
|
||||||
|
// 图片预览灯箱
|
||||||
|
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openFilter) return;
|
if (!openFilter) return;
|
||||||
@ -709,7 +724,7 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
||||||
</button>
|
</button>
|
||||||
<div className="prod-preview-h">// 三视图预览 · <span id="ov-tri-status">{triGenerating ? "生成中…" : triUrl ? "已生成" : "待生成"}</span></div>
|
<div className="prod-preview-h">// 三视图预览 · <span id="ov-tri-status">{triGenerating ? "生成中…" : triUrl ? "已生成" : "待生成"}</span></div>
|
||||||
<div className="placeholder prod-preview-img" id="ov-tri-img">
|
<div className="placeholder prod-preview-img" id="ov-tri-img" role={triUrl ? "button" : undefined} tabIndex={triUrl ? 0 : undefined} title={triUrl ? "点击放大" : undefined} style={triUrl ? { cursor: "zoom-in" } : undefined} onClick={triUrl ? () => setPreview({ src: triUrl, name: "三视图" }) : undefined} onKeyDown={triUrl ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: triUrl, name: "三视图" }); } } : undefined}>
|
||||||
{triUrl ? <img src={triUrl} alt="三视图" loading="lazy" /> : <span className="ph-frame">{triGenerating ? "// 生成中,请稍候…" : "// 尚未生成 · 点击下方按钮开始"}</span>}
|
{triUrl ? <img src={triUrl} alt="三视图" loading="lazy" /> : <span className="ph-frame">{triGenerating ? "// 生成中,请稍候…" : "// 尚未生成 · 点击下方按钮开始"}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="prod-preview-foot" id="ov-tri-foot">
|
<div className="prod-preview-foot" id="ov-tri-foot">
|
||||||
@ -793,9 +808,15 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid" id="ov-images-grid">
|
<div className="grid" id="ov-images-grid">
|
||||||
{productImages.map((image) => (
|
{productImages.map((image) => (
|
||||||
<div className="thumb placeholder" key={image.id}>
|
image.url ? (
|
||||||
{image.url ? <img src={image.url} alt={realName} loading="lazy" /> : <span className="ph-frame">1:1</span>}
|
<div className="thumb placeholder" key={image.id} role="button" tabIndex={0} title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: image.url, name: realName })} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: image.url, name: realName }); } }}>
|
||||||
</div>
|
<img src={image.url} alt={realName} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="thumb placeholder" key={image.id}>
|
||||||
|
<span className="ph-frame">1:1</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
<div className="img-upload" id="ov-img-add" title="上传图片" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
|
<div className="img-upload" id="ov-img-add" title="上传图片" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
@ -896,7 +917,7 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
|
|||||||
const status: "pass" | "fail" | "archive" = "pass";
|
const status: "pass" | "fail" | "archive" = "pass";
|
||||||
return (
|
return (
|
||||||
<div className="asset-card" key={asset.id}>
|
<div className="asset-card" key={asset.id}>
|
||||||
<div className="thumb placeholder">
|
<div className="thumb placeholder" role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} style={url ? { cursor: "zoom-in" } : undefined} onClick={url ? () => 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 ? <img src={url} alt={asset.name} loading="lazy" /> : null}
|
{url ? <img src={url} alt={asset.name} loading="lazy" /> : null}
|
||||||
<span className="type-pill">{pdAssetTypeLabel(asset)}</span>
|
<span className="type-pill">{pdAssetTypeLabel(asset)}</span>
|
||||||
{url ? null : <span className="ph-frame">3:4</span>}
|
{url ? null : <span className="ph-frame">3:4</span>}
|
||||||
@ -934,6 +955,8 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -188,6 +188,7 @@ export function SettingsPage({
|
|||||||
setName(user.username || "");
|
setName(user.username || "");
|
||||||
setEmail(user.email || "");
|
setEmail(user.email || "");
|
||||||
setPhone("");
|
setPhone("");
|
||||||
|
onNotify?.("已恢复为已保存的资料");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveProfile() {
|
async function handleSaveProfile() {
|
||||||
|
|||||||
@ -254,18 +254,27 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="members-tbody">
|
<tbody id="members-tbody">
|
||||||
{list.map((member) => {
|
{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 role = roleUi(member.role);
|
||||||
const monthly = Number(member.monthly_credit_limit || 0);
|
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";
|
const isOwner = member.role === "owner";
|
||||||
return (
|
return (
|
||||||
<tr key={member.id} data-id={member.id}>
|
<tr key={member.id} data-id={member.id}>
|
||||||
<td><span className="member-cell"><span className="av">{name.slice(0, 1).toUpperCase()}</span><span><span className="nm">{name}</span><span className="em">{member.user.email || ""}</span></span></span></td>
|
<td><span className="member-cell"><span className="av">{displayName.slice(0, 1).toUpperCase()}</span><span className="member-meta"><span className="nm">{displayName}</span>{showEmail && <span className="em">{email}</span>}</span></span></td>
|
||||||
<td><span className={`role-pill role-${role.key}`}><span className="dot"></span>{role.label}</span></td>
|
<td><span className={`role-pill role-${role.key}`}><span className="dot"></span>{role.label}</span></td>
|
||||||
<td><span className="quota-cell"><span className="v">不限</span></span></td>
|
<td><span className="quota-cell"><span className="v">不限</span></span></td>
|
||||||
<td><span className="quota-cell"><span className="v">{monthly > 0 ? money(monthly) : "不限"}</span></span></td>
|
<td><span className="quota-cell"><span className="v">{monthly > 0 ? money(monthly) : "不限"}</span></span></td>
|
||||||
<td><div className="quota-cell"><span className="v">¥0.00</span> <span className="lbl">/ {memberPct.toFixed(0)}%</span></div><div className="used-bar"><span className="ok" style={{ width: `${memberPct.toFixed(0)}%` }}></span></div></td>
|
<td><div className="quota-cell"><span className="v">{money(memberUsed)}</span> <span className="lbl">/ {monthly > 0 ? `${memberPct.toFixed(0)}%` : "不限"}</span></div><div className="used-bar"><span className={barClass} style={{ width: `${barWidth}%` }}></span></div></td>
|
||||||
<td><div className="acts">{isOwner
|
<td><div className="acts">{isOwner
|
||||||
? <span style={{ fontFamily: "var(--font-mono)", fontSize: "10.5px", color: "var(--black-alpha-32)", alignSelf: "center" }}>不可编辑</span>
|
? <span style={{ fontFamily: "var(--font-mono)", fontSize: "10.5px", color: "var(--black-alpha-32)", alignSelf: "center" }}>不可编辑</span>
|
||||||
: <>
|
: <>
|
||||||
|
|||||||
@ -78,10 +78,13 @@
|
|||||||
.pane h3 .spacer { margin-left: auto; }
|
.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 .who { display: flex; align-items: center; gap: 10px; }
|
||||||
.members-table .nm { font-weight: 500; font-size: 13.5px; line-height: 1.2; }
|
.members-table .member-cell { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||||
.members-table .em { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); }
|
.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 { 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-pill .dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||||
.members-table .role-super { background: var(--heat-12); color: var(--heat); }
|
.members-table .role-super { background: var(--heat-12); color: var(--heat); }
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export type TeamMember = {
|
|||||||
role: string;
|
role: string;
|
||||||
status: string;
|
status: string;
|
||||||
monthly_credit_limit: string;
|
monthly_credit_limit: string;
|
||||||
|
month_charged?: string;
|
||||||
user: User;
|
user: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -56,6 +57,7 @@ export type Asset = {
|
|||||||
category: string;
|
category: string;
|
||||||
description: string;
|
description: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
|
origin_task?: string | null;
|
||||||
files?: Array<{
|
files?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
object_key: string;
|
object_key: string;
|
||||||
@ -190,6 +192,7 @@ export type Ledger = {
|
|||||||
balance_after: string;
|
balance_after: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
user_label?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserPreference = {
|
export type UserPreference = {
|
||||||
@ -260,7 +263,20 @@ export type Notification = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NotificationList = Paginated<Notification> & { unread_count: number };
|
export type NotificationTypeCounts = {
|
||||||
|
all: number;
|
||||||
|
unread: number;
|
||||||
|
task: number;
|
||||||
|
team: number;
|
||||||
|
billing: number;
|
||||||
|
system: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationList = Paginated<Notification> & {
|
||||||
|
unread_count: number;
|
||||||
|
// 分类 chip 的绝对总数(后端按收件人全量算,不受分页/搜索影响);旧响应可能没有
|
||||||
|
type_counts?: NotificationTypeCounts;
|
||||||
|
};
|
||||||
|
|
||||||
export type RechargeResult = {
|
export type RechargeResult = {
|
||||||
account: BillingSummary["account"];
|
account: BillingSummary["account"];
|
||||||
|
|||||||
@ -35,6 +35,13 @@ function readPng(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function preparePage(page, url, shouldClearStorage, token) {
|
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" });
|
await page.goto(url, { waitUntil: "networkidle" });
|
||||||
if (shouldClearStorage) {
|
if (shouldClearStorage) {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user