Compare commits
No commits in common. "8959946241de13675a1c42166fa529dc3031158d" and "d41e487f08552c9ea7e6a90ff226a27a05d2ee0e" have entirely different histories.
8959946241
...
d41e487f08
@ -1,16 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
change_password,
|
||||
login,
|
||||
logout,
|
||||
me,
|
||||
register,
|
||||
team_member_detail,
|
||||
team_member_password,
|
||||
team_members,
|
||||
update_avatar,
|
||||
)
|
||||
from .views import login, logout, me, register, team_member_detail, team_member_password, team_members
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
@ -18,8 +8,6 @@ urlpatterns = [
|
||||
path("login/", login, name="auth-login"),
|
||||
path("logout/", logout, name="auth-logout"),
|
||||
path("me/", me, name="auth-me"),
|
||||
path("me/password/", change_password, name="auth-change-password"),
|
||||
path("me/avatar/", update_avatar, name="auth-avatar"),
|
||||
path("team/members/", team_members, name="team-members"),
|
||||
path("team/members/<uuid:member_id>/", team_member_detail, name="team-member-detail"),
|
||||
path("team/members/<uuid:member_id>/password/", team_member_password, name="team-member-password"),
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import authenticate
|
||||
from django.db import transaction
|
||||
from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.decorators import api_view, parser_classes, permission_classes
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
@ -58,69 +54,18 @@ def logout(request):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@api_view(["GET", "PATCH"])
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def me(request):
|
||||
user = request.user
|
||||
if request.method == "PATCH":
|
||||
if "name" in request.data:
|
||||
user.first_name = str(request.data.get("name") or "").strip()
|
||||
if "phone" in request.data:
|
||||
user.phone = str(request.data.get("phone") or "").strip()[:32]
|
||||
email = str(request.data.get("email") or "").strip()
|
||||
if email:
|
||||
user.email = email
|
||||
user.save(update_fields=["first_name", "phone", "email"])
|
||||
team = get_current_team(user)
|
||||
team = get_current_team(request.user)
|
||||
return Response(
|
||||
{
|
||||
"user": UserSerializer(user).data,
|
||||
"user": UserSerializer(request.user).data,
|
||||
"team": TeamSerializer(team).data,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def change_password(request):
|
||||
user = request.user
|
||||
old_password = str(request.data.get("old_password") or "")
|
||||
new_password = str(request.data.get("new_password") or "").strip()
|
||||
if not user.check_password(old_password):
|
||||
return Response({"old_password": ["原密码不正确"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if len(new_password) < 8:
|
||||
return Response({"new_password": ["新密码至少 8 位"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user.set_password(new_password)
|
||||
user.save(update_fields=["password"])
|
||||
Token.objects.filter(user=user).delete()
|
||||
token, _ = Token.objects.get_or_create(user=user)
|
||||
return Response({"token": token.key})
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@parser_classes([MultiPartParser, FormParser])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def update_avatar(request):
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "no file"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user = request.user
|
||||
suffix = Path(upload.name).suffix.lower() or ".png"
|
||||
object_key = f"users/{user.id}/avatar/{uuid.uuid4()}{suffix}"
|
||||
storage = TosStorage()
|
||||
storage.upload_fileobj(
|
||||
fileobj=upload.file,
|
||||
object_key=object_key,
|
||||
content_type=upload.content_type or "image/png",
|
||||
)
|
||||
# 头像直接存可访问的预签名 URL(长有效期);后续如需永久化可改为读时签发
|
||||
user.avatar_url = storage.presigned_get_url(object_key=object_key, expires_in=7 * 24 * 3600)
|
||||
user.save(update_fields=["avatar_url"])
|
||||
return Response(UserSerializer(user).data)
|
||||
|
||||
|
||||
def normalize_member_role(role):
|
||||
if role == "super":
|
||||
return TeamMember.Role.OWNER
|
||||
|
||||
@ -419,73 +419,3 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
|
||||
|
||||
def create_export_job(*, timeline, user) -> ExportJob:
|
||||
return ExportJob.objects.create(timeline=timeline, status=ExportJob.Status.QUEUED)
|
||||
|
||||
|
||||
_STANDALONE_CATEGORY = {
|
||||
"model": Asset.Category.PERSON,
|
||||
"cover": Asset.Category.PRODUCT_IMAGE,
|
||||
"image": Asset.Category.PRODUCT_IMAGE,
|
||||
}
|
||||
_STANDALONE_TASK_TYPE = {
|
||||
"model": AITask.Type.PERSON_IMAGE,
|
||||
"cover": AITask.Type.PRODUCT_IMAGE,
|
||||
"image": AITask.Type.PRODUCT_IMAGE,
|
||||
}
|
||||
|
||||
|
||||
def generate_standalone_image(*, team, user, prompt: str, mode: str = "image", count: int = 1) -> list[Asset]:
|
||||
"""不绑定项目的独立生图(图片创作 / 模特上身图 / 平台套图)。复用项目内生图链路,AITask.project=None。"""
|
||||
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
||||
if model_config is None:
|
||||
raise ValueError("no active image model configured")
|
||||
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
|
||||
task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE)
|
||||
count = max(1, min(int(count or 1), 4))
|
||||
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
|
||||
assets: list[Asset] = []
|
||||
for index in range(count):
|
||||
cost = estimate_cost(model_config)
|
||||
task = AITask.objects.create(
|
||||
team=team,
|
||||
created_by=user,
|
||||
project=None,
|
||||
task_type=task_type,
|
||||
status=AITask.Status.CREATED,
|
||||
model_config=model_config,
|
||||
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
|
||||
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode},
|
||||
estimated_cost=cost,
|
||||
)
|
||||
reserve_credit(team=team, user=user, task=task, amount=cost)
|
||||
task.status = AITask.Status.RESERVED
|
||||
task.save(update_fields=["status", "updated_at"])
|
||||
reservation = task.credit_reservation
|
||||
try:
|
||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
|
||||
media = provider.extract_first_media_url(response)
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
task.response_payload = response
|
||||
task.actual_cost = task.estimated_cost
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
|
||||
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
|
||||
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
|
||||
suffix = ".jpg" if "jpeg" in content_type else (".webp" if "webp" in content_type else ".png")
|
||||
asset_id = uuid.uuid4()
|
||||
object_key = f"teams/{team.id}/standalone/{asset_id}{suffix}"
|
||||
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {mode} · {index + 1}",
|
||||
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=category, origin_task=task,
|
||||
)
|
||||
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
|
||||
assets.append(asset)
|
||||
except Exception as exc:
|
||||
task.status = AITask.Status.FAILED
|
||||
task.error_message = str(exc)
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||
release_credit(reservation=reservation, reason=str(exc))
|
||||
raise
|
||||
return assets
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import AITaskViewSet, GenerateImageView, ModelConfigViewSet
|
||||
from .views import AITaskViewSet, ModelConfigViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("tasks", AITaskViewSet, basename="ai-task")
|
||||
router.register("models", ModelConfigViewSet, basename="model-config")
|
||||
|
||||
urlpatterns = [
|
||||
path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"),
|
||||
] + router.urls
|
||||
urlpatterns = router.urls
|
||||
|
||||
@ -1,36 +1,9 @@
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
|
||||
from apps.assets.serializers import AssetSerializer
|
||||
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
||||
from apps.common.api import TeamScopedViewSetMixin
|
||||
|
||||
from .models import AITask, ModelConfig
|
||||
from .serializers import AITaskSerializer, ModelConfigSerializer
|
||||
from .services import generate_standalone_image
|
||||
|
||||
|
||||
class GenerateImageView(APIView):
|
||||
"""POST /api/ai/generate-image/ — 独立生图(不绑项目)· 图片创作/模特图/平台套图共用。"""
|
||||
|
||||
def post(self, request):
|
||||
prompt = str(request.data.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
return Response({"detail": "prompt 不能为空"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
mode = str(request.data.get("mode") or "image")
|
||||
try:
|
||||
count = int(request.data.get("count") or 1)
|
||||
except (TypeError, ValueError):
|
||||
count = 1
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
assets = generate_standalone_image(team=team, user=request.user, prompt=prompt, mode=mode, count=count)
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as exc: # noqa: BLE001 — 生成失败已回滚额度,返回明确错误给前端
|
||||
return Response({"detail": f"生成失败: {exc}"}, status=status.HTTP_502_BAD_GATEWAY)
|
||||
return Response({"assets": AssetSerializer(assets, many=True).data}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
||||
|
||||
@ -1,23 +1,9 @@
|
||||
from django.conf import settings
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import Asset, AssetFile
|
||||
from .storage import TosStorage
|
||||
|
||||
|
||||
_tos_storage = None
|
||||
|
||||
|
||||
def _tos():
|
||||
global _tos_storage
|
||||
if _tos_storage is None:
|
||||
_tos_storage = TosStorage()
|
||||
return _tos_storage
|
||||
|
||||
|
||||
class AssetFileSerializer(serializers.ModelSerializer):
|
||||
preview_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = AssetFile
|
||||
fields = [
|
||||
@ -32,28 +18,7 @@ class AssetFileSerializer(serializers.ModelSerializer):
|
||||
"preview_url",
|
||||
"is_primary",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"object_key",
|
||||
"bucket",
|
||||
"content_type",
|
||||
"size_bytes",
|
||||
"width",
|
||||
"height",
|
||||
"duration_ms",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
def get_preview_url(self, obj):
|
||||
# 存储字段优先(如外部已写入绝对 URL);否则用 object_key 实时签发 TOS 预签名 GET URL
|
||||
if obj.preview_url:
|
||||
return obj.preview_url
|
||||
if not obj.object_key or not settings.TOS.get("endpoint"):
|
||||
return ""
|
||||
try:
|
||||
return _tos().presigned_get_url(object_key=obj.object_key)
|
||||
except Exception:
|
||||
return ""
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class AssetSerializer(serializers.ModelSerializer):
|
||||
|
||||
@ -2,7 +2,6 @@ from rest_framework import serializers
|
||||
|
||||
from .models import (
|
||||
BaseAssetGroup,
|
||||
BgmTrack,
|
||||
ExportJob,
|
||||
Project,
|
||||
ProjectStage,
|
||||
@ -10,7 +9,6 @@ from .models import (
|
||||
ScriptVersion,
|
||||
StoryboardFrame,
|
||||
StoryboardVersion,
|
||||
SubtitleTrack,
|
||||
Timeline,
|
||||
TimelineClip,
|
||||
VideoSegment,
|
||||
@ -26,17 +24,10 @@ class ProjectStageSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class VideoSegmentSerializer(serializers.ModelSerializer):
|
||||
adopted_asset = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = VideoSegment
|
||||
fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version", "adopted_asset"]
|
||||
read_only_fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version"]
|
||||
|
||||
def get_adopted_asset(self, obj):
|
||||
# pipeline stage4 缩略图:暴露已采用版本对应的资产 id(供前端在 assets 里解析 preview_url)
|
||||
version = obj.adopted_version
|
||||
return str(version.asset_id) if version and version.asset_id else None
|
||||
fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class BaseAssetGroupSerializer(serializers.ModelSerializer):
|
||||
@ -85,30 +76,14 @@ class TimelineExportJobSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class SubtitleTrackSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SubtitleTrack
|
||||
fields = ["id", "content", "style", "enabled"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class BgmTrackSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = BgmTrack
|
||||
fields = ["id", "asset", "volume", "start_ms"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class TimelineSerializer(serializers.ModelSerializer):
|
||||
clips = TimelineClipSerializer(many=True, read_only=True)
|
||||
export_jobs = TimelineExportJobSerializer(many=True, read_only=True)
|
||||
subtitle_tracks = SubtitleTrackSerializer(many=True, read_only=True)
|
||||
bgm_tracks = BgmTrackSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Timeline
|
||||
fields = ["id", "name", "aspect_ratio", "resolution", "duration_seconds", "metadata", "clips", "export_jobs", "subtitle_tracks", "bgm_tracks"]
|
||||
read_only_fields = ["id", "clips", "export_jobs", "subtitle_tracks", "bgm_tracks"]
|
||||
fields = ["id", "name", "aspect_ratio", "resolution", "duration_seconds", "metadata", "clips", "export_jobs"]
|
||||
read_only_fields = ["id", "clips", "export_jobs"]
|
||||
|
||||
|
||||
class ExportJobSerializer(serializers.ModelSerializer):
|
||||
|
||||
@ -1,105 +0,0 @@
|
||||
# 一次性灌演示数据(airshelf 团队)· 幂等 · 仅 ORM 插行,不碰表结构
|
||||
# 运行: ./.venv/Scripts/python.exe seed_demo.py
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
|
||||
django.setup()
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.common.api import get_current_team
|
||||
from apps.accounts.models import User
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.products.models import Product, ProductImage, ProductSellingPoint
|
||||
from apps.projects.models import (
|
||||
BaseAssetGroup, ExportJob, Project, ProjectStage, ScriptSegment, ScriptVersion,
|
||||
StoryboardFrame, StoryboardVersion, SubtitleTrack, BgmTrack, Timeline, TimelineClip,
|
||||
VideoSegment, VideoSegmentVersion,
|
||||
)
|
||||
|
||||
user = User.objects.get(username="airshelf")
|
||||
team = get_current_team(user)
|
||||
DEMO = "演示 · 透真玻尿酸补水面膜"
|
||||
if Product.objects.filter(team=team, title=DEMO).exists():
|
||||
print("ALREADY SEEDED — skip")
|
||||
raise SystemExit
|
||||
|
||||
src = list(AssetFile.objects.exclude(object_key="").values_list("object_key", "bucket"))
|
||||
if not src:
|
||||
print("NO SOURCE TOS OBJECTS — abort")
|
||||
raise SystemExit
|
||||
ctr = {"i": 0}
|
||||
now = timezone.now()
|
||||
|
||||
|
||||
def mkasset(name, category, atype="image", source=Asset.Source.AI_GENERATED):
|
||||
a = Asset.objects.create(team=team, created_by=user, name=name, asset_type=atype, source=source, category=category)
|
||||
ok, bk = src[ctr["i"] % len(src)]
|
||||
ctr["i"] += 1
|
||||
AssetFile.objects.create(asset=a, object_key=ok, bucket=bk, content_type="image/png", size_bytes=120000, is_primary=True)
|
||||
return a
|
||||
|
||||
|
||||
with transaction.atomic():
|
||||
prod_imgs = [mkasset("补水面膜 · 正面主图", "product_image", source=Asset.Source.UPLOAD),
|
||||
mkasset("补水面膜 · 质地细节", "product_image", source=Asset.Source.UPLOAD)]
|
||||
persons = [mkasset("模特 · 林夏(都市白领)", "person"), mkasset("模特 · 夜见(清新自然)", "person")]
|
||||
scenes = [mkasset("场景 · 深夜办公桌", "scene"), mkasset("场景 · 暖光化妆台", "scene")]
|
||||
clips = [mkasset("片段 · 场1 深夜办公桌", "video_clip"), mkasset("片段 · 场2 面膜特写", "video_clip"), mkasset("片段 · 场3 化妆台定格", "video_clip")]
|
||||
final = mkasset("成片 · 补水面膜痛点种草", "final_video", atype="video")
|
||||
bgm = mkasset("BGM · 轻电子节奏", "uncategorized", atype="audio")
|
||||
|
||||
p = Product.objects.create(
|
||||
team=team, created_by=user, title=DEMO, brand="透真", category="美妆个护",
|
||||
target_audience="22-32 岁女性 · 敏感肌 · 办公室通勤",
|
||||
description="30g 玻尿酸大精华面膜,深夜急救补水,敏感肌可用。", cover_asset=prod_imgs[0],
|
||||
)
|
||||
for i, a in enumerate(prod_imgs):
|
||||
ProductImage.objects.create(product=p, asset=a, sort_order=i, is_primary=(i == 0))
|
||||
for i, (t, d) in enumerate([("玻尿酸双效保湿", "4 小时持久水润"), ("30g 大精华", "一片顶三片"), ("敏感肌可用", "无香精 · 无酒精")]):
|
||||
ProductSellingPoint.objects.create(product=p, title=t, detail=d, sort_order=i)
|
||||
|
||||
Product.objects.create(team=team, created_by=user, title="演示 · 南卡 Lite Pro 蓝牙耳机", brand="南卡", category="数码 3C", description="主动降噪 · 35h 续航。")
|
||||
|
||||
proj = Project.objects.create(team=team, created_by=user, name="演示 · 补水面膜 · 痛点种草 v1", product=p, status=Project.Status.COMPLETED, current_stage="export")
|
||||
for st in ["script", "base_assets", "storyboard", "video", "export"]:
|
||||
ProjectStage.objects.create(project=proj, stage=st, status=ProjectStage.Status.SUCCEEDED, started_at=now, completed_at=now)
|
||||
|
||||
sv = ScriptVersion.objects.create(project=proj, title="痛点种草 v1", content="深夜办公→痛点→产品→使用→卖点收尾", source="ai", is_adopted=True)
|
||||
segs = []
|
||||
for i, (dur, nar, vis) in enumerate([
|
||||
(15, "加班三天,脸已经不能看了…", "深夜办公桌,疲惫特写"),
|
||||
(15, "还好我有这个透真玻尿酸面膜", "面膜包装特写"),
|
||||
(15, "敷完起来脸是软的,化妆都服帖", "化妆台,产品定格"),
|
||||
]):
|
||||
segs.append(ScriptSegment.objects.create(script_version=sv, sort_order=i, duration_seconds=dur, narration=nar, visual_prompt=vis))
|
||||
|
||||
for kind, adopted, cands in [
|
||||
(BaseAssetGroup.Kind.PRODUCT, prod_imgs[0], prod_imgs),
|
||||
(BaseAssetGroup.Kind.PERSON, persons[0], persons),
|
||||
(BaseAssetGroup.Kind.SCENE, scenes[0], scenes),
|
||||
]:
|
||||
g = BaseAssetGroup.objects.create(project=proj, kind=kind, prompt=f"{kind} 基础资产", adopted_asset=adopted, version=1)
|
||||
g.candidate_assets.set(cands)
|
||||
|
||||
sb = StoryboardVersion.objects.create(project=proj, prompt="统一商品/人物/场景风格,生成可指导视频的分镜", is_adopted=True)
|
||||
for i, (a, seg) in enumerate(zip([scenes[0], prod_imgs[1], scenes[1]], segs)):
|
||||
StoryboardFrame.objects.create(storyboard=sb, script_segment=seg, asset=a, sort_order=i, prompt=seg.visual_prompt)
|
||||
|
||||
for i, (seg, clip) in enumerate(zip(segs, clips)):
|
||||
vs = VideoSegment.objects.create(project=proj, script_segment=seg, sort_order=i, target_duration_seconds=15, status=VideoSegment.Status.SUCCEEDED)
|
||||
vv = VideoSegmentVersion.objects.create(video_segment=vs, asset=clip, prompt=seg.visual_prompt, is_adopted=True)
|
||||
vs.adopted_version = vv
|
||||
vs.save(update_fields=["adopted_version"])
|
||||
|
||||
tl = Timeline.objects.create(project=proj, name="补水面膜成片", aspect_ratio="9:16", resolution="1080x1920", duration_seconds=45)
|
||||
for i, clip in enumerate(clips):
|
||||
TimelineClip.objects.create(timeline=tl, asset=clip, sort_order=i, start_ms=i * 15000, duration_ms=15000)
|
||||
SubtitleTrack.objects.create(timeline=tl, content=[{"start_ms": i * 15000, "text": seg.narration} for i, seg in enumerate(segs)], enabled=True)
|
||||
BgmTrack.objects.create(timeline=tl, asset=bgm, volume=60, start_ms=0)
|
||||
ExportJob.objects.create(timeline=tl, status=ExportJob.Status.SUCCEEDED, output_asset=final, progress=100)
|
||||
|
||||
print("SEEDED ok | product=%s project=%s assets=%d products=%d" % (
|
||||
p.id, proj.id, Asset.objects.filter(team=team).count(), Product.objects.filter(team=team).count()))
|
||||
@ -7,7 +7,6 @@ import type {
|
||||
BillingSummary,
|
||||
Ledger,
|
||||
ModelConfig,
|
||||
Notification,
|
||||
Product,
|
||||
Project,
|
||||
Team,
|
||||
@ -76,8 +75,6 @@ export function App() {
|
||||
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
|
||||
const [billing, setBilling] = useState<BillingSummary | null>(null);
|
||||
const [ledgers, setLedgers] = useState<Ledger[]>([]);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
|
||||
|
||||
const [activeProductId, setActiveProductId] = useState(route.productId || "");
|
||||
@ -95,7 +92,7 @@ export function App() {
|
||||
);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const [productData, projectData, assetData, billingData, ledgerData, memberData, modelData, taskData, notificationData] =
|
||||
const [productData, projectData, assetData, billingData, ledgerData, memberData, modelData, taskData] =
|
||||
await Promise.all([
|
||||
api.products(),
|
||||
api.projects(),
|
||||
@ -104,8 +101,7 @@ export function App() {
|
||||
api.ledgers().catch(() => []),
|
||||
api.teamMembers().catch(() => []),
|
||||
api.modelConfigs().catch(() => null),
|
||||
api.aiTasks().catch(() => null),
|
||||
api.listNotifications().catch(() => null)
|
||||
api.aiTasks().catch(() => null)
|
||||
]);
|
||||
setProducts(productData.results);
|
||||
setProjects(projectData.results);
|
||||
@ -115,22 +111,10 @@ export function App() {
|
||||
setAiTasks(taskData?.results || []);
|
||||
if (billingData) setBilling(billingData);
|
||||
setLedgers(ledgerData);
|
||||
if (notificationData) {
|
||||
setNotifications(notificationData.results);
|
||||
setUnreadCount(notificationData.unread_count);
|
||||
}
|
||||
setActiveProjectId((current) => current || projectData.results[0]?.id || "");
|
||||
setActiveProductId((current) => current || productData.results[0]?.id || "");
|
||||
}, []);
|
||||
|
||||
const reloadNotifications = useCallback(async () => {
|
||||
const data = await api.listNotifications().catch(() => null);
|
||||
if (data) {
|
||||
setNotifications(data.results);
|
||||
setUnreadCount(data.unread_count);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Boot: validate token, hydrate identity + data.
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
@ -226,38 +210,6 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function markNotificationRead(id: string) {
|
||||
await api.markNotificationRead(id).catch(() => undefined);
|
||||
await reloadNotifications();
|
||||
}
|
||||
|
||||
async function markAllNotificationsRead() {
|
||||
await api.markAllNotificationsRead().catch(() => undefined);
|
||||
await reloadNotifications();
|
||||
}
|
||||
|
||||
async function saveProfile(payload: { name?: string; phone?: string; email?: string }) {
|
||||
const res = await action(() => api.updateProfile(payload), "资料已保存");
|
||||
if (res) {
|
||||
setUser(res.user);
|
||||
setTeam(res.team);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeOwnPassword(payload: { old_password: string; new_password: string }) {
|
||||
const res = await action(() => api.changePassword(payload), "密码已修改");
|
||||
if (res?.token) setToken(res.token);
|
||||
}
|
||||
|
||||
async function uploadOwnAvatar(formData: FormData) {
|
||||
const res = await action(() => api.uploadAvatar(formData), "头像已更新");
|
||||
if (res) setUser(res);
|
||||
}
|
||||
|
||||
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) {
|
||||
return action(() => api.generateImage(payload), "图片已生成");
|
||||
}
|
||||
|
||||
function onAuthed(payload: { token: string; user: User; team: Team }) {
|
||||
setToken(payload.token);
|
||||
setUser(payload.user);
|
||||
@ -343,7 +295,6 @@ export function App() {
|
||||
<ProductDetailPage
|
||||
product={activeProduct}
|
||||
projects={projects.filter((project) => project.product === activeProduct.id)}
|
||||
assets={assets}
|
||||
navigate={navigate}
|
||||
onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")}
|
||||
/>
|
||||
@ -390,56 +341,27 @@ export function App() {
|
||||
case "library":
|
||||
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} />;
|
||||
case "account":
|
||||
return (
|
||||
<AccountPage
|
||||
billing={billing}
|
||||
ledgers={ledgers}
|
||||
projects={projects}
|
||||
teamMembers={teamMembers}
|
||||
onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")}
|
||||
/>
|
||||
);
|
||||
return <AccountPage billing={billing} ledgers={ledgers} projects={projects} teamMembers={teamMembers} />;
|
||||
case "team":
|
||||
return (
|
||||
<TeamPage
|
||||
team={currentTeam}
|
||||
user={currentUser}
|
||||
members={teamMembers}
|
||||
billing={billing}
|
||||
navigate={navigate}
|
||||
onCreateMember={(payload) => action(() => api.createTeamMember(payload), "成员账户已创建")}
|
||||
onUpdateMember={(id, payload) => action(() => api.updateTeamMember(id, payload), "成员已更新")}
|
||||
onRemoveMember={(id) => action(() => api.removeTeamMember(id), "成员已移除")}
|
||||
onResetPassword={(id, password) => action(() => api.resetMemberPassword(id, password), "密码已重置")}
|
||||
onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")}
|
||||
/>
|
||||
);
|
||||
return <TeamPage team={currentTeam} user={currentUser} members={teamMembers} billing={billing} navigate={navigate} />;
|
||||
case "messages":
|
||||
return (
|
||||
<MessagesPage
|
||||
notifications={notifications}
|
||||
unreadCount={unreadCount}
|
||||
onMarkRead={markNotificationRead}
|
||||
onMarkAllRead={markAllNotificationsRead}
|
||||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
return <MessagesPage navigate={navigate} />;
|
||||
case "assetFactory":
|
||||
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} />;
|
||||
case "imageOptimize":
|
||||
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
|
||||
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
|
||||
case "modelPhoto":
|
||||
return <ImageWorkbenchPage mode="model" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
|
||||
return <ImageWorkbenchPage mode="model" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
|
||||
case "platformCover":
|
||||
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
|
||||
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
|
||||
case "modelPhotoDemoA":
|
||||
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} />;
|
||||
case "modelPhotoDemoB":
|
||||
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} />;
|
||||
case "settings":
|
||||
return <SettingsPage user={currentUser} team={currentTeam} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} />;
|
||||
return <SettingsPage user={currentUser} team={currentTeam} />;
|
||||
case "settingsNotify":
|
||||
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} />;
|
||||
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" />;
|
||||
default:
|
||||
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} navigate={navigate} />;
|
||||
}
|
||||
@ -460,10 +382,8 @@ export function App() {
|
||||
team={currentTeam}
|
||||
products={products}
|
||||
projects={projects}
|
||||
assets={assets}
|
||||
billing={billing}
|
||||
notice={notice}
|
||||
unreadCount={unreadCount}
|
||||
avatarChar={avatarChar}
|
||||
logout={logout}
|
||||
onRefresh={refreshProjectDetail}
|
||||
@ -524,7 +444,7 @@ export function App() {
|
||||
</span>
|
||||
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
|
||||
<IconKitSvg name="bell" />
|
||||
{unreadCount > 0 && <span className="count-noti">{unreadCount}</span>}
|
||||
<span className="count-noti">12</span>
|
||||
</button>
|
||||
<div className="topbar-avatar" onDoubleClick={logout} title="账户(双击退出)">
|
||||
<span>{avatarChar}</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -5,12 +5,9 @@ import type {
|
||||
BillingSummary,
|
||||
Ledger,
|
||||
ModelConfig,
|
||||
Notification,
|
||||
NotificationList,
|
||||
Paginated,
|
||||
Product,
|
||||
Project,
|
||||
RechargeResult,
|
||||
ScriptVersion,
|
||||
Team,
|
||||
TeamMember,
|
||||
@ -66,43 +63,12 @@ export const api = {
|
||||
me() {
|
||||
return request<{ user: User; team: Team }>("/api/auth/me/");
|
||||
},
|
||||
updateProfile(payload: { name?: string; phone?: string; email?: string }) {
|
||||
return request<{ user: User; team: Team }>("/api/auth/me/", { method: "PATCH", body: JSON.stringify(payload) });
|
||||
},
|
||||
changePassword(payload: { old_password: string; new_password: string }) {
|
||||
return request<{ token: string }>("/api/auth/me/password/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
uploadAvatar(formData: FormData) {
|
||||
return request<User>("/api/auth/me/avatar/", { method: "POST", body: formData });
|
||||
},
|
||||
logout() {
|
||||
return request<void>("/api/auth/logout/", { method: "POST" });
|
||||
},
|
||||
teamMembers() {
|
||||
return request<TeamMember[]>("/api/auth/team/members/");
|
||||
},
|
||||
createTeamMember(payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
monthly_credit_limit?: number | string;
|
||||
}) {
|
||||
return request<TeamMember>("/api/auth/team/members/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
updateTeamMember(id: string, payload: { role?: string; monthly_credit_limit?: number | string; name?: string }) {
|
||||
return request<TeamMember>(`/api/auth/team/members/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
},
|
||||
removeTeamMember(id: string) {
|
||||
return request<void>(`/api/auth/team/members/${id}/`, { method: "DELETE" });
|
||||
},
|
||||
resetMemberPassword(id: string, password: string) {
|
||||
return request<void>(`/api/auth/team/members/${id}/password/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
},
|
||||
products() {
|
||||
return request<Paginated<Product>>("/api/products/");
|
||||
},
|
||||
@ -191,26 +157,5 @@ export const api = {
|
||||
},
|
||||
aiTasks() {
|
||||
return request<Paginated<AITask>>("/api/ai/tasks/");
|
||||
},
|
||||
generateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) {
|
||||
return request<{ assets: Asset[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
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 }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.type && params.type !== "all") query.set("type", params.type);
|
||||
if (params?.unread) query.set("unread", "1");
|
||||
const qs = query.toString();
|
||||
return request<NotificationList>(`/api/ops/notifications/${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
markAllNotificationsRead() {
|
||||
return request<{ updated: number; unread_count: number }>("/api/ops/notifications/mark-all-read/", {
|
||||
method: "POST"
|
||||
});
|
||||
},
|
||||
markNotificationRead(id: string) {
|
||||
return request<Notification>(`/api/ops/notifications/${id}/mark-read/`, { method: "POST" });
|
||||
}
|
||||
};
|
||||
|
||||
33
core/frontend/src/exact-pages.css
Normal file
33
core/frontend/src/exact-pages.css
Normal file
@ -0,0 +1,33 @@
|
||||
/* Exact page-level CSS copied from 电商AI平台/index.html. */
|
||||
.exact-document-route {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: var(--background-base);
|
||||
}
|
||||
.exact-document-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
border: 0;
|
||||
background: var(--background-base);
|
||||
}
|
||||
.app .brand { gap: 0; padding: 0; }
|
||||
.app .search-box .kbd { background: transparent; border: 0; border-radius: 0; padding: 0; }
|
||||
.dash-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 24px; align-items: start; }
|
||||
.recent-row { display: grid; grid-template-columns: 54px 1fr 110px 130px 60px; align-items: center; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border-faint); cursor: pointer; width: auto; text-align: inherit; background: transparent; }
|
||||
.recent-row .prog, .recent-row .pill, .recent-row .btn { justify-self: start; }
|
||||
.recent-row:last-child { border-bottom: 0; }
|
||||
.recent-row:hover { background: var(--background-lighter); }
|
||||
.recent-row .thumb { width: 54px; height: 70px; border-radius: var(--r-md); }
|
||||
.recent-meta .name { font-weight: 600; font-size: 13.5px; color: var(--accent-black); }
|
||||
.recent-meta .sub { font-size: 12px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .01em; }
|
||||
.shortcuts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.shortcut { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 16px; display: flex; align-items: flex-start; gap: 12px; cursor: pointer; transition: background var(--t-base); }
|
||||
.shortcut:hover { background: var(--black-alpha-4); }
|
||||
.shortcut .ic { width: 32px; height: 32px; background: var(--heat-12); color: var(--heat); display: grid; place-items: center; border: 0; border-radius: var(--r-md); flex-shrink: 0; }
|
||||
.shortcut .ic svg { width: 16px; height: 16px; }
|
||||
.shortcut .t { font-size: 13px; font-weight: 600; }
|
||||
.shortcut .d { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .01em; }
|
||||
.tip { background: var(--surface); border: 1px dashed var(--border-faint); padding: 14px 16px; font-size: 12.5px; color: var(--black-alpha-56); line-height: 1.6; border-radius: var(--r-md); }
|
||||
.tip strong { color: var(--accent-black); font-weight: 600; display: block; margin-bottom: 4px; }
|
||||
.tip .mono { font-family: var(--font-mono); color: var(--heat); background: var(--heat-12); padding: 1px 5px; border-radius: var(--r-sm); font-size: 11.5px; }
|
||||
@ -6,8 +6,6 @@
|
||||
.asset-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
|
||||
.asset-thumb { aspect-ratio: 1; }
|
||||
.asset-card.video .asset-thumb { aspect-ratio: 9/16; max-height: 280px; }
|
||||
/* 有 preview_url 显真图:铺满缩略容器、cover 裁切、继承 8px 圆角(由 .placeholder overflow:hidden 裁切) */
|
||||
.asset-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: inherit; }
|
||||
.asset-body { padding: 12px 14px; }
|
||||
.asset-name { font-size: 13px; font-weight: 600; color: var(--accent-black); }
|
||||
.asset-meta { font-size: 11px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
|
||||
|
||||
@ -2,6 +2,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
import "./design-restraint.css";
|
||||
import "./exact-pages.css";
|
||||
import "./account-page.css";
|
||||
import "./product-detail-page.css";
|
||||
import "./team-page.css";
|
||||
@ -9,7 +10,5 @@ import "./pipeline-page.css";
|
||||
import "./projects-page.css";
|
||||
import "./products-page.css";
|
||||
import "./library-page.css";
|
||||
import "./messages-page.css";
|
||||
import "./settings-page.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@ -1,355 +0,0 @@
|
||||
/* messages-page.css · 对齐 public/exact/messages.html · 仅 token,scoped 在 .msg-* */
|
||||
.msg-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.msg-page .page-head {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.msg-head-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.msg-workbench {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
|
||||
min-height: 640px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.msg-panel {
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.msg-inbox,
|
||||
.msg-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.msg-inbox { border-right: 1px solid var(--border-faint); }
|
||||
.msg-panel-h {
|
||||
min-height: 58px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
.msg-panel-h .ti {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-black);
|
||||
}
|
||||
.msg-panel-h .mono {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--black-alpha-48);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.msg-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
.msg-filter {
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface);
|
||||
color: var(--black-alpha-56);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.msg-filter:hover {
|
||||
border-color: var(--black-alpha-24);
|
||||
color: var(--accent-black);
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
.msg-filter.active {
|
||||
border-color: var(--heat-20);
|
||||
background: var(--heat-12);
|
||||
color: var(--heat);
|
||||
font-weight: 600;
|
||||
}
|
||||
.msg-filter .ct {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.msg-search {
|
||||
position: relative;
|
||||
padding: 0 14px 12px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
.msg-search svg {
|
||||
position: absolute;
|
||||
left: 26px;
|
||||
top: 10px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
color: var(--black-alpha-48);
|
||||
pointer-events: none;
|
||||
}
|
||||
.msg-search input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 12px 0 32px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--background-lighter);
|
||||
color: var(--accent-black);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.msg-search input:focus {
|
||||
background: var(--surface);
|
||||
border-color: var(--heat-40);
|
||||
box-shadow: inset 0 0 0 1px var(--heat-40);
|
||||
}
|
||||
.msg-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.msg-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.msg-item:hover { background: var(--black-alpha-4); }
|
||||
.msg-item.active { background: var(--heat-12); }
|
||||
.msg-item.active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--heat);
|
||||
}
|
||||
.msg-item.read .msg-item-title { color: var(--black-alpha-56); font-weight: 500; }
|
||||
.msg-type-ic {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--background-lighter);
|
||||
color: var(--black-alpha-72);
|
||||
}
|
||||
.msg-type-ic svg { width: 14px; height: 14px; }
|
||||
.msg-type-ic.task { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
|
||||
.msg-type-ic.team { background: var(--black-alpha-4); color: var(--accent-black); }
|
||||
.msg-type-ic.billing { background: var(--honey-bg); border-color: var(--honey-bd); color: var(--accent-honey); }
|
||||
.msg-type-ic.system { background: var(--black-alpha-7); color: var(--black-alpha-72); }
|
||||
.msg-item-main { min-width: 0; }
|
||||
.msg-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.msg-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--heat);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.msg-item.read .msg-dot { display: none; }
|
||||
.msg-item-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--accent-black);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.msg-time {
|
||||
flex-shrink: 0;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.msg-brief {
|
||||
margin-top: 4px;
|
||||
color: var(--black-alpha-56);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.msg-item-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.msg-priority {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--background-lighter);
|
||||
color: var(--black-alpha-56);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.msg-priority.ok { background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest); }
|
||||
.msg-priority.warn { background: var(--honey-bg); border-color: var(--honey-bd); color: var(--accent-honey); }
|
||||
.msg-priority.err { background: var(--crimson-bg); border-color: var(--crimson-bd); color: var(--accent-crimson); }
|
||||
.msg-priority.info { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
|
||||
.msg-empty {
|
||||
min-height: 320px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
color: var(--black-alpha-48);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.msg-empty svg { width: 24px; height: 24px; color: var(--black-alpha-48); }
|
||||
.msg-detail-empty {
|
||||
flex: 1;
|
||||
min-height: 520px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
color: var(--black-alpha-48);
|
||||
text-align: center;
|
||||
}
|
||||
.msg-detail-empty .ic {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--background-lighter);
|
||||
}
|
||||
.msg-detail-empty svg { width: 21px; height: 21px; }
|
||||
.msg-detail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 22px 24px 24px;
|
||||
}
|
||||
.msg-detail-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
.msg-detail-title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.msg-detail-title h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 1.35;
|
||||
font-weight: 600;
|
||||
letter-spacing: -.012em;
|
||||
color: var(--accent-black);
|
||||
}
|
||||
.msg-detail-title .meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.msg-body-text {
|
||||
margin: 18px 0 0;
|
||||
color: var(--accent-black);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.msg-props {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr;
|
||||
gap: 10px 16px;
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--background-lighter);
|
||||
}
|
||||
.msg-props .k {
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.msg-props .v {
|
||||
min-width: 0;
|
||||
color: var(--accent-black);
|
||||
font-size: 13px;
|
||||
}
|
||||
.msg-props .v a { color: var(--heat); }
|
||||
.msg-detail-f {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid var(--border-faint);
|
||||
background: var(--background-lighter);
|
||||
}
|
||||
.msg-detail-f .spacer { flex: 1; }
|
||||
.msg-foot-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.msg-foot-note a { color: var(--heat); cursor: pointer; }
|
||||
@media (max-width: 1280px) {
|
||||
.msg-workbench { grid-template-columns: minmax(300px, 340px) minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.msg-workbench { grid-template-columns: 1fr; }
|
||||
.msg-inbox { border-right: 0; border-bottom: 1px solid var(--border-faint); }
|
||||
.msg-list { max-height: 360px; }
|
||||
}
|
||||
@ -1,267 +0,0 @@
|
||||
/* ============================================================
|
||||
product-create-upload · 新建商品(上传原图 + 基本信息)
|
||||
像素基线: public/exact/_archive/.../product-create-v2.html
|
||||
只用 design-restraint.css 的 token · 共享类 (.page-head/.field/
|
||||
.input/.select/.textarea/.btn/.bullet-list) 直接复用,本文件只放
|
||||
该页专属布局(双栏卡片 / 原图槽位 / 提示框 / 吸底操作栏)。
|
||||
全部规则 scope 在 .product-create-page 下,避免污染他页。
|
||||
============================================================ */
|
||||
|
||||
/* ─── 主表单双栏 ─── */
|
||||
.product-create-page .form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 1fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.product-create-page .form-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
padding: 24px;
|
||||
}
|
||||
.product-create-page .form-card-wide { margin-bottom: 24px; }
|
||||
|
||||
.product-create-page .form-card .card-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.product-create-page .form-card .card-h h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-black);
|
||||
}
|
||||
.product-create-page .form-card .req-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 2px 7px;
|
||||
background: var(--crimson-bg);
|
||||
color: var(--accent-crimson);
|
||||
border: 1px solid var(--crimson-bd);
|
||||
border-radius: var(--r-sm);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.product-create-page .form-card .opt-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 2px 7px;
|
||||
background: var(--background-lighter);
|
||||
color: var(--black-alpha-56);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.product-create-page .form-card .card-sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--black-alpha-48);
|
||||
margin: -10px 0 14px;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
|
||||
/* 字段:本页卡片内最后一个 field 去掉底距 */
|
||||
.product-create-page .field-last { margin-bottom: 0; }
|
||||
.product-create-page .form-card .field-hint { margin: 4px 0 8px; }
|
||||
|
||||
/* ─── 原图槽位 ─── */
|
||||
.product-create-page .photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.product-create-page .photo-slot {
|
||||
aspect-ratio: 1;
|
||||
border: 1px dashed var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--background-lighter);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 4px;
|
||||
color: var(--black-alpha-32);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: .04em;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.product-create-page .photo-slot-add { cursor: pointer; }
|
||||
.product-create-page .photo-slot-add:hover {
|
||||
border-color: var(--heat);
|
||||
color: var(--heat);
|
||||
background: var(--heat-8);
|
||||
}
|
||||
.product-create-page .photo-slot .plus {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--r-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.product-create-page .photo-slot .plus svg { width: 12px; height: 12px; }
|
||||
.product-create-page .photo-slot .slot-label {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
background: var(--surface);
|
||||
color: var(--black-alpha-48);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
/* ─── 上传提示(虚线 tip) ─── */
|
||||
.product-create-page .upload-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
background: var(--heat-8);
|
||||
border: 1px dashed var(--heat-40);
|
||||
border-radius: var(--r-md);
|
||||
font-size: 12px;
|
||||
color: var(--accent-black);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.product-create-page .upload-tip svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--heat);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.product-create-page .upload-tip strong { color: var(--heat); font-weight: 600; }
|
||||
|
||||
/* ─── AI 提示 banner(虚线 tip · 中性) ─── */
|
||||
.product-create-page .ai-tip {
|
||||
margin: -6px 0 16px;
|
||||
padding: 10px 12px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px dashed var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--black-alpha-64);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.product-create-page .ai-tip svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
color: var(--heat);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.product-create-page .ai-tip strong { color: var(--accent-black); font-weight: 600; }
|
||||
|
||||
/* ─── 卖点列表 · 复用 §4.17.5 .bullet-list 语义 ───
|
||||
共享定义 scope 在 .np-body 下,这里把同一套 token 规则
|
||||
挂到本页根,视觉与 restraint.css 完全一致。 */
|
||||
.product-create-page .bullet-list { list-style: none; padding: 0; }
|
||||
.product-create-page .bullet-list li {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--accent-black);
|
||||
transition: border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.product-create-page .bullet-list li.bl-item:hover { border-color: var(--black-alpha-24); }
|
||||
.product-create-page .bullet-list li.bl-add { background: var(--surface); border-style: dashed; }
|
||||
.product-create-page .bullet-list li.bl-add:focus-within { border-color: var(--heat-40); }
|
||||
.product-create-page .bullet-list .num {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--black-alpha-56);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.product-create-page .bullet-list li.bl-add .num {
|
||||
background: transparent;
|
||||
color: var(--heat);
|
||||
border-color: var(--heat-40);
|
||||
}
|
||||
.product-create-page .bullet-list .bl-text { flex: 1; min-width: 0; }
|
||||
.product-create-page .bullet-list .bl-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
padding: 0 4px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: var(--accent-black);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.product-create-page .bullet-list .bl-input::placeholder { color: var(--black-alpha-48); }
|
||||
.product-create-page .bullet-list .bl-x {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--black-alpha-32);
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity var(--t-base), background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.product-create-page .bullet-list li.bl-item:hover .bl-x { opacity: 1; }
|
||||
.product-create-page .bullet-list .bl-x:hover { background: var(--crimson-bg); color: var(--accent-crimson); }
|
||||
.product-create-page .bullet-list .bl-x svg { width: 11px; height: 11px; }
|
||||
|
||||
/* ─── 底部操作行(吸底) ─── */
|
||||
.product-create-page .form-foot {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
padding: 14px 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.product-create-page .form-foot .req-info {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--black-alpha-48);
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.product-create-page .form-foot .req-info .ok { color: var(--accent-forest); }
|
||||
.product-create-page .form-foot .req-info .miss { color: var(--accent-crimson); }
|
||||
.product-create-page .form-foot .foot-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ─── 响应式 · 窄屏单列 ─── */
|
||||
@media (max-width: 1100px) {
|
||||
.product-create-page .form-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@ -698,8 +698,6 @@
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 有 preview_url 显真图:铺满缩图、cover 裁切、继承圆角 */
|
||||
.asset-card .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: inherit; }
|
||||
.asset-card .thumb .type-pill {
|
||||
position: absolute; top: 8px; left: 8px;
|
||||
padding: 3px 8px;
|
||||
|
||||
@ -1,312 +0,0 @@
|
||||
/* 新建视频项目 · 向导页 · 从 public/exact/projects-new.html 内联 <style> 忠实移植。
|
||||
整段 scope 进 .project-wizard-page,避免污染同文件的 ProjectsPage(.projects-page)。
|
||||
token + 共享组件(.btn/.field/.input/.textarea/.select/.placeholder/.pp-chip/.pp-menu .mi)走全局 design-restraint;
|
||||
此处只覆盖向导专属:.wizard 网格 / .steps 步骤轨 / .pp- 商品选择器 / .opt-card / .source-card / .wiz-start-bar。 */
|
||||
.project-wizard-page {
|
||||
/* ── 两栏栅格:左 sticky 步骤轨 + 右主体 ── */
|
||||
.wizard { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 36px; align-items: start; max-width: 1400px; }
|
||||
|
||||
.steps {
|
||||
position: sticky;
|
||||
top: calc(64px + 24px);
|
||||
align-self: start;
|
||||
max-height: calc(100vh - 64px - 48px);
|
||||
overflow-y: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ── 单页式主体:Step 1 + Step 2 同时显示,底部「开始」CTA ── */
|
||||
.wiz-body { display: flex; flex-direction: column; gap: 14px; }
|
||||
.step-pane-wrap { display: block; }
|
||||
|
||||
/* ── 左侧步骤轨 .step ── */
|
||||
.step { display: flex; gap: 12px; padding: 12px 0; position: relative; }
|
||||
.step:not(:last-child)::after { content: ''; position: absolute; left: 11px; top: 36px; width: 1px; height: calc(100% - 24px); background: var(--border-faint); }
|
||||
.step .num { width: 24px; height: 24px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); display: grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--black-alpha-48); flex-shrink: 0; z-index: 1; font-family: var(--font-mono); }
|
||||
.step .num svg { width: 12px; height: 12px; }
|
||||
.step.done .num { background: var(--accent-black); border-color: var(--accent-black); color: var(--accent-white); }
|
||||
.step.active .num { background: var(--heat); border-color: var(--heat); color: var(--accent-white); }
|
||||
.step .label { font-size: 13.5px; font-weight: 500; color: var(--black-alpha-56); padding-top: 2px; }
|
||||
.step .desc { font-size: 11.5px; color: var(--black-alpha-48); padding-top: 3px; line-height: 1.4; font-family: var(--font-mono); letter-spacing: .02em; }
|
||||
.step.active .label { color: var(--accent-black); font-weight: 600; }
|
||||
.step.done .label { color: var(--black-alpha-56); }
|
||||
.step.done:not(:last-child)::after { background: var(--accent-black); }
|
||||
.step.clickable { cursor: pointer; }
|
||||
.step.clickable:hover .label { color: var(--heat); }
|
||||
.step.clickable:hover .num { border-color: var(--heat); }
|
||||
|
||||
/* ── 主体卡片 .wiz-pane ── */
|
||||
.wiz-pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 22px 24px; margin-bottom: 14px; }
|
||||
.wiz-pane:last-child { margin-bottom: 0; }
|
||||
.wiz-step-h { margin-bottom: 18px; }
|
||||
.wiz-step-h h2 { font-size: 20px; font-weight: 600; letter-spacing: -.015em; }
|
||||
.wiz-step-h p { font-size: 13px; color: var(--black-alpha-56); margin-top: 6px; line-height: 1.6; }
|
||||
|
||||
/* ── Step 1 · 商品选择器 toolbar(沿用商品库视觉,.pp- 命名空间)── */
|
||||
.pp-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.pp-toolbar .search-inline {
|
||||
flex: 1; min-width: 220px; max-width: 340px;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
height: 34px; padding: 0 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.pp-toolbar .search-inline:focus-within { border-color: var(--heat-40); }
|
||||
.pp-toolbar .search-inline svg { width: 14px; height: 14px; color: var(--black-alpha-48); flex-shrink: 0; }
|
||||
.pp-toolbar .search-inline input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; font-size: 13px; color: var(--accent-black); font-family: inherit; }
|
||||
.pp-toolbar .search-inline input::placeholder { color: var(--black-alpha-48); }
|
||||
.pp-toolbar .pp-chip-wrap { position: relative; }
|
||||
.pp-toolbar .pp-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 34px; padding: 0 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
font-size: 13px; font-family: inherit;
|
||||
color: var(--black-alpha-72);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.pp-toolbar .pp-chip:hover { border-color: var(--black-alpha-32); color: var(--accent-black); }
|
||||
.pp-toolbar .pp-chip.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); }
|
||||
.pp-toolbar .pp-chip svg { width: 11px; height: 11px; opacity: .6; }
|
||||
.pp-toolbar .pp-menu {
|
||||
position: absolute; top: calc(100% + 4px); left: 0;
|
||||
min-width: 160px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--shadow-floating);
|
||||
padding: 4px;
|
||||
display: none;
|
||||
z-index: 20;
|
||||
}
|
||||
.pp-toolbar .pp-chip-wrap.open .pp-menu { display: block; }
|
||||
.pp-toolbar .pp-menu .mi {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12.5px;
|
||||
color: var(--accent-black);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pp-toolbar .pp-menu .mi:hover { background: var(--background-lighter); }
|
||||
.pp-toolbar .pp-menu .mi.selected { color: var(--heat); font-weight: 600; }
|
||||
.pp-toolbar .pp-menu .mi-check { width: 12px; height: 12px; opacity: 0; flex-shrink: 0; }
|
||||
.pp-toolbar .pp-menu .mi.selected .mi-check { opacity: 1; }
|
||||
.pp-toolbar .pp-clear {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
height: 30px; padding: 0 10px;
|
||||
background: transparent; border: 0; border-radius: var(--r-sm);
|
||||
color: var(--black-alpha-56); font-size: 12.5px; font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pp-toolbar .pp-clear:hover { color: var(--accent-crimson); background: var(--crimson-bg); }
|
||||
.pp-toolbar .pp-clear svg { width: 11px; height: 11px; }
|
||||
|
||||
.pp-result-meta {
|
||||
font-family: var(--font-mono); font-size: 11.5px;
|
||||
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
|
||||
/* ── Step 1 · 商品网格(固定 4 列,沿用 .product-card 视觉)── */
|
||||
.pp-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
|
||||
.pp-grid .product-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
cursor: pointer; position: relative; overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
transition: background .15s, border-color .15s, transform .15s;
|
||||
}
|
||||
.pp-grid .product-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
|
||||
.pp-grid .product-card.selected { border-color: var(--heat); background: var(--heat-12); }
|
||||
.pp-grid .product-card.selected::after {
|
||||
content: ''; position: absolute; top: 0; right: 0;
|
||||
width: 0; height: 0;
|
||||
border-top: 28px solid var(--heat);
|
||||
border-left: 28px solid transparent;
|
||||
z-index: 2;
|
||||
}
|
||||
.pp-grid .product-card.selected::before {
|
||||
content: ''; position: absolute; top: 4px; right: 4px;
|
||||
width: 10px; height: 10px;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23ffffff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 8 7 12 13 4'/%3E%3C/svg%3E") no-repeat center / contain;
|
||||
z-index: 3;
|
||||
}
|
||||
.pp-grid .product-thumb { aspect-ratio: 1.4 / 1; }
|
||||
.pp-grid .product-body { padding: 14px 14px 12px; flex: 1; }
|
||||
.pp-grid .product-name {
|
||||
font-size: 14px; font-weight: 600; color: var(--accent-black);
|
||||
line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.pp-grid .product-cat {
|
||||
display: inline-flex; align-items: center;
|
||||
margin-top: 8px; padding: 2px 8px;
|
||||
background: var(--background-lighter); color: var(--black-alpha-72);
|
||||
border-radius: var(--r-sm); font-size: 11.5px;
|
||||
}
|
||||
.pp-grid .product-date {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px; color: var(--black-alpha-48);
|
||||
margin-top: 10px; letter-spacing: .02em;
|
||||
}
|
||||
.pp-grid .product-card.selected .product-cat { background: var(--surface); color: var(--heat); }
|
||||
|
||||
/* ── Step 1 · 创建新商品 空卡 ── */
|
||||
.pp-grid .pp-create-card {
|
||||
border: 1.5px dashed var(--black-alpha-24);
|
||||
border-radius: var(--r-md);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 10px; min-height: 220px;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: inherit;
|
||||
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.pp-grid .pp-create-card:hover { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }
|
||||
.pp-grid .pp-create-card .pc-plus {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--heat); color: var(--accent-white);
|
||||
display: grid; place-items: center;
|
||||
transition: filter var(--t-base);
|
||||
}
|
||||
.pp-grid .pp-create-card:hover .pc-plus { filter: brightness(1.06); }
|
||||
.pp-grid .pp-create-card .pc-plus svg { width: 18px; height: 18px; }
|
||||
.pp-grid .pp-create-card .pc-t { font-size: 13px; font-weight: 600; }
|
||||
.pp-grid .pp-create-card .pc-d { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; }
|
||||
|
||||
/* ── Step 1 · 列表视图 ── */
|
||||
.pp-grid.list-view { display: flex; flex-direction: column; gap: 6px; }
|
||||
.pp-grid.list-view .product-card { flex-direction: row; align-items: center; }
|
||||
.pp-grid.list-view .product-thumb { width: 96px; aspect-ratio: 1.4 / 1; flex-shrink: 0; }
|
||||
.pp-grid.list-view .product-body { flex: 1; padding: 10px 14px; }
|
||||
.pp-grid.list-view .pp-create-card { flex-direction: row; min-height: 56px; gap: 12px; }
|
||||
.pp-grid.list-view .pp-create-card .pc-plus { width: 32px; height: 32px; }
|
||||
|
||||
/* ── Step 1 · 空筛选结果 ── */
|
||||
.pp-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 48px 24px; text-align: center;
|
||||
border: 1px dashed var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--background-lighter);
|
||||
color: var(--black-alpha-48);
|
||||
font-size: 12.5px; font-family: var(--font-mono); letter-spacing: .02em;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.pp-empty .reset { display: inline-block; margin-top: 8px; color: var(--heat); cursor: pointer; }
|
||||
|
||||
/* ── Step 1 · 分页 ── */
|
||||
.pp-pager {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
margin-top: 18px; padding-top: 14px;
|
||||
border-top: 1px solid var(--border-faint);
|
||||
font-size: 12.5px; color: var(--black-alpha-56);
|
||||
}
|
||||
.pp-pager .total { font-family: var(--font-mono); letter-spacing: .02em; }
|
||||
.pp-pager .pages { display: inline-flex; gap: 4px; margin-left: auto; }
|
||||
.pp-pager .pages button {
|
||||
min-width: 28px; height: 28px; padding: 0 8px;
|
||||
border: 1px solid var(--border-faint); background: var(--surface);
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer; font-size: 12.5px; color: var(--black-alpha-72); font-family: inherit;
|
||||
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.pp-pager .pages button:hover:not(.active):not(:disabled) { border-color: var(--black-alpha-32); color: var(--accent-black); }
|
||||
.pp-pager .pages button.active { background: var(--heat); color: var(--accent-white); border-color: var(--heat); font-weight: 600; }
|
||||
.pp-pager .pages button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.pp-pager .pages .ellipsis {
|
||||
min-width: 22px; height: 28px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: var(--black-alpha-48); font-family: var(--font-mono);
|
||||
}
|
||||
.pp-pager .page-size {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
height: 28px; padding: 0 10px;
|
||||
background: var(--surface); border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
font-family: inherit; font-size: 12.5px; color: var(--black-alpha-72);
|
||||
}
|
||||
|
||||
/* ── Step 1 · 底部提示 ── */
|
||||
.pp-bottom-tip {
|
||||
margin-top: 14px;
|
||||
padding: 10px 14px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
font-size: 12.5px; color: var(--black-alpha-56);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.pp-bottom-tip svg { width: 14px; height: 14px; flex-shrink: 0; color: var(--black-alpha-48); }
|
||||
.pp-bottom-tip a { color: var(--heat); cursor: pointer; text-decoration: none; }
|
||||
.pp-bottom-tip a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Step 2 · 配置字段(共享 .field/.input/.select 走全局)── */
|
||||
.config-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start; margin-bottom: 16px; }
|
||||
.config-row .field { margin-bottom: 0; }
|
||||
.duration-select { cursor: pointer; }
|
||||
|
||||
/* ── Step 2 · 选项卡 .opt-card ── */
|
||||
.opt-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.opt-row.cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.opt-row.cols-6 { grid-template-columns: repeat(3, 1fr); }
|
||||
.opt-card { border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px; background: var(--surface); cursor: pointer; position: relative; display: flex; flex-direction: column; min-width: 0; transition: background var(--t-base), border-color var(--t-base); }
|
||||
.opt-card:hover { background: var(--background-lighter); }
|
||||
.opt-card.selected { border-color: var(--heat); background: var(--heat-12); }
|
||||
.opt-card.selected::after { content: ''; position: absolute; top: 8px; right: 10px; width: 16px; height: 16px; background-color: var(--heat); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 10px 10px; border-radius: var(--r-sm); }
|
||||
.opt-card h4 { font-size: 13px; font-weight: 600; }
|
||||
.opt-card .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 3px; letter-spacing: .02em; }
|
||||
.opt-card .note { font-size: 11.5px; color: var(--black-alpha-56); margin-top: 6px; line-height: 1.5; }
|
||||
.opt-card .metric { margin-top: auto; padding-top: 10px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
|
||||
.opt-card .metric .val { color: var(--accent-black); font-weight: 500; }
|
||||
.opt-card.selected .metric .val { color: var(--heat); }
|
||||
.opt-card .badge { font-family: var(--font-mono); font-size: 9.5px; padding: 1px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-48); display: inline-block; margin-top: 8px; letter-spacing: .04em; align-self: flex-start; }
|
||||
.opt-card.selected .badge { color: var(--heat); border-color: var(--heat-20); }
|
||||
|
||||
@media (min-width: 1280px) { .opt-row.cols-6 { grid-template-columns: repeat(6, 1fr); } }
|
||||
|
||||
/* ── Step 2 · 卖点胶囊 .theme-pill ── */
|
||||
.theme-pill-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.theme-pill { display: inline-flex; gap: 6px; align-items: center; height: 36px; padding: 0 16px; border: 1px solid var(--border-faint); border-radius: 999px; background: var(--surface); font-size: 13px; font-weight: 500; font-family: inherit; cursor: pointer; color: var(--accent-black); transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
|
||||
.theme-pill:hover { background: var(--background-lighter); }
|
||||
.theme-pill.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat); font-weight: 600; }
|
||||
.theme-pill svg { width: 13px; height: 13px; }
|
||||
|
||||
/* ── Step 2 · 人设推荐气泡 .reco-bubble ── */
|
||||
.reco-bubble { position: relative; margin-top: 10px; padding: 10px 14px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-md); display: flex; align-items: center; gap: 12px; font-size: 12.5px; color: var(--accent-black); }
|
||||
.reco-bubble::before { content: ''; position: absolute; top: -5px; left: 28px; width: 9px; height: 9px; background: var(--heat-12); border-left: 1px solid var(--heat-20); border-top: 1px solid var(--heat-20); transform: rotate(45deg); }
|
||||
.reco-bubble .ic { color: var(--heat); flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; }
|
||||
.reco-bubble .ic svg, .reco-bubble .dismiss svg { display: block; width: 16px; height: 16px; }
|
||||
.reco-bubble .txt { flex: 1; line-height: 1.5; }
|
||||
.reco-bubble .txt strong { color: var(--heat); font-weight: 600; }
|
||||
.reco-bubble .txt .meta { display: block; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 2px; letter-spacing: .02em; }
|
||||
.reco-bubble .btn-apply { height: 28px; padding: 0 12px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-md); font-size: 12px; font-weight: 600; cursor: pointer; flex-shrink: 0; box-shadow: var(--shadow-cta); transition: box-shadow var(--t-base); font-family: inherit; }
|
||||
.reco-bubble .btn-apply:hover { box-shadow: var(--shadow-cta-hover); }
|
||||
.reco-bubble .dismiss { background: transparent; color: var(--black-alpha-48); border: 0; width: 24px; height: 24px; padding: 0; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||
.reco-bubble .dismiss:hover { color: var(--accent-black); }
|
||||
|
||||
/* ── 底部「开始」CTA ── */
|
||||
.wiz-start-bar { display: flex; justify-content: flex-end; padding: 20px 0 8px; }
|
||||
.wiz-start-bar .btn-start { height: 44px; padding: 0 36px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: 999px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: var(--shadow-cta); display: inline-flex; align-items: center; gap: 8px; font-family: inherit; transition: box-shadow var(--t-base), opacity var(--t-base); }
|
||||
.wiz-start-bar .btn-start:hover:not(.disabled) { box-shadow: var(--shadow-cta-hover); }
|
||||
.wiz-start-bar .btn-start.disabled { opacity: .4; cursor: not-allowed; }
|
||||
.wiz-start-bar .btn-start svg { width: 14px; height: 14px; }
|
||||
|
||||
/* ── 响应式:窄屏单列 ── */
|
||||
@media (max-width: 1100px) {
|
||||
.pp-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.config-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.pp-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
}
|
||||
@ -4,11 +4,11 @@ import { money } from "./stage-config";
|
||||
|
||||
type Tab = "overview" | "by-project" | "by-member" | "bills";
|
||||
|
||||
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: "推荐" },
|
||||
{ amt: 1000, gift: "+ ¥80 赠送", bonus: true, bonusAmt: 80 },
|
||||
{ amt: 3000, gift: "+ ¥300 赠送", bonus: true, bonusAmt: 300 }
|
||||
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; ribbon?: string }> = [
|
||||
{ amt: 100, gift: "无赠送", bonus: false },
|
||||
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, ribbon: "推荐" },
|
||||
{ amt: 1000, gift: "+ ¥80 赠送", bonus: true },
|
||||
{ amt: 3000, gift: "+ ¥300 赠送", bonus: true }
|
||||
];
|
||||
|
||||
const STAGES: Array<{ k: string; color: string }> = [
|
||||
@ -18,26 +18,14 @@ const STAGES: Array<{ k: string; color: string }> = [
|
||||
{ k: "脚本 LLM", color: "var(--black-alpha-32)" }
|
||||
];
|
||||
|
||||
export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharge }: {
|
||||
export function AccountPage({ billing, ledgers, projects, teamMembers }: {
|
||||
billing: BillingSummary | null;
|
||||
ledgers: Ledger[];
|
||||
projects: Project[];
|
||||
teamMembers: TeamMember[];
|
||||
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
const [recharge, setRecharge] = useState(500);
|
||||
const [customAmt, setCustomAmt] = useState("");
|
||||
|
||||
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;
|
||||
|
||||
async function submitRecharge() {
|
||||
if (effectiveAmount <= 0) return;
|
||||
await onRecharge(effectiveAmount, effectiveBonus);
|
||||
setCustomAmt("");
|
||||
}
|
||||
|
||||
const balance = Number(billing?.account.balance || 0);
|
||||
const used = Number(billing?.charged_total || 0);
|
||||
@ -90,7 +78,7 @@ export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharg
|
||||
<h3>快速充值</h3>
|
||||
<div className="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</div>
|
||||
</div>
|
||||
<div className="topup-selected">已选 ¥{effectiveAmount}{effectiveBonus > 0 ? ` + ¥${effectiveBonus} 赠送` : ""}</div>
|
||||
<div className="topup-selected">已选 ¥{recharge}</div>
|
||||
</div>
|
||||
<div className="recharge-row">
|
||||
{RECHARGE.map((item) => (
|
||||
@ -110,13 +98,13 @@ export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharg
|
||||
</div>
|
||||
<div className="pay-row">
|
||||
<div className="pay-title">自定义金额</div>
|
||||
<input className="input" placeholder="最低 ¥50,可输入任意金额" type="number" value={customAmt} onChange={(event) => setCustomAmt(event.target.value)} />
|
||||
<input className="input" placeholder="最低 ¥50,可输入任意金额" />
|
||||
<div className="pay-btn-row">
|
||||
<button className="btn pay-method-btn pay-wechat" type="button" aria-label="微信支付" onClick={submitRecharge}>
|
||||
<button className="btn pay-method-btn pay-wechat" type="button" aria-label="微信支付">
|
||||
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-wechat.png" alt="" /></span>
|
||||
微信支付
|
||||
</button>
|
||||
<button className="btn pay-method-btn pay-alipay" type="button" aria-label="支付宝" onClick={submitRecharge}>
|
||||
<button className="btn pay-method-btn pay-alipay" type="button" aria-label="支付宝">
|
||||
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-alipay.png" alt="" /></span>
|
||||
支付宝
|
||||
</button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
72
core/frontend/src/routes/exact-dashboard.tsx
Normal file
72
core/frontend/src/routes/exact-dashboard.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { IconKitSvg } from "../components/IconKitSvg";
|
||||
import { CornerMark } from "../components/app-shell";
|
||||
import type { NavigateFn, Page } from "./route-config";
|
||||
|
||||
export function ExactDashboardApp({ navigate, logout }: { navigate: NavigateFn; logout: () => void }) {
|
||||
const go = (next: Page) => (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
navigate(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-head"><a className="brand" href="/dashboard" onClick={go("dashboard")}><span className="brand-clip"><img className="brand-logo" src="/assets/logo.png" alt="Airshelf" /></span></a></div>
|
||||
<div className="search-box"><IconKitSvg name="search" /><input placeholder="搜索" readOnly /><span className="kbd">Ctrl K</span></div>
|
||||
<div className="nav-section">主要</div>
|
||||
<nav>
|
||||
<a href="/dashboard" className="active" onClick={go("dashboard")}><IconKitSvg name="dashboard" /><span>工作台</span></a>
|
||||
<a href="/products" onClick={go("products")}><IconKitSvg name="package" /><span>商品库</span><span className="pill-mini">7</span></a>
|
||||
<a href="/projects" onClick={go("projects")}><IconKitSvg name="clapperboard" /><span>视频项目</span><span className="pill-mini">8</span></a>
|
||||
<a href="/asset-factory" onClick={go("assetFactory")}><IconKitSvg name="sparkles" /><span>图片生成</span></a>
|
||||
<a href="/library" onClick={go("library")}><IconKitSvg name="library" /><span>资产库</span></a>
|
||||
<a href="/team" onClick={go("team")}><IconKitSvg name="users" /><span>团队</span></a>
|
||||
<a href="/account" onClick={go("account")}><IconKitSvg name="creditCard" /><span>消费</span></a>
|
||||
<a href="/settings" onClick={go("settings")}><IconKitSvg name="settings" /><span>设置</span></a>
|
||||
</nav>
|
||||
<div className="aside-foot"><div className="user"><div className="av">李</div><div className="em">小李的店</div></div></div>
|
||||
</aside>
|
||||
<main>
|
||||
<div className="grid-bg"></div>
|
||||
<header className="topbar">
|
||||
<div className="crumbs"><span className="here">工作台</span></div>
|
||||
<div className="right">
|
||||
<button className="balance-chip" type="button" onClick={() => navigate("account")}><IconKitSvg name="creditCard" />余额 <strong>¥327.40</strong></button>
|
||||
<button className="icon-btn" type="button" onClick={() => navigate("messages")}><IconKitSvg name="bell" /><span className="count-noti">12</span></button>
|
||||
<button className="topbar-avatar" type="button" onDoubleClick={logout}><span>李</span></button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="content" id="page-content">
|
||||
<CornerMark pos="tl" /><CornerMark pos="tr" /><CornerMark pos="bl" /><CornerMark pos="br" />
|
||||
<div className="page-head">
|
||||
<div><h1>欢迎回来,小李</h1><div className="sub"><span className="mono">// 05.14 · 周三</span><span>·</span><span>你有 <b>3 个项目</b> 正在进行中</span></div></div>
|
||||
<div className="actions"><a className="btn btn-create" href="/products/new" onClick={go("productCreateUpload")}><IconKitSvg name="productPlus" />新建商品</a><a className="btn btn-primary btn-lg btn-create" href="/projects/new" onClick={go("projectWizard")}><IconKitSvg name="clapperboard" />新建项目</a></div>
|
||||
</div>
|
||||
<div className="stats with-corners">
|
||||
<span className="corner-tr" /><span className="corner-bl" />
|
||||
<a className="stat" href="/projects" onClick={go("projects")}><div className="lbl">总项目 <span className="badge">ALL</span></div><div className="v">8</div><div className="delta up"><IconKitSvg name="arrowUp" size={14} /> 本月 +3</div></a>
|
||||
<a className="stat" href="/projects" onClick={go("projects")}><div className="lbl">进行中 <span className="badge">WIP</span></div><div className="v">3</div><div className="delta">2 个待审核</div></a>
|
||||
<a className="stat" href="/projects" onClick={go("projects")}><div className="lbl">本月成片 <span className="badge">DONE</span></div><div className="v">3</div><div className="delta up">较上月 +33%</div></a>
|
||||
<a className="stat" href="/account" onClick={go("account")}><div className="lbl">余额 <span className="badge">¥</span></div><div className="v">¥327<small>.40</small></div><div className="bar"><span style={{ width: "33%" }} /></div><div className="sub">已用 ¥162.60 / ¥500</div></a>
|
||||
</div>
|
||||
<div className="dash-grid">
|
||||
<div><div className="section-h"><h2>最近项目</h2><a className="more" href="/projects" onClick={go("projects")}>[ ALL · 8 ]</a></div><div className="card-hard">{["补水面膜 · 痛点种草 · v3", "透真防晒 · 通勤对比", "蓝牙耳机 · 开箱测评", "春日新品 · 立体口红"].map((title) => <ExactRecentRow key={title} title={title} go={go("pipeline")} />)}</div></div>
|
||||
<div className="dash-side"><div className="section-h"><h2>快捷入口</h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts">{[["商品库", "products"], ["资产库", "library"], ["充值", "account"], ["所有项目", "projects"]].map(([label, target]) => <a className="shortcut" href={`/${target}`} onClick={go(target as Page)} key={label}><div className="ic"><IconKitSvg name="package" /></div><div><div className="t">{label}</div><div className="d">快速进入</div></div></a>)}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExactRecentRow({ title, go }: { title: string; go: (event: { preventDefault: () => void }) => void }) {
|
||||
return (
|
||||
<a className="recent-row" href="/pipeline" onClick={go}>
|
||||
<div className="placeholder thumb"><span className="ph-frame">9:16</span></div>
|
||||
<div className="recent-meta"><div className="name">{title}</div><div className="sub">AI 全生 / 4 段</div></div>
|
||||
<div className="prog"><span className="done" /><span className="done" /><span className="cur" /><span /><span /></div>
|
||||
<span className="pill info"><span className="dot" />进行中</span>
|
||||
<span className="btn btn-sm">继续</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
349
core/frontend/src/routes/exact-document.tsx
Normal file
349
core/frontend/src/routes/exact-document.tsx
Normal file
@ -0,0 +1,349 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { exactHtmlDocuments } from "./exact-html";
|
||||
import type { ExactHtmlKey } from "./exact-html";
|
||||
import type { AuthMode, NavigateFn, Page } from "./route-config";
|
||||
|
||||
const fileToPage: Record<string, Page | "login" | "register" | null> = {
|
||||
"account.html": "account",
|
||||
"asset-factory.html": "assetFactory",
|
||||
"image-optimize.html": "imageOptimize",
|
||||
"index.html": "dashboard",
|
||||
"library.html": "library",
|
||||
"login.html": "login",
|
||||
"messages.html": "messages",
|
||||
"model-photo.html": "modelPhoto",
|
||||
"model-photo-demo-a.html": "modelPhotoDemoA",
|
||||
"model-photo-demo-b.html": "modelPhotoDemoB",
|
||||
"pipeline.html": "pipeline",
|
||||
"platform-cover.html": "platformCover",
|
||||
"product-create.html": "productCreateUpload",
|
||||
"product-create-upload.html": "productCreateUpload",
|
||||
"product-detail.html": "productDetail",
|
||||
"products.html": "products",
|
||||
"projects-new.html": "projectWizard",
|
||||
"projects.html": "projects",
|
||||
"register.html": "register",
|
||||
"settings.html": "settings",
|
||||
"team.html": "team"
|
||||
};
|
||||
|
||||
const pageToExactKey: Record<Page, ExactHtmlKey> = {
|
||||
dashboard: "dashboard",
|
||||
products: "products",
|
||||
productDetail: "productDetail",
|
||||
productCreateUpload: "productCreateUpload",
|
||||
projects: "projects",
|
||||
projectWizard: "projectWizard",
|
||||
pipeline: "pipeline",
|
||||
library: "library",
|
||||
account: "account",
|
||||
team: "team",
|
||||
messages: "messages",
|
||||
assetFactory: "assetFactory",
|
||||
imageOptimize: "imageOptimize",
|
||||
modelPhoto: "modelPhoto",
|
||||
modelPhotoDemoA: "modelPhotoDemoA",
|
||||
modelPhotoDemoB: "modelPhotoDemoB",
|
||||
platformCover: "platformCover",
|
||||
settings: "settings",
|
||||
settingsNotify: "settings"
|
||||
};
|
||||
|
||||
const exactKeyToPage: Partial<Record<ExactHtmlKey, Page | "login" | "register">> = {
|
||||
account: "account",
|
||||
assetFactory: "assetFactory",
|
||||
dashboard: "dashboard",
|
||||
imageOptimize: "imageOptimize",
|
||||
library: "library",
|
||||
login: "login",
|
||||
messages: "messages",
|
||||
modelPhoto: "modelPhoto",
|
||||
modelPhotoDemoA: "modelPhotoDemoA",
|
||||
modelPhotoDemoB: "modelPhotoDemoB",
|
||||
pipeline: "pipeline",
|
||||
platformCover: "platformCover",
|
||||
productCreate: "productCreateUpload",
|
||||
productCreateUpload: "productCreateUpload",
|
||||
productDetail: "productDetail",
|
||||
products: "products",
|
||||
projectWizard: "projectWizard",
|
||||
projects: "projects",
|
||||
register: "register",
|
||||
settings: "settings",
|
||||
team: "team"
|
||||
};
|
||||
|
||||
const exactKeyToFile: Record<ExactHtmlKey, string> = {
|
||||
account: "account.html",
|
||||
assetFactory: "asset-factory.html",
|
||||
dashboard: "index.html",
|
||||
imageOptimize: "image-optimize.html",
|
||||
library: "library.html",
|
||||
login: "login.html",
|
||||
messages: "messages.html",
|
||||
modelPhoto: "model-photo.html",
|
||||
modelPhotoDemoA: "model-photo-demo-a.html",
|
||||
modelPhotoDemoB: "model-photo-demo-b.html",
|
||||
pipeline: "pipeline.html",
|
||||
platformCover: "platform-cover.html",
|
||||
productCreate: "product-create.html",
|
||||
productCreateUpload: "product-create-upload.html",
|
||||
productDetail: "product-detail.html",
|
||||
products: "products.html",
|
||||
projectWizard: "projects-new.html",
|
||||
projects: "projects.html",
|
||||
register: "register.html",
|
||||
settings: "settings.html",
|
||||
team: "team.html"
|
||||
};
|
||||
|
||||
const liveHydratePages = new Set<ExactHtmlKey>([
|
||||
"dashboard",
|
||||
"products",
|
||||
"productDetail",
|
||||
"projectWizard",
|
||||
"projects",
|
||||
"pipeline",
|
||||
"library",
|
||||
"account",
|
||||
"settings",
|
||||
"team"
|
||||
]);
|
||||
|
||||
function routeFromHref(rawHref: string | null) {
|
||||
if (!rawHref || rawHref === "#" || rawHref.startsWith("javascript:")) return null;
|
||||
const url = new URL(rawHref, "https://airshelf.local/exact/");
|
||||
const fileName = url.pathname.split("/").filter(Boolean).pop() || "index.html";
|
||||
const page = fileToPage[fileName];
|
||||
if (!page) return null;
|
||||
const params = new URLSearchParams(url.search);
|
||||
return {
|
||||
page,
|
||||
hash: url.hash || "",
|
||||
search: url.search || "",
|
||||
productId: params.get("product_id") || undefined,
|
||||
projectId: params.get("project_id") || undefined
|
||||
};
|
||||
}
|
||||
|
||||
function routeFromInlineAction(action: string | null) {
|
||||
if (!action) return null;
|
||||
const hrefMatch = action.match(/location\.href\s*=\s*['"]([^'"]+)/);
|
||||
if (hrefMatch) return routeFromHref(hrefMatch[1]);
|
||||
const hashMatch = action.match(/location\.hash\s*=\s*['"]([^'"]+)/);
|
||||
if (hashMatch) return { page: null, hash: hashMatch[1], search: "" };
|
||||
return null;
|
||||
}
|
||||
|
||||
function setFrameHeight(frame: HTMLIFrameElement) {
|
||||
frame.style.height = `${Math.max(window.innerHeight, 720)}px`;
|
||||
}
|
||||
|
||||
export type ExactDocumentPageProps = {
|
||||
pageKey: ExactHtmlKey;
|
||||
hash?: string;
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
navigate?: NavigateFn;
|
||||
onAuthModeChange?: (mode: AuthMode) => void;
|
||||
onAuthSubmit?: (mode: AuthMode, event: FormEvent<HTMLFormElement>, form: HTMLFormElement) => void;
|
||||
};
|
||||
|
||||
export function exactKeyForPage(page: Page): ExactHtmlKey {
|
||||
return pageToExactKey[page] || "dashboard";
|
||||
}
|
||||
|
||||
function contextSearch(pageKey: ExactHtmlKey, productId?: string, projectId?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (pageKey === "productDetail" && productId) params.set("product_id", productId);
|
||||
if (pageKey === "pipeline" && projectId) params.set("project_id", projectId);
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : "";
|
||||
}
|
||||
|
||||
export function ExactDocumentPage({
|
||||
pageKey,
|
||||
hash,
|
||||
productId,
|
||||
projectId,
|
||||
navigate,
|
||||
onAuthModeChange,
|
||||
onAuthSubmit
|
||||
}: ExactDocumentPageProps) {
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const html = useMemo(() => {
|
||||
const context = {
|
||||
page: exactKeyToFile[pageKey],
|
||||
search: contextSearch(pageKey, productId, projectId),
|
||||
hash: hash ? `#${hash.replace(/^#/, "")}` : "",
|
||||
liveHydrate: liveHydratePages.has(pageKey)
|
||||
};
|
||||
return exactHtmlDocuments[pageKey].replace(
|
||||
"</head>",
|
||||
`<script>window.__AIR_SHELF_EXACT_CONTEXT__=${JSON.stringify(context)};</script></head>`
|
||||
);
|
||||
}, [hash, pageKey, productId, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current;
|
||||
if (!frame) return;
|
||||
const currentFrame: HTMLIFrameElement = frame;
|
||||
|
||||
function onLoad() {
|
||||
const doc = currentFrame.contentDocument;
|
||||
const win = currentFrame.contentWindow;
|
||||
if (!doc || !win) return;
|
||||
|
||||
(win as Window & { __AIR_SHELF_HOST_NAVIGATE__?: (href: string) => void }).__AIR_SHELF_HOST_NAVIGATE__ = (
|
||||
href: string
|
||||
) => {
|
||||
const hostRoute = routeFromHref(href);
|
||||
if (!hostRoute?.page) return;
|
||||
if (hostRoute.page === "login" || hostRoute.page === "register") {
|
||||
onAuthModeChange?.(hostRoute.page);
|
||||
return;
|
||||
}
|
||||
navigate?.(hostRoute.page, {
|
||||
hash: hostRoute.hash || undefined,
|
||||
productId: hostRoute.productId || (hostRoute.page === "productDetail" ? productId : undefined),
|
||||
projectId: hostRoute.projectId || (hostRoute.page === "pipeline" ? projectId : undefined)
|
||||
});
|
||||
};
|
||||
|
||||
const applyFrameHash = (nextHash: string) => {
|
||||
const cleanHash = nextHash.replace(/^#/, "");
|
||||
const stageMatch = cleanHash.match(/^stage-(\d+)$/);
|
||||
const pipelineWindow = win as Window & { activateStage?: (stage: number) => void };
|
||||
if (pageKey === "pipeline" && stageMatch && typeof pipelineWindow.activateStage === "function") {
|
||||
pipelineWindow.activateStage(Number(stageMatch[1]));
|
||||
return;
|
||||
}
|
||||
const settingsWindow = win as Window & { showSection?: (sectionId: string) => void };
|
||||
if (pageKey === "settings" && cleanHash.startsWith("sec-") && typeof settingsWindow.showSection === "function") {
|
||||
settingsWindow.showSection(cleanHash);
|
||||
return;
|
||||
}
|
||||
doc.getElementById(cleanHash)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
if (hash) {
|
||||
setTimeout(() => {
|
||||
applyFrameHash(hash);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const clickHandler = (event: MouseEvent) => {
|
||||
const target = event.target as Element | null;
|
||||
if (!target) return;
|
||||
|
||||
const syncHashOnlyRoute = (nextHash: string) => {
|
||||
const cleanHash = nextHash.replace(/^#/, "");
|
||||
applyFrameHash(cleanHash);
|
||||
if (exactKeyToPage[pageKey]) {
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}#${cleanHash}`;
|
||||
window.history.replaceState(null, "", nextUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const actionNode = target.closest("[onclick]") as HTMLElement | null;
|
||||
const actionRoute = routeFromInlineAction(actionNode?.getAttribute("onclick") || null);
|
||||
if (actionRoute?.hash && actionRoute.page === null) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
syncHashOnlyRoute(actionRoute.hash);
|
||||
return;
|
||||
}
|
||||
if (actionRoute?.page) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
if (actionRoute.page === "login" || actionRoute.page === "register") {
|
||||
onAuthModeChange?.(actionRoute.page);
|
||||
return;
|
||||
}
|
||||
navigate?.(actionRoute.page, {
|
||||
hash: actionRoute.hash || undefined,
|
||||
productId: actionRoute.productId || (actionRoute.page === "productDetail" ? productId || "exact" : undefined),
|
||||
projectId: actionRoute.projectId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = target.closest("a[href]") as HTMLAnchorElement | null;
|
||||
const rawAnchorHref = anchor?.getAttribute("href") || null;
|
||||
if (rawAnchorHref?.startsWith("#")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
syncHashOnlyRoute(rawAnchorHref);
|
||||
return;
|
||||
}
|
||||
const anchorRoute = routeFromHref(rawAnchorHref);
|
||||
if (!anchorRoute) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (anchorRoute.page === "login" || anchorRoute.page === "register") {
|
||||
onAuthModeChange?.(anchorRoute.page);
|
||||
return;
|
||||
}
|
||||
navigate?.(anchorRoute.page, {
|
||||
hash: anchorRoute.hash || undefined,
|
||||
productId: anchorRoute.productId || (anchorRoute.page === "productDetail" ? productId || "exact" : undefined),
|
||||
projectId: anchorRoute.projectId
|
||||
});
|
||||
};
|
||||
|
||||
const submitHandler = (event: SubmitEvent) => {
|
||||
const form = event.target as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
const isLogin = pageKey === "login" && form.id === "login-form";
|
||||
const isRegister = pageKey === "register" && form.id === "register-form";
|
||||
if (!isLogin && !isRegister) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
onAuthSubmit?.(isLogin ? "login" : "register", event as unknown as FormEvent<HTMLFormElement>, form);
|
||||
};
|
||||
|
||||
doc.addEventListener("click", clickHandler, true);
|
||||
doc.addEventListener("submit", submitHandler, true);
|
||||
setFrameHeight(currentFrame);
|
||||
const resizeFrame = () => setFrameHeight(currentFrame);
|
||||
window.addEventListener("resize", resizeFrame);
|
||||
const observer = new ResizeObserver(() => setFrameHeight(currentFrame));
|
||||
observer.observe(doc.documentElement);
|
||||
observer.observe(doc.body);
|
||||
|
||||
const cleanup = () => {
|
||||
doc.removeEventListener("click", clickHandler, true);
|
||||
doc.removeEventListener("submit", submitHandler, true);
|
||||
window.removeEventListener("resize", resizeFrame);
|
||||
observer.disconnect();
|
||||
};
|
||||
currentFrame.dataset.cleanupKey = String(Date.now());
|
||||
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup?.();
|
||||
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup = cleanup;
|
||||
}
|
||||
|
||||
currentFrame.addEventListener("load", onLoad);
|
||||
if (currentFrame.contentDocument?.readyState !== "loading") onLoad();
|
||||
return () => {
|
||||
currentFrame.removeEventListener("load", onLoad);
|
||||
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup?.();
|
||||
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup = undefined;
|
||||
};
|
||||
}, [hash, navigate, onAuthModeChange, onAuthSubmit, pageKey]);
|
||||
|
||||
return (
|
||||
<div className="exact-document-route" data-exact-page={pageKey}>
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
title={`Airshelf ${pageKey}`}
|
||||
className="exact-document-frame"
|
||||
srcDoc={html}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
core/frontend/src/routes/exact-html.ts
Normal file
26
core/frontend/src/routes/exact-html.ts
Normal file
File diff suppressed because one or more lines are too long
9
core/frontend/src/routes/exact-pages/account.tsx
Normal file
9
core/frontend/src/routes/exact-pages/account.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactAccountPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="account" />;
|
||||
}
|
||||
|
||||
export default ExactAccountPage;
|
||||
9
core/frontend/src/routes/exact-pages/asset-factory.tsx
Normal file
9
core/frontend/src/routes/exact-pages/asset-factory.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactAssetFactoryPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="assetFactory" />;
|
||||
}
|
||||
|
||||
export default ExactAssetFactoryPage;
|
||||
9
core/frontend/src/routes/exact-pages/dashboard.tsx
Normal file
9
core/frontend/src/routes/exact-pages/dashboard.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactDashboardPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="dashboard" />;
|
||||
}
|
||||
|
||||
export default ExactDashboardPage;
|
||||
9
core/frontend/src/routes/exact-pages/image-optimize.tsx
Normal file
9
core/frontend/src/routes/exact-pages/image-optimize.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactImageOptimizePage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="imageOptimize" />;
|
||||
}
|
||||
|
||||
export default ExactImageOptimizePage;
|
||||
9
core/frontend/src/routes/exact-pages/library.tsx
Normal file
9
core/frontend/src/routes/exact-pages/library.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactLibraryPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="library" />;
|
||||
}
|
||||
|
||||
export default ExactLibraryPage;
|
||||
9
core/frontend/src/routes/exact-pages/login.tsx
Normal file
9
core/frontend/src/routes/exact-pages/login.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactLoginPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="login" />;
|
||||
}
|
||||
|
||||
export default ExactLoginPage;
|
||||
9
core/frontend/src/routes/exact-pages/messages.tsx
Normal file
9
core/frontend/src/routes/exact-pages/messages.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactMessagesPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="messages" />;
|
||||
}
|
||||
|
||||
export default ExactMessagesPage;
|
||||
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactModelPhotoDemoAPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="modelPhotoDemoA" />;
|
||||
}
|
||||
|
||||
export default ExactModelPhotoDemoAPage;
|
||||
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactModelPhotoDemoBPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="modelPhotoDemoB" />;
|
||||
}
|
||||
|
||||
export default ExactModelPhotoDemoBPage;
|
||||
9
core/frontend/src/routes/exact-pages/model-photo.tsx
Normal file
9
core/frontend/src/routes/exact-pages/model-photo.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactModelPhotoPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="modelPhoto" />;
|
||||
}
|
||||
|
||||
export default ExactModelPhotoPage;
|
||||
9
core/frontend/src/routes/exact-pages/pipeline.tsx
Normal file
9
core/frontend/src/routes/exact-pages/pipeline.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactPipelinePage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="pipeline" />;
|
||||
}
|
||||
|
||||
export default ExactPipelinePage;
|
||||
9
core/frontend/src/routes/exact-pages/platform-cover.tsx
Normal file
9
core/frontend/src/routes/exact-pages/platform-cover.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactPlatformCoverPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="platformCover" />;
|
||||
}
|
||||
|
||||
export default ExactPlatformCoverPage;
|
||||
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactProductCreateUploadPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="productCreateUpload" />;
|
||||
}
|
||||
|
||||
export default ExactProductCreateUploadPage;
|
||||
9
core/frontend/src/routes/exact-pages/product-create.tsx
Normal file
9
core/frontend/src/routes/exact-pages/product-create.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactProductCreatePage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="productCreate" />;
|
||||
}
|
||||
|
||||
export default ExactProductCreatePage;
|
||||
9
core/frontend/src/routes/exact-pages/product-detail.tsx
Normal file
9
core/frontend/src/routes/exact-pages/product-detail.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactProductDetailPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="productDetail" />;
|
||||
}
|
||||
|
||||
export default ExactProductDetailPage;
|
||||
9
core/frontend/src/routes/exact-pages/products.tsx
Normal file
9
core/frontend/src/routes/exact-pages/products.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactProductsPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="products" />;
|
||||
}
|
||||
|
||||
export default ExactProductsPage;
|
||||
9
core/frontend/src/routes/exact-pages/project-wizard.tsx
Normal file
9
core/frontend/src/routes/exact-pages/project-wizard.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactProjectWizardPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="projectWizard" />;
|
||||
}
|
||||
|
||||
export default ExactProjectWizardPage;
|
||||
9
core/frontend/src/routes/exact-pages/projects.tsx
Normal file
9
core/frontend/src/routes/exact-pages/projects.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactProjectsPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="projects" />;
|
||||
}
|
||||
|
||||
export default ExactProjectsPage;
|
||||
9
core/frontend/src/routes/exact-pages/register.tsx
Normal file
9
core/frontend/src/routes/exact-pages/register.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactRegisterPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="register" />;
|
||||
}
|
||||
|
||||
export default ExactRegisterPage;
|
||||
9
core/frontend/src/routes/exact-pages/settings.tsx
Normal file
9
core/frontend/src/routes/exact-pages/settings.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactSettingsPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="settings" />;
|
||||
}
|
||||
|
||||
export default ExactSettingsPage;
|
||||
9
core/frontend/src/routes/exact-pages/team.tsx
Normal file
9
core/frontend/src/routes/exact-pages/team.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactTeamPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="team" />;
|
||||
}
|
||||
|
||||
export default ExactTeamPage;
|
||||
@ -1,6 +1,7 @@
|
||||
|
||||
|
||||
export { AuthScreen } from "./auth-screen";
|
||||
export { ExactDashboardApp } from "./exact-dashboard";
|
||||
export { Dashboard } from "./dashboard";
|
||||
export { ProductsPage, ProductCreateUploadPage, ProductDetailPage } from "./products";
|
||||
export { ProjectWizardPage, ProjectsPage } from "./projects";
|
||||
|
||||
@ -102,17 +102,12 @@ export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (
|
||||
|
||||
{filtered.length ? (
|
||||
<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 || "";
|
||||
return (
|
||||
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
|
||||
<div className="placeholder asset-thumb">
|
||||
{cover ? <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>
|
||||
);
|
||||
})}
|
||||
{filtered.map((asset) => (
|
||||
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
|
||||
<div className="placeholder asset-thumb"><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>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-filter">// 当前分类暂无真实资产</div>
|
||||
|
||||
@ -1,195 +1,21 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { Bell, Clapperboard, CreditCard, Info, Search, Users } from "lucide-react";
|
||||
import type { Notification } from "../types";
|
||||
import { useState } from "react";
|
||||
import { Bell, Search } from "lucide-react";
|
||||
import type { Page } from "./route-config";
|
||||
import { routeLabels } from "./route-config";
|
||||
|
||||
type TabKey = "all" | "unread" | "task" | "team" | "billing" | "system";
|
||||
|
||||
const PRI_LABEL: Record<string, string> = { ok: "已完成", warn: "需关注", err: "风险", info: "更新" };
|
||||
const ZH_TYPE: Record<string, string> = { all: "全部", unread: "未读", task: "任务", team: "团队", billing: "计费", system: "系统" };
|
||||
|
||||
function typeIcon(type: string): ReactNode {
|
||||
if (type === "task") return <Clapperboard size={14} />;
|
||||
if (type === "team") return <Users size={14} />;
|
||||
if (type === "billing") return <CreditCard size={14} />;
|
||||
return <Info size={14} />;
|
||||
}
|
||||
|
||||
// 通知 related_url(.html 风格)→ 应用内 Page
|
||||
function targetPage(n: Notification): Page {
|
||||
const url = n.related_url || "";
|
||||
if (url.includes("pipeline")) return "pipeline";
|
||||
if (url.includes("account")) return "account";
|
||||
if (url.includes("library")) return "library";
|
||||
if (url.includes("settings")) return "settingsNotify";
|
||||
if (url.includes("product")) return "products";
|
||||
if (url.includes("team")) return "team";
|
||||
return "dashboard";
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const diff = Math.round((Date.now() - new Date(iso).getTime()) / 60000);
|
||||
if (diff < 1) return "刚刚";
|
||||
if (diff < 60) return `${diff}m`;
|
||||
if (diff < 1440) return `${Math.floor(diff / 60)}h`;
|
||||
return `${Math.floor(diff / 1440)}d`;
|
||||
}
|
||||
function fmtFull(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const z = (n: number) => String(n).padStart(2, "0");
|
||||
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[];
|
||||
unreadCount: number;
|
||||
onMarkRead: (id: string) => void | Promise<unknown>;
|
||||
onMarkAllRead: () => void | Promise<unknown>;
|
||||
navigate: (page: Page) => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<TabKey>("all");
|
||||
const [query, setQuery] = 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 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]);
|
||||
|
||||
const selected = notifications.find((n) => n.id === selectedId) || visible[0] || notifications[0] || null;
|
||||
|
||||
function selectItem(n: Notification) {
|
||||
setSelectedId(n.id);
|
||||
if (!n.is_read) void onMarkRead(n.id);
|
||||
}
|
||||
|
||||
const filters: Array<[TabKey, string, number]> = [
|
||||
["all", "全部", counts.all],
|
||||
["unread", "未读", counts.unread],
|
||||
["task", "任务", counts.task],
|
||||
["team", "团队", counts.team],
|
||||
["billing", "计费", counts.billing],
|
||||
["system", "系统", counts.system]
|
||||
export function MessagesPage({ navigate }: { navigate: (page: Page) => void }) {
|
||||
const messages = [
|
||||
{ id: "m1", type: "task", priority: "ok", title: "补水面膜 · 痛点种草 v3 成片已完成", brief: "7 镜 · 40 秒 · ¥18.40 已结算。", body: "视频生成全部完成。", target: "pipeline" as Page },
|
||||
{ id: "m2", type: "billing", priority: "warn", title: "团队余额低于预警线", brief: "当前余额低于 ¥100。", body: "建议先充值或降低任务量。", target: "account" as Page }
|
||||
];
|
||||
|
||||
const target = selected ? targetPage(selected) : "dashboard";
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedId, setSelectedId] = useState(messages[0].id);
|
||||
const visible = messages.filter((item) => !query || `${item.title} ${item.brief}`.toLowerCase().includes(query.toLowerCase()));
|
||||
const selected = messages.find((item) => item.id === selectedId) || visible[0] || messages[0];
|
||||
return (
|
||||
<div className="msg-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>消息中心</h1>
|
||||
<div className="sub"><span className="mono">// {counts.unread} 条未读 · {notifications.length} 条总计</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={() => 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-filters">
|
||||
{filters.map(([id, label, ct]) => (
|
||||
<button key={id} className={`msg-filter ${tab === id ? "active" : ""}`} type="button" onClick={() => setTab(id)}>
|
||||
{label}<span className="ct">{ct}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="msg-search">
|
||||
<Search />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
|
||||
</div>
|
||||
<div className="msg-list">
|
||||
{visible.length === 0 ? (
|
||||
<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>
|
||||
</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>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="msg-panel msg-detail">
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="msg-detail-body">
|
||||
<div className="msg-detail-top">
|
||||
<span className={`msg-type-ic ${selected.notification_type}`}>{typeIcon(selected.notification_type)}</span>
|
||||
<div className="msg-detail-title">
|
||||
<h2>{selected.title}</h2>
|
||||
<div className="meta"><span>{selected.source || ZH_TYPE[selected.notification_type]}</span><span>// {ZH_TYPE[selected.notification_type]}</span><span>{fmtFull(selected.created_at)}</span></div>
|
||||
</div>
|
||||
<span className={`msg-priority ${selected.priority}`}>{PRI_LABEL[selected.priority] || "更新"}</span>
|
||||
</div>
|
||||
<p className="msg-body-text">{selected.body || selected.brief}</p>
|
||||
<div className="msg-props">
|
||||
{([
|
||||
["来源", selected.source || "-"],
|
||||
["类别", ZH_TYPE[selected.notification_type] || selected.notification_type],
|
||||
["项目", selected.project_name || "-"],
|
||||
["阶段", selected.stage || "-"],
|
||||
["负责人", selected.owner_label || "-"],
|
||||
["费用", selected.cost_label || "-"],
|
||||
["时间", fmtFull(selected.created_at)]
|
||||
] as Array<[string, string]>).flatMap(([k, v]) => [
|
||||
<span className="k" key={`${k}-k`}>{k}</span>,
|
||||
<span className="v" key={`${k}-v`}>{v}</span>
|
||||
])}
|
||||
<span className="k">关联资源</span>
|
||||
<span className="v"><a onClick={() => navigate(target)}>{routeLabels[target]} →</a></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="msg-detail-f">
|
||||
{!selected.is_read && <button className="btn btn-ghost" type="button" onClick={() => void onMarkRead(selected.id)}>标为已读</button>}
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-primary" type="button" onClick={() => navigate(target)}>进入{routeLabels[target]}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="msg-detail-empty"><div className="ic"><Bell /></div><div>暂无消息</div></div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="msg-foot-note">
|
||||
<span>// 消息保留 90 天 · 高风险任务会同时进入工作台队列</span>
|
||||
<a onClick={() => navigate("settingsNotify")}>管理通知策略 →</a>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<div className="page-head"><div><h1>消息中心</h1><div className="sub"><span className="mono">// {messages.length} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div></div><div className="actions"><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-search"><Search size={14} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" /></div><div className="msg-list">{visible.map((item) => <button className={`msg-item ${selected.id === item.id ? "active" : ""}`} type="button" key={item.id} onClick={() => setSelectedId(item.id)}><span className={`msg-type-ic ${item.type}`}><Bell size={13} /></span><span className="msg-item-main"><span className="msg-item-title">{item.title}</span><span className="msg-brief">{item.brief}</span></span></button>)}</div></section><section className="msg-panel msg-detail"><div className="msg-detail-body"><div className="msg-detail-top"><span className={`msg-type-ic ${selected.type}`}><Bell size={15} /></span><div className="msg-detail-title"><h2>{selected.title}</h2><div className="meta"><span>{selected.type}</span></div></div></div><p className="msg-body-text">{selected.body}</p><div className="msg-props"><span className="k">关联资源</span><span className="v">{routeLabels[selected.target]}</span></div></div><div className="msg-detail-f"><span className="spacer" /><button className="btn btn-primary" type="button" onClick={() => navigate(selected.target)}>进入{routeLabels[selected.target]}</button></div></section></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,48 +1,51 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { Play } from "lucide-react";
|
||||
import type { Asset, BillingSummary, Product, Project, Team, User } from "../types";
|
||||
import type { BillingSummary, Product, Project, Team, User } from "../types";
|
||||
import type { Notice, Page } from "./route-config";
|
||||
import { money, stageOrder, statusPill } from "./stage-config";
|
||||
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
|
||||
import { IconKitSvg } from "../components/IconKitSvg";
|
||||
|
||||
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
|
||||
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
||||
// 基础资产组 kind → 中文区块名(对齐 design.md 商品/人物/场景三类)
|
||||
const KIND_LABEL: Record<string, string> = { product: "商品", person: "人物", scene: "场景" };
|
||||
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
|
||||
function statusLabel(status: string): string {
|
||||
if (["succeeded", "completed", "done", "ok"].includes(status)) return "完成";
|
||||
if (["failed", "error"].includes(status)) return "失败";
|
||||
if (["running", "queued", "polling"].includes(status)) return "生成中";
|
||||
if (status === "needs_review") return "待确认";
|
||||
return "待生成";
|
||||
}
|
||||
// 毫秒 → mm:ss(时间轴 / 字幕展示)
|
||||
function fmtMs(ms: number): string {
|
||||
const total = Math.max(0, Math.round(ms / 1000));
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
|
||||
}
|
||||
// 镜像 shell.js→mock-media.js 给 .placeholder 注入 mock 图;React 手动复刻对应映射
|
||||
const mock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties);
|
||||
|
||||
// 时间轴 clip 真实定位:start_ms / duration_ms 相对轨道总长(timeline.duration_seconds)算百分比
|
||||
function clipLayout(startMs: number, durationMs: number, totalMs: number): { leftPct: number; widthPct: number } {
|
||||
const total = totalMs > 0 ? totalMs : 1;
|
||||
return { leftPct: Math.max(0, (startMs / total) * 100), widthPct: Math.max(0, Math.min(100, (durationMs / total) * 100)) };
|
||||
}
|
||||
// 标尺刻度:按轨道总秒数生成每秒一刻,偶数秒为主刻度带秒标
|
||||
function buildRuler(totalSec: number): Array<{ leftPct: number; major: boolean; t?: string }> {
|
||||
const secs = Math.max(1, Math.round(totalSec));
|
||||
const ticks: Array<{ leftPct: number; major: boolean; t?: string }> = [];
|
||||
for (let s = 0; s <= secs; s += 1) {
|
||||
const major = s % 2 === 0;
|
||||
ticks.push({ leftPct: (s / secs) * 100, major, t: major ? `${s}s` : undefined });
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
// Stage 3 故事板 · 镜像 JS 注入的 3 场(全 mock)
|
||||
const SB_SCENES = [
|
||||
{ sid: "sc1", nm: "场 1", sub: "0-15s", frame: "深夜办公桌", img: "scene-office.png" },
|
||||
{ sid: "sc2", nm: "场 2", sub: "15-30s", frame: "面膜包装/特写", img: "product-mask.png" },
|
||||
{ sid: "sc3", nm: "场 3", sub: "30-45s", frame: "化妆台/产品定格", img: "cover-mask-final.png" }
|
||||
];
|
||||
const SB_PROMPT = "中景 / 固定机位\n光线:台灯暖光 + 屏幕冷光\n演员:林夕(疲倦状态)\n关键道具:面膜盒(从抽屉露半角)\n氛围:午夜、安静、些许焦虑";
|
||||
|
||||
// 装饰用伪波形(BGM 轨铺底,纯视觉,无真实波形数据时使用)
|
||||
// Stage 4 视频 · 镜像 3 场(全 mock,video-thumb 经 mock-media 映射封面图)
|
||||
const VIDEO_CARDS = [
|
||||
{ vid: "v1", frame: "场 1 · 0-15s", title: "场 1 · 深夜办公桌", meta: "15s · 1080×1920 · ¥0.45", img: "cover-mask-v3.png" },
|
||||
{ vid: "v2", frame: "场 2 · 15-27s", title: "场 2 · 面膜包装/特写", meta: "12s · 1080×1920 · ¥0.45", img: "product-mask.png" },
|
||||
{ vid: "v3", frame: "场 3 · 27-40s", title: "场 3 · 化妆台/产品定格", meta: "13s · 1080×1920 · ¥0.45", img: "cover-mask-final.png" }
|
||||
];
|
||||
|
||||
// Stage 5 拼接编辑器 · 全 mock(镜像时间轴引擎按 data-dur 累计定位,React 直接算 left/width)
|
||||
const ED_VIDEO_CLIPS = [
|
||||
{ n: 1, lbl: "深夜办公桌", dur: 2 }, { n: 2, lbl: "面膜包装", dur: 3 }, { n: 3, lbl: "精华液微距", dur: 3 },
|
||||
{ n: 4, lbl: "敷面膜平躺", dur: 3 }, { n: 5, lbl: "化妆台", dur: 2 }, { n: 6, lbl: "产品定格", dur: 2 }
|
||||
];
|
||||
const ED_SUB_CLIPS = [
|
||||
{ lbl: "加班三天 脸已经不能看了…", dur: 2 }, { lbl: "还好我有这个 透真玻尿酸面膜", dur: 3 }, { lbl: "30g 精华 一片顶三片", dur: 3 },
|
||||
{ lbl: "敷完起来脸是软的", dur: 3 }, { lbl: "化妆都能看出来", dur: 2 }, { lbl: "5 片 ¥39.9 囤起来", dur: 2 }
|
||||
];
|
||||
const ED_RULER: Array<{ left: string; major: boolean; t?: string }> = [
|
||||
{ left: "0%", major: true, t: "0s" }, { left: "6.67%", major: false }, { left: "13.33%", major: true, t: "2s" }, { left: "20%", major: false },
|
||||
{ left: "26.67%", major: true, t: "4s" }, { left: "33.33%", major: false }, { left: "40%", major: true, t: "6s" }, { left: "46.67%", major: false },
|
||||
{ left: "53.33%", major: true, t: "8s" }, { left: "60%", major: false }, { left: "66.67%", major: true, t: "10s" }, { left: "73.33%", major: false },
|
||||
{ left: "80%", major: true, t: "12s" }, { left: "86.67%", major: false }, { left: "93.33%", major: true, t: "14s" }, { left: "100%", major: true, t: "15s" }
|
||||
];
|
||||
const ED_WAVE: Array<[number, number]> = [[8,4],[6,8],[3,14],[7,6],[4,12],[2,16],[6,8],[8,4],[5,10],[3,14],[7,6],[4,12],[6,8],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8]];
|
||||
function edLayout<T extends { dur: number }>(clips: T[]): Array<T & { leftPct: number; widthPct: number }> {
|
||||
let acc = 0;
|
||||
return clips.map((c) => { const left = acc; acc += c.dur; return { ...c, leftPct: (left / 15) * 100, widthPct: (c.dur / 15) * 100 }; });
|
||||
}
|
||||
|
||||
const STAGE_STEPS: Array<{ n: number; label: string }> = [
|
||||
{ n: 1, label: "脚本" },
|
||||
{ n: 2, label: "基础资产" },
|
||||
@ -59,10 +62,8 @@ export function PipelinePage(props: {
|
||||
team: Team;
|
||||
products: Product[];
|
||||
projects: Project[];
|
||||
assets: Asset[];
|
||||
billing: BillingSummary | null;
|
||||
notice: Notice | null;
|
||||
unreadCount: number;
|
||||
avatarChar: string;
|
||||
logout: () => void;
|
||||
onRefresh: () => void;
|
||||
@ -78,47 +79,11 @@ export function PipelinePage(props: {
|
||||
onSubmitExport: () => void;
|
||||
}) {
|
||||
const {
|
||||
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
|
||||
project, loading, navigate, user, team, products, projects, billing, notice, avatarChar, logout,
|
||||
onGenerateScript, onGenerateBaseAsset, onGenerateStoryboard, onSkipStoryboard,
|
||||
onSubmitVideo, onSubmitAllVideos, onSubmitExport
|
||||
onSubmitVideo, onPollVideo, onSubmitAllVideos, onPollAllVideos, onSubmitExport
|
||||
} = props;
|
||||
|
||||
// ── 资产解析:把各阶段引用的 asset id → 真实缩略图 preview_url(主图优先,其次首张)──
|
||||
const byId = new Map(assets.map((a) => [a.id, a] as const));
|
||||
const assetUrl = (id: string | null | undefined): string => {
|
||||
if (!id) return "";
|
||||
const a = byId.get(id);
|
||||
return a?.files?.find((f) => f.is_primary)?.preview_url || a?.files?.[0]?.preview_url || "";
|
||||
};
|
||||
const assetName = (id: string | null | undefined): string => (id ? byId.get(id)?.name || "" : "");
|
||||
|
||||
// ── Stage 2:基础资产按 kind 分组(product/person/scene),保持设计稿三区顺序 ──
|
||||
const groups = project.base_asset_groups ?? [];
|
||||
const groupsByKind = (kind: string) => groups.filter((g) => g.kind === kind);
|
||||
const KIND_ORDER: Array<"product" | "person" | "scene"> = ["product", "person", "scene"];
|
||||
|
||||
// ── Stage 3:取已采用(is_adopted)的故事板版本,无则取第一版 ──
|
||||
const storyboards = project.storyboard_versions ?? [];
|
||||
const adoptedStoryboard = storyboards.find((s) => s.is_adopted) || storyboards[0] || null;
|
||||
const sbFrames = [...(adoptedStoryboard?.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const [sbSelected, setSbSelected] = useState(0);
|
||||
const sbActiveFrame = sbFrames[Math.min(sbSelected, Math.max(0, sbFrames.length - 1))] || null;
|
||||
|
||||
// ── Stage 4:视频片段(adopted_asset 缩略图 + 状态 pill + 时长)──
|
||||
const segments = [...(project.video_segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const segDone = segments.filter((s) => ["succeeded", "completed", "done"].includes(s.status)).length;
|
||||
const segTotalSec = segments.reduce((sum, s) => sum + (s.target_duration_seconds || 0), 0);
|
||||
|
||||
// ── Stage 5:时间轴 / 字幕 / BGM(真实定位,轨道总长用 timeline.duration_seconds)──
|
||||
const timeline = project.timeline;
|
||||
const tlTotalMs = (timeline?.duration_seconds || 0) * 1000;
|
||||
const tlClips = [...(timeline?.clips ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const tlRulerMs = tlTotalMs > 0 ? tlTotalMs : tlClips.reduce((m, c) => Math.max(m, c.start_ms + c.duration_ms), 0) || 15000;
|
||||
const ruler = buildRuler(tlRulerMs / 1000);
|
||||
const subtitleTrack = (timeline?.subtitle_tracks ?? []).find((t) => t.enabled) || (timeline?.subtitle_tracks ?? [])[0] || null;
|
||||
const subtitleCues = [...(subtitleTrack?.content ?? [])].sort((a, b) => a.start_ms - b.start_ms);
|
||||
const bgmTracks = timeline?.bgm_tracks ?? [];
|
||||
|
||||
// 步进器:对齐镜像 activateStage 逻辑。默认(无 hash)pane=脚本(1) 但步进器 active=项目真实阶段;
|
||||
// 一旦导航(hash 或点击),active 跟随所看阶段,completed=max(项目阶段-1, 所看阶段-1)。
|
||||
const projectStage = project.status === "completed" ? 5 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1);
|
||||
@ -131,10 +96,8 @@ export function PipelinePage(props: {
|
||||
const [storyboardPrompt, setStoryboardPrompt] = useState("统一商品、人物、场景风格,生成可直接指导视频的分镜图");
|
||||
const [videoPrompt, setVideoPrompt] = useState("竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感");
|
||||
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
|
||||
// 真实商品名 + 封面资产 id(商品组无 adopted_asset 时,商品缩图回退到商品库封面)
|
||||
const productRecord = products.find((item) => item.id === project.product);
|
||||
const productName = productRecord?.title || "透真补水面膜";
|
||||
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
|
||||
// 真实商品名(api-bridge 仅 hydrate 商品名,其余人物/场景沿用设计稿 mock)
|
||||
const productName = products.find((item) => item.id === project.product)?.title || "透真补水面膜";
|
||||
|
||||
function goStage(n: number) {
|
||||
setViewStage(n);
|
||||
@ -169,7 +132,7 @@ export function PipelinePage(props: {
|
||||
</span>
|
||||
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
|
||||
<IconKitSvg name="bell" />
|
||||
{unreadCount > 0 && <span className="count-noti">{unreadCount}</span>}
|
||||
<span className="count-noti">12</span>
|
||||
</button>
|
||||
<div className="topbar-avatar" onDoubleClick={logout} title="账户(双击退出)">
|
||||
<span>{avatarChar}</span>
|
||||
@ -275,24 +238,14 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
|
||||
{viewStage === 2 && (() => {
|
||||
const productGroup = groupsByKind("product")[0] || null;
|
||||
const productAssetUrl = assetUrl(productGroup?.adopted_asset) || assetUrl(productGroup?.candidate_assets?.[0]) || assetUrl(productCover);
|
||||
const productCandidates = (productGroup?.candidate_assets ?? []).filter((id) => id !== productGroup?.adopted_asset);
|
||||
return (
|
||||
{/* Stage 2-5 · 暂沿用既有功能性结构(默认隐藏),后续逐阶段做像素还原 */}
|
||||
{viewStage === 2 && (
|
||||
<section className="stage active" data-stage-pane="2">
|
||||
<div className="stage-assets">
|
||||
<div className="asset-side">
|
||||
{KIND_ORDER.map((kind) => {
|
||||
const list = groupsByKind(kind);
|
||||
const adopted = list.filter((g) => g.adopted_asset).length;
|
||||
return (
|
||||
<div className={`ttab${kind === "product" ? " active" : ""}`} key={kind} data-jump={`asset-sec-${kind}`}>
|
||||
<span>{KIND_LABEL[kind]}</span><span className="num">{list.length ? `${adopted}/${list.length}` : "0"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="ttab active" data-jump="asset-sec-products"><span>商品</span><span className="num">3 张</span></div>
|
||||
<div className="ttab" data-jump="asset-sec-characters"><span>人物</span><span className="num">2/2</span></div>
|
||||
<div className="ttab" data-jump="asset-sec-scenes"><span>场景</span><span className="num">3/3</span></div>
|
||||
<div className="info">
|
||||
基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。
|
||||
<br /><br />
|
||||
@ -303,31 +256,29 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
|
||||
<div className="asset-main">
|
||||
<section className="asset-sec" id="asset-sec-product">
|
||||
<section className="asset-sec" id="asset-sec-products">
|
||||
<div className="sec-h"><h3>商品 · <span id="asset-prod-name">{productName}</span></h3><span className="spacer"></span></div>
|
||||
<div className="prod-row">
|
||||
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id={productGroup?.adopted_asset || "prod-main"} id="asset-prod-card">
|
||||
<div className={`placeholder prod-thumb${productAssetUrl ? " has-mock-media" : ""}`} style={productAssetUrl ? mediaStyle(productAssetUrl) : undefined}>
|
||||
{!productGroup?.adopted_asset && (
|
||||
<span className="tri-missing-badge" id="asset-prod-tri-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明">
|
||||
<span className="ico" aria-hidden="true"></span>
|
||||
<span className="lbl-mono">缺三视图</span>
|
||||
<span className="tri-missing-pop" role="tooltip">
|
||||
<span className="pop-h">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
|
||||
MISSING TRI-VIEW
|
||||
</span>
|
||||
<span className="pop-body">该商品还未生成 <b>正 / 侧 / 背</b> 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。</span>
|
||||
<span className="pop-tip">建议:点右下 <b>AI 生成三视图</b> 先补齐三视图,再发起后续生成。</span>
|
||||
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id="prod-main" id="asset-prod-card">
|
||||
<div className="placeholder prod-thumb has-mock-media" style={mock("product-mask.png")}>
|
||||
<span className="tri-missing-badge" id="asset-prod-tri-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明">
|
||||
<span className="ico" aria-hidden="true"></span>
|
||||
<span className="lbl-mono">缺三视图</span>
|
||||
<span className="tri-missing-pop" role="tooltip">
|
||||
<span className="pop-h">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
|
||||
MISSING TRI-VIEW
|
||||
</span>
|
||||
<span className="pop-body">该商品还未生成 <b>正 / 侧 / 背</b> 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。</span>
|
||||
<span className="pop-tip">建议:点右下 <b>AI 生成三视图</b> 先补齐三视图,再发起后续生成。</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="ph-frame" id="asset-prod-thumb-label">{productName} · 主图</span>
|
||||
</div>
|
||||
<div className="prod-body">
|
||||
<div className="prod-name" id="asset-prod-card-name">{productName}</div>
|
||||
<div className="prod-cat">{products.find((item) => item.id === project.product)?.category || "未分类"}</div>
|
||||
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
|
||||
<div className="prod-cat">美妆个护</div>
|
||||
<div className="prod-date">2026-05-15 创建</div>
|
||||
</div>
|
||||
<div className="prod-action" id="asset-prod-action">
|
||||
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={loading} onClick={() => onGenerateBaseAsset("product", `${productName} 三视图`)}>
|
||||
@ -336,108 +287,109 @@ export function PipelinePage(props: {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`prod-preview${productCandidates.length ? " show" : ""}`} id="asset-prod-preview">
|
||||
<div className="prod-preview-h">// 候选三视图 · <span id="prod-preview-status">{productCandidates.length} 张</span></div>
|
||||
<div className={`placeholder prod-preview-img${assetUrl(productCandidates[0]) ? " has-mock-media" : ""}`} id="prod-preview-img" style={assetUrl(productCandidates[0]) ? mediaStyle(assetUrl(productCandidates[0])) : undefined}><span className="ph-frame">候选 #1</span></div>
|
||||
<div className="prod-preview-foot" id="prod-preview-foot">
|
||||
{productCandidates.slice(0, 4).map((id) => (
|
||||
<div className={`placeholder${assetUrl(id) ? " has-mock-media" : ""}`} key={id} style={{ ...(assetUrl(id) ? mediaStyle(assetUrl(id)) : {}), width: "44px", height: "44px", flex: "0 0 44px" }}><span className="ph-frame"></span></div>
|
||||
))}
|
||||
<div className="prod-preview" id="asset-prod-preview">
|
||||
<div className="prod-preview-h">// 三视图预览 · <span id="prod-preview-status">生成中</span></div>
|
||||
<div className="placeholder prod-preview-img" id="prod-preview-img"></div>
|
||||
<div className="prod-preview-foot" id="prod-preview-foot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="asset-sec" id="asset-sec-characters">
|
||||
<div className="sec-h"><h3>人物 · 2 个</h3><span className="spacer"></span></div>
|
||||
<div className="asset-grid-2">
|
||||
<div className="asset-card-2" data-asset-kind="character" data-asset-id="ch-linxi">
|
||||
<div className="placeholder thumb-2 has-mock-media" style={mock("person-linxi.png")}><span className="ph-frame">林夕 · 都市白领</span></div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>主角 · 林夕</strong><span className="spacer"></span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>25-30 岁都市白领,长发,穿宽松米色家居服,温柔但带点疲倦感。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop>替换</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-card-2" data-asset-kind="character" data-asset-id="ch-anan">
|
||||
<div className="placeholder thumb-2">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", alignItems: "center" }}>
|
||||
<div className="spinner"></div>
|
||||
<span className="ph-frame">生成中 · 约 8s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>朋友/同事 · 阿楠</strong><span className="spacer"></span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>25-30 岁同龄女性,短发,穿白色衬衫,妆容精致皮肤好,作为对比。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop disabled>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop disabled>替换</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(["person", "scene"] as const).map((kind) => {
|
||||
const list = groupsByKind(kind);
|
||||
return (
|
||||
<section className="asset-sec" id={`asset-sec-${kind}`} key={kind}>
|
||||
<div className="sec-h"><h3>{KIND_LABEL[kind]} · {list.length} 个</h3><span className="spacer"></span></div>
|
||||
{list.length ? (
|
||||
<div className="asset-grid-2">
|
||||
{list.map((group, gi) => {
|
||||
const mainUrl = assetUrl(group.adopted_asset) || assetUrl(group.candidate_assets?.[0]);
|
||||
const cands = (group.candidate_assets ?? []).filter((id) => id !== group.adopted_asset).slice(0, 4);
|
||||
return (
|
||||
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={group.id} key={group.id}>
|
||||
<div className={`placeholder thumb-2${mainUrl ? " has-mock-media" : ""}`} style={mainUrl ? mediaStyle(mainUrl) : undefined}>
|
||||
<span className="ph-frame">{assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`}</span>
|
||||
</div>
|
||||
<div className="body-2">
|
||||
<div className="hstack">
|
||||
<strong style={{ fontSize: "13.5px" }}>{assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`}</strong>
|
||||
<span className="spacer"></span>
|
||||
{group.adopted_asset
|
||||
? <span className="pill ok"><span className="dot"></span>已采用</span>
|
||||
: <span className="pill neutral"><span className="dot"></span>待采用</span>}
|
||||
</div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>{group.prompt || "(暂无提示词)"}</div>
|
||||
{cands.length > 0 && (
|
||||
<div className="hstack" style={{ marginTop: "10px", gap: "6px", flexWrap: "wrap" }}>
|
||||
{cands.map((id) => (
|
||||
<div className={`placeholder${assetUrl(id) ? " has-mock-media" : ""}`} key={id} style={{ ...(assetUrl(id) ? mediaStyle(assetUrl(id)) : {}), width: "40px", height: "40px", flex: "0 0 40px" }}><span className="ph-frame"></span></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<section className="asset-sec" id="asset-sec-scenes">
|
||||
<div className="sec-h"><h3>场景 · 3 个</h3><span className="spacer"></span></div>
|
||||
<div className="asset-grid-2">
|
||||
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-desk">
|
||||
<div className="placeholder thumb-2 has-mock-media" style={mock("scene-office.png")}><span className="ph-frame">深夜办公桌</span></div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>深夜办公桌</strong><span className="spacer"></span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>深夜居家办公环境,木质书桌,台灯暖光,电脑屏幕亮着。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop>替换</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-bed">
|
||||
<div className="placeholder thumb-2 has-mock-media" style={mock("scene-bedroom.png")}><span className="ph-frame">床头特写</span></div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>卧室床头</strong><span className="spacer"></span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>米白色床品,木质床头柜,闹钟显示晚间时间。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop>替换</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-subway">
|
||||
<div className="placeholder thumb-2">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px", alignItems: "center" }}>
|
||||
<div className="fail-icon">!</div>
|
||||
<span className="ph-frame">生成失败</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="placeholder" style={{ minHeight: "120px" }}><span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 待生成</span></div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>通勤地铁</strong><span className="spacer"></span><span className="pill err"><span className="dot"></span>失败</span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>早高峰地铁车厢,光线偏冷,年轻通勤族,氛围紧张。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop>替换</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ 基础资产同时展示 · 商品图复用商品库 · 失败不扣 ]</span></div>
|
||||
<div className="info"><span className="mono">[ 已确认 ¥0.85 · 待生成 ¥0.20 · 失败 ¥0(不扣) ]</span></div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(1)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回脚本</button>
|
||||
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(3)}>确认资产,进入故事板 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
{/* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */}
|
||||
)}
|
||||
{viewStage === 3 && (
|
||||
<section className="stage active" data-stage-pane="3">
|
||||
<div className="stage-storyboard">
|
||||
<div className="sb-canvas">
|
||||
<div className="sb-scenes-col" id="sb-scenes-row">
|
||||
{sbFrames.length ? sbFrames.map((frame, idx) => {
|
||||
const url = assetUrl(frame.asset);
|
||||
return (
|
||||
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={frame.id} data-sid={frame.id} onClick={() => setSbSelected(idx)}>
|
||||
<div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}><span className="ph-frame">场 {idx + 1}</span></div>
|
||||
<div className="nm">场 {idx + 1}</div>
|
||||
<div className="sub">#{frame.sort_order + 1}</div>
|
||||
</div>
|
||||
);
|
||||
}) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">// 暂无</span></div>}
|
||||
</div>
|
||||
{(() => {
|
||||
const url = assetUrl(sbActiveFrame?.asset);
|
||||
return (
|
||||
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? mediaStyle(url) : undefined}>
|
||||
<span className="ph-frame">{sbActiveFrame ? `场 ${sbSelected + 1}` : "// 故事板未生成"}</span>
|
||||
{SB_SCENES.map((scene) => (
|
||||
<div className={`sb-scene-thumb${scene.sid === "sc1" ? " selected" : ""}`} key={scene.sid} data-sid={scene.sid}>
|
||||
<div className="placeholder has-mock-media" style={mock(scene.img)}><span className="ph-frame">{scene.frame}</span></div>
|
||||
<div className="nm">{scene.nm}</div>
|
||||
<div className="sub">{scene.sub}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
))}
|
||||
</div>
|
||||
<div className="placeholder sb-main-img has-mock-media" id="sb-main-img" style={mock("cover-mask-v3.png")}><span className="ph-frame">场 1 · 深夜办公桌 · v1</span></div>
|
||||
</div>
|
||||
|
||||
<div className="sb-side">
|
||||
<div className="pane" style={{ padding: "18px" }}>
|
||||
<div className="hstack" style={{ marginBottom: "10px" }}>
|
||||
<strong style={{ fontSize: "14px" }}>故事板 · <span id="sb-side-scene">{sbActiveFrame ? `场 ${sbSelected + 1}` : "—"}</span></strong>
|
||||
<strong style={{ fontSize: "14px" }}>故事板 · <span id="sb-side-scene">场 1</span></strong>
|
||||
<span className="spacer"></span>
|
||||
{adoptedStoryboard
|
||||
? <span className="pill ok"><span className="dot"></span>已生成</span>
|
||||
: <span className="pill neutral"><span className="dot"></span>未生成</span>}
|
||||
<span className="pill ok"><span className="dot"></span>已生成</span>
|
||||
</div>
|
||||
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}>整张故事板由 image-2 一次性输出,包含画面 + 镜头说明。</div>
|
||||
<div className="sb-rerun-note">
|
||||
@ -447,7 +399,7 @@ export function PipelinePage(props: {
|
||||
<div className="note-copy"><strong>仅支持整张重跑</strong> · 不能局部改某一镜。如需调单镜,先在 <a href="#stage-1" onClick={(event) => { event.preventDefault(); goStage(1); }}>Stage 1 脚本</a> 改镜头描述,再回此处整张重跑。</div>
|
||||
</div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>// 本场提示词</div>
|
||||
<div className="prompt-edit" contentEditable suppressContentEditableWarning id="sb-prompt-edit">{sbActiveFrame?.prompt || adoptedStoryboard?.prompt || "(暂无提示词)"}</div>
|
||||
<div className="prompt-edit" contentEditable suppressContentEditableWarning id="sb-prompt-edit">{SB_PROMPT}</div>
|
||||
<div className="sb-stage-actions">
|
||||
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={loading} onClick={() => onGenerateStoryboard(storyboardPrompt)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>
|
||||
@ -457,32 +409,26 @@ export function PipelinePage(props: {
|
||||
<span className="muted-2 mono" style={{ fontSize: "11px", alignSelf: "center" }}>~¥0.45/场</span>
|
||||
</div>
|
||||
<div className="sb-history">
|
||||
<div className="sb-history-h">// 历史版本(<span id="sb-history-ct">{storyboards.length}</span>)</div>
|
||||
<div className="sb-history-h">// 历史版本(<span id="sb-history-ct">1</span>)</div>
|
||||
<div className="sb-history-row" id="sb-history-row">
|
||||
{storyboards.length ? storyboards.map((ver) => {
|
||||
const cover = assetUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]?.asset);
|
||||
return (
|
||||
<div className={`sb-history-thumb${ver.is_adopted ? " current" : ""}`} key={ver.id} data-vi={ver.id}>
|
||||
<div className={`placeholder${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}><span className="ph-frame">{ver.is_adopted ? "采用" : "历史"}</span></div>
|
||||
<div className="ts">{(ver.created_at || "").slice(11, 16) || "--:--"}</div>
|
||||
</div>
|
||||
);
|
||||
}) : <span className="muted-2 mono" style={{ fontSize: "11px" }}>// 暂无历史</span>}
|
||||
<div className="sb-history-thumb current" data-vi="0">
|
||||
<div className="placeholder has-mock-media" style={mock("cover-mask-final.png")}><span className="ph-frame">v1</span></div>
|
||||
<div className="ts">14:02</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider" style={{ marginTop: "16px" }}></div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 绑定的资产</div>
|
||||
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }} id="sb-bound-assets">
|
||||
{groups.filter((g) => g.adopted_asset).length ? groups.filter((g) => g.adopted_asset).map((g) => (
|
||||
<span className="asset-tag" key={g.id}><span className="dotc"></span>{assetName(g.adopted_asset) || KIND_LABEL[g.kind] || g.kind}({KIND_LABEL[g.kind] || g.kind})</span>
|
||||
)) : <span className="muted-2 mono" style={{ fontSize: "11px" }}>// 暂无绑定资产</span>}
|
||||
<span className="asset-tag"><span className="dotc"></span>林夕(人物)</span>
|
||||
<span className="asset-tag"><span className="dotc"></span>深夜办公桌(场景)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ image-2 整张输出 · {sbFrames.length} 场 · 整张重跑,失败不扣 ]</span></div>
|
||||
<div className="info"><span className="mono">[ image-2 单场 ¥0.45 · 累计 ¥1.35 · 整张重跑,失败不扣 ]</span></div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(2)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回资产</button>
|
||||
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(4)}>确认故事板,开始生成视频 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||
@ -490,83 +436,61 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */}
|
||||
{viewStage === 4 && (() => {
|
||||
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
|
||||
return (
|
||||
{viewStage === 4 && (
|
||||
<section className="stage active" data-stage-pane="4">
|
||||
<div className="queue-bar">
|
||||
<div>
|
||||
<div style={{ fontSize: "14px", fontWeight: 600 }}>视频生成 · {segDone} / {segments.length} 完成</div>
|
||||
<div className="muted-2 mono" style={{ fontSize: "11px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 生成 · {segments.length ? (segDone === segments.length ? "已完成所有场次" : "生成中") : "暂无片段"}</div>
|
||||
<div style={{ fontSize: "14px", fontWeight: 600 }}>视频生成 · 3 / 3 完成</div>
|
||||
<div className="muted-2 mono" style={{ fontSize: "11px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 约 <span id="seedance-avg">15</span> 秒 · 已完成所有场次</div>
|
||||
</div>
|
||||
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
|
||||
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
|
||||
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => onSubmitAllVideos(videoPrompt)}>↻ 全部重跑</button>
|
||||
<div className="bar-wrap"><span style={{ width: "100%" }}></span></div>
|
||||
<span className="muted mono" style={{ fontSize: "12px" }}>100%</span>
|
||||
<button className="btn btn-sm" type="button" disabled={loading} onClick={() => onSubmitAllVideos(videoPrompt)}>↻ 全部重跑</button>
|
||||
<button className="btn btn-sm" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
|
||||
上传视频
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{segments.length ? (
|
||||
<div className="video-grid" id="video-grid">
|
||||
{segments.map((seg) => {
|
||||
const url = assetUrl(seg.adopted_asset);
|
||||
const tone = statusPill(seg.status);
|
||||
return (
|
||||
<div className="video-card" key={seg.id} data-video-id={seg.id}>
|
||||
<div className={`placeholder video-thumb${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}>
|
||||
<span className="ph-frame">场 {seg.sort_order + 1}</span>
|
||||
{url && <div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>}
|
||||
</div>
|
||||
<div className="body">
|
||||
<div className="video-card-head">
|
||||
<strong className="video-card-title">场 {seg.sort_order + 1}</strong>
|
||||
<span className={`pill ${tone}`}><span className="dot"></span>{statusLabel(seg.status)}</span>
|
||||
</div>
|
||||
<div className="video-meta">{seg.target_duration_seconds}s{seg.error_message ? ` · ${seg.error_message}` : ""}</div>
|
||||
<div className="video-actions">
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading} onClick={() => onSubmitVideo(seg.id, `${videoPrompt} 第 ${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds} 秒`)}>重跑</button>
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={!url}>下载</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-grid" id="video-grid">
|
||||
{VIDEO_CARDS.map((card) => (
|
||||
<div className="video-card" key={card.vid} data-video-id={card.vid}>
|
||||
<div className="placeholder video-thumb has-mock-media" style={mock(card.img)}>
|
||||
<span className="ph-frame">{card.frame}</span>
|
||||
<div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>
|
||||
</div>
|
||||
<div className="body">
|
||||
<div className="video-card-head"><strong className="video-card-title">{card.title}</strong><span className="pill ok"><span className="dot"></span>完成</span></div>
|
||||
<div className="video-meta">{card.meta}</div>
|
||||
<div className="video-actions">
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-vstop>重跑</button>
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-vstop>下载</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="placeholder" style={{ minHeight: "200px", margin: "18px 28px" }}><span className="ph-frame">// 暂无视频片段 · 先在故事板确认后生成</span></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ 已完成 {segDone} 场 · 总时长 {segTotalSec}s · 失败不扣 · 通过后扣 ]</span></div>
|
||||
<div className="info"><span className="mono">[ 已完成 3 场 · 累计 ¥1.35 · 总时长 <span id="seedance-total">40</span>s · 失败不扣 · 通过后扣 ]</span></div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(3)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回故事板</button>
|
||||
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(5)}>确认视频,进入拼接 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
{/* ============= STAGE 5 · 拼接导出(timeline.clips / subtitle_tracks / bgm_tracks 真实定位)============= */}
|
||||
{viewStage === 5 && (() => {
|
||||
const previewUrl = assetUrl(tlClips[0]?.asset) || assetUrl(segments.find((s) => s.adopted_asset)?.adopted_asset);
|
||||
const aspect = timeline?.aspect_ratio || "9:16";
|
||||
const resolution = timeline?.resolution || "1080×1920";
|
||||
const bgm = bgmTracks[0] || null;
|
||||
const bgmName = assetName(bgm?.asset) || (bgm ? "背景音乐" : "");
|
||||
return (
|
||||
)}
|
||||
{viewStage === 5 && (
|
||||
<section className="stage active" data-stage-pane="5">
|
||||
<div className="editor">
|
||||
<div className="editor-preview">
|
||||
<div className={`canvas${previewUrl ? " has-mock-media" : ""}`} id="ed-canvas" style={previewUrl ? mediaStyle(previewUrl) : undefined}><span id="ed-canvas-label">{aspect} 预览 · {resolution}</span></div>
|
||||
<div className="canvas has-mock-media" id="ed-canvas" style={{ backgroundImage: "url(/exact/assets/mock/cover-mask-final.png)" }}><span id="ed-canvas-label">9:16 预览 · 1080×1920</span></div>
|
||||
<div className="controls">
|
||||
<button className="ctl-btn" type="button" id="ed-prev-btn" title="上一帧 (←)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3v10l4-5zM9 3v10l4-5z" fill="currentColor" /></svg></button>
|
||||
<button className="ctl-btn" type="button" id="ed-play-btn" title="播放 / 暂停 (空格)"><svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M5 4l7 4-7 4z" fill="currentColor" /></svg></button>
|
||||
<button className="ctl-btn" type="button" id="ed-next-btn" title="下一帧 (→)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M13 3v10l-4-5zM7 3v10l-4-5z" fill="currentColor" /></svg></button>
|
||||
<span className="muted mono" style={{ fontSize: "12px", marginLeft: "8px" }}><span id="ed-cur-time">0:00</span> / <span id="ed-total-time">{fmtMs(tlRulerMs)}</span></span>
|
||||
<span className="muted mono" style={{ fontSize: "12px", marginLeft: "8px" }}><span id="ed-cur-time">00:00.00</span> / <span id="ed-total-time">00:15.00</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -580,18 +504,15 @@ export function PipelinePage(props: {
|
||||
<div className="swatch-card"><div className="demo d">真实分享</div><div className="nm">综艺暖黄</div></div>
|
||||
</div>
|
||||
<div className="divider"></div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 时间轴(<span id="ed-inspect-name">{timeline?.name || "未命名"}</span>)</div>
|
||||
<div className="props-row"><span className="k">总时长</span><input className="input-mini" id="ed-inspect-start" defaultValue={fmtMs(tlRulerMs)} readOnly /></div>
|
||||
<div className="props-row"><span className="k">片段</span><input className="input-mini" id="ed-inspect-dur" defaultValue={`${tlClips.length} 段`} readOnly /></div>
|
||||
<div className="props-row"><span className="k">字幕</span><input className="input-mini" defaultValue={`${subtitleCues.length} 条`} readOnly /></div>
|
||||
<div className="props-row"><span className="k">分辨率</span><span className="mono" style={{ fontSize: "11.5px" }}>{resolution}</span></div>
|
||||
{bgm && (
|
||||
<>
|
||||
<div className="divider"></div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// BGM</div>
|
||||
<div className="props-row" style={{ borderBottom: 0 }}><span style={{ fontSize: "12px", flex: 1 }}>{bgmName} · 音量 {bgm.volume}</span><button className="btn btn-ghost btn-sm" type="button">替换</button></div>
|
||||
</>
|
||||
)}
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 当前选中(<span id="ed-inspect-name">未选</span>)</div>
|
||||
<div className="props-row"><span className="k">起始</span><input className="input-mini" id="ed-inspect-start" defaultValue="—" /></div>
|
||||
<div className="props-row"><span className="k">时长</span><input className="input-mini" id="ed-inspect-dur" defaultValue="—" /></div>
|
||||
<div className="props-row"><span className="k">音量</span><input className="input-mini" defaultValue="100" /></div>
|
||||
<div className="props-row"><span className="k">速度</span><input className="input-mini" defaultValue="1.0x" /></div>
|
||||
<div className="props-row"><span className="k">入场</span><span className="mono" style={{ fontSize: "11.5px" }}>交叉淡化</span></div>
|
||||
<div className="divider"></div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// BGM</div>
|
||||
<div className="props-row" style={{ borderBottom: 0 }}><span style={{ fontSize: "12px", flex: 1 }}>温柔治愈钢琴 · 0:42</span><button className="btn btn-ghost btn-sm" type="button">替换</button></div>
|
||||
</div>
|
||||
|
||||
<div className="timeline" id="ed-timeline">
|
||||
@ -609,8 +530,8 @@ export function PipelinePage(props: {
|
||||
<div className="tl-ruler">
|
||||
<div className="l">// time</div>
|
||||
<div className="rule-track" id="ed-ruler">
|
||||
{ruler.map((tick, i) => (
|
||||
<span className={`tick ${tick.major ? "major" : "minor"}`} key={i} style={{ left: `${tick.leftPct}%` }}>{tick.t && <span className="t">{tick.t}</span>}</span>
|
||||
{ED_RULER.map((tick, i) => (
|
||||
<span className={`tick ${tick.major ? "major" : "minor"}`} key={i} style={{ left: tick.left }}>{tick.t && <span className="t">{tick.t}</span>}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -618,66 +539,47 @@ export function PipelinePage(props: {
|
||||
<div className="tl-track video-track">
|
||||
<div className="label video"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" /><path d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5" /></svg></span>视频</div>
|
||||
<div className="lane" id="ed-lane-video" data-track="video">
|
||||
{tlClips.length ? tlClips.map((clip, idx) => {
|
||||
const { leftPct, widthPct } = clipLayout(clip.start_ms, clip.duration_ms, tlRulerMs);
|
||||
const lbl = assetName(clip.asset) || `片段 ${idx + 1}`;
|
||||
const frameCount = Math.max(1, Math.round(clip.duration_ms / 1000));
|
||||
return (
|
||||
<div className="clip video" key={clip.id} data-track="video" data-label={lbl} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}>
|
||||
<span className="frames">{Array.from({ length: frameCount + 1 }).map((_, i) => <span className="fr" key={i}></span>)}</span>
|
||||
<span className="num">{idx + 1}</span><span className="lbl">{lbl}</span>
|
||||
</div>
|
||||
);
|
||||
}) : <span className="muted-2 mono" style={{ position: "absolute", left: "8px", top: "50%", transform: "translateY(-50%)", fontSize: "11px" }}>// 暂无片段</span>}
|
||||
{edLayout(ED_VIDEO_CLIPS).map((clip) => (
|
||||
<div className="clip video" key={clip.n} data-track="video" data-label={clip.lbl} style={{ left: `${clip.leftPct}%`, width: `${clip.widthPct}%` }}>
|
||||
<span className="frames">{Array.from({ length: clip.dur + 1 }).map((_, i) => <span className="fr" key={i}></span>)}</span>
|
||||
<span className="num">{clip.n}</span><span className="lbl">{clip.lbl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tl-track subtitle-track">
|
||||
<div className="label subtitle"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7V4h16v3" /><path d="M9 20h6" /><path d="M12 4v16" /></svg></span>字幕</div>
|
||||
<div className="lane" id="ed-lane-subtitle" data-track="subtitle">
|
||||
{subtitleCues.map((cue, i) => {
|
||||
const next = subtitleCues[i + 1];
|
||||
const endMs = next ? next.start_ms : tlRulerMs;
|
||||
const { leftPct, widthPct } = clipLayout(cue.start_ms, Math.max(0, endMs - cue.start_ms), tlRulerMs);
|
||||
return (
|
||||
<div className="clip subtitle" key={i} data-track="subtitle" data-label={cue.text} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}><span className="lbl">{cue.text}</span></div>
|
||||
);
|
||||
})}
|
||||
{edLayout(ED_SUB_CLIPS).map((clip, i) => (
|
||||
<div className="clip subtitle" key={i} data-track="subtitle" data-label={clip.lbl} style={{ left: `${clip.leftPct}%`, width: `${clip.widthPct}%` }}><span className="lbl">{clip.lbl}</span></div>
|
||||
))}
|
||||
<div className="playhead" id="ed-playhead" style={{ left: "0%" }}><span className="ph-grab"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bgmTracks.length > 0 && (
|
||||
<div className="tl-track bgm-track">
|
||||
<div className="label bgm"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg></span>BGM</div>
|
||||
<div className="lane">
|
||||
{bgmTracks.map((track) => {
|
||||
const { leftPct, widthPct } = clipLayout(track.start_ms, Math.max(0, tlRulerMs - track.start_ms), tlRulerMs);
|
||||
const name = assetName(track.asset) || "背景音乐";
|
||||
return (
|
||||
<div className="clip bgm" key={track.id} data-track="bgm" data-label={name} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}>
|
||||
<span className="wave"><svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor">{ED_WAVE.map(([y, h], i) => <rect key={i} x={i * 4} y={y} width="2" height={h} />)}</svg></span>
|
||||
<span className="lbl">{name} · 音量 {track.volume}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="tl-track bgm-track">
|
||||
<div className="label bgm"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg></span>BGM</div>
|
||||
<div className="lane">
|
||||
<div className="clip bgm" data-track="bgm" data-label="温柔治愈钢琴" style={{ left: "0%", width: "100%" }}>
|
||||
<span className="wave"><svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor">{ED_WAVE.map(([y, h], i) => <rect key={i} x={i * 4} y={y} width="2" height={h} />)}</svg></span>
|
||||
<span className="lbl">温柔治愈钢琴 · 0:42(循环 1 次,淡入淡出)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ 时间轴 {fmtMs(tlRulerMs)} · {tlClips.length} 段 · 拼接 / 导出全程 0 token ]</span></div>
|
||||
<div className="info"><span className="mono">[ 合成预估 ~30s · 拼接 / 导出全程 0 token · 已结算 ¥1.39 ]</span></div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(4)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回片段</button>
|
||||
<button className="btn" type="button">保存草稿</button>
|
||||
<button className="btn btn-primary btn-lg" type="button" disabled={!canExport} onClick={onSubmitExport}>导出 MP4 · {resolution.includes("1080") || resolution.includes("1920") ? "1080P" : resolution} {aspect} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg></button>
|
||||
<button className="btn btn-primary btn-lg" type="button" onClick={onSubmitExport}>导出 MP4 · 1080P 9:16 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import type { CSSProperties, FormEvent, KeyboardEvent } from "react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import type { Asset, Product, Project } from "../types";
|
||||
import type { CSSProperties, FormEvent } from "react";
|
||||
import { ArrowLeft, Upload } from "lucide-react";
|
||||
import type { Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { Drawer } from "../components/overlays";
|
||||
import "../product-create-page.css";
|
||||
|
||||
type ProductPayload = {
|
||||
title?: string;
|
||||
@ -141,222 +140,72 @@ export function ProductCard({ product, onOpen }: { product: Product; onOpen: ()
|
||||
);
|
||||
}
|
||||
|
||||
const PC_PHOTO_SLOTS = ["主图", "细节 02", "细节 03", "细节 04", "细节 05"];
|
||||
const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
|
||||
|
||||
export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (payload: ProductPayload) => Promise<unknown> | void; onBack: () => void }) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [price, setPrice] = useState("");
|
||||
const [audience, setAudience] = useState("");
|
||||
const [points, setPoints] = useState<string[]>([]);
|
||||
const [pointDraft, setPointDraft] = useState("");
|
||||
|
||||
const ready = title.trim().length > 0 && category.length > 0;
|
||||
const missing: string[] = [];
|
||||
if (title.trim().length === 0) missing.push("商品名");
|
||||
if (category.length === 0) missing.push("品类");
|
||||
missing.push("≥1 张图");
|
||||
|
||||
function addPoint(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
const value = pointDraft.trim();
|
||||
if (!value) return;
|
||||
setPoints((list) => [...list, value]);
|
||||
setPointDraft("");
|
||||
}
|
||||
function removePoint(index: number) {
|
||||
setPoints((list) => list.filter((_, position) => position !== index));
|
||||
}
|
||||
const [title, setTitle] = useState("补水保湿精华液");
|
||||
const [brand, setBrand] = useState("透真");
|
||||
const [category, setCategory] = useState("美妆个护");
|
||||
const [audience, setAudience] = useState("熬夜党 / 学生党 / 通勤白领");
|
||||
const [description, setDescription] = useState("主打补水、舒缓、快速上脸,适合短视频痛点种草。");
|
||||
const [points, setPoints] = useState("透明质酸 + B5\n30g 大容量精华\n0 香精 0 酒精");
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!ready) return;
|
||||
onCreate({
|
||||
title,
|
||||
category,
|
||||
target_audience: audience,
|
||||
specs: { source: "product-create-upload", ...(price ? { price } : {}) },
|
||||
selling_points: points.map((item, index) => ({ title: item, detail: item, sort_order: index }))
|
||||
});
|
||||
onCreate({ title, brand, category, target_audience: audience, description, specs: { source: "product-create-upload" }, selling_points: points.split("\n").filter(Boolean).map((item, index) => ({ title: item, detail: item, sort_order: index })) });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="product-create-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>新建商品</h1>
|
||||
<div className="sub"><span className="mono">// 上传原图 + 填写基本信息</span> · 保存后可在工作台逐步丰富素材</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-ghost" type="button" onClick={onBack}><ArrowLeft size={14} />返回商品库</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<div className="form-grid">
|
||||
|
||||
{/* 左:商品原图 */}
|
||||
<div className="form-card">
|
||||
<div className="card-h">
|
||||
<h3>商品原图</h3>
|
||||
<span className="req-tag">必填</span>
|
||||
</div>
|
||||
<div className="card-sub">// 1-5 张 · 这是后续所有 AI 生成的源材料</div>
|
||||
<div className="photo-grid">
|
||||
<div className="photo-slot photo-slot-add" role="button" tabIndex={0} title="上传图片">
|
||||
<span className="plus">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
</span>
|
||||
<span>添加</span>
|
||||
</div>
|
||||
{PC_PHOTO_SLOTS.slice(1).map((label) => (
|
||||
<div className="photo-slot" key={label}><span className="slot-label">{label}</span></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="upload-tip">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16h.01" /></svg>
|
||||
<span>建议上传 <strong>正面 / 侧面 / 细节 / 包装</strong> 4 张,后续在工作台生成的<strong>白底三视图</strong>更准确。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右:基本信息 */}
|
||||
<div className="form-card">
|
||||
<div className="card-h">
|
||||
<h3>基本信息</h3>
|
||||
<span className="req-tag">必填</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">商品名称<span className="req">*</span></label>
|
||||
<input className="input" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="例: 透真玻尿酸补水面膜" required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">品类<span className="req">*</span></label>
|
||||
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)} required>
|
||||
<option value="">— 选择品类 —</option>
|
||||
{PC_CAT_OPTIONS.map((option) => <option key={option}>{option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field field-last">
|
||||
<label className="field-label">价格(元)</label>
|
||||
<input className="input" type="number" value={price} onChange={(event) => setPrice(event.target.value)} placeholder="选填 · 仅用于素材生成参考" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 卖点 & 人群 */}
|
||||
<div className="form-card form-card-wide">
|
||||
<div className="card-h">
|
||||
<h3>卖点 & 人群</h3>
|
||||
<span className="opt-tag">选填 · 推荐</span>
|
||||
</div>
|
||||
<div className="ai-tip">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z" /></svg>
|
||||
<span>填上这两项,后续 AI 生脚本(<strong>痛点种草 / 剧情带货</strong> 等模板)质量明显更高 —— 系统会用卖点构造钩子,用人群定语气。现在不填也可以,做视频项目时仍可补。</span>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">核心卖点</label>
|
||||
<div className="field-hint">3-5 条要点,回车添加</div>
|
||||
<ul className="bullet-list">
|
||||
{points.map((point, index) => (
|
||||
<li className="bl-item" key={index}>
|
||||
<span className="num">{index + 1}</span>
|
||||
<span className="bl-text">{point}</span>
|
||||
<button className="bl-x" type="button" aria-label="删除" onClick={() => removePoint(index)}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li className="bl-add">
|
||||
<span className="num">+</span>
|
||||
<input className="bl-input" value={pointDraft} onChange={(event) => setPointDraft(event.target.value)} onKeyDown={addPoint} placeholder="例: 玻尿酸双效保湿,4 小时持久水润" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="field field-last">
|
||||
<label className="field-label">目标人群</label>
|
||||
<input className="input" value={audience} onChange={(event) => setAudience(event.target.value)} placeholder="例: 22-32 岁女性、敏感肌、办公室通勤" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="form-foot">
|
||||
<span className="req-info">
|
||||
{ready
|
||||
? <>// 必填检查:<span className="ok">已全部完成 ✓</span> · 可进入工作台</>
|
||||
: <>// 必填检查:<span className="miss">{missing.join(" / ")}</span> 未完成</>}
|
||||
</span>
|
||||
<div className="foot-actions">
|
||||
<button className="btn" type="button" onClick={onBack}>取消</button>
|
||||
<button className="btn btn-primary" type="submit" disabled={!ready}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
|
||||
保存并进入工作台
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<div className="page-head"><div><h1>上传创建商品</h1><div className="sub"><span className="mono">// product-create-upload</span> · 图片 / 文案 / 卖点一次补齐</div></div><div className="actions"><button className="btn btn-ghost" type="button" onClick={onBack}><ArrowLeft size={13} />返回商品库</button></div></div>
|
||||
<form className="create-product-layout" onSubmit={submit}>
|
||||
<section className="card-hard create-upload-zone with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="mono muted-2">[ PRODUCT IMAGES ]</div><div className="upload-stage"><Upload size={28} /><strong>拖入商品图片或点击上传</strong><span>// 文件后续走资产库 TOS 上传接口</span></div></section>
|
||||
<section className="pane create-form-pane"><div className="pane-h"><strong>商品信息</strong><span className="spacer" /><span className="mono muted-2">AUTO SAVE READY</span></div><div className="field"><label className="field-label">商品名称<span className="req">*</span></label><input className="input" value={title} onChange={(event) => setTitle(event.target.value)} required /></div><div className="two-col"><div className="field"><label className="field-label">品牌</label><input className="input" value={brand} onChange={(event) => setBrand(event.target.value)} /></div><div className="field"><label className="field-label">类目</label><input className="input" value={category} onChange={(event) => setCategory(event.target.value)} /></div></div><div className="field"><label className="field-label">目标人群</label><input className="input" value={audience} onChange={(event) => setAudience(event.target.value)} /></div><div className="field"><label className="field-label">商品描述</label><textarea className="textarea" value={description} onChange={(event) => setDescription(event.target.value)} /></div><div className="field"><label className="field-label">核心卖点<span className="req">*</span></label><textarea className="textarea" value={points} onChange={(event) => setPoints(event.target.value)} /></div><div className="drawer-actions"><button className="btn" type="button" onClick={onBack}>取消</button><button className="btn btn-primary" type="submit">创建商品并查看详情</button></div></section>
|
||||
<aside className="wiz-preview"><div className="pv-h"><span>资料完整度</span><span className="live">LIVE</span></div><div className="pv-title">{title}</div><div className="pv-section"><div className="lbl">下一步</div><ul className="pv-list"><li>进入商品详情页</li><li>生成模特图 / 平台套图</li><li>创建视频项目</li></ul></div></aside>
|
||||
</form>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 商品详情页 · 从 public/exact/product-detail.html 忠实转写。
|
||||
// 名称 / 品类 / 目标人群 / 卖点 来自 product;商品图网格 / AI 素材卡 / 视频项目卡
|
||||
// 已接入真实数据(product.images + 团队 assets + 该商品 projects),保持设计稿像素布局。
|
||||
// 真实数据仅注入 api-bridge renderProductDetail 实际 hydrate 的 4 个字段
|
||||
// (名称 / 品类 / 目标人群 / 卖点),其余 图片/素材/项目 网格沿用设计稿镜像的
|
||||
// 静态占位(api-bridge 在 ?product_id 加载时同样保留 mock,故像素对齐)。
|
||||
const PD_ASSETS: Array<{ type: string; status: "pass" | "fail" | "archive" }> = [
|
||||
{ type: "模特上身图", status: "pass" },
|
||||
{ type: "模特上身图", status: "pass" },
|
||||
{ type: "模特上身图", status: "fail" },
|
||||
{ type: "模特上身图", status: "pass" },
|
||||
{ type: "模特上身图", status: "archive" },
|
||||
{ type: "平台套图", status: "pass" },
|
||||
{ type: "平台套图", status: "pass" },
|
||||
{ type: "平台套图", status: "fail" },
|
||||
{ type: "平台套图", status: "archive" },
|
||||
{ type: "平台套图", status: "pass" },
|
||||
{ type: "三视图", status: "pass" },
|
||||
{ type: "三视图", status: "archive" }
|
||||
];
|
||||
const PD_ASSET_STATUS_LABEL: Record<"pass" | "fail" | "archive", string> = { pass: "通过", fail: "不通过", archive: "归档" };
|
||||
|
||||
const PD_VIDEOS: Array<{ proj: string; pill: string; ver: string; label: string; date: string }> = [
|
||||
{ proj: "done", pill: "ok", ver: "补水面膜 · v3", label: "已完成", date: "2026-05-20 12:08" },
|
||||
{ proj: "wip", pill: "info", ver: "补水面膜 · v2", label: "视频生成 4/6", date: "2026-05-19 10:24" },
|
||||
{ proj: "archived", pill: "neutral", ver: "熬夜急救 · v1", label: "已归档", date: "2026-05-18 21:42" },
|
||||
{ proj: "fail", pill: "err", ver: "补水面膜 · v1", label: "故事板失败", date: "2026-05-17 16:00" }
|
||||
];
|
||||
|
||||
const PD_CAT_OPTIONS = ["美妆个护 / 精华液", "美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
|
||||
|
||||
// 取一个 Asset 的预览图(优先主文件,其次首文件)
|
||||
function pdAssetPreview(asset?: Asset): string {
|
||||
if (!asset) return "";
|
||||
return asset.files?.find((file) => file.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
|
||||
}
|
||||
// AI 素材分类 → 中文类型标签(用于缩图左上角 type-pill)
|
||||
const PD_ASSET_TYPE_LABEL: Record<string, string> = {
|
||||
product_image: "商品图", person: "模特上身图", scene: "平台套图", tri_view: "三视图", background: "背景图"
|
||||
};
|
||||
function pdAssetTypeLabel(asset: Asset): string {
|
||||
return PD_ASSET_TYPE_LABEL[asset.category] || PD_ASSET_TYPE_LABEL[asset.asset_type] || asset.category || "素材";
|
||||
}
|
||||
// 项目状态 → 分桶 / 友好标签 / pill 类(对齐 projects.tsx 语义,组件内自洽)
|
||||
function pdProjBucket(project: Project) { return project.status === "completed" ? "done" : project.status === "failed" ? "fail" : "wip"; }
|
||||
function pdProjStatusLabel(project: Project) {
|
||||
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中";
|
||||
}
|
||||
function pdProjPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; }
|
||||
|
||||
export function ProductDetailPage({ product, projects, assets, navigate, onUpdate }: {
|
||||
export function ProductDetailPage({ product, navigate, onUpdate }: {
|
||||
product: Product;
|
||||
projects: Project[];
|
||||
assets: Asset[];
|
||||
navigate: (page: Page) => void;
|
||||
onUpdate: (payload: Partial<Product>) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"assets" | "videos">("assets");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [triOpen, setTriOpen] = useState(false);
|
||||
|
||||
// 商品图网格 · 用 product.images 的 asset id 在团队 assets 里查到真图;再叠加 cover_asset(去重)
|
||||
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
|
||||
const imageRefs = [...(product.images || [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const imageIds = imageRefs.map((ref) => ref.asset);
|
||||
if (product.cover_asset && !imageIds.includes(product.cover_asset)) imageIds.unshift(product.cover_asset);
|
||||
const productImages = imageIds
|
||||
.map((id) => ({ id, url: pdAssetPreview(assetById.get(id)) }));
|
||||
|
||||
// AI 生成素材 · 团队资产中筛与该商品相关的类别(模特/场景/三视图/商品图/背景),取真图;无则回退到全部图片资产
|
||||
const AI_CATS = new Set(["product_image", "person", "scene", "tri_view", "background"]);
|
||||
const aiSource = assets.filter((asset) => AI_CATS.has(asset.category) || AI_CATS.has(asset.asset_type));
|
||||
const imageAssets = (aiSource.length ? aiSource : assets.filter((asset) => asset.asset_type === "image"))
|
||||
.slice()
|
||||
.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
|
||||
const assetCount = imageAssets.length;
|
||||
|
||||
// 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段
|
||||
const videoProjects = projects;
|
||||
// 素材状态筛选 · 镜像默认即「通过」(api-bridge ALWAYS_APPLY status),只显示通过卡
|
||||
const [assetStatus] = useState<"pass" | "fail" | "archive">("pass");
|
||||
const assetCount = PD_ASSETS.filter((asset) => asset.status === assetStatus).length;
|
||||
|
||||
// 真实字段 · 缺省时回退到设计稿镜像默认值(对齐 api-bridge setField 行为)
|
||||
const realName = product.title || "补水保湿精华液";
|
||||
@ -484,14 +333,15 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
|
||||
<div className="ov-images-sub">
|
||||
<div className="sub-h">
|
||||
<span className="ti">商品图片</span>
|
||||
<span className="ct">({productImages.length})</span>
|
||||
<span className="ct">(6)</span>
|
||||
</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>
|
||||
))}
|
||||
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
|
||||
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
|
||||
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
|
||||
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
|
||||
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
|
||||
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
|
||||
<div className="img-upload" id="ov-img-add" title="上传图片">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
</div>
|
||||
@ -570,17 +420,13 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
|
||||
</div>
|
||||
|
||||
<div className="asset-grid">
|
||||
{imageAssets.map((asset) => {
|
||||
const url = pdAssetPreview(asset);
|
||||
const status: "pass" | "fail" | "archive" = "pass";
|
||||
{PD_ASSETS.map((asset, index) => {
|
||||
// 镜像 mock-media:平台套图 占位匹配 scene → scene-tabletop.png
|
||||
const hasMock = asset.type === "平台套图";
|
||||
return (
|
||||
<div className="asset-card" key={asset.id}>
|
||||
<div className="thumb placeholder">
|
||||
{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>}
|
||||
</div>
|
||||
<div className="meta"><span className={`pill ${status}`} data-status={status}>{PD_ASSET_STATUS_LABEL[status]}</span><span className="date">{(asset.created_at || "").slice(0, 10)}</span></div>
|
||||
<div className="asset-card" key={index} style={asset.status === assetStatus ? undefined : { display: "none" }}>
|
||||
<div className={`thumb placeholder${hasMock ? " has-mock-media" : ""}`} style={hasMock ? ({ "--mock-media-url": "url(/exact/assets/mock/scene-tabletop.png)" } as CSSProperties) : undefined}><span className="type-pill">{asset.type}</span><span className="ph-frame">3:4</span></div>
|
||||
<div className="meta"><span className={`pill ${asset.status}`} data-status={asset.status} title="点击切换状态">{PD_ASSET_STATUS_LABEL[asset.status]}</span><span className="date">2026-05-19 15:30</span></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -592,7 +438,7 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
|
||||
{/* ===== 视频项目 ===== */}
|
||||
<div className={`tab-pane${tab === "videos" ? " active" : ""}`} data-pane="videos">
|
||||
<div className="pd-toolbar">
|
||||
<div className="total">该商品视频项目 <span className="ct">({videoProjects.length})</span></div>
|
||||
<div className="total">该商品视频项目 <span className="ct">(4)</span></div>
|
||||
<div className="right">
|
||||
<button className="filter" type="button" data-key="sort">
|
||||
最新导出
|
||||
@ -602,10 +448,10 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
|
||||
</div>
|
||||
|
||||
<div className="asset-grid">
|
||||
{videoProjects.map((project) => (
|
||||
<div className="asset-card" data-proj-status={pdProjBucket(project)} key={project.id}>
|
||||
<div className="thumb placeholder" style={{ aspectRatio: "9/16" }}><span className="type-pill">视频 · 9:16</span><span className="ph-frame">{project.name}</span></div>
|
||||
<div className="meta"><span className={`pill ${pdProjPillClass(project)}`}><span className="dot"></span>{pdProjStatusLabel(project)}</span><span className="date">{(project.updated_at || "").slice(0, 10)}</span></div>
|
||||
{PD_VIDEOS.map((video, index) => (
|
||||
<div className="asset-card" data-proj-status={video.proj} key={index}>
|
||||
<div className="thumb placeholder" style={{ aspectRatio: "9/16" }}><span className="type-pill">视频 · 9:16</span><span className="ph-frame">{video.ver}</span></div>
|
||||
<div className="meta"><span className={`pill ${video.pill}`}><span className="dot"></span>{video.label}</span><span className="date">{video.date}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,33 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { CSSProperties, FormEvent } from "react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import type { Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { ConfirmModal, EmptyPanel } from "../components/overlays";
|
||||
import "../project-wizard-page.css";
|
||||
|
||||
// 时长 / 脚本风格 / 人设 — 与 projects-new.html 基线对齐(创建页仅作视觉选择,
|
||||
// 脚本走向细节进入 Stage 1;onCreate 契约只携带 name + product)。
|
||||
const WIZ_DURATIONS = [
|
||||
{ id: "0-10", label: "0-10 秒", shots: [3, 4], tag: "黄金完播" },
|
||||
{ id: "0-15", label: "0-15 秒", shots: [4, 5], tag: "完播率最佳" },
|
||||
{ id: "0-30", label: "0-30 秒", shots: [6, 8], tag: "卖点详解" },
|
||||
{ id: "0-60", label: "0-60 秒", shots: [10, 12], tag: "故事化" }
|
||||
];
|
||||
const WIZ_STYLES = [
|
||||
{ id: "pain", name: "痛点种草", note: "用户痛点切入,以「我懂你」的口吻引出产品。", tag: "最常用" },
|
||||
{ id: "review", name: "开箱测评", note: "朋友式分享,从开箱到使用感受娓娓道来。", tag: "" },
|
||||
{ id: "compare", name: "对比展示", note: "「用前 vs 用后 / 同类 vs 本品」直观呈现。", tag: "" }
|
||||
];
|
||||
const WIZ_PERSONAS = [
|
||||
{ id: "urban", name: "都市白领女性", sub: "25-30 岁", metric: "大盘消费力", dur: "0-15", style: "pain" },
|
||||
{ id: "bestie", name: "闺蜜种草", sub: "邻家女孩", metric: "复购最高", dur: "0-15", style: "pain" },
|
||||
{ id: "ceo", name: "总裁亲选", sub: "创始人 IP", metric: "30 万销额案例", dur: "0-30", style: "pain" },
|
||||
{ id: "reviewer", name: "专业测评师", sub: "垂类达人", metric: "互动 +30%", dur: "0-30", style: "review" },
|
||||
{ id: "mom", name: "实用宝妈", sub: "家庭决策者", metric: "母婴/家清稳", dur: "0-30", style: "pain" },
|
||||
{ id: "genz", name: "学生党", sub: "Z 世代 18-24", metric: "平价快消", dur: "0-10", style: "compare" }
|
||||
];
|
||||
|
||||
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
|
||||
|
||||
export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
products: Product[];
|
||||
@ -36,311 +12,26 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
}) {
|
||||
const [productId, setProductId] = useState(products[0]?.id || "");
|
||||
const product = products.find((item) => item.id === productId) || products[0];
|
||||
const [name, setName] = useState(() => `${(products[0]?.title || "商品").split(" ")[0]} · 痛点种草 · v1`);
|
||||
|
||||
// Step 1 · 商品选择器本地交互态
|
||||
const [pickSearch, setPickSearch] = useState("");
|
||||
const [pickCat, setPickCat] = useState("全部");
|
||||
const [catOpen, setCatOpen] = useState(false);
|
||||
const [pickView, setPickView] = useState<"grid" | "list">("grid");
|
||||
const [pickPage, setPickPage] = useState(1);
|
||||
|
||||
// Step 2 · 配置(视觉选择,详见 Stage 1)
|
||||
const [duration, setDuration] = useState<string | null>("0-15");
|
||||
const [scriptStyle, setScriptStyle] = useState<string | null>("pain");
|
||||
const [persona, setPersona] = useState<string | null>(null);
|
||||
const [recoDismissed, setRecoDismissed] = useState(false);
|
||||
const [points, setPoints] = useState<Record<string, boolean>>({});
|
||||
const [name, setName] = useState(() => `${products[0]?.title || "商品"} · 短视频 · ${new Date().toLocaleDateString("zh-CN")}`);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId && products[0]) setProductId(products[0].id);
|
||||
}, [productId, products]);
|
||||
|
||||
// 分类清单
|
||||
const cats = useMemo(
|
||||
() => ["全部", ...Array.from(new Set(products.map((p) => p.category || "未分类")))],
|
||||
[products]
|
||||
);
|
||||
|
||||
// 筛选 + 排序后的商品
|
||||
const filtered = useMemo(() => {
|
||||
const q = pickSearch.trim().toLowerCase();
|
||||
return products.filter((p) => {
|
||||
const cat = p.category || "未分类";
|
||||
if (pickCat !== "全部" && cat !== pickCat) return false;
|
||||
if (q) {
|
||||
const blob = `${p.title} ${cat} ${(p.selling_points || []).map((s) => s.title).join(" ")}`.toLowerCase();
|
||||
if (!blob.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [products, pickSearch, pickCat]);
|
||||
|
||||
const hasFilter = !!pickSearch || pickCat !== "全部";
|
||||
const total = filtered.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / WIZ_PAGE_SIZE));
|
||||
const cur = Math.min(pickPage, totalPages);
|
||||
const pageList = filtered.slice((cur - 1) * WIZ_PAGE_SIZE, cur * WIZ_PAGE_SIZE);
|
||||
|
||||
function selectProduct(id: string) {
|
||||
setProductId(id);
|
||||
const p = products.find((item) => item.id === id);
|
||||
if (p) {
|
||||
const seed: Record<string, boolean> = {};
|
||||
(p.selling_points || []).forEach((sp) => { seed[sp.title] = false; });
|
||||
setPoints(seed);
|
||||
}
|
||||
}
|
||||
|
||||
function clearPickFilters() {
|
||||
setPickSearch("");
|
||||
setPickCat("全部");
|
||||
setPickPage(1);
|
||||
}
|
||||
|
||||
function applyPreset() {
|
||||
const p = WIZ_PERSONAS.find((item) => item.id === persona);
|
||||
if (!p) return;
|
||||
setDuration(p.dur);
|
||||
setScriptStyle(p.style);
|
||||
setRecoDismissed(false);
|
||||
}
|
||||
|
||||
const personaObj = WIZ_PERSONAS.find((p) => p.id === persona);
|
||||
const durObj = WIZ_DURATIONS.find((d) => d.id === duration);
|
||||
const styleObj = WIZ_STYLES.find((s) => s.id === scriptStyle);
|
||||
const showReco =
|
||||
!!personaObj && !!duration && !!scriptStyle && !recoDismissed &&
|
||||
(personaObj.dur !== duration || personaObj.style !== scriptStyle);
|
||||
const recoDur = personaObj && WIZ_DURATIONS.find((d) => d.id === personaObj.dur);
|
||||
const recoStyle = personaObj && WIZ_STYLES.find((s) => s.id === personaObj.style);
|
||||
|
||||
const product1Done = !!productId;
|
||||
const config2Done = !!duration && !!styleObj && name.trim().length >= 2;
|
||||
const canStart = product1Done && config2Done;
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!canStart || !product) return;
|
||||
void onCreate({ name: name.trim() || `${product.title} · 短视频`, product: product.id });
|
||||
if (product) void onCreate({ name: name || `${product.title} · 短视频`, product: product.id });
|
||||
}
|
||||
|
||||
const productCover = (p: Product): CSSProperties | undefined => {
|
||||
const file = p.cover_asset || p.images?.find((img) => img.is_primary)?.asset || p.images?.[0]?.asset;
|
||||
return file ? ({ ["--mock-media-url"]: `url(${file})` } as CSSProperties) : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="project-wizard-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>新建项目</h1>
|
||||
<div className="sub"><span className="mono">// 商品 → 配置 · 2 步开始生成</span></div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-ghost" type="button" onClick={onBack}>退出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<div className="page-head"><div><h1>新建视频项目</h1><div className="sub"><span className="mono">// 选择商品 → 创建项目 → 进入 Stage 1 脚本</span></div></div><div className="actions"><button className="btn btn-ghost" type="button" onClick={onBack}>退出</button></div></div>
|
||||
<form className="wizard" onSubmit={submit}>
|
||||
{/* ── 左侧步骤轨 ── */}
|
||||
<nav className="steps" aria-label="新建项目步骤">
|
||||
<div className={`step ${product1Done ? "done" : "active"}`}>
|
||||
<div className="num">{product1Done ? (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>
|
||||
) : "1"}</div>
|
||||
<div>
|
||||
<div className="label">选择商品</div>
|
||||
<div className="desc">{product?.title || "未选择"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`step ${config2Done ? "done" : product1Done ? "active" : ""}`}>
|
||||
<div className="num">{config2Done ? (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>
|
||||
) : "2"}</div>
|
||||
<div>
|
||||
<div className="label">项目配置</div>
|
||||
<div className="desc">{durObj && styleObj ? `${durObj.label} · ${styleObj.name}` : "时长 · 风格 · 人物"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── 主体 ── */}
|
||||
<div className="wiz-body">
|
||||
{/* Step 1 · 商品选择 */}
|
||||
<section className="step-pane-wrap">
|
||||
<div className="wiz-pane">
|
||||
<div className="wiz-step-h">
|
||||
<h2>第 1 步 · 选择商品</h2>
|
||||
<p>从商品库选一个 SKU。它的主图与卖点会被 LLM 作为脚本/资产生成的素材。</p>
|
||||
</div>
|
||||
|
||||
<div className="pp-toolbar">
|
||||
<div className="search-inline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||
<input type="text" placeholder="搜索商品名称、标签" value={pickSearch} onChange={(event) => { setPickSearch(event.target.value); setPickPage(1); }} />
|
||||
</div>
|
||||
<div className={`pp-chip-wrap${catOpen ? " open" : ""}`}>
|
||||
<button className={`pp-chip${pickCat !== "全部" ? " active" : ""}`} type="button" onClick={() => setCatOpen((open) => !open)}>
|
||||
<span>{pickCat === "全部" ? "全部分类" : pickCat}</span>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="pp-menu">
|
||||
{cats.map((c) => (
|
||||
<div className={`mi${pickCat === c ? " selected" : ""}`} key={c} onClick={() => { setPickCat(c); setPickPage(1); setCatOpen(false); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6"><polyline points="3 8 7 12 13 4" /></svg>
|
||||
<span>{c}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{hasFilter && (
|
||||
<button className="pp-clear" type="button" onClick={clearPickFilters}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||
清空筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pp-result-meta">// 显示 {pageList.length} / {total} 个商品{hasFilter ? " (已筛选)" : ""}</div>
|
||||
|
||||
<div className={`pp-grid${pickView === "list" ? " list-view" : ""}`}>
|
||||
<div className="pp-create-card" onClick={onBack}>
|
||||
<div className="pc-plus"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg></div>
|
||||
<div className="pc-t">创建新商品</div>
|
||||
<div className="pc-d">// 在此添加一个新商品</div>
|
||||
</div>
|
||||
{total === 0 ? (
|
||||
<div className="pp-empty">// NO MATCH<br />没有符合筛选条件的商品 <span className="reset" onClick={clearPickFilters}>[ 清空筛选 ]</span></div>
|
||||
) : (
|
||||
pageList.map((p) => (
|
||||
<div className={`product-card${productId === p.id ? " selected" : ""}`} key={p.id} onClick={() => selectProduct(p.id)}>
|
||||
<div className={`placeholder product-thumb${productCover(p) ? " has-mock-media" : ""}`} style={productCover(p)}><span className="ph-frame">{p.title} · 1200×800</span></div>
|
||||
<div className="product-body">
|
||||
<div className="product-name">{p.title}</div>
|
||||
<div className="product-cat">{p.category || "未分类"}</div>
|
||||
<div className="product-date">{(p.created_at || "").slice(0, 10)} 创建</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{total > WIZ_PAGE_SIZE && (
|
||||
<div className="pp-pager">
|
||||
<span className="total">共 {total} 条</span>
|
||||
<div className="pages">
|
||||
<button type="button" disabled={cur === 1} onClick={() => setPickPage(cur - 1)}>‹</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
|
||||
<button type="button" key={n} className={n === cur ? "active" : ""} onClick={() => setPickPage(n)}>{n}</button>
|
||||
))}
|
||||
<button type="button" disabled={cur === totalPages} onClick={() => setPickPage(cur + 1)}>›</button>
|
||||
</div>
|
||||
<span className="page-size">每页 {WIZ_PAGE_SIZE} 条</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pp-bottom-tip">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v5M12 16h.01" /></svg>
|
||||
<span>找不到想要的商品?可<a onClick={onBack}>创建新商品</a>,或前往 <a onClick={onBack}>商品库 · 管理商品</a></span>
|
||||
</div>
|
||||
|
||||
{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={onBack} />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Step 2 · 项目配置 */}
|
||||
<section className="step-pane-wrap">
|
||||
<div className="wiz-pane">
|
||||
<div className="wiz-step-h">
|
||||
<h2>第 2 步 · 项目配置</h2>
|
||||
<p>这些设置会影响 LLM 生成脚本的方向,确认后会进入流水线第 1 步(脚本生成)。</p>
|
||||
</div>
|
||||
|
||||
<div className="config-row">
|
||||
<div className="field">
|
||||
<label className="field-label">项目名称<span className="req">*</span></label>
|
||||
<input className="input" value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">视频时长<span className="req">*</span></label>
|
||||
<select className="select duration-select" value={duration || ""} onChange={(event) => setDuration(event.target.value || null)}>
|
||||
<option value="" disabled>选择时长</option>
|
||||
{WIZ_DURATIONS.map((d) => (
|
||||
<option value={d.id} key={d.id}>{d.label} · {d.shots[0]}-{d.shots[1]} 镜</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">脚本风格</label>
|
||||
<div className="opt-row cols-4">
|
||||
{WIZ_STYLES.map((s) => (
|
||||
<div className={`opt-card${scriptStyle === s.id ? " selected" : ""}`} key={s.id} onClick={() => setScriptStyle(s.id)}>
|
||||
<h4>{s.name}</h4>
|
||||
<div className="note">{s.note}</div>
|
||||
{s.tag && <span className="badge">[ {s.tag} ]</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">人物设定</label>
|
||||
<div className="opt-row cols-6">
|
||||
{WIZ_PERSONAS.map((p) => (
|
||||
<div className={`opt-card${persona === p.id ? " selected" : ""}`} key={p.id} onClick={() => { setPersona(p.id); setRecoDismissed(false); }}>
|
||||
<h4>{p.name}</h4>
|
||||
<div className="sub">{p.sub}</div>
|
||||
<div className="metric"><span className="val">{p.metric}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showReco && recoDur && recoStyle && durObj && styleObj && (
|
||||
<div className="reco-bubble">
|
||||
<span className="ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 16v-4M12 8h.01" /></svg>
|
||||
</span>
|
||||
<div className="txt">
|
||||
<span>抖音同人设 TOP 视频更常用 <strong>{recoDur.label}</strong> + <strong>{recoStyle.name}</strong></span>
|
||||
<span className="meta">当前 {durObj.label} · {styleObj.name} → 推荐换为同人设最优组合</span>
|
||||
</div>
|
||||
<button className="btn-apply" type="button" onClick={applyPreset}>一键套用</button>
|
||||
<button className="dismiss" type="button" onClick={() => setRecoDismissed(true)} aria-label="忽略">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Object.keys(points).length > 0 && (
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="field-label">关键卖点(可勾选要重点突出的)</label>
|
||||
<div className="theme-pill-row">
|
||||
{Object.entries(points).map(([k, v]) => (
|
||||
<button className={`theme-pill${v ? " active" : ""}`} type="button" key={k} aria-pressed={v} onClick={() => setPoints((prev) => ({ ...prev, [k]: !prev[k] }))}>
|
||||
{v && <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>}
|
||||
<span>{k}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 底部「开始」CTA ── */}
|
||||
<div className="wiz-start-bar">
|
||||
<button className={`btn-start${canStart ? "" : " disabled"}`} type="submit" disabled={!canStart}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 3l14 9-14 9V3z" /></svg>
|
||||
<span>开始</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="steps" aria-label="新建项目步骤"><div className="step active"><div className="num">1</div><div><div className="label">选择商品</div><div className="desc">{product?.title || "未选择"}</div></div></div><div className="step"><div className="num">2</div><div><div className="label">项目名称</div><div className="desc">自动生成可修改</div></div></div><div className="step"><div className="num">3</div><div><div className="label">进入脚本</div><div className="desc">Stage 1 配置脚本</div></div></div></nav>
|
||||
<div><section className="wiz-pane active"><div className="wiz-step-h"><h2>为哪个商品创建视频?</h2><p>按定稿,创建页只绑定商品和项目名称,脚本风格、卖点重点放到 Stage 1。</p></div><div className="product-select-grid">{products.map((item) => <button className={`product-pick ${productId === item.id ? "selected" : ""}`} type="button" key={item.id} onClick={() => setProductId(item.id)}><div className="placeholder"><span className="ph-frame">{item.title}</span></div><strong>{item.title}</strong><span>{item.category || "未分类"} · {item.selling_points.length} 个卖点</span></button>)}</div>{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={onBack} />}<div className="field" style={{ marginTop: 18 }}><label className="field-label">项目名称</label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="wiz-foot"><button className="btn" type="button" onClick={onBack}><ArrowLeft size={13} />返回</button><div className="hstack"><span className="muted-2 mono">// 下一步:流水线 Stage 1</span><button className="btn btn-primary btn-lg" type="submit" disabled={!product}><ArrowRight size={13} />创建并进入脚本</button></div></div></section></div>
|
||||
<aside className="wiz-preview"><div className="pv-h"><span>实时预估</span><span className="live">LIVE</span></div><div className="pv-title">{name || product?.title || "未命名项目"}</div><div className="pv-metrics"><div className="pv-metric"><div className="l">片段</div><div className="v">4<small>段</small></div></div><div className="pv-metric accent"><div className="l">总时长</div><div className="v">60<small>s</small></div></div><div className="pv-metric"><div className="l">阶段</div><div className="v">5</div></div></div></aside>
|
||||
</form>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,679 +1,18 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ChangeEvent, ReactNode } from "react";
|
||||
import {
|
||||
Bell,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
Monitor,
|
||||
ShieldCheck,
|
||||
Sliders,
|
||||
Smartphone,
|
||||
Upload,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { LogOut, Upload } from "lucide-react";
|
||||
import type { Team, User } from "../types";
|
||||
import { TeamModal } from "../components/overlays";
|
||||
import { ConfirmModal, SettingRow, TeamModal } from "../components/overlays";
|
||||
|
||||
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
|
||||
|
||||
const NAV: Array<{ group: string; items: Array<{ key: SectionKey; label: string; icon: ReactNode; badge?: string }> }> = [
|
||||
{
|
||||
group: "个人",
|
||||
items: [
|
||||
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
|
||||
{ key: "security", label: "安全", icon: <ShieldCheck />, badge: "3 设备" },
|
||||
{ key: "notify", label: "通知", icon: <Bell />, badge: "4/4" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "偏好",
|
||||
items: [
|
||||
{ key: "pref", label: "创作默认", icon: <Sliders /> },
|
||||
{ key: "display", label: "显示", icon: <Monitor /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const TEMPLATE_CHOICES = [
|
||||
{ v: "pain", t: "痛点种草", d: "// 30s 默认档" },
|
||||
{ v: "unbox", t: "开箱测评", d: "// 45s 默认档" },
|
||||
{ v: "compare", t: "对比展示", d: "// 45s 默认档" },
|
||||
{ v: "howto", t: "教程演示", d: "// 60s 默认档" },
|
||||
{ v: "drama", t: "剧情带货", d: "// 60s 默认档" },
|
||||
];
|
||||
|
||||
const SUBTITLE_CHOICES = [
|
||||
{ v: "big-variety", t: "大字综艺", d: "// 抖音热门" },
|
||||
{ v: "clean-ec", t: "简洁电商", d: "// 信息清晰" },
|
||||
{ v: "premium", t: "高级排版", d: "// 居中衬线" },
|
||||
{ v: "bullet", t: "弹幕轻量", d: "// 滚动出现" },
|
||||
{ v: "emphasis", t: "强调爆款", d: "// 高对比" },
|
||||
];
|
||||
|
||||
const DURATIONS = ["30", "45", "60"];
|
||||
|
||||
const DEVICES: Array<{ name: string; meta: string; current?: boolean; phone?: boolean }> = [
|
||||
{ name: "MacBook Pro · Chrome", meta: "// 上海 · 2026-05-21 14:08 · IP 116.xxx.xxx.42", current: true },
|
||||
{ name: "iPhone 15 · Safari", meta: "// 上海 · 2026-05-20 21:43", phone: true },
|
||||
{ name: "Windows · Edge", meta: "// 杭州 · 2026-05-18 09:12" },
|
||||
];
|
||||
|
||||
const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: string }> = [
|
||||
{ key: "n-export", title: "项目完成通知", sub: "// 视频导出后", channels: "站内 · 邮件 · 短信" },
|
||||
{ key: "n-fail", title: "任务失败告警", channels: "站内 · 邮件" },
|
||||
{ key: "n-quota", title: "额度不足提醒", sub: "// 团队或个人剩余 < 20%", channels: "站内 · 短信" },
|
||||
{ key: "n-login", title: "异地登录告警", channels: "短信" },
|
||||
];
|
||||
|
||||
// ─── 偏好持久化 · 后端无字段,纯本地 localStorage ───
|
||||
const PREFS_KEY = "airshelf_settings_prefs";
|
||||
|
||||
type Prefs = {
|
||||
template: string;
|
||||
duration: string;
|
||||
subtitle: string;
|
||||
twoFactor: boolean;
|
||||
notify: Record<string, boolean>;
|
||||
appearance: string;
|
||||
language: string;
|
||||
density: string;
|
||||
};
|
||||
|
||||
const DEFAULT_PREFS: Prefs = {
|
||||
template: "pain",
|
||||
duration: "60",
|
||||
subtitle: "big-variety",
|
||||
twoFactor: false,
|
||||
notify: { "n-export": true, "n-fail": true, "n-quota": true, "n-login": true },
|
||||
appearance: "system",
|
||||
language: "zh",
|
||||
density: "standard",
|
||||
};
|
||||
|
||||
function loadPrefs(): Prefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFS_KEY);
|
||||
if (!raw) return DEFAULT_PREFS;
|
||||
const parsed = JSON.parse(raw) as Partial<Prefs>;
|
||||
return {
|
||||
...DEFAULT_PREFS,
|
||||
...parsed,
|
||||
notify: { ...DEFAULT_PREFS.notify, ...(parsed.notify ?? {}) },
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_PREFS;
|
||||
}
|
||||
}
|
||||
|
||||
function Switch({ checked, disabled, onChange }: { checked: boolean; disabled?: boolean; onChange?: (next: boolean) => void }) {
|
||||
export function SettingsPage({ user, team, initialSection = "profile" }: { user: User; team: Team; initialSection?: string }) {
|
||||
const [section, setSection] = useState(initialSection);
|
||||
const [modal, setModal] = useState<"" | "avatar" | "logout">("");
|
||||
const sections = [["profile", "个人资料"], ["security", "安全"], ["notify", "通知"], ["pref", "创作偏好"], ["display", "显示"]];
|
||||
return (
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange?.(event.target.checked)} />
|
||||
<span className="slider" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage({
|
||||
user,
|
||||
team,
|
||||
initialSection = "profile",
|
||||
onSaveProfile,
|
||||
onChangePassword,
|
||||
onUploadAvatar,
|
||||
}: {
|
||||
user: User;
|
||||
team: Team;
|
||||
initialSection?: string;
|
||||
onSaveProfile: (payload: { name?: string; phone?: string; email?: string }) => void | Promise<unknown>;
|
||||
onChangePassword: (payload: { old_password: string; new_password: string }) => void | Promise<unknown>;
|
||||
onUploadAvatar: (formData: FormData) => void | Promise<unknown>;
|
||||
}) {
|
||||
const normalizedInitial = (["profile", "security", "notify", "pref", "display"] as const).includes(initialSection as SectionKey)
|
||||
? (initialSection as SectionKey)
|
||||
: "profile";
|
||||
const [section, setSection] = useState<SectionKey>(normalizedInitial);
|
||||
const [modal, setModal] = useState<"" | "avatar" | "logout" | "password">("");
|
||||
|
||||
// 个人信息 · 受控输入(初值取真实用户数据)
|
||||
const [name, setName] = useState(user.username || "");
|
||||
const [email, setEmail] = useState(user.email || "");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
|
||||
// 偏好 · localStorage 持久化(读 localStorage 初始化)
|
||||
const initialPrefs = useMemo(() => loadPrefs(), []);
|
||||
const [template, setTemplate] = useState(initialPrefs.template);
|
||||
const [duration, setDuration] = useState(initialPrefs.duration);
|
||||
const [subtitle, setSubtitle] = useState(initialPrefs.subtitle);
|
||||
const [twoFactor, setTwoFactor] = useState(initialPrefs.twoFactor);
|
||||
const [notify, setNotify] = useState<Record<string, boolean>>(initialPrefs.notify);
|
||||
const [appearance, setAppearance] = useState(initialPrefs.appearance);
|
||||
const [language, setLanguage] = useState(initialPrefs.language);
|
||||
const [density, setDensity] = useState(initialPrefs.density);
|
||||
|
||||
// 偏好改动即写回 localStorage(不调后端)
|
||||
useEffect(() => {
|
||||
const prefs: Prefs = { template, duration, subtitle, twoFactor, notify, appearance, language, density };
|
||||
try {
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
/* localStorage 不可用时静默降级 */
|
||||
}
|
||||
}, [template, duration, subtitle, twoFactor, notify, appearance, language, density]);
|
||||
|
||||
// 改密 · 受控输入
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [pwSubmitted, setPwSubmitted] = useState(false);
|
||||
const [savingPassword, setSavingPassword] = useState(false);
|
||||
const pwTooShort = newPassword.length > 0 && newPassword.length < 8;
|
||||
const pwReady = oldPassword.length > 0 && newPassword.length >= 8;
|
||||
|
||||
// 头像 · 文件选择 + 本地预览
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string>("");
|
||||
const [savingAvatar, setSavingAvatar] = useState(false);
|
||||
|
||||
const avatarChar = useMemo(() => (name || user.username || "李").slice(0, 1).toUpperCase(), [name, user.username]);
|
||||
|
||||
function resetProfile() {
|
||||
setName(user.username || "");
|
||||
setEmail(user.email || "");
|
||||
setPhone("");
|
||||
}
|
||||
|
||||
async function handleSaveProfile() {
|
||||
if (savingProfile) return;
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
await onSaveProfile({ name: name.trim(), email: email.trim(), phone: phone.trim() });
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openPasswordModal() {
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
setPwSubmitted(false);
|
||||
setModal("password");
|
||||
}
|
||||
|
||||
async function handleChangePassword() {
|
||||
setPwSubmitted(true);
|
||||
if (!pwReady || savingPassword) return;
|
||||
setSavingPassword(true);
|
||||
try {
|
||||
await onChangePassword({ old_password: oldPassword, new_password: newPassword });
|
||||
setModal("");
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
setPwSubmitted(false);
|
||||
} finally {
|
||||
setSavingPassword(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openAvatarModal() {
|
||||
setAvatarFile(null);
|
||||
setAvatarPreview("");
|
||||
setModal("avatar");
|
||||
}
|
||||
|
||||
function onPickAvatar(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAvatarFile(file);
|
||||
setAvatarPreview(URL.createObjectURL(file));
|
||||
}
|
||||
|
||||
async function handleUploadAvatar() {
|
||||
if (!avatarFile || savingAvatar) return;
|
||||
setSavingAvatar(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", avatarFile);
|
||||
await onUploadAvatar(fd);
|
||||
setModal("");
|
||||
setAvatarFile(null);
|
||||
setAvatarPreview("");
|
||||
} finally {
|
||||
setSavingAvatar(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 预览 URL 在切换/卸载时释放,避免内存泄漏
|
||||
useEffect(() => {
|
||||
if (!avatarPreview) return;
|
||||
return () => URL.revokeObjectURL(avatarPreview);
|
||||
}, [avatarPreview]);
|
||||
|
||||
return (
|
||||
<section className="settings-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>设置</h1>
|
||||
<div className="sub"><span className="mono">// 个人信息 · 偏好 · 通知 · 安全</span></div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn" type="button" onClick={resetProfile} disabled={savingProfile}>取消</button>
|
||||
<button className="btn btn-primary" type="button" onClick={handleSaveProfile} disabled={savingProfile}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
|
||||
保存所有变更
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-grid">
|
||||
{/* 左侧 nav */}
|
||||
<aside className="settings-nav" role="tablist" aria-label="设置分区">
|
||||
{NAV.map((group, gi) => (
|
||||
<div key={group.group}>
|
||||
<div className="nav-h" style={gi > 0 ? { marginTop: 16 } : undefined}>{group.group}</div>
|
||||
{group.items.map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={`#sec-${item.key}`}
|
||||
className={section === item.key ? "active" : ""}
|
||||
role="tab"
|
||||
aria-selected={section === item.key}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setSection(item.key);
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
{item.badge ? <span className="nav-badge">{item.badge}</span> : null}
|
||||
<span className="nav-dot" aria-hidden="true" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="nav-h" style={{ marginTop: 16 }}>账号</div>
|
||||
<button className="logout-pill" type="button" onClick={() => setModal("logout")}>
|
||||
<LogOut />
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{/* 右侧内容 */}
|
||||
<main>
|
||||
{section === "profile" && (
|
||||
<section className="pane" aria-label="个人信息">
|
||||
<h3>个人信息</h3>
|
||||
<div className="pane-desc">// 头像、姓名、联系方式 · 邮箱用于接收通知</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="lbl">头像</div>
|
||||
<div className="val">
|
||||
<div className="avatar-edit">
|
||||
<div className="av-big">{avatarChar}</div>
|
||||
<div className="av-actions">
|
||||
<button className="btn btn-sm" type="button" onClick={openAvatarModal}>上传新头像</button>
|
||||
<button className="btn btn-ghost btn-sm" type="button">恢复默认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">显示名称<span className="req">*</span></div>
|
||||
<div className="val"><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">登录邮箱</div>
|
||||
<div className="val">
|
||||
<input className="input" type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
<button className="btn btn-ghost btn-sm" type="button">验证</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">手机号</div>
|
||||
<div className="val">
|
||||
<input className="input" value={phone} onChange={(event) => setPhone(event.target.value)} placeholder="138****8000" />
|
||||
<button className="btn btn-ghost btn-sm" type="button">更换</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">所属团队<div className="lbl-sub">// 一人一团队</div></div>
|
||||
<div className="val">
|
||||
<span className="static">{team.name}</span>
|
||||
<span className="role-tag"><span className="dot" />超管 · 创建者</span>
|
||||
<a href="#team" className="row-link">管理团队 →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">用户 ID<div className="lbl-sub">// 不可改</div></div>
|
||||
<div className="val"><span className="static mono">{user.id}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{section === "security" && (
|
||||
<section className="pane" aria-label="安全">
|
||||
<h3>安全</h3>
|
||||
<div className="pane-desc">// 登录密码、双因素、在用设备</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="lbl">登录密码</div>
|
||||
<div className="val">
|
||||
<span className="static mono">●●●●●●●●●●</span>
|
||||
<span className="row-note" style={{ marginLeft: "auto" }}>上次修改 2026-04-12</span>
|
||||
<button className="btn btn-sm" type="button" style={{ marginLeft: 10 }} onClick={openPasswordModal}>修改</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">两步验证<div className="lbl-sub">// 推荐开启</div></div>
|
||||
<div className="val">
|
||||
<Switch checked={twoFactor} onChange={setTwoFactor} />
|
||||
<span className="switch-note">短信 + Authenticator</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="sub-head">在用设备</h3>
|
||||
<div className="pane-desc">// 不在此列表上的设备登录会触发短信告警</div>
|
||||
<div className="device-list">
|
||||
{DEVICES.map((device) => (
|
||||
<div className="device-row" key={device.name}>
|
||||
<div className="ic">
|
||||
{device.phone ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2" width="12" height="20" rx="2" /><path d="M11 18h2" /></svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="14" rx="2" /><path d="M2 20h20" /></svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="nm">{device.name}{device.current ? <span className="tag-cur">CURRENT</span> : null}</div>
|
||||
<div className="meta">{device.meta}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
{device.current
|
||||
? <span className="row-note">当前会话</span>
|
||||
: <button className="btn btn-ghost btn-sm" type="button">下线</button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<button className="btn" type="button">下线所有其他设备</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{section === "notify" && (
|
||||
<section className="pane" aria-label="通知">
|
||||
<h3>通知</h3>
|
||||
<div className="pane-desc">// 邮件、短信、站内提示开关</div>
|
||||
{NOTIFY_ROWS.map((row) => (
|
||||
<div className="form-row" key={row.key}>
|
||||
<div className="lbl">{row.title}{row.sub ? <div className="lbl-sub">{row.sub}</div> : null}</div>
|
||||
<div className="val">
|
||||
<Switch checked={!!notify[row.key]} onChange={(next) => setNotify((prev) => ({ ...prev, [row.key]: next }))} />
|
||||
<span className="switch-note">{row.channels}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{section === "pref" && (
|
||||
<section className="pane" aria-label="创作默认">
|
||||
<h3>创作默认</h3>
|
||||
<div className="pane-desc">// 新建项目时的预填值,可在向导中改</div>
|
||||
|
||||
<div className="form-row row-top">
|
||||
<div className="lbl">默认模板</div>
|
||||
<div className="val">
|
||||
<div className="pref-choices">
|
||||
{TEMPLATE_CHOICES.map((choice) => (
|
||||
<div
|
||||
key={choice.v}
|
||||
className={`pref-choice ${template === choice.v ? "selected" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setTemplate(choice.v)}
|
||||
>
|
||||
<div className="t">{choice.t}</div>
|
||||
<div className="d">{choice.d}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">默认时长档</div>
|
||||
<div className="val">
|
||||
<div className="duration-row">
|
||||
{DURATIONS.map((d) => (
|
||||
<span
|
||||
key={d}
|
||||
className={`dur-chip ${duration === d ? "selected" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setDuration(d)}
|
||||
>
|
||||
{d}s
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="switch-note" style={{ marginLeft: 10 }}>// 60s = 4 段 × 15s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row row-top">
|
||||
<div className="lbl">默认字幕样式</div>
|
||||
<div className="val">
|
||||
<div className="pref-choices">
|
||||
{SUBTITLE_CHOICES.map((choice) => (
|
||||
<div
|
||||
key={choice.v}
|
||||
className={`pref-choice ${subtitle === choice.v ? "selected" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSubtitle(choice.v)}
|
||||
>
|
||||
<div className="t">{choice.t}</div>
|
||||
<div className="d">{choice.d}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">默认 BGM 库</div>
|
||||
<div className="val">
|
||||
<select className="select" defaultValue="kapian">
|
||||
<option value="kapian">抖音 Top10 卡点曲库</option>
|
||||
<option value="emotion">情绪向 · 治愈/悬念</option>
|
||||
<option value="urban">都市电子 · 通勤场景</option>
|
||||
<option value="none">无 BGM</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">默认转场</div>
|
||||
<div className="val">
|
||||
<select className="select" defaultValue="fade">
|
||||
<option value="none">无转场</option>
|
||||
<option value="fade">淡入淡出 · 0.3s</option>
|
||||
<option value="slide">滑动 · 0.3s</option>
|
||||
<option value="zoom">缩放 · 0.3s</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">导出水印<div className="lbl-sub">// VIP 可关闭</div></div>
|
||||
<div className="val">
|
||||
<Switch checked disabled />
|
||||
<span className="switch-note">右下角 · Airshelf</span>
|
||||
<a href="#account" className="row-link">升级 VIP →</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{section === "display" && (
|
||||
<section className="pane" aria-label="显示">
|
||||
<h3>显示</h3>
|
||||
<div className="pane-desc">// 界面外观与语言</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="lbl">外观</div>
|
||||
<div className="val">
|
||||
<select className="select" value={appearance} onChange={(event) => setAppearance(event.target.value)}>
|
||||
<option value="system">跟随系统</option>
|
||||
<option value="light">浅色</option>
|
||||
<option value="dark" disabled>深色(V2)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">语言</div>
|
||||
<div className="val">
|
||||
<select className="select" value={language} onChange={(event) => setLanguage(event.target.value)}>
|
||||
<option value="zh">简体中文</option>
|
||||
<option value="en" disabled>English(V2)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">表格密度</div>
|
||||
<div className="val">
|
||||
<select className="select" value={density} onChange={(event) => setDensity(event.target.value)}>
|
||||
<option value="compact">紧凑</option>
|
||||
<option value="standard">标准</option>
|
||||
<option value="loose">宽松</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="settings-foot">// Airshelf · v2.1 · build 20260521</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* 上传头像 modal · 选图 → FormData(file) → onUploadAvatar */}
|
||||
<TeamModal
|
||||
open={modal === "avatar"}
|
||||
title="上传头像"
|
||||
subtitle="// 用于个人主页、评论与团队展示"
|
||||
icon={<Upload size={16} />}
|
||||
close={() => setModal("")}
|
||||
footer={
|
||||
<button className="btn btn-primary" type="button" onClick={handleUploadAvatar} disabled={!avatarFile || savingAvatar}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
|
||||
确认使用
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="av-up-preview-row">
|
||||
<div className="av-up-preview">
|
||||
{avatarPreview ? <img src={avatarPreview} alt="头像预览" /> : avatarChar}
|
||||
</div>
|
||||
<div className="av-up-preview-meta">
|
||||
<div className="t">{avatarFile ? avatarFile.name : "当前头像 · 默认"}</div>
|
||||
<div className="d">{avatarFile ? `// ${(avatarFile.size / 1024).toFixed(0)} KB · 已选择` : "// 系统生成 · 取姓氏首字"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={onPickAvatar}
|
||||
/>
|
||||
<div
|
||||
className="upload-zone"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="点击选择图片上传"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="uz-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></svg>
|
||||
</span>
|
||||
<div><strong>点击选择</strong> · 图片文件</div>
|
||||
<span className="uz-hint">JPG / PNG / WebP · ≤ 2 MB · 推荐 256 × 256</span>
|
||||
</div>
|
||||
|
||||
<div className="av-up-rules">
|
||||
<div className="li">最大 2 MB · 长宽比建议 1:1 · 系统会自动裁切为圆形</div>
|
||||
<div className="li">不要上传含他人肖像的图片,违规可能导致账号封停</div>
|
||||
</div>
|
||||
</TeamModal>
|
||||
|
||||
{/* 修改密码 modal · 原密码 + 新密码(≥8)→ onChangePassword */}
|
||||
<TeamModal
|
||||
open={modal === "password"}
|
||||
title="修改登录密码"
|
||||
subtitle="// CHANGE PASSWORD"
|
||||
icon={<KeyRound size={16} />}
|
||||
close={() => setModal("")}
|
||||
footer={
|
||||
<button className="btn btn-primary" type="button" onClick={handleChangePassword} disabled={!pwReady || savingPassword}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
|
||||
确认修改
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="pw-old">原密码<span className="req">*</span></label>
|
||||
<input
|
||||
id="pw-old"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={oldPassword}
|
||||
onChange={(event) => setOldPassword(event.target.value)}
|
||||
placeholder="输入当前密码"
|
||||
/>
|
||||
{pwSubmitted && !oldPassword ? <span className="field-hint pw-err">请输入原密码</span> : null}
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="field-label" htmlFor="pw-new">新密码<span className="req">*</span></label>
|
||||
<input
|
||||
id="pw-new"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
placeholder="至少 8 位"
|
||||
/>
|
||||
{pwTooShort || (pwSubmitted && newPassword.length < 8)
|
||||
? <span className="field-hint pw-err">新密码至少 8 位</span>
|
||||
: <span className="field-hint">// 建议混合字母、数字与符号</span>}
|
||||
</div>
|
||||
</TeamModal>
|
||||
|
||||
{/* 退出登录确认 modal · 仅视觉还原,无后端接入 */}
|
||||
<TeamModal
|
||||
open={modal === "logout"}
|
||||
title="退出当前账号"
|
||||
subtitle="// LOG OUT CURRENT SESSION"
|
||||
icon={<LogOut size={16} />}
|
||||
close={() => setModal("")}
|
||||
footer={
|
||||
<button className="btn btn-primary" type="button" onClick={() => setModal("")}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="m16 17 5-5-5-5" /><path d="M21 12H9" /></svg>
|
||||
确认退出
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<p className="logout-confirm-copy">确认后将退出当前设备上的 Airshelf,再次使用需要重新登录。</p>
|
||||
<div className="logout-confirm-points">
|
||||
<div className="li">项目、资产、团队成员与余额数据都会保留</div>
|
||||
<div className="li">仅影响当前浏览器会话,不会下线其他设备</div>
|
||||
</div>
|
||||
</TeamModal>
|
||||
</section>
|
||||
<>
|
||||
<div className="page-head"><div><h1>设置</h1><div className="sub"><span className="mono">// profile · security · notify · preference · display</span></div></div><div className="actions"><button className="btn" type="button">取消</button><button className="btn btn-primary" type="button">保存设置</button></div></div>
|
||||
<div className="settings-layout"><aside className="settings-side">{sections.map(([key, label]) => <button className={section === key ? "active" : ""} type="button" key={key} onClick={() => setSection(key)}>{label}</button>)}<button className="logout-pill" type="button" onClick={() => setModal("logout")}><LogOut size={13} />退出当前设备</button></aside><section className="settings-main">{section === "profile" && <div className="pane"><h3>个人资料</h3><div className="profile-row"><div className="av-big">{user.username.slice(0, 1).toUpperCase()}</div><button className="btn btn-sm" type="button" onClick={() => setModal("avatar")}>上传新头像</button></div><div className="field"><label className="field-label">用户名</label><input className="input" value={user.username} readOnly /></div><div className="field"><label className="field-label">邮箱</label><input className="input" value={user.email || ""} readOnly /></div><div className="field"><label className="field-label">团队</label><input className="input" value={team.name} readOnly /></div></div>}{section === "security" && <div className="pane"><h3>安全</h3><SettingRow title="登录密码" desc="上次更新: 2026-05-28" action="修改" /><SettingRow title="双因素认证" desc="建议超管开启" toggle /></div>}{section === "notify" && <div className="pane"><h3>通知策略</h3><SettingRow title="导出完成" desc="成片 / 套图完成后提醒" toggle checked /><SettingRow title="任务失败" desc="失败和扣费异常必须提醒" toggle checked /><SettingRow title="额度预警" desc="余额低于阈值时提醒" toggle checked /></div>}{section === "pref" && <div className="pane"><h3>创作偏好</h3><SettingRow title="自动水印" desc="VIP 可关闭" toggle checked /></div>}{section === "display" && <div className="pane"><h3>显示偏好</h3><div className="field"><label className="field-label">主题</label><select className="select"><option>跟随系统</option><option>浅色</option></select></div></div>}</section></div>
|
||||
<TeamModal open={modal === "avatar"} title="上传头像" subtitle="// JPG / PNG / WebP" icon={<Upload size={16} />} close={() => setModal("")}><div className="upload-zone">点击或拖入图片上传<br /><span className="mono">// 头像上传接口待后端补充</span></div></TeamModal>
|
||||
<ConfirmModal open={modal === "logout"} title="退出当前账号" detail="当前有未保存的设置变更时,退出后这些变更不会保存。" confirmText="退出" onCancel={() => setModal("")} onConfirm={() => setModal("")} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { CircleDollarSign, KeyRound, UserPlus } from "lucide-react";
|
||||
import { CircleDollarSign, UserPlus } from "lucide-react";
|
||||
import type { BillingSummary, Team, TeamMember, User } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { money } from "./stage-config";
|
||||
import { ConfirmModal, TeamModal } from "../components/overlays";
|
||||
import { TeamModal } from "../components/overlays";
|
||||
|
||||
// 角色 → pill key/label(对齐 api-bridge roleUi)
|
||||
function roleUi(role: string): { key: "super" | "admin" | "member"; label: string } {
|
||||
@ -24,43 +24,16 @@ const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: bo
|
||||
{ cap: "创建项目 / 用 AI 流程", cells: ["✓", "✓", "✓"], last: true }
|
||||
];
|
||||
|
||||
export function TeamPage({ team, user, members, billing, navigate, onCreateMember, onUpdateMember, onRemoveMember, onResetPassword, onRecharge }: {
|
||||
export function TeamPage({ team, user, members, billing, navigate }: {
|
||||
team: Team;
|
||||
user: User;
|
||||
members: TeamMember[];
|
||||
billing: BillingSummary | null;
|
||||
navigate: (page: Page) => void;
|
||||
onCreateMember: (payload: { username: string; password: string; name?: string; role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
|
||||
onUpdateMember: (id: string, payload: { role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
|
||||
onRemoveMember: (id: string) => void | Promise<unknown>;
|
||||
onResetPassword: (id: string, password: string) => void | Promise<unknown>;
|
||||
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
|
||||
}) {
|
||||
const [modal, setModal] = useState<"" | "invite" | "limit" | "recharge">("");
|
||||
const [modal, setModal] = useState<"" | "invite" | "limit">("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// 创建账户表单
|
||||
const [cuUser, setCuUser] = useState("");
|
||||
const [cuPass, setCuPass] = useState("");
|
||||
const [cuName, setCuName] = useState("");
|
||||
const [cuRole, setCuRole] = useState("member");
|
||||
const [cuMonthly, setCuMonthly] = useState("");
|
||||
|
||||
// 编辑成员
|
||||
const [editTarget, setEditTarget] = useState<TeamMember | null>(null);
|
||||
const [edRole, setEdRole] = useState("member");
|
||||
const [edMonthly, setEdMonthly] = useState("");
|
||||
|
||||
// 重置密码
|
||||
const [resetTarget, setResetTarget] = useState<TeamMember | null>(null);
|
||||
const [resetPwd, setResetPwd] = useState("");
|
||||
|
||||
// 移除成员
|
||||
const [removeTarget, setRemoveTarget] = useState<TeamMember | null>(null);
|
||||
|
||||
// 团队充值
|
||||
const [rechargeAmt, setRechargeAmt] = useState("500");
|
||||
|
||||
const rows: TeamMember[] = members.length
|
||||
? members
|
||||
: [{ id: "owner", role: "owner", status: "active", monthly_credit_limit: "0", user } as TeamMember];
|
||||
@ -79,55 +52,6 @@ export function TeamPage({ team, user, members, billing, navigate, onCreateMembe
|
||||
return !needle || `${name} ${email}`.toLowerCase().includes(needle);
|
||||
});
|
||||
|
||||
function openEdit(member: TeamMember) {
|
||||
setEditTarget(member);
|
||||
setEdRole(member.role === "owner" ? "admin" : member.role || "member");
|
||||
setEdMonthly(Number(member.monthly_credit_limit || 0) > 0 ? String(Number(member.monthly_credit_limit)) : "");
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!cuUser.trim() || cuPass.length < 8) return;
|
||||
await onCreateMember({
|
||||
username: cuUser.trim(),
|
||||
password: cuPass,
|
||||
name: cuName.trim() || undefined,
|
||||
role: cuRole,
|
||||
monthly_credit_limit: Number(cuMonthly) || 0
|
||||
});
|
||||
setModal("");
|
||||
setCuUser("");
|
||||
setCuPass("");
|
||||
setCuName("");
|
||||
setCuRole("member");
|
||||
setCuMonthly("");
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editTarget) return;
|
||||
await onUpdateMember(editTarget.id, { role: edRole, monthly_credit_limit: Number(edMonthly) || 0 });
|
||||
setEditTarget(null);
|
||||
}
|
||||
|
||||
async function submitReset() {
|
||||
if (!resetTarget || resetPwd.length < 8) return;
|
||||
await onResetPassword(resetTarget.id, resetPwd);
|
||||
setResetTarget(null);
|
||||
setResetPwd("");
|
||||
}
|
||||
|
||||
async function submitRemove() {
|
||||
if (!removeTarget) return;
|
||||
await onRemoveMember(removeTarget.id);
|
||||
setRemoveTarget(null);
|
||||
}
|
||||
|
||||
async function submitRecharge() {
|
||||
const amt = Number(rechargeAmt) || 0;
|
||||
if (amt <= 0) return;
|
||||
await onRecharge(amt, 0);
|
||||
setModal("");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="team-page">
|
||||
<div className="page-head">
|
||||
@ -156,7 +80,7 @@ export function TeamPage({ team, user, members, billing, navigate, onCreateMembe
|
||||
<div className="meta">// 团队 ID: {team.id} · {rows.length} 名成员</div>
|
||||
</div>
|
||||
<div className="banner-actions">
|
||||
<button className="btn btn-sm" type="button" onClick={() => setModal("recharge")}>
|
||||
<button className="btn btn-sm" type="button" onClick={() => navigate("account")}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4" /></svg>
|
||||
充值
|
||||
</button>
|
||||
@ -195,7 +119,7 @@ export function TeamPage({ team, user, members, billing, navigate, onCreateMembe
|
||||
<div className="h">
|
||||
<h3>团队动态</h3>
|
||||
<span className="ct">// 真实动态接口待接入</span>
|
||||
<a className="more" id="open-feed-all" role="button" tabIndex={0} onClick={() => navigate("messages")}>全部 →</a>
|
||||
<a className="more" id="open-feed-all" role="button" tabIndex={0}>全部 →</a>
|
||||
</div>
|
||||
<div className="feed-list">
|
||||
<div className="feed-item">
|
||||
@ -247,9 +171,9 @@ export function TeamPage({ team, user, members, billing, navigate, onCreateMembe
|
||||
<td><div className="acts">{isOwner
|
||||
? <span style={{ fontFamily: "var(--font-mono)", fontSize: "10.5px", color: "var(--black-alpha-32)", alignSelf: "center" }}>不可编辑</span>
|
||||
: <>
|
||||
<button className="icon-btn-sm" type="button" title="编辑" onClick={() => openEdit(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z" /></svg></button>
|
||||
<button className="icon-btn-sm" type="button" title="重置密码" onClick={() => { setResetTarget(member); setResetPwd(""); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg></button>
|
||||
<button className="icon-btn-sm danger" type="button" title="移出" onClick={() => setRemoveTarget(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
|
||||
<button className="icon-btn-sm" type="button" title="编辑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z" /></svg></button>
|
||||
<button className="icon-btn-sm" type="button" title="重置密码"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg></button>
|
||||
<button className="icon-btn-sm danger" type="button" title="移出"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
|
||||
</>}</div></td>
|
||||
</tr>
|
||||
);
|
||||
@ -283,83 +207,8 @@ export function TeamPage({ team, user, members, billing, navigate, onCreateMembe
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设置月限额(团队级)· 注:后端暂无团队级限额端点,真实限额请在「编辑成员」逐人设置 */}
|
||||
<TeamModal open={modal === "limit"} title="设置月限额" subtitle="// 团队级限额暂存本地 · 成员限额请在编辑成员中设置" icon={<CircleDollarSign size={16} />} close={() => setModal("")}><div className="field"><label className="field-label">月限额 ¥</label><input className="input" defaultValue="3000" /></div></TeamModal>
|
||||
|
||||
{/* 创建账户 */}
|
||||
<TeamModal
|
||||
open={modal === "invite"}
|
||||
title="创建账户"
|
||||
subtitle="// 直接生成账号 · 分享给成员登录"
|
||||
icon={<UserPlus size={16} />}
|
||||
close={() => setModal("")}
|
||||
footer={<button className="btn btn-primary" type="button" onClick={submitCreate}>创建账户</button>}
|
||||
>
|
||||
<div className="field"><label className="field-label">用户名</label><input className="input" value={cuUser} onChange={(e) => setCuUser(e.target.value)} placeholder="zhang.yunying" /></div>
|
||||
<div className="field"><label className="field-label">登录密码</label><input className="input mono" value={cuPass} onChange={(e) => setCuPass(e.target.value)} placeholder="至少 8 位" /></div>
|
||||
<div className="field"><label className="field-label">姓名(可选)</label><input className="input" value={cuName} onChange={(e) => setCuName(e.target.value)} placeholder="张运营" /></div>
|
||||
<div className="field"><label className="field-label">角色</label>
|
||||
<select className="input" value={cuRole} onChange={(e) => setCuRole(e.target.value)}>
|
||||
<option value="admin">团管</option>
|
||||
<option value="member">成员</option>
|
||||
<option value="viewer">访客</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label className="field-label">月度额度 ¥(0 = 不限)</label><input className="input" type="number" value={cuMonthly} onChange={(e) => setCuMonthly(e.target.value)} placeholder="0" /></div>
|
||||
</TeamModal>
|
||||
|
||||
{/* 团队充值 */}
|
||||
<TeamModal
|
||||
open={modal === "recharge"}
|
||||
title="团队充值"
|
||||
subtitle="// 充值后立即到账 · 仅超管可操作"
|
||||
icon={<CircleDollarSign size={16} />}
|
||||
close={() => setModal("")}
|
||||
footer={<button className="btn btn-primary" type="button" onClick={submitRecharge}>确认充值</button>}
|
||||
>
|
||||
<div className="field"><label className="field-label">充值金额 ¥</label><input className="input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /></div>
|
||||
</TeamModal>
|
||||
|
||||
{/* 编辑成员 */}
|
||||
<TeamModal
|
||||
open={!!editTarget}
|
||||
title="编辑成员"
|
||||
subtitle={editTarget ? `// ${editTarget.user.username || editTarget.user.email}` : ""}
|
||||
icon={<UserPlus size={16} />}
|
||||
close={() => setEditTarget(null)}
|
||||
footer={<button className="btn btn-primary" type="button" onClick={submitEdit}>保存</button>}
|
||||
>
|
||||
<div className="field"><label className="field-label">角色</label>
|
||||
<select className="input" value={edRole} onChange={(e) => setEdRole(e.target.value)}>
|
||||
<option value="admin">团管</option>
|
||||
<option value="member">成员</option>
|
||||
<option value="viewer">访客</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label className="field-label">月度额度 ¥(0 = 不限)</label><input className="input" type="number" value={edMonthly} onChange={(e) => setEdMonthly(e.target.value)} placeholder="0" /></div>
|
||||
</TeamModal>
|
||||
|
||||
{/* 重置密码 */}
|
||||
<TeamModal
|
||||
open={!!resetTarget}
|
||||
title="重置密码"
|
||||
subtitle={resetTarget ? `// ${resetTarget.user.username || resetTarget.user.email}` : ""}
|
||||
icon={<KeyRound size={16} />}
|
||||
close={() => setResetTarget(null)}
|
||||
footer={<button className="btn btn-primary" type="button" onClick={submitReset}>重置密码</button>}
|
||||
>
|
||||
<div className="field"><label className="field-label">新密码(至少 8 位)</label><input className="input mono" value={resetPwd} onChange={(e) => setResetPwd(e.target.value)} placeholder="新密码" /></div>
|
||||
</TeamModal>
|
||||
|
||||
{/* 移除成员确认 */}
|
||||
<ConfirmModal
|
||||
open={!!removeTarget}
|
||||
title="移除成员"
|
||||
detail={removeTarget ? `确认将「${removeTarget.user.username || removeTarget.user.email}」移出团队?移除后该成员将失去登录与访问权限。` : ""}
|
||||
confirmText="移除"
|
||||
onCancel={() => setRemoveTarget(null)}
|
||||
onConfirm={submitRemove}
|
||||
/>
|
||||
<TeamModal open={modal === "limit"} title="设置月限额" subtitle="// 自然月重置 · 仅超管可改" icon={<CircleDollarSign size={16} />} close={() => setModal("")}><div className="field"><label className="field-label">月限额 ¥</label><input className="input" defaultValue="3000" /></div></TeamModal>
|
||||
<TeamModal open={modal === "invite"} title="创建账户" subtitle="// 直接生成账号 · 分享给成员登录" icon={<UserPlus size={16} />} close={() => setModal("")}><div className="field"><label className="field-label">用户名</label><input className="input" defaultValue="zhang.yunying" /></div><div className="field"><label className="field-label">登录密码</label><input className="input mono" defaultValue="AirShelf2026" /></div></TeamModal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,191 +0,0 @@
|
||||
/* 设置页 · 从 public/exact/settings.html 内联 <style> 忠实移植,整段 scope 进 .settings-page 防外泄 · 只用 token */
|
||||
.settings-page {
|
||||
/* ─── 设置布局:左 nav + 右 panel ─── */
|
||||
.settings-grid { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 24px; align-items: start; }
|
||||
|
||||
.settings-nav { position: sticky; top: 16px; }
|
||||
.settings-nav .nav-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; padding: 0 12px 8px; }
|
||||
.settings-nav :where(a, button) { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; font: inherit; font-size: 13px; color: var(--accent-black); border-radius: var(--r-md); border: 1px solid transparent; background: transparent; cursor: pointer; text-decoration: none; text-align: left; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); position: relative; }
|
||||
.settings-nav :where(a, button):hover { background: var(--background-lighter); }
|
||||
.settings-nav :where(a, button):focus-visible { outline: 2px solid var(--heat); outline-offset: 2px; }
|
||||
.settings-nav a.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }
|
||||
.settings-nav :where(a, button) svg { width: 16px; height: 16px; stroke-width: 1.5; flex: 0 0 auto; }
|
||||
.settings-nav a .nav-badge { margin-left: auto; font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); padding: 1px 6px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-pill); letter-spacing: .02em; line-height: 14px; }
|
||||
.settings-nav a.active .nav-badge { color: var(--heat); background: var(--accent-white); border-color: var(--heat-20); }
|
||||
.settings-nav a .nav-dot { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--heat); display: none; }
|
||||
.settings-nav a.has-changes .nav-dot { display: block; }
|
||||
.settings-nav a.active .nav-dot { right: -4px; }
|
||||
.settings-nav .logout-pill {
|
||||
width: calc(100% - 24px);
|
||||
height: 38px;
|
||||
margin: 4px 12px 0;
|
||||
justify-content: center;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--accent-black);
|
||||
border-color: var(--accent-black);
|
||||
color: var(--accent-white);
|
||||
font-weight: 500;
|
||||
}
|
||||
.settings-nav .logout-pill:hover,
|
||||
.settings-nav .logout-pill:focus-visible {
|
||||
background: var(--black-alpha-88);
|
||||
border-color: var(--black-alpha-88);
|
||||
color: var(--accent-white);
|
||||
}
|
||||
|
||||
/* ─── pane ─── */
|
||||
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 24px; margin-bottom: 16px; }
|
||||
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
||||
.pane .pane-desc { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; margin-bottom: 18px; }
|
||||
|
||||
/* ─── form row ─── */
|
||||
.form-row { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--border-faint); align-items: center; }
|
||||
.form-row:last-child { border-bottom: 0; }
|
||||
.form-row .lbl { font-size: 12.5px; color: var(--black-alpha-56); }
|
||||
.form-row .lbl .req { color: var(--accent-crimson); margin-left: 2px; }
|
||||
.form-row .lbl-sub { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 2px; letter-spacing: .02em; }
|
||||
.form-row .val { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.form-row .val .input, .form-row .val .select { width: 100%; max-width: 380px; }
|
||||
.form-row .val .static { font-size: 13px; color: var(--accent-black); font-variant-numeric: tabular-nums; }
|
||||
.form-row .val .static.mono { font-family: var(--font-mono); font-size: 12.5px; color: var(--black-alpha-56); }
|
||||
.form-row .val .role-tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 11px; font-weight: 500; background: var(--heat-12); color: var(--heat); }
|
||||
.form-row .val .role-tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--heat); }
|
||||
.form-row .val .row-link { font-size: 12px; color: var(--heat); text-decoration: none; margin-left: auto; }
|
||||
.form-row .val .row-note { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); }
|
||||
.form-row .val .switch-note { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); }
|
||||
.form-row.row-top { align-items: flex-start; }
|
||||
.form-row.row-top .lbl { padding-top: 4px; }
|
||||
.form-row.row-top .val { display: block; }
|
||||
|
||||
/* ─── 头像上传 ─── */
|
||||
.avatar-edit { display: flex; align-items: center; gap: 16px; }
|
||||
.avatar-edit .av-big { width: 64px; height: 64px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 24px; font-weight: 600; color: var(--accent-black); overflow: hidden; }
|
||||
.avatar-edit .av-big img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.avatar-edit .av-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* ─── toggle switch ─── */
|
||||
.switch { position: relative; width: 36px; height: 20px; flex: 0 0 36px; display: inline-block; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.switch .slider { position: absolute; inset: 0; background: var(--black-alpha-24); border-radius: 20px; cursor: pointer; transition: background var(--t-base); }
|
||||
.switch .slider::before { content: ''; position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; background: var(--accent-white); border-radius: 50%; transition: transform var(--t-base); }
|
||||
.switch input:checked + .slider { background: var(--heat); }
|
||||
.switch input:checked + .slider::before { transform: translateX(16px); }
|
||||
.switch input:disabled + .slider { cursor: not-allowed; opacity: .55; }
|
||||
|
||||
/* ─── 偏好选项卡 ─── */
|
||||
.pref-choices { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 8px; max-width: 540px; }
|
||||
.pref-choice { padding: 10px 12px; border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); }
|
||||
.pref-choice:hover { background: var(--background-lighter); }
|
||||
.pref-choice.selected { border-color: var(--heat); background: var(--heat-12); }
|
||||
.pref-choice .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); }
|
||||
.pref-choice .d { font-size: 11px; color: var(--black-alpha-48); margin-top: 2px; font-family: var(--font-mono); letter-spacing: .02em; }
|
||||
.pref-choice.selected .t { color: var(--heat); }
|
||||
|
||||
/* ─── 时长档 ─── */
|
||||
.duration-row { display: flex; gap: 8px; }
|
||||
.dur-chip { padding: 6px 14px; border: 1px solid var(--border-faint); border-radius: var(--r-md); font-size: 13px; cursor: pointer; font-family: var(--font-mono); font-variant-numeric: tabular-nums; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); background: var(--surface); }
|
||||
.dur-chip:hover { background: var(--background-lighter); }
|
||||
.dur-chip.selected { border-color: var(--heat); background: var(--heat-12); color: var(--heat); font-weight: 600; }
|
||||
|
||||
/* ─── 设备列表 ─── */
|
||||
.device-list { /* 容器 · 仅承载行 */ }
|
||||
.device-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border-faint); }
|
||||
.device-row:last-child { border-bottom: 0; }
|
||||
.device-row .ic { width: 36px; height: 36px; border-radius: var(--r-md); background: var(--background-lighter); display: grid; place-items: center; color: var(--black-alpha-56); flex: 0 0 36px; }
|
||||
.device-row .ic svg { width: 18px; height: 18px; }
|
||||
.device-row .nm { font-size: 13px; font-weight: 500; display: flex; align-items: center; }
|
||||
.device-row .meta { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 2px; letter-spacing: .02em; }
|
||||
.device-row .tag-cur { font-family: var(--font-mono); font-size: 10.5px; padding: 1px 6px; background: var(--accent-forest); color: var(--accent-white); border-radius: var(--r-sm); margin-left: 8px; letter-spacing: .04em; font-weight: 600; }
|
||||
.device-row .spacer { margin-left: auto; }
|
||||
.device-row .row-note { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); }
|
||||
|
||||
.sub-head { margin-top: 24px; }
|
||||
|
||||
/* ─── 头像上传 modal 正文 · 装订线分隔 ─── */
|
||||
.av-up-preview-row { display: flex; align-items: center; gap: 14px; padding-bottom: 14px; margin-bottom: 14px; position: relative; }
|
||||
.av-up-preview-row::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: repeating-linear-gradient(to right, var(--border-faint) 0, var(--border-faint) 4px, transparent 4px, transparent 8px); }
|
||||
.av-up-preview { width: 64px; height: 64px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 22px; font-weight: 600; color: var(--accent-black); overflow: hidden; flex: 0 0 64px; }
|
||||
.av-up-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.av-up-preview-meta { min-width: 0; }
|
||||
.av-up-preview-meta .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); margin-bottom: 3px; letter-spacing: .01em; }
|
||||
.av-up-preview-meta .d { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; line-height: 1.55; }
|
||||
|
||||
.av-up-rules { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--border-faint); font-size: 11px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; line-height: 1.7; }
|
||||
.av-up-rules .li { display: flex; gap: 8px; }
|
||||
.av-up-rules .li::before { content: '//'; color: var(--black-alpha-32); flex: 0 0 auto; }
|
||||
|
||||
/* 头像上传 modal 的 upload-zone · 共享样式仅 scope 在 .np-body,故在此页内补齐 uz-ic / uz-hint */
|
||||
.upload-zone {
|
||||
border: 1.5px dashed var(--black-alpha-24);
|
||||
border-radius: var(--r-md);
|
||||
padding: 22px 20px;
|
||||
text-align: center;
|
||||
background: var(--background-lighter);
|
||||
color: var(--black-alpha-56);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.upload-zone:hover, .upload-zone.dragover { border-color: var(--heat); background: var(--heat-8); color: var(--heat); }
|
||||
.upload-zone:hover .uz-ic, .upload-zone.dragover .uz-ic { background: var(--heat); color: var(--accent-white); border-color: var(--heat); }
|
||||
.upload-zone strong { color: var(--heat); font-weight: 600; }
|
||||
.upload-zone .uz-ic {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface);
|
||||
color: var(--heat);
|
||||
border: 1px solid var(--heat-20);
|
||||
display: grid; place-items: center;
|
||||
margin-bottom: 8px;
|
||||
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.upload-zone .uz-ic svg { width: 18px; height: 18px; }
|
||||
.upload-zone .uz-hint { display: block; margin-top: 2px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
|
||||
|
||||
/* ─── 退出登录确认 modal 正文 ─── */
|
||||
.logout-confirm-copy { margin: 0 0 12px; color: var(--black-alpha-72); }
|
||||
.logout-confirm-points {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.logout-confirm-points .li {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
.logout-confirm-points .li::before {
|
||||
content: '//';
|
||||
flex: 0 0 auto;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--black-alpha-32);
|
||||
}
|
||||
.logout-unsaved-note {
|
||||
margin-top: 12px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--heat-20);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--heat-12);
|
||||
color: var(--heat);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ─── 改密 modal · 校验提示 ─── */
|
||||
.field .pw-err { color: var(--accent-crimson); }
|
||||
|
||||
/* ─── 页脚 build 标记 ─── */
|
||||
.settings-foot { text-align: center; padding: 24px 0 8px; color: var(--black-alpha-32); font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em; }
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.settings-grid { grid-template-columns: 1fr; }
|
||||
.settings-nav { position: static; }
|
||||
.form-row { grid-template-columns: 1fr; gap: 6px; }
|
||||
}
|
||||
}
|
||||
@ -89,7 +89,6 @@ export type VideoSegment = {
|
||||
status: string;
|
||||
error_message: string;
|
||||
adopted_version: string | null;
|
||||
adopted_asset?: string | null;
|
||||
};
|
||||
|
||||
export type StoryboardVersion = {
|
||||
@ -108,13 +107,6 @@ export type Timeline = {
|
||||
resolution: string;
|
||||
duration_seconds: number;
|
||||
clips: Array<{ id: string; asset: string; sort_order: number; start_ms: number; duration_ms: number }>;
|
||||
subtitle_tracks?: Array<{
|
||||
id: string;
|
||||
content: Array<{ start_ms: number; text: string }>;
|
||||
style?: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
bgm_tracks?: Array<{ id: string; asset: string; volume: number; start_ms: number }>;
|
||||
export_jobs?: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
@ -185,34 +177,3 @@ export type AITask = {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Notification = {
|
||||
id: string;
|
||||
type: string;
|
||||
notification_type: string;
|
||||
priority: string;
|
||||
title: string;
|
||||
brief: string;
|
||||
body: string;
|
||||
source: string;
|
||||
project: string | null;
|
||||
project_name?: string;
|
||||
stage: string;
|
||||
owner_label: string;
|
||||
cost_label: string;
|
||||
related_url: string;
|
||||
is_read: boolean;
|
||||
unread: boolean;
|
||||
read_at: string | null;
|
||||
archived_at: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type NotificationList = Paginated<Notification> & { unread_count: number };
|
||||
|
||||
export type RechargeResult = {
|
||||
account: BillingSummary["account"];
|
||||
ledger: Ledger;
|
||||
};
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
const BASE = "http://127.0.0.1:5180", API = "http://127.0.0.1:8010";
|
||||
const OUT = process.argv[2] || "shots-aitools";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const tok = (await (await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "airshelf", password: "Restraint2026" }) })).json()).token;
|
||||
const pages = [["model-photo", "/model-photo"], ["platform-cover", "/platform-cover"], ["demo-a", "/model-photo/demo-a"], ["demo-b", "/model-photo/demo-b"]];
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
|
||||
await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), tok);
|
||||
const page = await ctx.newPage();
|
||||
for (const [name, route] of pages) {
|
||||
await page.goto(BASE + route, { waitUntil: "networkidle", timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: true });
|
||||
console.log("shot", name);
|
||||
}
|
||||
await browser.close();
|
||||
console.log("DONE");
|
||||
@ -1,21 +0,0 @@
|
||||
// 抓图:灌库 + TOS 后,现有页面的真数据+真图效果
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
const BASE = "http://127.0.0.1:5180";
|
||||
const API = "http://127.0.0.1:8010";
|
||||
const OUT = process.argv[2] || "shots-data";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const token = (await (await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "airshelf", password: "Restraint2026" }) })).json()).token;
|
||||
const pages = [["dashboard", "/dashboard"], ["products", "/products"], ["library", "/library"]];
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
|
||||
await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), token);
|
||||
const page = await ctx.newPage();
|
||||
for (const [name, route] of pages) {
|
||||
await page.goto(BASE + route, { waitUntil: "networkidle", timeout: 30000 });
|
||||
await page.waitForTimeout(1800);
|
||||
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: true });
|
||||
console.log("shot", name);
|
||||
}
|
||||
await browser.close();
|
||||
console.log("DONE ->", OUT);
|
||||
@ -1,62 +0,0 @@
|
||||
// shot-p1.mjs · P1 像素还原验收截图(React 实页 vs public/exact 基线)
|
||||
// 用法: node shot-p1.mjs [outDir]
|
||||
// 前置: 后端 8010 + 前端 5173 在跑;playwright 已装;chromium 已缓存。
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const BASE = "http://127.0.0.1:5180";
|
||||
const API = "http://127.0.0.1:8010";
|
||||
const OUT = process.argv[2] || "shots-p1";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
// [name, React 路由, 基线 html]
|
||||
const PAGES = [
|
||||
["settings", "/settings", "/exact/settings.html"],
|
||||
["messages", "/messages", "/exact/messages.html"],
|
||||
["asset-factory", "/asset-factory", "/exact/asset-factory.html"],
|
||||
["image-optimize", "/image-optimize", "/exact/image-optimize.html"],
|
||||
["model-photo", "/model-photo", "/exact/model-photo.html"],
|
||||
["platform-cover", "/platform-cover", "/exact/platform-cover.html"],
|
||||
["product-create", "/products/new", "/exact/product-create-upload.html"],
|
||||
["project-wizard", "/projects/new", "/exact/projects-new.html"]
|
||||
];
|
||||
|
||||
const res = await fetch(`${API}/api/auth/login/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "airshelf", password: "Restraint2026" })
|
||||
});
|
||||
const data = await res.json();
|
||||
const token = data.token;
|
||||
if (!token) {
|
||||
console.error("login failed:", JSON.stringify(data));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("token ok");
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
|
||||
await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), token);
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => console.error(" pageerror:", e.message));
|
||||
|
||||
for (const [name, route, baseline] of PAGES) {
|
||||
try {
|
||||
await page.goto(BASE + route, { waitUntil: "networkidle", timeout: 30000 });
|
||||
await page.waitForTimeout(1400);
|
||||
await page.screenshot({ path: `${OUT}/${name}.react.png`, fullPage: true });
|
||||
console.log("react ", name);
|
||||
} catch (e) {
|
||||
console.error("FAIL react", name, e.message);
|
||||
}
|
||||
try {
|
||||
await page.goto(BASE + baseline, { waitUntil: "networkidle", timeout: 30000 });
|
||||
await page.waitForTimeout(900);
|
||||
await page.screenshot({ path: `${OUT}/${name}.baseline.png`, fullPage: true });
|
||||
console.log("baseline", name);
|
||||
} catch (e) {
|
||||
console.error("FAIL baseline", name, e.message);
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
console.log("DONE ->", OUT);
|
||||
@ -1,39 +0,0 @@
|
||||
// 抓图:pipeline 五阶段真数据 + 商品详情 + ai-tools(用 demo 项目/商品)
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
const BASE = "http://127.0.0.1:5180";
|
||||
const API = "http://127.0.0.1:8010";
|
||||
const OUT = process.argv[2] || "shots-pipeline";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const tok = (await (await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "airshelf", password: "Restraint2026" }) })).json()).token;
|
||||
const projs = (await (await fetch(`${API}/api/projects/`, { headers: { Authorization: `Token ${tok}` } })).json()).results;
|
||||
const demo = projs.find((p) => p.name.startsWith("演示")) || projs[0];
|
||||
const prods = (await (await fetch(`${API}/api/products/`, { headers: { Authorization: `Token ${tok}` } })).json()).results;
|
||||
const prod = prods.find((p) => p.title.includes("补水")) || prods[0];
|
||||
console.log("demo project", demo?.id, "| product", prod?.id);
|
||||
|
||||
const shots = [
|
||||
["pipeline-stage2", `/pipeline/${demo.id}?st=2#stage-2`],
|
||||
["pipeline-stage3", `/pipeline/${demo.id}?st=3#stage-3`],
|
||||
["pipeline-stage4", `/pipeline/${demo.id}?st=4#stage-4`],
|
||||
["pipeline-stage5", `/pipeline/${demo.id}?st=5#stage-5`],
|
||||
["product-detail", `/products/${prod.id}`],
|
||||
["image-optimize", `/image-optimize`]
|
||||
];
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
|
||||
await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), tok);
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => console.error(" pageerror:", e.message));
|
||||
for (const [name, route] of shots) {
|
||||
try {
|
||||
await page.goto(BASE + route, { waitUntil: "networkidle", timeout: 30000 });
|
||||
await page.waitForTimeout(1600);
|
||||
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: true });
|
||||
console.log("shot", name);
|
||||
} catch (e) {
|
||||
console.error("FAIL", name, e.message);
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
console.log("DONE ->", OUT);
|
||||
Loading…
x
Reference in New Issue
Block a user