fix: 测试清单(8) 新一轮 — ZWQ/PMC/设置/团队 多项修复

视频/工作台/商品库(ZWQ):
- row19 视频阶段移除已取消的【上传视频】按钮 (pipeline.tsx)
- row20 工作台统计栏(总项目/进行中/成片)点击跳转视频项目对应 tab (dashboard/projects/route-config/App)
- row21 最新项目跳转改用真实 projectId, 不再跳到最近打开项目 (dashboard.tsx)
- row22+PMC28 网格/列表视图模式持久化(localStorage), 跨菜单记住用户选择 (projects/ai-tools + 新 hook use-view-mode.ts)
- row24 已有三视图的演员/模特直接复用, 不再强制点AI生成三视图 (pipeline.tsx)

图片生成/设置(PMC):
- row27 任务中心: 6个进行中状态补全归类, 不再把生成中误判为失败栏 (stage-config.ts)
- row29 头像上传500: avatar_url 存公读直链替代超长预签名URL(URLField max_length=200溢出) (accounts/views.py, 需部署)
- row30 显示名保存后回显: 改读 first_name 回退 username, 修刷新仍旧名 (settings.tsx)
- row31/32 隐藏【创作默认】【显示】两个设置模块 (settings.tsx)

平台套图(YYX):
- row3 图片预览铺满占位 (ai-tools-page.css)

团队:
- PMC25 充值/邀请成员/设置月限额三弹窗禁止点遮罩误关 (team.tsx)

含平台套图线上提示词优化(services.py/views.py/api.ts, 前序会话遗留一并提交)
构建: tsc --noEmit=0, npm run build=0 (1755模块); 后端 py_compile OK
注: row29 头像/后端项需部署生效

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-29 16:45:29 +08:00
co-authored by Claude Opus 4.8
parent f3b89f3d49
commit ffada7caab
15 changed files with 292 additions and 82 deletions
+6 -3
View File
@@ -211,7 +211,7 @@ def update_avatar(request):
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"
suffix = Path(upload.name or "").suffix.lower() or ".png"
object_key = f"users/{user.id}/avatar/{uuid.uuid4()}{suffix}"
storage = TosStorage()
storage.upload_fileobj(
@@ -219,8 +219,11 @@ def update_avatar(request):
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)
# 桶公读,存稳定的虚拟主机式直链(全站资产同款 public_url)——不存预签名 URL:
# 预签名 URL 带一长串 SigV4 查询参数(常 >300 字符),会超出 avatar_url 这个 URLField
# 的默认 max_length=200,MySQL 严格模式下 save 抛 DataError(Data too long)→ 500;
# 且预签名 1h/7d 必过期。直链短、稳定、可缓存,既修 500 又一劳永逸。
user.avatar_url = storage.public_url(object_key=object_key)
user.save(update_fields=["avatar_url"])
return Response(UserSerializer(user).data)
+187 -24
View File
@@ -751,11 +751,16 @@ def _find_entity_group(project, kind: str, label: str, group_id: str | None):
def _ratio_to_image_size(ratio: str) -> str:
"""前端比例 → gpt-image 支持的尺寸(宽高均需被 16 整除)。16:9 用真 16:9 的 1536x864,4:3 用 1536x1024。"""
"""前端比例 → gpt-image 支持的尺寸(宽高均需被 16 整除)。
⚠️ 该网关只接受 1024x1024 / 1024x1536 / 1536x1024 三种固定 size,无法直接产出精确 3:4 / 9:16 / 4:5,
这里只能取最接近的「竖/横/方」近似;要拿到精确像素比例需在出图后做主体保护裁切/补边(见优化文档 §7.1
normalize_output_image,属待部署的后处理项)。修复点:4:5 原会 fallback 成 1024x1024(方图)→ 比例错误,
现归到竖图 1024x1536。"""
return {
"1:1": "1024x1024",
"3:4": "1024x1536",
"9:16": "1024x1536",
"3:4": "1024x1536", # 近似竖图(网关无精确 3:4)
"4:5": "1024x1536", # 近似竖图(原 fallback 成方图是比例 bug)
"9:16": "1024x1536", # 近似竖图(网关无精确 9:16)
"4:3": "1536x1024",
"16:9": "1536x864", # 真 16:9(原来误用 1536x1024 = 3:2)
}.get((ratio or "").strip(), "1024x1024")
@@ -964,6 +969,7 @@ def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "
# 平台套图同批多张要「同款商品、不同版式」,否则 N 张文案/构图雷同(PMC#24)。
# 锁死商品一致性,只让排版/构图/视角/配色基调按张变化。
# 注:仅纯文生图回落路径(无参考图)仍用这个简表;refs 版改用下面的 slot 体系。
_COVER_VARIATIONS = [
"本张:正面居中主图版式,商品占画面主体,纯净背景,经典电商主图构图",
"本张:换一种排版——商品偏置 + 卖点文案分区,场景化背景,杂志感构图",
@@ -973,27 +979,173 @@ _COVER_VARIATIONS = [
"本张:促销封面版式,留出标题/价签区,高对比配色,强视觉冲击构图",
]
# 平台套图优化版(对照「平台套图线上提示词优化版.md」):平台差异 + slot 版式 + 低信息密度上限。
# 平台 id 用规范化 key(前端 dy/tb… 已在 PLATFORM_ID_MAP 里映射成这些 canonical key)。
_PLATFORM_NAMES = {
"taobao": "淘宝", "tmall": "天猫", "jd": "京东", "pdd": "拼多多", "douyin": "抖音电商",
"xhs": "小红书", "kuaishou": "快手", "wechat": "视频号", "amazon": "亚马逊", "1688": "1688",
}
def build_platform_cover_prompt_refs(product, has_model: bool, base_prompt: str = "", index: int = 0) -> str:
"""平台套图 image_edit 提示词(refs 版):参考图1=商品真实主图(锁外形/品牌/配色/Logo),
有模特时参考图2=出镜模特(锁人脸/身形)。生成电商平台主图 / 封面套图,商品须还原真实包装。
`index` 让同批每张的版式/构图/视角不同,避免 N 张雷同(PMC#24)。"""
# §4 平台块:每块只描述平台调性 / 版式倾向,统一服从「头图低信息密度」,不写比例(比例由 size 控制)。
_PLATFORM_COVER_BLOCKS = {
"taobao": (
"平台:淘宝。画面像淘宝搜索货架和商品主图,移动端缩略图下商品一眼可识别;"
"常见左上小品牌区、中央或偏右商品主体、底部克制活动条 / 轻卖点条;"
"背景干净但不单调,可用浅灰棚拍 / 浅色墙面 / 试衣间 / 窗边挂拍 / 干净台面;"
"禁止详情页 / 长图 / 平台 UI / 二维码 / 直播间界面 / 虚假价格与未提供折扣。"
),
"tmall": (
"平台:天猫。画面像品牌旗舰店商品首图,品牌感 / 质感 / 留白 / 材质光影更重要;"
"促销感弱于淘宝,不要大红大黄低价感,不要拼多多式强利益区。"
),
"jd": (
"平台:京东。画面清晰 / 可信 / 理性 / 标准,像京东商品主图;白底或浅灰商品图、克制品质短标题、包装 / 配件 / 材质细节;"
"信息区极少,只允许 1 个短标题或 1-2 个短标签;不要参数表 / 功能说明长区 / 详情页式模块,不要小红书滤镜,不要强生活方式过度氛围。"
),
"pdd": (
"平台:拼多多。主体大 / 信息直接 / 利益区明显,像商品主图或活动头图;"
"可有高对比活动区和大标题但只讲一个利益点,不能编造价格 / 优惠 / 折扣 / 销量 / 平台补贴;"
"背景明亮,商品边缘清楚,不要复杂品牌大片感。"
),
"douyin": (
"平台:抖音电商。像信息流商品封面 / 短视频电商封面,近景 / 强裁切 / 真实使用瞬间 / 动作感强;"
"常见背景:衣橱 / 梳妆台 / 卧室 / 开箱 / 手部整理 / 拿取 / 穿搭准备;文案更短、商品主体更大;"
"不要抖音 UI / 播放按钮 / 直播间贴片 / 字幕条,也不要套淘宝底部活动条。"
),
"xhs": (
"平台:小红书。像生活方式笔记封面,自然光 / 真实体验 / 种草氛围;"
"常见背景:卧室 / 衣橱 / 梳妆台 / 桌面 / 浴室 / 旅行收纳 / 通勤准备;标题像用户体验表达,少硬广 / 少参数表 / 少促销条。"
),
"kuaishou": (
"平台:快手。像快手小店商品图 / 直播前置封面素材,真实 / 直接 / 可信;"
"可有桌面展示 / 手持展示 / 打包台 / 家中真实环境 / 开箱场景;不要直播 UI / 主播贴片 / 平台水印。"
),
"wechat": (
"平台:视频号 / 微信小店。画面克制可信,适合社交分享和商品卡;"
"背景可用干净家居 / 礼赠 / 办公 / 生活空间 / 柔和自然光;不要微信聊天界面 / 二维码 / 公众号 UI。"
),
"amazon": (
"平台:亚马逊。第 1 张为 MAIN 合规图:纯白背景、只展示售卖商品本体、无文字 / 无道具 / 无边框 / 无水印,商品占画面主要区域;"
"第 2 张以后可为 Lifestyle / Feature / Detail / Package 辅图;不要 Amazon 徽章 / 评分 / 排名 / 优惠 / 平台 UI。"
),
"1688": (
"平台:1688。像批发采购图,商品清楚,偏规格 / 材质 / 工艺 / 包装 / 供货信息;"
"背景可用白底 / 浅灰 / 工厂台面 / 仓储 / 包装台 / 材料工艺背景;头图仍保持低信息密度,只允许 1 个短标题或 1-2 个短标签;"
"不要做成规格表详情页,不要虚构工厂资质 / 库存 / 起订量 / 认证与价格。"
),
}
# §3 / §3.1 slot 版式骨架(低信息密度版):默认 4 张按 hero→scene→selling→detail 取,8/12 张再补 multi/promo。
_COVER_SLOTS = {
"hero": "正面或四分之三角度的商品 / 上身主视觉,主体占画面 60%-80%,纯净干净背景,经典电商主图构图,无文案",
"scene": "场景主视觉:挂拍 / 衣架 / 生活场景 / 手部整理 / 使用情境,环境自然光,氛围感,无文案或仅 1 个极短标题",
"selling": "轻卖点封面:商品主体居中或偏置,最多 1 个短标题加 1-2 个极短标签,文字区克制,不堆参数",
"detail": "质感特写:放大材质 / 做工 / 关键结构(扣位 / 肩带 / 边缘走线),近景视角,突出质感,无文案或 1 个短标签",
"multi": "多角度组合:商品换一个朝向 / 视角,几何分区干净背景,现代简约风,无文案",
"promo": "促销封面:主体大、利益点单一,底部或角落留 1 条活动短语,高对比配色,不铺满文字、不编造价格",
}
_COVER_SLOT_ORDER = ["hero", "scene", "selling", "detail", "multi", "promo"]
# §3.1 / §8 头图低信息密度上限:所有平台 / 所有模型都必须遵守,防止漂成详情页。
_COVER_LOW_DENSITY = (
"这是一张商品头图,不是详情页:画面第一优先级是商品主体 / 上身效果 / 使用场景,"
"商品或模特主体占画面 60%-80%,文字与装饰不得抢主体;如需文案最多 1 个短标题(≤8 字)加 1-2 个极短标签;"
"严禁参数表 / 规格表 / 长句卖点 / 三段式说明 / 密集图标 / 大量箭头 / 2x3 或 3x3 信息宫格 / 左右对比详情页 / 多屏排版。"
)
# §5 背景反差:避免商品与背景同色相融(无法逐图取色时给通用避让规则)。
_COVER_BG_CONTRAST = (
"背景必须与商品主色形成清楚反差,不能让商品与背景同色相融;"
"浅色商品避免奶白 / 浅米 / 浅粉等近似大面积底,优先冷灰 / 蓝灰 / 浅绿 / 自然木色或明确投影、边缘光;"
"深色商品避免黑 / 深灰 / 深棕大底,优先暖白 / 浅灰 / 浅木色 / 日光窗边;"
"除亚马逊 MAIN 或明确白底图外,不要整组都做成纯白 / 浅灰底。"
)
# §6.1 内衣真人上身强约束:防止被第二件衣服遮挡、防性感化漂移。
_UNDERWEAR_ON_MODEL = (
"这是真人试穿商品头图:成人模特直接穿着参考商品中的内衣,内衣是唯一服饰重点,"
"罩杯 / 肩带 / 下围 / 杯型 / 面料 / 颜色与关键版型须完整可见且与参考商品一致;"
"不要出现 T 恤 / 衬衫 / 吊带背心 / 运动上衣 / 连衣裙 / 制服 / 外套等遮挡内衣的第二件衣服,"
"不要把内衣穿在其他衣物外面或在其下面加衣;最多允许轻薄开衫 / 薄纱松搭在肩臂外侧但不得盖住罩杯 / 肩带 / 下围;"
"正规电商服饰目录拍摄,成人、克制、商品展示向,非性感化、非挑逗姿势,关键结构不被裁切。"
)
# §9 通用负面提示词(平台套图专用,压缩版)。
_COVER_NEGATIVE = (
"请规避:详情页 / 详情长图 / 多屏排版 / 店铺页 / 平台 UI / 直播间界面 / 二维码 / 联系方式 / 水印 / 平台 Logo / 虚假角标;"
"不要重新设计商品或改其包装 / Logo / 配色 / 材质 / 文字 / 图案 / 比例,不要生成相似款或改品类,不要擅自加套装 / 配件 / 赠品;"
"不要出现商品数据外的品牌名 / 英文 Logo / 系列名 / 角落签名 / 模型名 / 伪水印;"
"不要编造价格 / 优惠券 / 满减 / 折扣 / 限时活动 / 销量 / 排名 / 认证 / 功效承诺 / 绝对化用语;"
"不要错别字 / 乱码 / 小字堆叠 / 大段文案;不要让文字遮挡主体;不要让商品与背景同色相融。"
)
# 内衣 / 贴身衣物品类提示(比通用穿戴更窄,用于触发内衣强约束分支)。
_UNDERWEAR_HINTS = ("内衣", "文胸", "胸罩", "bra", "内裤", "", "比基尼", "bikini", "睡衣", "塑身", "束身")
def _is_underwear_product(product) -> bool:
blob = f"{getattr(product, 'category', '') or ''} {getattr(product, 'title', '') or ''}".lower()
return any(h in blob for h in _UNDERWEAR_HINTS)
def build_platform_cover_prompt_refs(
product,
*,
has_model: bool = False,
base_prompt: str = "",
platform_id: str = "",
index: int = 0,
count: int = 4, # noqa: ARG001 — 透传保留,后续可据张数扩展 slot 选择
product_ref_count: int = 1,
) -> str:
"""平台套图 image_edit 提示词(refs 优化版,对照「平台套图线上提示词优化版.md」):
参考图1~N=同一件真实商品(锁外形/品牌/配色/Logo/比例),有模特时参考图N+1=出镜模特。
按 `platform_id` 注入平台块、按 `index` 选 slot 版式,统一服从「头图低信息密度」与「背景反差」,
内衣 + 有模特时叠加强约束。只出头图 / 主图 / 封面候选,不再出详情。"""
name = (getattr(product, "title", "") or "商品").strip()
lines = [f"参考图1是「{name}」的真实商品主图。"]
if has_model:
lines += [
"参考图2是出镜模特。请生成参考图2中的这位模特展示参考图1中商品的电商平台套图(主图 / 封面 / 详情);",
"模特的五官、发型、肤色、身形必须与参考图2高度一致,不要换人;",
]
n = max(1, int(product_ref_count or 1))
if n <= 1:
ref_word = "参考图1"
intro = f"参考图1是「{name}」的真实商品参考图。"
else:
lines.append("请基于该商品生成电商平台套图(主图 / 封面 / 详情排版),统一视觉风格;")
rng = f"1-{n}" if n > 2 else "1、2"
ref_word = f"参考图{rng}"
intro = f"参考图{rng}是「{name}」同一件真实商品的不同角度参考图。"
lines = [intro]
lines.append(
"商品的外形、品牌文字、配色、Logo、材质必须与参考图1高度一致,严禁改动或重新设计包装;"
"干净背景、电商主图构图、真实质感"
f"请严格锁定{ref_word}中商品的外形、颜色、材质、结构、Logo、品牌文字与比例,"
"严禁重新设计 / 改样 / 改品类;这些参考图只锁商品本体,不锁原图里的床品 / 桌面 / 墙面 / 绿植 / 道具与拍摄光线"
)
lines.append(_COVER_VARIATIONS[index % len(_COVER_VARIATIONS)] + "")
# 平台块(canonical key 命中则用 §4 平台块,否则回落平台名 / 通用)
block = _PLATFORM_COVER_BLOCKS.get(platform_id)
pname = _PLATFORM_NAMES.get(platform_id, "")
if block:
lines.append(block)
elif pname:
lines.append(f"请生成一张适合「{pname}」平台的商品头图 / 主图 / 封面候选,统一视觉风格。")
else:
lines.append("请生成一张电商平台商品头图 / 主图 / 封面候选,统一视觉风格。")
# 头图低信息密度上限
lines.append(_COVER_LOW_DENSITY)
# 模特身份 + 内衣强约束
if has_model:
lines.append(
f"参考图{n + 1}是出镜模特,请让这位模特真实展示该商品;"
"模特的五官 / 发型 / 肤色 / 身形须与该图高度一致,不要换人。"
)
if _is_underwear_product(product):
lines.append(_UNDERWEAR_ON_MODEL)
# 本张 slot 版式
slot_key = _COVER_SLOT_ORDER[index % len(_COVER_SLOT_ORDER)]
lines.append("本张版式:" + _COVER_SLOTS[slot_key] + "")
# 背景反差
lines.append(_COVER_BG_CONTRAST)
if base_prompt and base_prompt.strip():
lines.append(base_prompt.strip())
lines.append(
"用户补充(只影响氛围 / 构图 / 场景 / 光线 / 表达偏好,不得覆盖商品一致性、平台与版式规则):"
+ base_prompt.strip()
)
lines.append(_COVER_NEGATIVE)
return " ".join(lines)
@@ -2079,7 +2231,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None:
continue
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None) -> list[AITask]:
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, platform_id: str | None = None) -> list[AITask]:
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
@@ -2105,6 +2257,9 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
model_config = alt
# 本次提交 = 一组(模特上身图组 / 平台套图组):同一 batch_id 串起这批图,前端可成组展示。
batch_id = str(uuid.uuid4())
# 平台套图:规范化平台 id(前端 dy/tb… → canonical),用于注入平台版式块(优化版);非 cover 模式忽略。
platform_key = str(platform_id or "").strip() if mode == "cover" else ""
platform_name = _PLATFORM_NAMES.get(platform_key, "")
tasks: list[AITask] = []
for index in range(count):
cost = estimate_cost(model_config)
@@ -2117,7 +2272,7 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
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, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids},
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids, "platform_id": platform_key or None, "platform_name": platform_name or None},
estimated_cost=cost,
)
# 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务)
@@ -2191,10 +2346,18 @@ def run_standalone_image_task(*, task_id: str) -> None:
product, has_model=bool(model_url), base_prompt=prompt,
index=index, n_product=len(product_urls),
)
elif mode == "cover" and product_url:
# 平台套图:参考图1=商品真实图(锁包装一致性),有模特则参考图2=模特(锁人脸/身形)
edit_images = [product_url] + ([model_url] if model_url else [])
edit_prompt = build_platform_cover_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt, index=index)
elif mode == "cover" and product_urls:
# 平台套图:参考图1~N=商品真实图(多角度,_product_reference_urls 已优先真实上传图/排除 AI 图),
# 有模特则参考图N+1=模特(锁人脸/身形)。platform_id 注入平台版式块(优化版)。
edit_images = product_urls + ([model_url] if model_url else [])
edit_prompt = build_platform_cover_prompt_refs(
product,
has_model=bool(model_url),
base_prompt=prompt,
platform_id=str(payload.get("platform_id") or ""),
index=index,
product_ref_count=len(product_urls),
)
elif bool(payload.get("reference_product")) and product_url:
edit_images = [product_url]
edit_prompt = build_product_triview_prompt_refs(product, "")
+3 -1
View File
@@ -38,6 +38,8 @@ class GenerateImageView(APIView):
model_entity_id = str(request.data.get("model_entity_id") or "").strip() or None
ratio = str(request.data.get("ratio") or "").strip() or None
image_model = str(request.data.get("image_model") or "").strip() or None
# 平台套图:前端传规范化平台 id(taobao/douyin/…),用于后端注入平台版式块(优化版)
platform_id = str(request.data.get("platform_id") or "").strip() or None
conversation_id = str(request.data.get("conversation_id") or "").strip() or None
# 用户在图片创作里上传的参考图(已先传成 Asset),按 id 列表带入 → 生成时作多图参考(image_edit)
raw_refs = request.data.get("reference_image_ids") or []
@@ -58,7 +60,7 @@ class GenerateImageView(APIView):
title=(prompt[:24] or "默认创作"),
)
try:
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio, image_model=image_model, conversation=conversation, reference_image_ids=reference_image_ids)
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio, image_model=image_model, conversation=conversation, reference_image_ids=reference_image_ids, platform_id=platform_id)
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
# 本次提交即刷新对话活跃时间,左栏「最近」据此置顶
+3 -2
View File
@@ -433,7 +433,7 @@ export function App() {
if (options.productId !== undefined) setActiveProductId(options.productId);
if (options.projectId !== undefined) setActiveProjectId(options.projectId);
const hash = options.hash?.replace(/^#/, "");
setRoute({ page: next, authMode, productId, projectId, hash });
setRoute({ page: next, authMode, productId, projectId, hash, tab: options.tab });
const path = `${pathForPage(next, { productId, projectId })}${hash ? `#${hash}` : ""}`;
if (`${window.location.pathname}${window.location.hash}` !== path || window.location.search) {
const method = options.replace ? "replaceState" : "pushState";
@@ -535,7 +535,7 @@ export function App() {
if (res) setUser(res);
}
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) {
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) {
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
@@ -776,6 +776,7 @@ export function App() {
projects={projects}
loading={!dataLoaded}
navigate={navigate}
initialTab={route.tab}
onCreate={(payload) => action(() => api.createProject(payload), "项目已创建")}
openPipeline={(projectId) => navigate("pipeline", { projectId })}
onDelete={async (projectId) => {
+1 -1
View File
@@ -1914,7 +1914,7 @@
.asset-factory .history-card:hover { background: var(--black-alpha-4); }
.asset-factory .history-card .placeholder { width: 78px; height: 78px; }
/* 任务结果图:有真实生成图时铺满占位框,去掉斜纹底与编号 */
.asset-factory .placeholder.has-img { overflow: hidden; position: relative; }
.asset-factory .placeholder.has-img { overflow: hidden; position: relative; padding: 0; }
.asset-factory .placeholder.has-img::after { display: none; }
.asset-factory .placeholder.has-img .ph-frame { display: none; }
.asset-factory .placeholder.has-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
+1 -1
View File
@@ -601,7 +601,7 @@ export const api = {
},
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果。
// 带 conversation_id 则归属该对话;不带则后端自动开一条新对话并回传其 id。
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[] }) {
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[] }) {
return request<{ conversation_id: string; tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
},
// 图片创作对话 CRUD —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除
@@ -0,0 +1,24 @@
import { useState } from "react";
// 网格/列表(或任意字面量)视图模式持久化 hook。
// 按页面独立 key 存 localStorage,切走再切回恢复上次选择。
// SSR / 无 localStorage / 读写抛错全部容错 —— 失败时退回 defaultVal,绝不报错。
export function useViewMode<T extends string>(key: string, defaultVal: T): [T, (value: T) => void] {
const [view, setViewState] = useState<T>(() => {
try {
const saved = window.localStorage.getItem(key);
return saved ? (saved as T) : defaultVal;
} catch {
return defaultVal;
}
});
const setView = (value: T) => {
setViewState(value);
try {
window.localStorage.setItem(key, value);
} catch {
// 无 localStorage / 隐私模式写满 —— 静默,内存态仍生效本次会话
}
};
return [view, setView];
}
+21 -10
View File
@@ -33,6 +33,7 @@ import { ActorLibrary } from "../components/actor-library";
import { SkeletonRows } from "../components/loading";
import { MediaLightbox } from "../components/overlays";
import { Pager } from "../components/pager";
import { useViewMode } from "../components/use-view-mode";
import type { Page } from "./route-config";
import { statusPill } from "./stage-config";
import { productMockCoverUrl } from "./products";
@@ -182,7 +183,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
const [query, setQuery] = useState("");
const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all");
const [typeFilter, setTypeFilter] = useState("");
const [view, setView] = useState<"grid" | "list">("grid");
const [view, setView] = useViewMode<"grid" | "list">("viewmode:tasks", "grid");
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
// 任务中心分页:每页 10 条,筛选/搜索变化时回第 1 页
const TASKS_PER_PAGE = 10;
@@ -514,10 +515,11 @@ const MODE_META: Record<
cover: {
title: "平台套图",
tag: "[ PLATFORM · KIT ]",
desc: "选择平台模板,一键生成电商平台头图 / 主图。",
ratio: "4:5",
desc: "选择平台模板,一键生成平台商品头图。",
// 优化版:商品上架主图默认 1:1(原 4:5 会 fallback 成方图致比例错乱);竖图按平台/类目再选
ratio: "1:1",
// YYX#4:只出平台头图,去掉「详情排版」
promptTemplate: (title) => `${title}电商平台头图,统一视觉,主图特写`
promptTemplate: (title) => `${title},平台商品头图,统一视觉,商品主体清晰`
}
};
@@ -565,6 +567,12 @@ const PLATFORM_OPTIONS = [
{ id: "al", name: "1688", logo: "阿", img: "/assets/svg/icon-platform-1688.svg" }
];
/* 前端平台 id(dy/tb…) → 后端规范化 platform_id(优化版:后端按此 key 注入平台版式块)。 */
const PLATFORM_ID_MAP: Record<string, string> = {
dy: "douyin", tb: "taobao", tm: "tmall", jd: "jd", pdd: "pdd",
xhs: "xhs", ks: "kuaishou", sph: "wechat", amz: "amazon", al: "1688"
};
/* YYX#row10: logo logo (public/assets/svg),退+
className p-logo(/), cg-logo */
function PlatformLogo({ p, className }: { p: { id: string; name: string; logo: string; img?: string }; className?: string }) {
@@ -623,7 +631,7 @@ export function ImageWorkbenchPage({
modelConfigs: ModelConfig[];
onBack: () => void;
navigate?: (page: Page) => void;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) => Promise<{ assets: Asset[]; conversation_id?: string } | null>;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) => Promise<{ assets: Asset[]; conversation_id?: string } | null>;
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
initialProductId?: string;
@@ -963,6 +971,8 @@ export function ImageWorkbenchPage({
modelId?: string;
modelName?: string;
platformIds?: string[];
/** 优化版:规范化平台 id(douyin/taobao…),透传给后端注入平台版式块 */
platformId?: string;
/** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */
refs?: { name: string; url: string; file?: File; assetId?: string }[];
/** PMC#25:原地重跑——复位这张已有批次卡(清旧结果、重新生成),不新开任务卡 */
@@ -1027,7 +1037,7 @@ export function ImageWorkbenchPage({
}
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
// onSubmitted:提交成功拿到任务 id 即把本批 pendingIds 落盘 → 切走再回来本批能各自续轮询(PMC#5/#10)
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds,
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds,
onSubmitted: (taskIds) => setBatches((prev) => { const next = prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds } : b)); persistBatches(next); return next; }) });
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
const convId = result?.conversation_id;
@@ -1083,10 +1093,9 @@ export function ImageWorkbenchPage({
};
if (mode === "cover") {
// P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section;
// 平台名拼进提示词,让各平台套图按其版式倾向出图(后端无 platform 字段,靠 prompt 表达)
// 优化版:不再把平台名拼进 prompt,改传规范化 platform_id 给后端注入平台版式块(平台调性/版式/负面约束)
for (const pid of pickedIds) {
const platName = PLATFORM_OPTIONS.find((p) => p.id === pid)?.name;
void startBatch({ ...base, prompt: platName ? `${base.prompt}${platName}平台头图版式` : base.prompt, platformIds: [pid] });
void startBatch({ ...base, platformId: PLATFORM_ID_MAP[pid], platformIds: [pid] });
}
return;
}
@@ -1116,6 +1125,7 @@ export function ImageWorkbenchPage({
modelId: src.modelId,
modelName: src.modelName,
platformIds: src.platformIds,
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
reuseBatchId: src.id
});
}
@@ -1196,6 +1206,7 @@ export function ImageWorkbenchPage({
modelId: src.modelId,
modelName: src.modelName,
platformIds: src.platformIds,
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
appendToBatchId: src.id
});
}
@@ -2114,7 +2125,7 @@ export function ImageWorkbenchPage({
for (const pid of plDraft.slice(1)) {
const p = products.find((x) => x.id === pid);
if (mode === "cover") {
for (const platId of pickedIds) void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, platformIds: [platId] });
for (const platId of pickedIds) void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, platformId: PLATFORM_ID_MAP[platId], platformIds: [platId] });
} else {
void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, modelId: mode === "model" ? pickedIds[0] : undefined, modelName: mode === "model" ? pickedModelName : undefined });
}
+9 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import type { BillingSummary, Product, Project } from "../types";
import type { Page } from "./route-config";
import type { NavigateFn } from "./route-config";
import { api } from "../api";
import { SkeletonRows } from "../components/loading";
import { money, stageMeta, statusPill } from "./stage-config";
@@ -38,7 +38,7 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
billing: BillingSummary | null;
userName?: string;
loading?: boolean;
navigate: (page: Page) => void;
navigate: NavigateFn;
// YYX#11:工作台点「新建商品」直接弹抽屉(复用商品库同一组件),而非跳商品库
onCreateProduct?: (payload: ProductCreatePayload) => Promise<Product | null | undefined> | void;
}) {
@@ -95,8 +95,8 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
</button>
</div>
</div>
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={totalDelta} /><KpiStat label="进行中" badge="WIP" value={running} /><KpiStat label="成片" badge="DONE" value={completed} /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl"> <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: `${reservedPct}%` }} /></div><div className="sub"> {money(reservedNum)} / {money(fundsTotal)}</div></button></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] </button></div><div className="card-hard">{loading && projects.length === 0 ? <SkeletonRows count={4} /> : projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{[productTitle(project.product), "AI 全生", project.video_segment_count ? `${project.video_segment_count}` : null].filter(Boolean).join(" / ")}</div></div><DashProgress project={project} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assetCount ?? ""} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projCount} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={totalDelta} onClick={() => navigate("projects", { tab: "all" })} /><KpiStat label="进行中" badge="WIP" value={running} onClick={() => navigate("projects", { tab: "wip" })} /><KpiStat label="成片" badge="DONE" value={completed} onClick={() => navigate("projects", { tab: "done" })} /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl"> <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: `${reservedPct}%` }} /></div><div className="sub"> {money(reservedNum)} / {money(fundsTotal)}</div></button></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] </button></div><div className="card-hard">{loading && projects.length === 0 ? <SkeletonRows count={4} /> : projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline", { projectId: project.id })}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{[productTitle(project.product), "AI 全生", project.video_segment_count ? `${project.video_segment_count}` : null].filter(Boolean).join(" / ")}</div></div><DashProgress project={project} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assetCount ?? ""} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projCount} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
{/* YYX#11:新建商品抽屉 · 复用商品库同一组件,创建成功后跳商品库看新商品 */}
{onCreateProduct && (
<ProductCreateDrawer
@@ -110,8 +110,11 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
);
}
export function KpiStat({ label, badge, value, delta }: { label: string; badge: string; value: string | number; delta?: string }) {
return <div className="stat"><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div>{delta ? <div className="delta up">{delta}</div> : null}</div>;
export function KpiStat({ label, badge, value, delta, onClick }: { label: string; badge: string; value: string | number; delta?: string; onClick?: () => void }) {
const inner = <><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div>{delta ? <div className="delta up">{delta}</div> : null}</>;
// 可点的统计块复用现有 .stat 类(与余额块一致),不新造样式/色值。
if (onClick) return <button className="stat" type="button" onClick={onClick}>{inner}</button>;
return <div className="stat">{inner}</div>;
}
export function Shortcut({ name, title, desc, onClick }: { name: string; title: string; desc: string; onClick: () => void }) {
+12 -19
View File
@@ -1615,8 +1615,6 @@ export function PipelinePage(props: {
const [propsTab, setPropsTab] = useState<"subtitle" | "transition" | "bgm">("subtitle");
const edHydratedRef = useRef(false);
const bgmFileRef = useRef<HTMLInputElement | null>(null);
const videoUploadRef = useRef<HTMLInputElement | null>(null);
const [uploadTargetSeg, setUploadTargetSeg] = useState<string | null>(null);
// 提交一个新编辑态(进 undo 栈)
const commitEdit = useCallback((next: EditorState) => {
setEdHistory((h) => [...h.slice(-49), edState]);
@@ -2350,16 +2348,6 @@ export function PipelinePage(props: {
// (旧的转场黑闪提示层已删:双缓冲槽位的 opacity 过渡就是真转场预览)
// Stage 4 / 5 文件上传
function onPickVideoFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (file && uploadTargetSeg) onUploadVideoSegment(uploadTargetSeg, file);
setUploadTargetSeg(null);
}
function triggerVideoUpload(segmentId: string) {
setUploadTargetSeg(segmentId);
videoUploadRef.current?.click();
}
// 导出全部:把本项目所有已采用视频片段打成一个 zip 下载
const [exporting, setExporting] = useState(false);
const [exportErr, setExportErr] = useState("");
@@ -3355,18 +3343,12 @@ export function PipelinePage(props: {
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
<button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => guardVideoGen(shots, null, submitAllVideosOptimistic)}>{loading ? <><span className="spinner btn-spin" aria-hidden="true" /></> : anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => triggerVideoUpload((segments.find((s) => s.status !== "succeeded") || segments[0]).id)}>
<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>
{/* 导出全部:把所有已完成片段打包成 zip 一次性下载 */}
<button className="btn btn-sm" type="button" disabled={exporting || segDone === 0} title={exportErr || "把所有已完成视频片段打包下载"} onClick={() => void exportAllVideos()}>
<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="M7 10l5 5 5-5" /><path d="M12 15V3" /></svg>
{exporting ? "导出中…" : exportErr ? "导出失败" : "导出全部"}
</button>
</div>
<input ref={videoUploadRef} type="file" accept="video/*" style={{ display: "none" }} onChange={onPickVideoFile} />
{segments.length ? (
<div className="video-grid" id="video-grid">
{segments.map((seg) => {
@@ -3963,6 +3945,17 @@ export function PipelinePage(props: {
const triVersions = triGroup?.candidate_assets ?? [];
const viewTriAsset = (adTriId && triVersions.includes(adTriId)) ? adTriId : (triGroup?.adopted_asset || triVersions.at(-1) || "");
const triUrl = candUrl(triGroup, viewTriAsset);
// 已有三视图判定(ZWQ-row24):优先看 BaseGroup(流水线内生成的三视图组);其次看资产 metadata
// 沿用 library.tsx 同款判定:tri_view/triview/has_triview/three_view/tri_views 任一为真即已有;
// 模特库正面图(metadata.view==="frontal")本身与三视图同批生成,直接算已就绪。
const assetAlreadyHasTri: boolean = (() => {
if (triUrl) return true;
const m: Record<string, unknown> = (viewPortraitAsset ? byId.get(viewPortraitAsset)?.metadata : null) || {};
const has = m.tri_view ?? m.triview ?? m.has_triview ?? m.three_view ?? m.tri_views;
if (has === true || (Array.isArray(has) && has.length > 0) || (typeof has === "string" && (has as string).trim())) return true;
if (m.view === "frontal") return true; // 模特库正面图:generate_model 同批出了三视图
return false;
})();
const pBK = `addet-portrait:${entity.key}`;
// 没立绘时(尤其 seed 首次生成),立绘在 worker 跑 ~30s,本地 isBusy 早已落回 → 用 pendingHas 兜底转圈
const busyPortrait = isBusy(pBK) || (!portraitUrl && pendingHas(adDetail.kind, entity.name));
@@ -4037,7 +4030,7 @@ export function PipelinePage(props: {
{triUrl && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: triUrl, kind: "image", name: `${entity.name} · 三视图` })}>{zoomSvg}</button>}
</div>
</div>
{!triUrl && !busyTri && (
{!assetAlreadyHasTri && !busyTri && (
<div className="asset-detail-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>{viewPortraitAsset ? "三视图据这版立绘生成,保证正/侧/背多角度一致。点下方按钮生成。" : "请先生成左侧「立绘」,有了立绘才能据它生成三视图。"}</span>
+11 -2
View File
@@ -6,6 +6,7 @@ import { ConfirmModal, EmptyPanel } from "../components/overlays";
import { ProductCreateDrawer } from "../components/product-create-drawer";
import { Pager } from "../components/pager";
import { SkeletonRows } from "../components/loading";
import { useViewMode } from "../components/use-view-mode";
import "../project-wizard-page.css";
const PROJ_PAGE_SIZE = 10; // 项目列表/网格每页条数
@@ -416,7 +417,7 @@ const PROJ_TABS: Array<{ filter: "all" | "wip" | "done" | "fail"; label: string
{ filter: "all", label: "全部" }, { filter: "wip", label: "进行中" }, { filter: "done", label: "已完成" }, { filter: "fail", label: "失败" }
];
export function ProjectsPage({ products, projects, loading = false, navigate, openPipeline, onDelete }: {
export function ProjectsPage({ products, projects, loading = false, navigate, openPipeline, onDelete, initialTab }: {
products: Product[];
projects: Project[];
loading?: boolean;
@@ -424,9 +425,17 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
onCreate: (payload: { name: string; product: string }) => Promise<unknown> | void;
openPipeline: (projectId: string) => void;
onDelete: (projectId: string) => Promise<unknown> | void;
// 工作台统计块点击带入的初始 tab(all/wip/done/fail);变化时覆盖当前 tab。
initialTab?: string;
}) {
const [view, setView] = useState<"list" | "grid">("list");
const [view, setView] = useViewMode<"list" | "grid">("viewmode:projects", "list");
const [tab, setTab] = useState<"all" | "wip" | "done" | "fail">("all");
// initialTab 从工作台统计块带入时同步到本地 tab(仅识别合法值)。
useEffect(() => {
if (initialTab && ["all", "wip", "done", "fail"].includes(initialTab)) {
setTab(initialTab as "all" | "wip" | "done" | "fail");
}
}, [initialTab]);
const [query, setQuery] = useState("");
const [editMode, setEditMode] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
+4
View File
@@ -43,6 +43,8 @@ export type ResolvedRoute = {
productId?: string;
projectId?: string;
hash?: string;
// 视频项目页初始 tab(all/wip/done/fail):由 navigate 透传,刷新/前进后退不持久(回默认 all)。
tab?: string;
// 平台超管后台:/admin → ""(概览),/admin/<section> → section slug。undefined = 非 admin 路由。
admin?: string;
};
@@ -51,6 +53,8 @@ export type NavigateOptions = {
projectId?: string;
replace?: boolean;
hash?: string;
// 视频项目页初始 tab(all/wip/done/fail):仅经 route state 透传给 ProjectsPage,不进 URL path。
tab?: string;
};
export type NavigateFn = (page: Page, options?: NavigateOptions) => void;
export type Notice = { type: "success" | "error" | "info"; text: string } | null;
+6 -12
View File
@@ -4,9 +4,7 @@ import {
Bell,
KeyRound,
LogOut,
Monitor,
ShieldCheck,
Sliders,
Upload,
User as UserIcon,
} from "lucide-react";
@@ -15,7 +13,8 @@ import { TeamModal } from "../components/overlays";
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify", "pref", "display"];
// 可导航(UI 可见)的分区。pref(创作默认)/ display(显示)已隐藏入口,故不在此列。
const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify"];
const NAV: Array<{ group: string; items: Array<{ key: SectionKey; label: string; icon: ReactNode }> }> = [
{
@@ -26,13 +25,7 @@ const NAV: Array<{ group: string; items: Array<{ key: SectionKey; label: string;
{ key: "notify", label: "通知", icon: <Bell /> },
],
},
{
group: "偏好",
items: [
{ key: "pref", label: "创作默认", icon: <Sliders /> },
{ key: "display", label: "显示", icon: <Monitor /> },
],
},
// 「偏好」组(创作默认 / 显示)已隐藏 —— 入口移除,对应面板因导航不可达而不再显示。
];
const TEMPLATE_CHOICES = [
@@ -177,7 +170,8 @@ export function SettingsPage({
const cd = preferences?.creation_defaults;
const dp = preferences?.display;
return {
name: user.username || "",
// 显示名称存在后端 first_name(UserSerializer 暴露),username 不随保存变;优先读 first_name 回退 username
name: (user as { first_name?: string }).first_name || user.username || "",
email: user.email || "",
phone: "",
template: cd?.template ?? DEFAULT_PREFS.template,
@@ -191,7 +185,7 @@ export function SettingsPage({
language: dp?.language ?? DEFAULT_PREFS.language,
density: dp?.density ?? DEFAULT_PREFS.density,
};
}, [user.username, user.email, preferences]);
}, [user, preferences]);
// baseline = 已保存值;draft = 当前编辑值。diff(draft, baseline) = 脏字段。
const [baseline, setBaseline] = useState<TrackedState>(baselineFromProps);
+1 -1
View File
@@ -17,6 +17,6 @@ export function money(value: string | number | undefined) {
export function statusPill(status: string) {
if (["succeeded", "completed", "done", "ok", "skipped"].includes(status)) return "ok";
if (["failed", "error"].includes(status)) return "err";
if (["running", "queued", "needs_review", "polling"].includes(status)) return "info";
if (["running", "queued", "needs_review", "polling", "created", "reserved", "submitted", "postprocessing", "compensating"].includes(status)) return "info";
return "neutral";
}
+3
View File
@@ -466,6 +466,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
subtitle="// 生成邀请码 · 新成员凭码注册即加入本团队"
icon={<UserPlus size={16} />}
close={() => setModal("")}
dismissable={false}
footer={inviteResult
? <button className="btn btn-primary" type="button" onClick={() => setModal("")}></button>
: <button className="btn btn-primary" type="button" disabled={inviteBusy} onClick={() => {
@@ -516,6 +517,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
subtitle="// 自然月重置 · 仅超管可改"
icon={<CircleDollarSign size={16} />}
close={() => setModal("")}
dismissable={false}
footer={<button className="btn btn-primary" type="button" onClick={() => setModal("")}></button>}
>
<div className="limit-modal">
@@ -646,6 +648,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
subtitle="// 充值后立即到账 · 仅超管可操作"
icon={<CircleDollarSign size={16} />}
close={() => setModal("")}
dismissable={false}
footer={<button className="btn btn-primary" type="button" onClick={submitRecharge}></button>}
>
<div className="field"><label className="field-label"> ¥</label><input className="input num-input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /></div>