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):
|
||||
user = UserSerializer(read_only=True)
|
||||
# 本月已消费(自然月,按 CreditLedger CHARGE 流水按人聚合);由 view 经 context 注入 charged_map
|
||||
month_charged = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TeamMember
|
||||
fields = ["id", "team", "user", "role", "status", "monthly_credit_limit"]
|
||||
fields = ["id", "team", "user", "role", "status", "monthly_credit_limit", "month_charged"]
|
||||
read_only_fields = ["id", "team", "user", "status"]
|
||||
|
||||
def get_month_charged(self, obj):
|
||||
charged_map = self.context.get("charged_map") or {}
|
||||
return str(charged_map.get(obj.user_id, 0))
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.Serializer):
|
||||
username = serializers.CharField(max_length=150)
|
||||
|
||||
@ -40,13 +40,22 @@ def _client_ip(request):
|
||||
|
||||
|
||||
def record_login_session(request, user):
|
||||
"""登录成功后记录一条会话(设备 UA / IP),供设置页「在用设备」展示。"""
|
||||
"""登录成功后记录设备会话(UA / IP)。去重:同一台电脑(UA)+ 同一 IP 视为同一台设备,
|
||||
已存在未下线的同设备会话则只刷新 last_seen_at,不再新增一行(避免「在用设备」列表里同设备重复堆叠)。"""
|
||||
try:
|
||||
LoginSession.objects.create(
|
||||
user=user,
|
||||
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:400],
|
||||
ip_address=_client_ip(request),
|
||||
user_agent = (request.META.get("HTTP_USER_AGENT") or "")[:400]
|
||||
ip_address = _client_ip(request)
|
||||
existing = (
|
||||
LoginSession.objects.filter(
|
||||
user=user, user_agent=user_agent, ip_address=ip_address, revoked_at__isnull=True
|
||||
)
|
||||
.order_by("-last_seen_at")
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
existing.save(update_fields=["last_seen_at"]) # auto_now 刷新最近活跃时间
|
||||
else:
|
||||
LoginSession.objects.create(user=user, user_agent=user_agent, ip_address=ip_address)
|
||||
except Exception: # noqa: BLE001 — 会话记录失败不应阻断登录
|
||||
pass
|
||||
|
||||
@ -170,13 +179,36 @@ def can_manage_team(user, team):
|
||||
return bool(member and member.role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN})
|
||||
|
||||
|
||||
def _month_charged_by_user(team):
|
||||
"""本团队当前自然月每个成员的消费(CHARGE 流水)合计:{user_id: Decimal}。"""
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.billing.models import CreditLedger
|
||||
|
||||
month_start = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
rows = (
|
||||
CreditLedger.objects.filter(
|
||||
team=team,
|
||||
ledger_type=CreditLedger.Type.CHARGE,
|
||||
created_at__gte=month_start,
|
||||
)
|
||||
.values("user_id")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
return {row["user_id"]: row["total"] for row in rows if row["user_id"] is not None}
|
||||
|
||||
|
||||
@api_view(["GET", "POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def team_members(request):
|
||||
team = get_current_team(request.user)
|
||||
if request.method == "GET":
|
||||
members = TeamMember.objects.filter(team=team).select_related("user").order_by("created_at")
|
||||
return Response(TeamMemberSerializer(members, many=True).data)
|
||||
charged_map = _month_charged_by_user(team)
|
||||
return Response(
|
||||
TeamMemberSerializer(members, many=True, context={"charged_map": charged_map}).data
|
||||
)
|
||||
|
||||
if not can_manage_team(request.user, team):
|
||||
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
|
||||
@ -277,23 +309,41 @@ def preferences(request):
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def login_sessions(request):
|
||||
"""在用设备:返回未下线的登录会话(最近 20 条)。"""
|
||||
sessions = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True)[:20]
|
||||
"""在用设备:返回未下线的登录会话(去重后最近 20 台)。
|
||||
去重规则:同一台电脑(UA)+ 同一 IP 只算一台,取该设备最近一次会话展示(兼容历史已堆叠的重复行)。"""
|
||||
queryset = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True).order_by("-last_seen_at")
|
||||
seen: set = set()
|
||||
unique = []
|
||||
for session in queryset:
|
||||
key = (session.user_agent, session.ip_address)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(session)
|
||||
if len(unique) >= 20:
|
||||
break
|
||||
current_ip = _client_ip(request)
|
||||
current_ua = (request.META.get("HTTP_USER_AGENT") or "")[:400]
|
||||
data = LoginSessionSerializer(sessions, many=True, context={"current_ip": current_ip, "current_ua": current_ua}).data
|
||||
data = LoginSessionSerializer(unique, many=True, context={"current_ip": current_ip, "current_ua": current_ua}).data
|
||||
return Response(data)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def revoke_login_session(request, session_id):
|
||||
"""下线单个设备会话。"""
|
||||
"""下线单个设备:把同一台设备(UA + IP)下的所有未下线会话一并下线,
|
||||
否则去重展示的一台设备点「下线」后,底层其它重复会话仍存活会再次冒出来。"""
|
||||
from django.utils import timezone
|
||||
|
||||
updated = LoginSession.objects.filter(user=request.user, id=session_id, revoked_at__isnull=True).update(
|
||||
revoked_at=timezone.now()
|
||||
)
|
||||
target = LoginSession.objects.filter(user=request.user, id=session_id).first()
|
||||
if not target:
|
||||
return Response({"revoked": 0})
|
||||
updated = LoginSession.objects.filter(
|
||||
user=request.user,
|
||||
user_agent=target.user_agent,
|
||||
ip_address=target.ip_address,
|
||||
revoked_at__isnull=True,
|
||||
).update(revoked_at=timezone.now())
|
||||
return Response({"revoked": updated})
|
||||
|
||||
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
@ -184,6 +188,34 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id
|
||||
raise
|
||||
|
||||
|
||||
def _generate_video_poster(*, video_bytes: bytes, team, project, asset_id) -> "StoredObject | None":
|
||||
"""用 ffmpeg 抽视频首帧作为封面(poster)并上传 TOS。best-effort:任何失败都返回 None,不影响视频资产落地。"""
|
||||
if not video_bytes:
|
||||
return None
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="airshelf-poster-") as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
video_path = tmp_dir / "in.mp4"
|
||||
poster_path = tmp_dir / "poster.jpg"
|
||||
video_path.write_bytes(video_bytes)
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-y", "-ss", "0", "-i", str(video_path), "-frames:v", "1", "-q:v", "3", str(poster_path)],
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
if proc.returncode != 0 or not poster_path.exists():
|
||||
return None
|
||||
poster_bytes = poster_path.read_bytes()
|
||||
if not poster_bytes:
|
||||
return None
|
||||
object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}-poster.jpg"
|
||||
return TosStorage().upload_fileobj(
|
||||
fileobj=BytesIO(poster_bytes), object_key=object_key, content_type="image/jpeg"
|
||||
)
|
||||
except Exception: # noqa: BLE001 — poster 仅用于展示,失败不阻断
|
||||
return None
|
||||
|
||||
|
||||
def _store_generated_media(*, team, user, project, task, media: str, name: str, category: str, asset_type: str) -> Asset:
|
||||
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
|
||||
suffix = ".png"
|
||||
@ -214,6 +246,22 @@ def _store_generated_media(*, team, user, project, task, media: str, name: str,
|
||||
size_bytes=stored.size_bytes,
|
||||
is_primary=True,
|
||||
)
|
||||
# 视频资产:额外抽首帧作为封面图,挂成同一 Asset 下的 image 文件,供任务中心/列表显示缩略图
|
||||
if "video" in content_type:
|
||||
try:
|
||||
video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b""
|
||||
except Exception: # noqa: BLE001
|
||||
video_bytes = b""
|
||||
poster = _generate_video_poster(video_bytes=video_bytes, team=team, project=project, asset_id=asset_id)
|
||||
if poster:
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key=poster.object_key,
|
||||
bucket=poster.bucket,
|
||||
content_type=poster.content_type,
|
||||
size_bytes=poster.size_bytes,
|
||||
is_primary=False,
|
||||
)
|
||||
return asset
|
||||
|
||||
|
||||
|
||||
@ -70,6 +70,7 @@ class AssetSerializer(serializers.ModelSerializer):
|
||||
"description",
|
||||
"metadata",
|
||||
"is_deleted",
|
||||
"origin_task",
|
||||
"files",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
|
||||
@ -11,11 +11,15 @@ class CreditAccountSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class CreditLedgerSerializer(serializers.ModelSerializer):
|
||||
# 成员展示名:优先真实姓名 → 用户名 → 邮箱;系统流水(无 user)留空
|
||||
user_label = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = CreditLedger
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"user_label",
|
||||
"project",
|
||||
"task",
|
||||
"ledger_type",
|
||||
@ -27,6 +31,12 @@ class CreditLedgerSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_user_label(self, obj):
|
||||
user = obj.user
|
||||
if user is None:
|
||||
return ""
|
||||
return user.first_name or user.username or user.email or ""
|
||||
|
||||
|
||||
class CreditReservationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
||||
@ -29,7 +29,7 @@ def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
|
||||
ledger_type=CreditLedger.Type.RESERVE,
|
||||
amount=amount,
|
||||
balance_after=account.balance,
|
||||
reason="reserve ai task credit",
|
||||
reason="AI 任务预扣额度",
|
||||
)
|
||||
return reservation
|
||||
|
||||
@ -52,7 +52,7 @@ def release_credit(*, reservation: CreditReservation, reason: str = "") -> None:
|
||||
ledger_type=CreditLedger.Type.RELEASE,
|
||||
amount=reservation.amount,
|
||||
balance_after=account.balance,
|
||||
reason=reason or "release reserved credit",
|
||||
reason=reason or "释放预留额度",
|
||||
)
|
||||
|
||||
|
||||
@ -77,7 +77,7 @@ def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Dec
|
||||
ledger_type=CreditLedger.Type.CHARGE,
|
||||
amount=actual_amount,
|
||||
balance_after=account.balance,
|
||||
reason="charge ai task credit",
|
||||
reason="AI 任务扣费",
|
||||
)
|
||||
if reservation.amount > actual_amount:
|
||||
CreditLedger.objects.create(
|
||||
@ -88,6 +88,6 @@ def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Dec
|
||||
ledger_type=CreditLedger.Type.RELEASE,
|
||||
amount=reservation.amount - actual_amount,
|
||||
balance_after=account.balance,
|
||||
reason="release unused reserved credit",
|
||||
reason="释放未用预留额度",
|
||||
)
|
||||
|
||||
|
||||
@ -56,7 +56,27 @@ def ledgers(request):
|
||||
queryset = queryset.filter(project_id=project_id)
|
||||
if user_id:
|
||||
queryset = queryset.filter(user_id=user_id)
|
||||
return Response(CreditLedgerSerializer(queryset[:100], many=True).data)
|
||||
# 服务端分页:总数随流水增长(原先写死 [:100] 导致永远 100 条)
|
||||
try:
|
||||
page = max(1, int(request.query_params.get("page", 1)))
|
||||
except (TypeError, ValueError):
|
||||
page = 1
|
||||
try:
|
||||
page_size = int(request.query_params.get("page_size", 10))
|
||||
except (TypeError, ValueError):
|
||||
page_size = 10
|
||||
page_size = max(1, min(page_size, 100))
|
||||
total = queryset.count()
|
||||
start = (page - 1) * page_size
|
||||
rows = queryset[start:start + page_size]
|
||||
return Response(
|
||||
{
|
||||
"count": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"results": CreditLedgerSerializer(rows, many=True).data,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
|
||||
@ -2,9 +2,17 @@ from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
|
||||
class NotificationPagination(PageNumberPagination):
|
||||
# 收件箱滚动加载:每批 10 条,前端可用 ?page_size= 覆盖(上限 100)
|
||||
page_size = 10
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
from apps.assets.models import Asset
|
||||
from apps.billing.models import CreditAccount
|
||||
from apps.common.api import TeamScopedViewSetMixin
|
||||
@ -109,14 +117,19 @@ def ensure_team_notifications(team, user):
|
||||
class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
serializer_class = NotificationSerializer
|
||||
queryset = Notification.objects.select_related("team", "recipient", "project").all()
|
||||
pagination_class = NotificationPagination
|
||||
search_fields = ["title", "brief", "body", "source", "stage"]
|
||||
ordering_fields = ["created_at", "updated_at"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
# 团队 + 收件人 + 未归档:分类计数的基准集(不含 tab/未读/搜索过滤)
|
||||
def _recipient_scope(self):
|
||||
queryset = super().get_queryset().filter(archived_at__isnull=True)
|
||||
user = self.request.user
|
||||
queryset = queryset.filter(Q(recipient=user) | Q(recipient__isnull=True))
|
||||
return queryset.filter(Q(recipient=user) | Q(recipient__isnull=True))
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self._recipient_scope()
|
||||
notification_type = self.request.query_params.get("type")
|
||||
if notification_type and notification_type not in {"all", "unread"}:
|
||||
queryset = queryset.filter(notification_type=notification_type)
|
||||
@ -128,9 +141,19 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
ensure_team_notifications(self.get_team(), request.user)
|
||||
response = super().list(request, *args, **kwargs)
|
||||
data = response.data
|
||||
unread_count = self.get_queryset().filter(is_read=False).count()
|
||||
if isinstance(data, dict):
|
||||
# 分类 chip 计数取绝对总数(忽略当前 tab/搜索),与设计稿一致
|
||||
base = self._recipient_scope()
|
||||
unread_count = base.filter(is_read=False).count()
|
||||
data["unread_count"] = unread_count
|
||||
data["type_counts"] = {
|
||||
"all": base.count(),
|
||||
"unread": unread_count,
|
||||
"task": base.filter(notification_type="task").count(),
|
||||
"team": base.filter(notification_type="team").count(),
|
||||
"billing": base.filter(notification_type="billing").count(),
|
||||
"system": base.filter(notification_type="system").count(),
|
||||
}
|
||||
return response
|
||||
|
||||
def perform_create(self, serializer):
|
||||
|
||||
@ -44,6 +44,19 @@ def _download_asset_primary_file(asset, target_path: Path) -> None:
|
||||
target_path.write_bytes(response.content)
|
||||
|
||||
|
||||
def _has_audio_stream(path: Path) -> bool:
|
||||
"""探测视频文件是否含音频流(决定是否保留该片段的原声/人声)。ffprobe 失败时保守按无声处理。"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries",
|
||||
"stream=index", "-of", "csv=p=0", str(path)],
|
||||
capture_output=True, timeout=60,
|
||||
)
|
||||
return bool(proc.stdout.strip())
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _load_font(size: int):
|
||||
from PIL import ImageFont
|
||||
|
||||
@ -123,9 +136,14 @@ def _output_starts(specs: list[dict], xfade: float) -> tuple[list[float], float]
|
||||
return starts, max(0.1, total)
|
||||
|
||||
|
||||
_AFMT = "aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo"
|
||||
|
||||
|
||||
def _build_export_command(*, n: int, specs: list[dict], starts: list[float], total: float,
|
||||
transition: str, sub_overlays: list[tuple[str, float, float]],
|
||||
bgm_name: str | None, bgm_volume: float) -> list[str]:
|
||||
bgm_name: str | None, bgm_volume: float,
|
||||
has_audio: list[bool] | None = None) -> list[str]:
|
||||
has_audio = has_audio or [False] * n
|
||||
parts: list[str] = []
|
||||
for i, s in enumerate(specs):
|
||||
parts.append(
|
||||
@ -153,8 +171,30 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
|
||||
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-150:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
|
||||
)
|
||||
vlabel = out
|
||||
|
||||
# 音频:片段自带的人声/原声必须保留(有声片段取原音轨,无声片段补等长静音,否则 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[aout]")
|
||||
parts.append(f"[{n}:a]volume={bgm_volume:.3f},atrim=0:{total:.3f},asetpts=PTS-STARTPTS,{_AFMT}[abgm]")
|
||||
parts.append("[avoice][abgm]amix=inputs=2:duration=longest:dropout_transition=0:normalize=0[aout]")
|
||||
audio_label = "aout"
|
||||
|
||||
cmd = ["ffmpeg", "-y"]
|
||||
for i in range(n):
|
||||
@ -164,10 +204,10 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
|
||||
for png, _s, _e in sub_overlays:
|
||||
cmd += ["-loop", "1", "-i", png]
|
||||
cmd += ["-filter_complex", ";".join(parts), "-map", f"[{vlabel}]"]
|
||||
if bgm_name:
|
||||
cmd += ["-map", "[aout]"]
|
||||
if audio_label:
|
||||
cmd += ["-map", f"[{audio_label}]"]
|
||||
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", "-preset", "veryfast"]
|
||||
if bgm_name:
|
||||
if audio_label:
|
||||
cmd += ["-c:a", "aac", "-b:a", "192k"]
|
||||
cmd += ["-t", f"{total:.3f}", "-movflags", "+faststart", "output.mp4"]
|
||||
return cmd
|
||||
@ -237,6 +277,8 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
tmp = Path(tmp_dir)
|
||||
for index, clip in enumerate(clips):
|
||||
_download_asset_primary_file(clip.asset, tmp / f"clip{index}.mp4")
|
||||
# 逐片段探测是否自带音轨:有声→保留原声,无声→补静音(见 _build_export_command)
|
||||
has_audio = [_has_audio_stream(tmp / f"clip{index}.mp4") for index in range(len(clips))]
|
||||
|
||||
bgm_name = None
|
||||
if bgm_track is not None and bgm_track.asset_id:
|
||||
@ -258,6 +300,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
command = _build_export_command(
|
||||
n=len(clips), specs=specs, starts=starts, total=total, transition=transition,
|
||||
sub_overlays=sub_overlays, bgm_name=bgm_name, bgm_volume=(bgm_track.volume / 100.0) if bgm_track else 1.0,
|
||||
has_audio=has_audio,
|
||||
)
|
||||
proc = subprocess.run(command, cwd=str(tmp), capture_output=True)
|
||||
if proc.returncode != 0:
|
||||
|
||||
@ -107,23 +107,23 @@ export function App() {
|
||||
await Promise.all([
|
||||
api.products(),
|
||||
api.projects(),
|
||||
api.assets(),
|
||||
api.allAssets(),
|
||||
api.billingSummary().catch(() => null),
|
||||
api.ledgers().catch(() => []),
|
||||
api.ledgers(1, 10).catch(() => ({ count: 0, page: 1, page_size: 10, results: [] as Ledger[] })),
|
||||
api.billingTrend().catch(() => null),
|
||||
api.teamMembers().catch(() => []),
|
||||
api.modelConfigs().catch(() => null),
|
||||
api.aiTasks().catch(() => null),
|
||||
api.listNotifications().catch(() => null)
|
||||
api.allNotifications().catch(() => null)
|
||||
]);
|
||||
setProducts(productData.results);
|
||||
setProjects(projectData.results);
|
||||
setAssets(assetData.results);
|
||||
setAssets(assetData);
|
||||
setTeamMembers(memberData);
|
||||
setModelConfigs(modelData?.results || []);
|
||||
setAiTasks(taskData?.results || []);
|
||||
if (billingData) setBilling(billingData);
|
||||
setLedgers(ledgerData);
|
||||
setLedgers(ledgerData.results);
|
||||
setBillingTrend(trendData);
|
||||
if (notificationData) {
|
||||
setNotifications(notificationData.results);
|
||||
@ -161,7 +161,7 @@ export function App() {
|
||||
}
|
||||
|
||||
const reloadNotifications = useCallback(async () => {
|
||||
const data = await api.listNotifications().catch(() => null);
|
||||
const data = await api.allNotifications().catch(() => null);
|
||||
if (data) {
|
||||
setNotifications(data.results);
|
||||
setUnreadCount(data.unread_count);
|
||||
@ -513,7 +513,6 @@ export function App() {
|
||||
case "messages":
|
||||
return (
|
||||
<MessagesPage
|
||||
notifications={notifications}
|
||||
unreadCount={unreadCount}
|
||||
onMarkRead={markNotificationRead}
|
||||
onMarkAllRead={markAllNotificationsRead}
|
||||
@ -521,7 +520,7 @@ export function App() {
|
||||
/>
|
||||
);
|
||||
case "assetFactory":
|
||||
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} />;
|
||||
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} assets={assets} />;
|
||||
case "imageOptimize":
|
||||
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
|
||||
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 .bars { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; align-items: end; height: 100%; }
|
||||
.trend-chart .bar { background: var(--background-lighter); border-radius: 2px 2px 0 0; position: relative; transition: background var(--t-base); cursor: pointer; }
|
||||
.trend-chart .bar > span { display: block; width: 100%; background: var(--heat); border-radius: 2px 2px 0 0; }
|
||||
.trend-chart .bar { background: var(--background-lighter); border-radius: 2px 2px 0 0; position: relative; transition: background var(--t-base); cursor: pointer; height: 100%; }
|
||||
.trend-chart .bar > span { position: absolute; left: 0; bottom: 0; display: block; width: 100%; flex: none; min-height: 0; background: var(--heat); border-radius: 2px 2px 0 0; }
|
||||
.trend-chart .bar:hover > span { background: var(--accent-black); }
|
||||
.trend-chart .bar.peak > span { background: var(--accent-black); }
|
||||
.trend-chart .x-axis { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-32); text-align: center; letter-spacing: .02em; }
|
||||
@ -127,14 +127,23 @@
|
||||
.quota-rules .step .formula { font-family: var(--font-mono); font-size: 11.5px; color: var(--heat); background: var(--heat-12); padding: 0 4px; border-radius: var(--r-sm); }
|
||||
|
||||
.billing-table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border-muted); border-radius: var(--r-md); overflow: hidden; }
|
||||
.billing-table th, .billing-table td { padding: 11px 14px; text-align: left; font-size: 12.5px; border-bottom: 0; }
|
||||
.billing-table th, .billing-table td { padding: 13px 16px; text-align: left; font-size: 12.5px; border-bottom: 0; }
|
||||
.billing-table thead th { background: var(--background-lighter); border-bottom: 1px solid var(--border-muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
|
||||
/* 行间淡分隔线,长流水更易扫读;最后一行不画 */
|
||||
.billing-table tbody td { border-bottom: 1px solid var(--border-faint); }
|
||||
.billing-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.billing-table tbody tr:hover { background: var(--background-lighter); }
|
||||
.billing-table .ts { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
|
||||
.billing-table .neg { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-black); text-align: right; }
|
||||
.billing-table .pos { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-forest); text-align: right; }
|
||||
.billing-table .zero { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--black-alpha-32); text-align: right; }
|
||||
/* 已用 / 月度额度:已用部分本色,额度部分弱化,整列左对齐 */
|
||||
.billing-table .quota { font-variant-numeric: tabular-nums; text-align: left; }
|
||||
.billing-table .quota .used { font-weight: 500; color: var(--accent-black); }
|
||||
.billing-table .quota .lim { color: var(--black-alpha-32); }
|
||||
.billing-table .muted { color: var(--black-alpha-56); font-size: 11.5px; }
|
||||
/* 系统流水(无成员):弱化的 mono 占位,不喧宾夺主 */
|
||||
.billing-table .sys { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-32); }
|
||||
.billing-table .ref { color: var(--black-alpha-48); font-size: 10.5px; font-family: var(--font-mono); }
|
||||
.billing-table .who { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.billing-table .who .av { width: 24px; height: 24px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: inline-grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--accent-black); }
|
||||
@ -150,6 +159,15 @@
|
||||
.billing-table .status-tag.ok { background: rgba(66,195,102,.12); color: var(--accent-forest); }
|
||||
.billing-table .status-tag.wip { background: var(--heat-12); color: var(--heat); }
|
||||
.billing-table .status-tag.fail { background: rgba(235,52,36,.10); color: var(--accent-crimson); }
|
||||
|
||||
.bill-pager { display: flex; align-items: center; gap: 12px; margin-top: 14px; font-size: 12px; }
|
||||
.bill-pager .total { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
|
||||
.bill-pager .pages { display: inline-flex; gap: 4px; margin-left: auto; }
|
||||
.bill-pager .pages button { min-width: 28px; height: 28px; padding: 0 8px; border: 1px solid var(--border-muted); border-radius: var(--r-sm); background: var(--surface); font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-56); cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }
|
||||
.bill-pager .pages button:hover:not(.active):not(:disabled) { border-color: var(--black-alpha-32); color: var(--accent-black); }
|
||||
.bill-pager .pages button.active { background: var(--heat); color: var(--accent-white); border-color: var(--heat); font-weight: 600; }
|
||||
.bill-pager .pages button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.bill-pager .pages .ellipsis { min-width: 20px; height: 28px; display: inline-flex; align-items: flex-end; justify-content: center; padding-bottom: 4px; color: var(--black-alpha-32); font-family: var(--font-mono); font-size: 11px; user-select: none; }
|
||||
.billing-table .progress-mini { width: 80px; height: 4px; background: var(--background-lighter); border-radius: 2px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 8px; }
|
||||
.billing-table .progress-mini > span { display: block; height: 100%; background: var(--heat); }
|
||||
|
||||
|
||||
@ -158,6 +158,8 @@
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
letter-spacing: .04em; margin-bottom: 6px;
|
||||
}
|
||||
.asset-factory .task-load-more { display: flex; justify-content: center; margin-top: 18px; }
|
||||
.asset-factory .task-load-more .lm-rest { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-left: 6px; }
|
||||
|
||||
/* ============================================================
|
||||
B · 图片工作室壳(.image-workbench)
|
||||
@ -1529,6 +1531,11 @@
|
||||
.asset-factory .history-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px; display: grid; grid-template-columns: 78px 1fr; gap: 14px; align-items: center; transition: background var(--t-base); position: relative; }
|
||||
.asset-factory .history-card:hover { background: var(--black-alpha-4); }
|
||||
.asset-factory .history-card .placeholder { width: 78px; height: 78px; }
|
||||
/* 任务结果图:有真实生成图时铺满占位框,去掉斜纹底与编号 */
|
||||
.asset-factory .placeholder.has-img { overflow: hidden; position: relative; }
|
||||
.asset-factory .placeholder.has-img::after { display: none; }
|
||||
.asset-factory .placeholder.has-img .ph-frame { display: none; }
|
||||
.asset-factory .placeholder.has-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.asset-factory .history-body { min-width: 0; }
|
||||
.asset-factory .history-name { font-weight: 600; color: var(--accent-black); font-size: 13.5px; }
|
||||
.asset-factory .history-type { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
|
||||
|
||||
@ -231,14 +231,26 @@ export const 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) {
|
||||
return request<Asset>("/api/assets/upload/", { method: "POST", body: formData });
|
||||
},
|
||||
billingSummary() {
|
||||
return request<BillingSummary>("/api/billing/summary/");
|
||||
},
|
||||
ledgers() {
|
||||
return request<Ledger[]>("/api/billing/ledgers/");
|
||||
ledgers(page = 1, pageSize = 10) {
|
||||
return request<{ count: number; page: number; page_size: number; results: Ledger[] }>(`/api/billing/ledgers/?page=${page}&page_size=${pageSize}`);
|
||||
},
|
||||
billingTrend(range?: "day" | "week" | "month") {
|
||||
return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`);
|
||||
@ -255,13 +267,32 @@ export const api = {
|
||||
recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) {
|
||||
return request<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();
|
||||
if (params?.type && params.type !== "all") query.set("type", params.type);
|
||||
if (params?.unread) query.set("unread", "1");
|
||||
if (params?.search) query.set("search", params.search);
|
||||
if (params?.page) query.set("page", String(params.page));
|
||||
if (params?.pageSize) query.set("page_size", String(params.pageSize));
|
||||
const qs = query.toString();
|
||||
return request<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() {
|
||||
return request<{ updated: number; unread_count: number }>("/api/ops/notifications/mark-all-read/", {
|
||||
method: "POST"
|
||||
|
||||
@ -39,13 +39,13 @@ function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: (
|
||||
return createPortal(
|
||||
<div
|
||||
id="shell-command-bg"
|
||||
className="show"
|
||||
className="shell-command-bg show"
|
||||
aria-hidden="false"
|
||||
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="shell-command" role="dialog" aria-modal="true" aria-label="命令面板">
|
||||
<div className="shell-command-head">
|
||||
<IconKitSvg name="search" />
|
||||
<div className="shell-command-h">
|
||||
<span className="ic"><IconKitSvg name="search" /></span>
|
||||
<input
|
||||
id="shell-command-input"
|
||||
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";
|
||||
|
||||
// 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放(复用 .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 }) {
|
||||
return (
|
||||
<div className="setting-row">
|
||||
@ -20,7 +59,8 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
|
||||
return createPortal(
|
||||
<div className="modal-bg show" onClick={close}>
|
||||
<div className="modal invite-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<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-f"><button className="btn" type="button" onClick={close}>取消</button>{footer || <button className="btn btn-primary" type="button" onClick={close}>保存</button>}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@ -41,7 +82,8 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon
|
||||
onConfirm: () => void | Promise<unknown>;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
|
||||
return createPortal(
|
||||
<div className="modal-bg show" onClick={onCancel}>
|
||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||||
<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-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>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="drawer-bg show" onClick={close} />
|
||||
<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-b">{children}</div>
|
||||
</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-badge { position: absolute; top: 8px; left: 8px; font-family: var(--font-mono); font-size: 10px; letter-spacing: .04em; padding: 2px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); }
|
||||
.asset-card { position: relative; }
|
||||
/* 视频资产缩略图上的播放角标(点击整卡弹窗播放) */
|
||||
.lib-play-badge { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 40px; height: 40px; border-radius: 50%; background: rgba(0, 0, 0, .58); color: var(--accent-white); display: grid; place-items: center; pointer-events: none; transition: background .15s; }
|
||||
.lib-play-badge svg { width: 18px; height: 18px; margin-left: 2px; }
|
||||
.asset-thumb:hover .lib-play-badge { background: rgba(0, 0, 0, .76); }
|
||||
}
|
||||
|
||||
/* 编辑模式:开启「管理资产」后,资产卡删除按钮常显(否则全局只 hover 显) */
|
||||
|
||||
@ -15,7 +15,10 @@
|
||||
.msg-workbench {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
|
||||
min-height: 640px;
|
||||
/* 收件箱在面板内部滚动(.msg-list 已 overflow:auto)—— 把工作台收进视口高度,
|
||||
而不是让消息把整页撑高;减去 shell 头 + content 上下内边距 + 页头/页脚 */
|
||||
height: calc(100vh - 280px);
|
||||
min-height: 480px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
@ -128,6 +131,13 @@
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.msg-load-more {
|
||||
padding: 14px 16px 18px;
|
||||
text-align: center;
|
||||
font-size: 10.5px;
|
||||
color: var(--black-alpha-48);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.msg-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@ -385,6 +385,10 @@
|
||||
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 真实示例图:图片铺满,去掉斜纹底 */
|
||||
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb--img { background: var(--background-lighter); }
|
||||
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb--img::after { display: none; }
|
||||
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb--img img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.pc-drawer .form-card .pf-example .ex-d {
|
||||
font-size: 12px; color: var(--black-alpha-56); line-height: 1.5;
|
||||
}
|
||||
|
||||
@ -3,6 +3,9 @@ import { api } from "../api";
|
||||
import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types";
|
||||
import { money } from "./stage-config";
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" };
|
||||
const STATUS_LABEL: Record<string, string> = { active: "活跃", invited: "待激活", disabled: "已停用" };
|
||||
|
||||
type TrendRange = "day" | "week" | "month";
|
||||
const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: string; avgLabel: string }> = {
|
||||
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";
|
||||
|
||||
// 账单类型 / 详情 中文化(后端历史英文流水也一并映射;未知值原样透出)
|
||||
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 }> = [
|
||||
{ amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 },
|
||||
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, bonusAmt: 30, ribbon: "推荐" },
|
||||
@ -38,6 +68,23 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
|
||||
const [recharge, setRecharge] = useState(500);
|
||||
const [customAmt, setCustomAmt] = useState("");
|
||||
|
||||
// 账单流水分页:服务端分页(总数随流水增长,不再写死 100),每页 10 条
|
||||
const BILLS_PER_PAGE = 10;
|
||||
const [billPage, setBillPage] = useState(1);
|
||||
const [ledgerRows, setLedgerRows] = useState<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 effectiveAmount = Number(customAmt) > 0 ? Number(customAmt) : recharge;
|
||||
const effectiveBonus = Number(customAmt) > 0 ? 0 : selectedCard?.bonusAmt || 0;
|
||||
@ -158,7 +205,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
|
||||
<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-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 className={`tab-panel ${tab === "overview" ? "active" : ""}`}>
|
||||
@ -238,18 +285,34 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
|
||||
<table className="billing-table">
|
||||
<thead><tr><th>时间</th><th>项目 / 类型</th><th>详情</th><th>成员</th><th>状态</th><th style={{ textAlign: "right" }}>金额</th></tr></thead>
|
||||
<tbody>
|
||||
{ledgers.map((l) => (
|
||||
{ledgerRows.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="ts">{new Date(l.created_at).toLocaleString("zh-CN")}</td>
|
||||
<td>{l.ledger_type}</td>
|
||||
<td className="muted">{l.reason}</td>
|
||||
<td></td>
|
||||
<td><span className="status-tag ok">OK</span></td>
|
||||
<td>{ledgerTypeLabel(l.ledger_type)}</td>
|
||||
<td className="muted">{ledgerReasonLabel(l.reason)}</td>
|
||||
<td>{l.user_label
|
||||
? <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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</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 className={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
|
||||
@ -270,9 +333,12 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
|
||||
<table className="billing-table">
|
||||
<thead><tr><th>成员</th><th>角色</th><th>已用 / 月度额度</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
{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>
|
||||
))}
|
||||
{teamMembers.map((m) => {
|
||||
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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
X
|
||||
} from "lucide-react";
|
||||
import type { AITask, Asset, ModelConfig, Product } from "../types";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import type { Page } from "./route-config";
|
||||
import { statusPill } from "./stage-config";
|
||||
import "../ai-tools-page.css";
|
||||
@ -46,7 +47,14 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
running: "生成中",
|
||||
queued: "排队中",
|
||||
polling: "生成中",
|
||||
needs_review: "待确认"
|
||||
needs_review: "待确认",
|
||||
// 后端 AITask.Status 全量中文化(原先缺这些会直接透出英文)
|
||||
created: "待处理",
|
||||
reserved: "排队中",
|
||||
submitted: "已提交",
|
||||
postprocessing: "处理中",
|
||||
compensating: "回滚中",
|
||||
cancelled: "已取消"
|
||||
};
|
||||
|
||||
function statusText(status: string) {
|
||||
@ -71,7 +79,18 @@ async function downloadImage(url: string, filename: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) => void; aiTasks: AITask[] }) {
|
||||
export function AssetFactoryPage({ navigate, aiTasks, assets = [] }: { navigate: (page: Page) => void; aiTasks: AITask[]; assets?: Asset[] }) {
|
||||
// 任务 → 生成结果图:按 asset.origin_task 关联,取首张有预览 URL 的图片文件(脚本/视频任务无图则留占位)
|
||||
const taskImage = useMemo(() => {
|
||||
const map: Record<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 = [
|
||||
{
|
||||
page: "modelPhoto" as Page,
|
||||
@ -112,8 +131,11 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
|
||||
const [query, setQuery] = useState("");
|
||||
const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all");
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [view, setView] = useState<"grid" | "list">("list");
|
||||
const [view, setView] = useState<"grid" | "list">("grid");
|
||||
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
|
||||
// 任务中心分页:每次加载 12 条,「加载更多」递增;筛选/搜索变化时重置
|
||||
const TASKS_PER_LOAD = 12;
|
||||
const [shown, setShown] = useState(TASKS_PER_LOAD);
|
||||
useEffect(() => {
|
||||
if (!openChip) return;
|
||||
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); };
|
||||
@ -141,6 +163,10 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// 筛选条件变化时回到第一屏(12 条)
|
||||
useEffect(() => { setShown(TASKS_PER_LOAD); }, [filter, query, timeFilter, typeFilter]);
|
||||
const paged = visible.slice(0, shown);
|
||||
const hasMore = visible.length > paged.length;
|
||||
|
||||
return (
|
||||
<div className="asset-factory">
|
||||
@ -246,7 +272,7 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
|
||||
</div>
|
||||
|
||||
<div className="result-meta">
|
||||
// 显示 {visible.length} / {aiTasks.length} 个任务
|
||||
// 显示 {paged.length} / {visible.length} 个任务
|
||||
</div>
|
||||
|
||||
{aiTasks.length === 0 ? (
|
||||
@ -261,12 +287,13 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
|
||||
</div>
|
||||
) : view === "grid" ? (
|
||||
<div className="history-grid">
|
||||
{visible.map((task) => {
|
||||
{paged.map((task) => {
|
||||
const pill = statusPill(task.status);
|
||||
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
|
||||
const img = taskImage[task.id];
|
||||
return (
|
||||
<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-name">{typeLabel}</div>
|
||||
<div className="history-type">// {task.task_type}</div>
|
||||
@ -292,15 +319,16 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map((task) => {
|
||||
{paged.map((task) => {
|
||||
const pill = statusPill(task.status);
|
||||
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
|
||||
const img = taskImage[task.id];
|
||||
return (
|
||||
<tr key={task.id}>
|
||||
<td>
|
||||
<div className="task-name-cell">
|
||||
<div className="placeholder task-thumb">
|
||||
<span className="ph-frame">{task.id.slice(0, 4)}</span>
|
||||
<div className={`placeholder task-thumb${img ? " has-img" : ""}`}>
|
||||
{img ? <img src={img} alt={typeLabel} /> : <span className="ph-frame">{task.id.slice(0, 4)}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="task-name">{typeLabel}</div>
|
||||
@ -337,6 +365,14 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
|
||||
</table>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -451,6 +487,8 @@ export function ImageWorkbenchPage({
|
||||
const [results, setResults] = useState<Asset[] | null>(null);
|
||||
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
|
||||
const refInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// 生成结果图片放大预览
|
||||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||
// 模特/平台 工作台头部:搜索 + 时间排序 + 模特筛选(对左侧网格真实生效)
|
||||
const [gridQuery, setGridQuery] = useState("");
|
||||
const [gridSort, setGridSort] = useState<"recent" | "name">("recent");
|
||||
@ -502,6 +540,8 @@ export function ImageWorkbenchPage({
|
||||
function renderResultGrid() {
|
||||
const cols = (results?.length ?? candidateCount) >= 4 ? 4 : 2;
|
||||
return (
|
||||
<>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
<div
|
||||
className="gen-images"
|
||||
style={{ "--cols": cols, "--ratio": ratioVar } as React.CSSProperties}
|
||||
@ -512,7 +552,7 @@ export function ImageWorkbenchPage({
|
||||
).map(({ key, index, url }) => (
|
||||
<div className={`gen-image ${generating && !url ? "gen" : ""}`} key={key}>
|
||||
{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">
|
||||
<span className="ph-frame">
|
||||
@ -538,6 +578,7 @@ export function ImageWorkbenchPage({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import type { Asset } from "../types";
|
||||
import { ConfirmModal, Drawer } from "../components/overlays";
|
||||
import { ConfirmModal, Drawer, MediaLightbox } from "../components/overlays";
|
||||
|
||||
// asset.source / asset.asset_type → 中文标签(筛选下拉用)
|
||||
const SOURCE_LABELS: Record<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 [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
// 资产预览灯箱(图片放大 / 视频播放)
|
||||
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle("edit-mode", editMode);
|
||||
return () => document.body.classList.remove("edit-mode");
|
||||
@ -213,6 +215,8 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
<div className="asset-grid" id="asset-grid">
|
||||
{filtered.map((asset) => {
|
||||
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
|
||||
const isVideo = asset.asset_type === "video";
|
||||
const openPreview = cover ? () => setPreview({ src: cover, kind: isVideo ? "video" : "image", name: asset.name }) : undefined;
|
||||
return (
|
||||
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
|
||||
{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>
|
||||
</button>
|
||||
)}
|
||||
<div className="placeholder asset-thumb">
|
||||
{cover ? <img src={cover} alt={asset.name} loading="lazy" /> : <span className="ph-frame">{asset.asset_type}</span>}
|
||||
<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
|
||||
? (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 className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{asset.category} · {asset.source}</div></div>
|
||||
</article>
|
||||
@ -232,6 +243,8 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
<div className="empty-filter">// 当前分类暂无真实资产</div>
|
||||
)}
|
||||
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(confirmId)}
|
||||
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 type { Notification } from "../types";
|
||||
import { api } from "../api";
|
||||
import type { Notification, NotificationTypeCounts } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { routeLabels } from "./route-config";
|
||||
|
||||
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 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())}`;
|
||||
}
|
||||
|
||||
export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAllRead, navigate }: {
|
||||
notifications: Notification[];
|
||||
export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate }: {
|
||||
unreadCount: number;
|
||||
onMarkRead: (id: string) => 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 [query, setQuery] = useState("");
|
||||
const [debounced, setDebounced] = useState("");
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: notifications.length,
|
||||
unread: notifications.filter((n) => !n.is_read).length,
|
||||
task: notifications.filter((n) => n.notification_type === "task").length,
|
||||
team: notifications.filter((n) => n.notification_type === "team").length,
|
||||
billing: notifications.filter((n) => n.notification_type === "billing").length,
|
||||
system: notifications.filter((n) => n.notification_type === "system").length
|
||||
}),
|
||||
[notifications]
|
||||
const [items, setItems] = useState<Notification[]>([]);
|
||||
const [counts, setCounts] = useState<NotificationTypeCounts>(() => ({ ...ZERO_COUNTS, unread: unreadCount }));
|
||||
const [total, setTotal] = useState(0); // 当前筛选(tab/搜索)下的总条数,作「已加载 X / Y」的分母
|
||||
const [page, setPage] = useState(1); // 下一个要拉的页码
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loadingRef = useRef(false); // 防滚动重复触发追加
|
||||
const genRef = useRef(0); // 代号:tab/搜索一变就 +1,丢弃旧请求的回包
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 搜索去抖 300ms 再打服务端
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(query.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
const load = useCallback(
|
||||
async (pageToLoad: number, replace: boolean) => {
|
||||
// 追加(滚动)要防并发;重拉(replace)不阻塞,靠代号作废在途旧请求
|
||||
if (!replace && loadingRef.current) return;
|
||||
const gen = replace ? (genRef.current += 1) : genRef.current;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api
|
||||
.listNotifications({ ...tabParams(tab), search: debounced || undefined, page: pageToLoad, pageSize: PAGE_SIZE })
|
||||
.catch(() => null);
|
||||
if (gen !== genRef.current) return; // tab/搜索已切换,丢弃过期结果
|
||||
if (!res) return;
|
||||
setItems((prev) => (replace ? res.results : [...prev, ...res.results]));
|
||||
setHasMore(Boolean(res.next));
|
||||
setPage(pageToLoad + 1);
|
||||
setTotal(res.count);
|
||||
if (res.type_counts) setCounts(res.type_counts);
|
||||
} finally {
|
||||
if (gen === genRef.current) {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[tab, debounced]
|
||||
);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return notifications.filter((n) => {
|
||||
if (tab === "unread" && n.is_read) return false;
|
||||
if (!["all", "unread"].includes(tab) && n.notification_type !== tab) return false;
|
||||
if (q && ![n.title, n.brief, n.body, n.source, n.project_name, n.stage].join(" ").toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [notifications, tab, query]);
|
||||
// tab / 搜索变化 → 清空重拉第 1 页
|
||||
useEffect(() => {
|
||||
setItems([]);
|
||||
setSelectedId("");
|
||||
setHasMore(false);
|
||||
void load(1, true);
|
||||
}, [load]);
|
||||
|
||||
const selected = notifications.find((n) => n.id === selectedId) || visible[0] || notifications[0] || null;
|
||||
// 滚到接近底部就拉下一批
|
||||
const onScroll = useCallback(() => {
|
||||
const el = listRef.current;
|
||||
if (!el || !hasMore || loadingRef.current) return;
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 120) void load(page, false);
|
||||
}, [hasMore, page, load]);
|
||||
|
||||
// 首批撑不满面板(没出现滚动条)却还有更多 → 自动续拉,保证可触达
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (el && hasMore && !loadingRef.current && el.scrollHeight <= el.clientHeight) void load(page, false);
|
||||
}, [items, hasMore, page, load]);
|
||||
|
||||
const selected = items.find((n) => n.id === selectedId) || items[0] || null;
|
||||
|
||||
// 标记单条已读:同步后端/侧边栏徽标 + 本地乐观更新(列表与未读计数)
|
||||
function markOne(id: string) {
|
||||
void onMarkRead(id);
|
||||
setItems((prev) => prev.map((x) => (x.id === id ? { ...x, is_read: true, unread: false } : x)));
|
||||
setCounts((c) => ({ ...c, unread: Math.max(0, c.unread - 1) }));
|
||||
}
|
||||
|
||||
function selectItem(n: Notification) {
|
||||
setSelectedId(n.id);
|
||||
if (!n.is_read) void onMarkRead(n.id);
|
||||
if (!n.is_read) markOne(n.id);
|
||||
}
|
||||
|
||||
async function markAll() {
|
||||
await onMarkAllRead();
|
||||
setItems((prev) => prev.map((x) => ({ ...x, is_read: true, unread: false })));
|
||||
setCounts((c) => ({ ...c, unread: 0 }));
|
||||
}
|
||||
|
||||
const filters: Array<[TabKey, string, number]> = [
|
||||
@ -97,17 +165,17 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="msg-workbench">
|
||||
<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">
|
||||
{filters.map(([id, label, ct]) => (
|
||||
<button key={id} className={`msg-filter ${tab === id ? "active" : ""}`} type="button" onClick={() => setTab(id)}>
|
||||
@ -119,11 +187,12 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
|
||||
<Search />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
|
||||
</div>
|
||||
<div className="msg-list">
|
||||
{visible.length === 0 ? (
|
||||
<div className="msg-list" ref={listRef} onScroll={onScroll}>
|
||||
{items.length === 0 && !loading ? (
|
||||
<div className="msg-empty"><Search /><span>没有符合条件的消息</span></div>
|
||||
) : (
|
||||
visible.map((n) => (
|
||||
<>
|
||||
{items.map((n) => (
|
||||
<button key={n.id} className={`msg-item ${selected?.id === n.id ? "active" : ""} ${n.is_read ? "read" : ""}`} type="button" onClick={() => selectItem(n)}>
|
||||
<span className={`msg-type-ic ${n.notification_type}`}>{typeIcon(n.notification_type)}</span>
|
||||
<span className="msg-item-main">
|
||||
@ -139,7 +208,10 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
))}
|
||||
{(loading || hasMore) && <div className="msg-load-more mono">{loading ? "// 加载中…" : "// 滚动加载更多"}</div>}
|
||||
{!loading && !hasMore && items.length > 0 && <div className="msg-load-more mono">// 已全部加载</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@ -175,7 +247,7 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<button className="btn btn-primary" type="button" onClick={() => navigate(target)}>进入{routeLabels[target]}</button>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@ import type { Asset, BillingSummary, ExportPoll, Product, Project, Team, Timelin
|
||||
import type { Notice, Page } from "./route-config";
|
||||
import { money, stageOrder, statusPill } from "./stage-config";
|
||||
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import { IconKitSvg } from "../components/IconKitSvg";
|
||||
|
||||
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
|
||||
@ -158,6 +159,8 @@ export function PipelinePage(props: {
|
||||
const activeDot = navigated ? viewStage : projectStage;
|
||||
const completed = Math.max(projectStage - 1, activeDot - 1);
|
||||
const [chatText, setChatText] = useState("");
|
||||
// 媒体预览灯箱(视频片段播放 / 故事板分镜放大)
|
||||
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
|
||||
const [chatMode, setChatMode] = useState<"ai" | "theme" | "manual">("ai");
|
||||
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
|
||||
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@ -796,7 +799,7 @@ export function PipelinePage(props: {
|
||||
{(() => {
|
||||
const url = frameUrl(sbActiveFrame);
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
@ -899,7 +902,7 @@ export function PipelinePage(props: {
|
||||
const busy = ["running", "queued"].includes(seg.status);
|
||||
return (
|
||||
<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
|
||||
? <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>}
|
||||
@ -1199,6 +1202,7 @@ export function PipelinePage(props: {
|
||||
})()}
|
||||
</div>
|
||||
</main>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { ConfirmModal } from "../components/overlays";
|
||||
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
||||
import type { Asset, Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import "../product-create-page.css";
|
||||
@ -74,6 +75,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
const [imagePreview, setImagePreview] = useState<string>("");
|
||||
const imgInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const [titleError, setTitleError] = useState(false);
|
||||
function pickImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) setImagePreview(URL.createObjectURL(file));
|
||||
@ -131,8 +133,17 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
setBullets((list) => list.filter((_, position) => position !== index));
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setTitle("");
|
||||
setCategory("");
|
||||
setTarget("");
|
||||
setBullets([]);
|
||||
setBulletDraft("");
|
||||
setImagePreview("");
|
||||
setTitleError(false);
|
||||
}
|
||||
function submit() {
|
||||
if (!title.trim()) return;
|
||||
if (!title.trim()) { setTitleError(true); return; } // 空名:给必填校验提示(不再静默无反应)
|
||||
onCreate({
|
||||
title: title.trim(),
|
||||
category: category || PC_CAT_OPTIONS[0],
|
||||
@ -140,11 +151,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
selling_points: bullets.map((item, index) => ({ title: item, detail: item, sort_order: index }))
|
||||
});
|
||||
setDrawer(false);
|
||||
setTitle("");
|
||||
setCategory("");
|
||||
setTarget("");
|
||||
setBullets([]);
|
||||
setBulletDraft("");
|
||||
resetForm();
|
||||
}
|
||||
|
||||
return (
|
||||
@ -159,7 +166,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
<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>
|
||||
</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>
|
||||
新建商品
|
||||
</button>
|
||||
@ -249,7 +256,9 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
onConfirm={doDelete}
|
||||
/>
|
||||
|
||||
{/* 新建商品 · 右侧 Drawer · 在商品库页面原地打开(转写自 products.html #pc-drawer) */}
|
||||
{/* 新建商品 · 右侧 Drawer · portal 到 body,脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏(转写自 products.html #pc-drawer) */}
|
||||
{createPortal(
|
||||
<>
|
||||
<div className={`drawer-bg${drawer ? " show" : ""}`} onClick={() => setDrawer(false)} />
|
||||
<aside className={`drawer pc-drawer${drawer ? " show" : ""}`} role="dialog" aria-label="新建商品" aria-hidden={!drawer}>
|
||||
<div className="drawer-h">
|
||||
@ -263,7 +272,8 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
<div className="form-card">
|
||||
<div className="field">
|
||||
<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 className="field-row">
|
||||
@ -299,9 +309,9 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
<div className="pf-example">
|
||||
<div className="ex-h">示例图</div>
|
||||
<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"><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"><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-earbuds.png" alt="示例:蓝牙耳机" loading="lazy" /></div>
|
||||
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-mask.png" alt="示例:面膜" loading="lazy" /></div>
|
||||
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-air-fryer.png" alt="示例:空气炸锅" loading="lazy" /></div>
|
||||
</div>
|
||||
<div className="ex-d">优质的商品图有助于生成更好的素材效果</div>
|
||||
</div>
|
||||
@ -350,6 +360,9 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -590,6 +603,8 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
|
||||
const [assetSortDesc, setAssetSortDesc] = useState(true);
|
||||
const [assetLimit, setAssetLimit] = useState(12);
|
||||
const [videoSortDesc, setVideoSortDesc] = useState(true);
|
||||
// 图片预览灯箱
|
||||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openFilter) return;
|
||||
@ -709,7 +724,7 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
|
||||
<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>
|
||||
<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>}
|
||||
</div>
|
||||
<div className="prod-preview-foot" id="ov-tri-foot">
|
||||
@ -793,9 +808,15 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
|
||||
</div>
|
||||
<div className="grid" id="ov-images-grid">
|
||||
{productImages.map((image) => (
|
||||
<div className="thumb placeholder" key={image.id}>
|
||||
{image.url ? <img src={image.url} alt={realName} loading="lazy" /> : <span className="ph-frame">1:1</span>}
|
||||
image.url ? (
|
||||
<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 }); } }}>
|
||||
<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(); } }}>
|
||||
{uploading ? (
|
||||
@ -896,7 +917,7 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
|
||||
const status: "pass" | "fail" | "archive" = "pass";
|
||||
return (
|
||||
<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}
|
||||
<span className="type-pill">{pdAssetTypeLabel(asset)}</span>
|
||||
{url ? null : <span className="ph-frame">3:4</span>}
|
||||
@ -934,6 +955,8 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -188,6 +188,7 @@ export function SettingsPage({
|
||||
setName(user.username || "");
|
||||
setEmail(user.email || "");
|
||||
setPhone("");
|
||||
onNotify?.("已恢复为已保存的资料");
|
||||
}
|
||||
|
||||
async function handleSaveProfile() {
|
||||
|
||||
@ -254,18 +254,27 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
|
||||
</thead>
|
||||
<tbody id="members-tbody">
|
||||
{list.map((member) => {
|
||||
const name = member.user.username || member.user.email || "成员";
|
||||
const rawName = (member.user.username || "").trim();
|
||||
const email = (member.user.email || "").trim();
|
||||
// 用户名是邮箱时取 @ 前作为显示名,完整邮箱作副行,避免名字与邮箱重复显示
|
||||
const displayName = rawName && !rawName.includes("@") ? rawName : (email ? email.split("@")[0] : (rawName || "成员"));
|
||||
const showEmail = !!email && email.toLowerCase() !== displayName.toLowerCase();
|
||||
const role = roleUi(member.role);
|
||||
const monthly = Number(member.monthly_credit_limit || 0);
|
||||
const memberPct = monthly > 0 ? Math.min(100, (0 / monthly) * 100) : 0;
|
||||
const memberUsed = Number(member.month_charged || 0);
|
||||
// 月度不限时按团队月限额/余额作分母给参考进度;有消费即显可见细条,避免「用了钱进度条却空白」
|
||||
const quotaDenom = monthly > 0 ? monthly : limit;
|
||||
const memberPct = quotaDenom > 0 ? Math.min(100, (memberUsed / quotaDenom) * 100) : 0;
|
||||
const barWidth = memberUsed > 0 ? Math.max(memberPct, 3) : 0;
|
||||
const barClass = memberPct >= 80 ? "warn" : "ok";
|
||||
const isOwner = member.role === "owner";
|
||||
return (
|
||||
<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="quota-cell"><span className="v">不限</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
|
||||
? <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; }
|
||||
|
||||
/* ─── 成员表 ─── */
|
||||
.members-table .av { width: 32px; height: 32px; border-radius: 50%; background: var(--background-lighter); display: inline-grid; place-items: center; font-weight: 600; font-size: 13px; color: var(--accent-black); border: 1px solid var(--border-faint); }
|
||||
.members-table th { white-space: nowrap; }
|
||||
.members-table .av { flex: 0 0 32px; width: 32px; height: 32px; border-radius: 50%; background: var(--background-lighter); display: inline-grid; place-items: center; font-weight: 600; font-size: 13px; color: var(--accent-black); border: 1px solid var(--border-faint); }
|
||||
.members-table .who { display: flex; align-items: center; gap: 10px; }
|
||||
.members-table .nm { font-weight: 500; font-size: 13.5px; line-height: 1.2; }
|
||||
.members-table .em { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); }
|
||||
.members-table .member-cell { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.members-table .member-cell .member-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.members-table .nm { font-weight: 500; font-size: 13.5px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; }
|
||||
.members-table .em { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; }
|
||||
.members-table .role-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 11px; font-weight: 500; }
|
||||
.members-table .role-pill .dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.members-table .role-super { background: var(--heat-12); color: var(--heat); }
|
||||
|
||||
@ -16,6 +16,7 @@ export type TeamMember = {
|
||||
role: string;
|
||||
status: string;
|
||||
monthly_credit_limit: string;
|
||||
month_charged?: string;
|
||||
user: User;
|
||||
};
|
||||
|
||||
@ -56,6 +57,7 @@ export type Asset = {
|
||||
category: string;
|
||||
description: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
origin_task?: string | null;
|
||||
files?: Array<{
|
||||
id: string;
|
||||
object_key: string;
|
||||
@ -190,6 +192,7 @@ export type Ledger = {
|
||||
balance_after: string;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
user_label?: string;
|
||||
};
|
||||
|
||||
export type UserPreference = {
|
||||
@ -260,7 +263,20 @@ export type Notification = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type NotificationList = Paginated<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 = {
|
||||
account: BillingSummary["account"];
|
||||
|
||||
@ -35,6 +35,13 @@ function readPng(file) {
|
||||
}
|
||||
|
||||
async function preparePage(page, url, shouldClearStorage, token) {
|
||||
// 先到同源根页注入 token,再进目标路由 —— 否则目标路由在「无 token 首跳」时
|
||||
// 会跑一遍登出/重定向 boot,某些路由(如 /model-photo)即便随后 reload 也回不来,
|
||||
// 导致截图停在登录页(假性 ~27% diff)。
|
||||
if (token) {
|
||||
await page.goto(new URL(url).origin + "/", { waitUntil: "domcontentloaded" });
|
||||
await page.evaluate((value) => localStorage.setItem("airshelf_token", value), token);
|
||||
}
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
if (shouldClearStorage) {
|
||||
await page.evaluate(() => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user