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

消息中心:全量渲染 → 真·后端分页滚动加载
- backend(ops/views): NotificationPagination(10/页,page_size 可覆盖)+
  响应回 type_counts(按收件人绝对计数,不受分页/搜索影响)
- frontend(messages): 自管分页,滚到底加载下一批;tab/搜索走服务端并重置到第1页;
  代号作废在途旧请求防切换卡空白;乐观标已读;「已加载 X / Y」分母用当前筛选总数
- api/App/types: listNotifications 支持 page/page_size/search;allNotifications 携带 type_counts

命令面板(侧边栏搜索):修复点开后 UI 错位
- app-shell: 遮罩 className 漏了基类 shell-command-bg(只有 .show)致无定位塌到左下;
  补回基类 + header 类名对齐 .shell-command-h
- messages-page.css: 工作台收进视口高度,收件箱在面板内滚动

本次提交一并带入此前若干未提交 WIP(account/ai-tools/library/pipeline/products/settings +
accounts/ai/assets/billing/projects 后端),按用户要求整体推 dev。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc 2026-06-10 09:37:41 +08:00
parent aa4bdeac83
commit 3fac38c5ef
29 changed files with 724 additions and 150 deletions

View File

@ -41,12 +41,18 @@ class TeamSerializer(serializers.ModelSerializer):
class TeamMemberSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
# 本月已消费(自然月,按 CreditLedger CHARGE 流水按人聚合);由 view 经 context 注入 charged_map
month_charged = serializers.SerializerMethodField()
class Meta:
model = TeamMember
fields = ["id", "team", "user", "role", "status", "monthly_credit_limit"]
fields = ["id", "team", "user", "role", "status", "monthly_credit_limit", "month_charged"]
read_only_fields = ["id", "team", "user", "status"]
def get_month_charged(self, obj):
charged_map = self.context.get("charged_map") or {}
return str(charged_map.get(obj.user_id, 0))
class RegisterSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150)

View File

@ -40,13 +40,22 @@ def _client_ip(request):
def record_login_session(request, user):
"""登录成功后记录一条会话(设备 UA / IP),供设置页「在用设备」展示。"""
"""登录成功后记录设备会话(UA / IP)。去重:同一台电脑(UA)+ 同一 IP 视为同一台设备,
已存在未下线的同设备会话则只刷新 last_seen_at,不再新增一行(避免在用设备列表里同设备重复堆叠)"""
try:
LoginSession.objects.create(
user=user,
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:400],
ip_address=_client_ip(request),
user_agent = (request.META.get("HTTP_USER_AGENT") or "")[:400]
ip_address = _client_ip(request)
existing = (
LoginSession.objects.filter(
user=user, user_agent=user_agent, ip_address=ip_address, revoked_at__isnull=True
)
.order_by("-last_seen_at")
.first()
)
if existing:
existing.save(update_fields=["last_seen_at"]) # auto_now 刷新最近活跃时间
else:
LoginSession.objects.create(user=user, user_agent=user_agent, ip_address=ip_address)
except Exception: # noqa: BLE001 — 会话记录失败不应阻断登录
pass
@ -170,13 +179,36 @@ def can_manage_team(user, team):
return bool(member and member.role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN})
def _month_charged_by_user(team):
"""本团队当前自然月每个成员的消费(CHARGE 流水)合计:{user_id: Decimal}。"""
from django.db.models import Sum
from django.utils import timezone
from apps.billing.models import CreditLedger
month_start = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
rows = (
CreditLedger.objects.filter(
team=team,
ledger_type=CreditLedger.Type.CHARGE,
created_at__gte=month_start,
)
.values("user_id")
.annotate(total=Sum("amount"))
)
return {row["user_id"]: row["total"] for row in rows if row["user_id"] is not None}
@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def team_members(request):
team = get_current_team(request.user)
if request.method == "GET":
members = TeamMember.objects.filter(team=team).select_related("user").order_by("created_at")
return Response(TeamMemberSerializer(members, many=True).data)
charged_map = _month_charged_by_user(team)
return Response(
TeamMemberSerializer(members, many=True, context={"charged_map": charged_map}).data
)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
@ -277,23 +309,41 @@ def preferences(request):
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def login_sessions(request):
"""在用设备:返回未下线的登录会话(最近 20 条)。"""
sessions = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True)[:20]
"""在用设备:返回未下线的登录会话(去重后最近 20 台)。
去重规则:同一台电脑(UA)+ 同一 IP 只算一台,取该设备最近一次会话展示(兼容历史已堆叠的重复行)"""
queryset = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True).order_by("-last_seen_at")
seen: set = set()
unique = []
for session in queryset:
key = (session.user_agent, session.ip_address)
if key in seen:
continue
seen.add(key)
unique.append(session)
if len(unique) >= 20:
break
current_ip = _client_ip(request)
current_ua = (request.META.get("HTTP_USER_AGENT") or "")[:400]
data = LoginSessionSerializer(sessions, many=True, context={"current_ip": current_ip, "current_ua": current_ua}).data
data = LoginSessionSerializer(unique, many=True, context={"current_ip": current_ip, "current_ua": current_ua}).data
return Response(data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def revoke_login_session(request, session_id):
"""下线单个设备会话。"""
"""下线单个设备:把同一台设备(UA + IP)下的所有未下线会话一并下线,
否则去重展示的一台设备点下线,底层其它重复会话仍存活会再次冒出来"""
from django.utils import timezone
updated = LoginSession.objects.filter(user=request.user, id=session_id, revoked_at__isnull=True).update(
revoked_at=timezone.now()
)
target = LoginSession.objects.filter(user=request.user, id=session_id).first()
if not target:
return Response({"revoked": 0})
updated = LoginSession.objects.filter(
user=request.user,
user_agent=target.user_agent,
ip_address=target.ip_address,
revoked_at__isnull=True,
).update(revoked_at=timezone.now())
return Response({"revoked": updated})

View File

@ -1,7 +1,11 @@
import re
import subprocess
import tempfile
import uuid
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
from pathlib import Path
from django.db import transaction
from django.utils import timezone
@ -184,6 +188,34 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id
raise
def _generate_video_poster(*, video_bytes: bytes, team, project, asset_id) -> "StoredObject | None":
"""用 ffmpeg 抽视频首帧作为封面(poster)并上传 TOS。best-effort:任何失败都返回 None,不影响视频资产落地。"""
if not video_bytes:
return None
try:
with tempfile.TemporaryDirectory(prefix="airshelf-poster-") as tmp:
tmp_dir = Path(tmp)
video_path = tmp_dir / "in.mp4"
poster_path = tmp_dir / "poster.jpg"
video_path.write_bytes(video_bytes)
proc = subprocess.run(
["ffmpeg", "-y", "-ss", "0", "-i", str(video_path), "-frames:v", "1", "-q:v", "3", str(poster_path)],
capture_output=True,
timeout=60,
)
if proc.returncode != 0 or not poster_path.exists():
return None
poster_bytes = poster_path.read_bytes()
if not poster_bytes:
return None
object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}-poster.jpg"
return TosStorage().upload_fileobj(
fileobj=BytesIO(poster_bytes), object_key=object_key, content_type="image/jpeg"
)
except Exception: # noqa: BLE001 — poster 仅用于展示,失败不阻断
return None
def _store_generated_media(*, team, user, project, task, media: str, name: str, category: str, asset_type: str) -> Asset:
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
suffix = ".png"
@ -214,6 +246,22 @@ def _store_generated_media(*, team, user, project, task, media: str, name: str,
size_bytes=stored.size_bytes,
is_primary=True,
)
# 视频资产:额外抽首帧作为封面图,挂成同一 Asset 下的 image 文件,供任务中心/列表显示缩略图
if "video" in content_type:
try:
video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b""
except Exception: # noqa: BLE001
video_bytes = b""
poster = _generate_video_poster(video_bytes=video_bytes, team=team, project=project, asset_id=asset_id)
if poster:
AssetFile.objects.create(
asset=asset,
object_key=poster.object_key,
bucket=poster.bucket,
content_type=poster.content_type,
size_bytes=poster.size_bytes,
is_primary=False,
)
return asset

View File

@ -70,6 +70,7 @@ class AssetSerializer(serializers.ModelSerializer):
"description",
"metadata",
"is_deleted",
"origin_task",
"files",
"created_at",
"updated_at",

View File

@ -11,11 +11,15 @@ class CreditAccountSerializer(serializers.ModelSerializer):
class CreditLedgerSerializer(serializers.ModelSerializer):
# 成员展示名:优先真实姓名 → 用户名 → 邮箱;系统流水(无 user)留空
user_label = serializers.SerializerMethodField()
class Meta:
model = CreditLedger
fields = [
"id",
"user",
"user_label",
"project",
"task",
"ledger_type",
@ -27,6 +31,12 @@ class CreditLedgerSerializer(serializers.ModelSerializer):
]
read_only_fields = fields
def get_user_label(self, obj):
user = obj.user
if user is None:
return ""
return user.first_name or user.username or user.email or ""
class CreditReservationSerializer(serializers.ModelSerializer):
class Meta:

View File

@ -29,7 +29,7 @@ def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
ledger_type=CreditLedger.Type.RESERVE,
amount=amount,
balance_after=account.balance,
reason="reserve ai task credit",
reason="AI 任务预扣额度",
)
return reservation
@ -52,7 +52,7 @@ def release_credit(*, reservation: CreditReservation, reason: str = "") -> None:
ledger_type=CreditLedger.Type.RELEASE,
amount=reservation.amount,
balance_after=account.balance,
reason=reason or "release reserved credit",
reason=reason or "释放预留额度",
)
@ -77,7 +77,7 @@ def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Dec
ledger_type=CreditLedger.Type.CHARGE,
amount=actual_amount,
balance_after=account.balance,
reason="charge ai task credit",
reason="AI 任务扣费",
)
if reservation.amount > actual_amount:
CreditLedger.objects.create(
@ -88,6 +88,6 @@ def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Dec
ledger_type=CreditLedger.Type.RELEASE,
amount=reservation.amount - actual_amount,
balance_after=account.balance,
reason="release unused reserved credit",
reason="释放未用预留额度",
)

View File

@ -56,7 +56,27 @@ def ledgers(request):
queryset = queryset.filter(project_id=project_id)
if user_id:
queryset = queryset.filter(user_id=user_id)
return Response(CreditLedgerSerializer(queryset[:100], many=True).data)
# 服务端分页:总数随流水增长(原先写死 [:100] 导致永远 100 条)
try:
page = max(1, int(request.query_params.get("page", 1)))
except (TypeError, ValueError):
page = 1
try:
page_size = int(request.query_params.get("page_size", 10))
except (TypeError, ValueError):
page_size = 10
page_size = max(1, min(page_size, 100))
total = queryset.count()
start = (page - 1) * page_size
rows = queryset[start:start + page_size]
return Response(
{
"count": total,
"page": page,
"page_size": page_size,
"results": CreditLedgerSerializer(rows, many=True).data,
}
)
@api_view(["POST"])

View File

@ -2,9 +2,17 @@ from django.db.models import Q
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
class NotificationPagination(PageNumberPagination):
# 收件箱滚动加载:每批 10 条,前端可用 ?page_size= 覆盖(上限 100)
page_size = 10
page_size_query_param = "page_size"
max_page_size = 100
from apps.assets.models import Asset
from apps.billing.models import CreditAccount
from apps.common.api import TeamScopedViewSetMixin
@ -109,14 +117,19 @@ def ensure_team_notifications(team, user):
class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
serializer_class = NotificationSerializer
queryset = Notification.objects.select_related("team", "recipient", "project").all()
pagination_class = NotificationPagination
search_fields = ["title", "brief", "body", "source", "stage"]
ordering_fields = ["created_at", "updated_at"]
ordering = ["-created_at"]
def get_queryset(self):
# 团队 + 收件人 + 未归档:分类计数的基准集(不含 tab/未读/搜索过滤)
def _recipient_scope(self):
queryset = super().get_queryset().filter(archived_at__isnull=True)
user = self.request.user
queryset = queryset.filter(Q(recipient=user) | Q(recipient__isnull=True))
return queryset.filter(Q(recipient=user) | Q(recipient__isnull=True))
def get_queryset(self):
queryset = self._recipient_scope()
notification_type = self.request.query_params.get("type")
if notification_type and notification_type not in {"all", "unread"}:
queryset = queryset.filter(notification_type=notification_type)
@ -128,9 +141,19 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
ensure_team_notifications(self.get_team(), request.user)
response = super().list(request, *args, **kwargs)
data = response.data
unread_count = self.get_queryset().filter(is_read=False).count()
if isinstance(data, dict):
# 分类 chip 计数取绝对总数(忽略当前 tab/搜索),与设计稿一致
base = self._recipient_scope()
unread_count = base.filter(is_read=False).count()
data["unread_count"] = unread_count
data["type_counts"] = {
"all": base.count(),
"unread": unread_count,
"task": base.filter(notification_type="task").count(),
"team": base.filter(notification_type="team").count(),
"billing": base.filter(notification_type="billing").count(),
"system": base.filter(notification_type="system").count(),
}
return response
def perform_create(self, serializer):

View File

@ -44,6 +44,19 @@ def _download_asset_primary_file(asset, target_path: Path) -> None:
target_path.write_bytes(response.content)
def _has_audio_stream(path: Path) -> bool:
"""探测视频文件是否含音频流(决定是否保留该片段的原声/人声)。ffprobe 失败时保守按无声处理。"""
try:
proc = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries",
"stream=index", "-of", "csv=p=0", str(path)],
capture_output=True, timeout=60,
)
return bool(proc.stdout.strip())
except Exception: # noqa: BLE001
return False
def _load_font(size: int):
from PIL import ImageFont
@ -123,9 +136,14 @@ def _output_starts(specs: list[dict], xfade: float) -> tuple[list[float], float]
return starts, max(0.1, total)
_AFMT = "aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo"
def _build_export_command(*, n: int, specs: list[dict], starts: list[float], total: float,
transition: str, sub_overlays: list[tuple[str, float, float]],
bgm_name: str | None, bgm_volume: float) -> list[str]:
bgm_name: str | None, bgm_volume: float,
has_audio: list[bool] | None = None) -> list[str]:
has_audio = has_audio or [False] * n
parts: list[str] = []
for i, s in enumerate(specs):
parts.append(
@ -153,8 +171,30 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-150:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
)
vlabel = out
if bgm_name:
parts.append(f"[{n}:a]volume={bgm_volume:.3f},atrim=0:{total:.3f},asetpts=PTS-STARTPTS[aout]")
# 音频:片段自带的人声/原声必须保留(有声片段取原音轨,无声片段补等长静音,否则 concat 会缺流);
# 若另挂了 BGM,则把 BGM 混到原声之上(amix,normalize=0 不自动衰减原声音量)。
# 三种片段全无声且无 BGM 时,保持旧行为=纯视频不带音轨。
want_audio = any(has_audio) or bool(bgm_name)
audio_label: str | None = None
if want_audio:
for i, s in enumerate(specs):
if has_audio[i]:
parts.append(
f"[{i}:a]atrim=start={s['ts']:.3f}:end={s['te']:.3f},asetpts=PTS-STARTPTS,{_AFMT}[a{i}]"
)
else:
parts.append(
f"anullsrc=channel_layout=stereo:sample_rate=44100,atrim=0:{s['dur']:.3f},"
f"asetpts=PTS-STARTPTS,{_AFMT}[a{i}]"
)
parts.append("".join(f"[a{i}]" for i in range(n)) + f"concat=n={n}:v=0:a=1[avoice0]")
parts.append(f"[avoice0]atrim=0:{total:.3f},asetpts=PTS-STARTPTS[avoice]")
audio_label = "avoice"
if bgm_name:
parts.append(f"[{n}:a]volume={bgm_volume:.3f},atrim=0:{total:.3f},asetpts=PTS-STARTPTS,{_AFMT}[abgm]")
parts.append("[avoice][abgm]amix=inputs=2:duration=longest:dropout_transition=0:normalize=0[aout]")
audio_label = "aout"
cmd = ["ffmpeg", "-y"]
for i in range(n):
@ -164,10 +204,10 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
for png, _s, _e in sub_overlays:
cmd += ["-loop", "1", "-i", png]
cmd += ["-filter_complex", ";".join(parts), "-map", f"[{vlabel}]"]
if bgm_name:
cmd += ["-map", "[aout]"]
if audio_label:
cmd += ["-map", f"[{audio_label}]"]
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", "-preset", "veryfast"]
if bgm_name:
if audio_label:
cmd += ["-c:a", "aac", "-b:a", "192k"]
cmd += ["-t", f"{total:.3f}", "-movflags", "+faststart", "output.mp4"]
return cmd
@ -237,6 +277,8 @@ def run_export_job(export_job_id: str) -> ExportJob:
tmp = Path(tmp_dir)
for index, clip in enumerate(clips):
_download_asset_primary_file(clip.asset, tmp / f"clip{index}.mp4")
# 逐片段探测是否自带音轨:有声→保留原声,无声→补静音(见 _build_export_command)
has_audio = [_has_audio_stream(tmp / f"clip{index}.mp4") for index in range(len(clips))]
bgm_name = None
if bgm_track is not None and bgm_track.asset_id:
@ -258,6 +300,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
command = _build_export_command(
n=len(clips), specs=specs, starts=starts, total=total, transition=transition,
sub_overlays=sub_overlays, bgm_name=bgm_name, bgm_volume=(bgm_track.volume / 100.0) if bgm_track else 1.0,
has_audio=has_audio,
)
proc = subprocess.run(command, cwd=str(tmp), capture_output=True)
if proc.returncode != 0:

View File

@ -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":

View File

@ -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); }

View File

@ -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; }

View File

@ -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"

View File

@ -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

View File

@ -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,
);
}

View File

@ -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 显) */

View File

@ -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%;

View File

@ -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;
}

View File

@ -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>

View File

@ -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>
</>
);
}

View File

@ -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="删除资产"

View File

@ -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,27 +187,31 @@ 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) => (
<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">
<span className="msg-item-row">
<span className="msg-dot"></span>
<span className="msg-item-title">{n.title}</span>
<span className="msg-time">{fmtTime(n.created_at)}</span>
<>
{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">
<span className="msg-item-row">
<span className="msg-dot"></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 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>
</button>
))
</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>

View File

@ -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>
);
}

View File

@ -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>}
</div>
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>
);
}

View File

@ -188,6 +188,7 @@ export function SettingsPage({
setName(user.username || "");
setEmail(user.email || "");
setPhone("");
onNotify?.("已恢复为已保存的资料");
}
async function handleSaveProfile() {

View File

@ -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>
: <>

View File

@ -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); }

View File

@ -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"];

View File

@ -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(() => {