diff --git a/core/backend/apps/accounts/views.py b/core/backend/apps/accounts/views.py index c040db2..7d5e843 100644 --- a/core/backend/apps/accounts/views.py +++ b/core/backend/apps/accounts/views.py @@ -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) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index fd805b9..7beb6a8 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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, "") diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 4ec11d4..cf0e886 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -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) # 本次提交即刷新对话活跃时间,左栏「最近」据此置顶 diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index b15726c..a4324e7 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -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) => { diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index 673e136..6e0fa97 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -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; } diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 07ead17..11f0dd2 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -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 —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除 diff --git a/core/frontend/src/components/use-view-mode.ts b/core/frontend/src/components/use-view-mode.ts new file mode 100644 index 0000000..7e7cc2c --- /dev/null +++ b/core/frontend/src/components/use-view-mode.ts @@ -0,0 +1,24 @@ +import { useState } from "react"; + +// 网格/列表(或任意字面量)视图模式持久化 hook。 +// 按页面独立 key 存 localStorage,切走再切回恢复上次选择。 +// SSR / 无 localStorage / 读写抛错全部容错 —— 失败时退回 defaultVal,绝不报错。 +export function useViewMode(key: string, defaultVal: T): [T, (value: T) => void] { + const [view, setViewState] = useState(() => { + 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]; +} diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 9a63dd7..3d10814 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -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 = { + 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 }); } diff --git a/core/frontend/src/routes/dashboard.tsx b/core/frontend/src/routes/dashboard.tsx index 19317fb..d2539e3 100644 --- a/core/frontend/src/routes/dashboard.tsx +++ b/core/frontend/src/routes/dashboard.tsx @@ -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 | void; }) { @@ -95,8 +95,8 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill -
++
-

最近项目

{loading && projects.length === 0 ? : projects.slice(0, 6).map((project) => )}

快捷入口

[ /shortcuts ]
navigate("products")} /> navigate("library")} /> navigate("account")} /> navigate("projects")} />

提示

[ FAQ ]
扣费规则生成失败、超时、用户重跑 — 均不扣费。仅在你点 [ 确认通过 ] 时按 token 实际结算。
+
++ navigate("projects", { tab: "all" })} /> navigate("projects", { tab: "wip" })} /> navigate("projects", { tab: "done" })} />
+

最近项目

{loading && projects.length === 0 ? : projects.slice(0, 6).map((project) => )}

快捷入口

[ /shortcuts ]
navigate("products")} /> navigate("library")} /> navigate("account")} /> navigate("projects")} />

提示

[ FAQ ]
扣费规则生成失败、超时、用户重跑 — 均不扣费。仅在你点 [ 确认通过 ] 时按 token 实际结算。
{/* YYX#11:新建商品抽屉 · 复用商品库同一组件,创建成功后跳商品库看新商品 */} {onCreateProduct && (
{label} {badge}
{value}
{delta ?
{delta}
: null}; +export function KpiStat({ label, badge, value, delta, onClick }: { label: string; badge: string; value: string | number; delta?: string; onClick?: () => void }) { + const inner = <>
{label} {badge}
{value}
{delta ?
{delta}
: null}; + // 可点的统计块复用现有 .stat 类(与余额块一致),不新造样式/色值。 + if (onClick) return ; + return
{inner}
; } export function Shortcut({ name, title, desc, onClick }: { name: string; title: string; desc: string; onClick: () => void }) { diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 003e0b6..5086b68 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -1615,8 +1615,6 @@ export function PipelinePage(props: { const [propsTab, setPropsTab] = useState<"subtitle" | "transition" | "bgm">("subtitle"); const edHydratedRef = useRef(false); const bgmFileRef = useRef(null); - const videoUploadRef = useRef(null); - const [uploadTargetSeg, setUploadTargetSeg] = useState(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) { - 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: {
{pct}% - {/* 导出全部:把所有已完成片段打包成 zip 一次性下载 */} - - {segments.length ? (
{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 = (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 && }
- {!triUrl && !busyTri && ( + {!assetAlreadyHasTri && !busyTri && (
{viewPortraitAsset ? "三视图据这版立绘生成,保证正/侧/背多角度一致。点下方按钮生成。" : "请先生成左侧「立绘」,有了立绘才能据它生成三视图。"} diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index 47934e0..7e7f63b 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -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 | void; openPipeline: (projectId: string) => void; onDelete: (projectId: string) => Promise | 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(null); diff --git a/core/frontend/src/routes/route-config.ts b/core/frontend/src/routes/route-config.ts index a086357..9b15540 100644 --- a/core/frontend/src/routes/route-config.ts +++ b/core/frontend/src/routes/route-config.ts @@ -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 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; diff --git a/core/frontend/src/routes/settings.tsx b/core/frontend/src/routes/settings.tsx index 44b1643..6660c0e 100644 --- a/core/frontend/src/routes/settings.tsx +++ b/core/frontend/src/routes/settings.tsx @@ -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: }, ], }, - { - group: "偏好", - items: [ - { key: "pref", label: "创作默认", icon: }, - { key: "display", label: "显示", icon: }, - ], - }, + // 「偏好」组(创作默认 / 显示)已隐藏 —— 入口移除,对应面板因导航不可达而不再显示。 ]; 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(baselineFromProps); diff --git a/core/frontend/src/routes/stage-config.ts b/core/frontend/src/routes/stage-config.ts index 30c2f0c..9071b80 100644 --- a/core/frontend/src/routes/stage-config.ts +++ b/core/frontend/src/routes/stage-config.ts @@ -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"; } diff --git a/core/frontend/src/routes/team.tsx b/core/frontend/src/routes/team.tsx index 27daa99..590be5d 100644 --- a/core/frontend/src/routes/team.tsx +++ b/core/frontend/src/routes/team.tsx @@ -466,6 +466,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea subtitle="// 生成邀请码 · 新成员凭码注册即加入本团队" icon={} close={() => setModal("")} + dismissable={false} footer={inviteResult ? : } >
@@ -646,6 +648,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea subtitle="// 充值后立即到账 · 仅超管可操作" icon={} close={() => setModal("")} + dismissable={false} footer={} >
setRechargeAmt(e.target.value)} placeholder="最低 ¥50" />