diff --git a/core/backend/apps/ai/test_video_replace.py b/core/backend/apps/ai/test_video_replace.py index aebd488..d39d72a 100644 --- a/core/backend/apps/ai/test_video_replace.py +++ b/core/backend/apps/ai/test_video_replace.py @@ -119,6 +119,32 @@ DIGEST_FOUR = """片名:加长 台词/旁白:第四句。 """ +SEMANTIC_SAMPLE = """【成片规则】 +参考视频仅保留环境、镜头语言与节奏。@目标商品仅作为新商品外观和包装参考;不得出现原商品、原动作或原台词。 + +【镜头 01】 +时间:00:00-00:04 +时长:4秒 +景别:中近景 +机位:平视 +运镜:固定 +画面:女生站在窗边,拿起@目标商品观察包装。 +人物动作:双手展示商品外观。 +人物表情:轻松。 +台词/旁白:这是净颜精华。 + +【镜头 02】 +时间:00:04-00:08 +时长:4秒 +景别:特写 +机位:平视 +运镜:缓慢推近 +画面:镜头拍摄@目标商品包装细节。 +人物动作:缓慢转动商品。 +人物表情:不可见。 +台词/旁白:包装细节清晰可见。 +""" + def _asset(team, user, *, kind=Asset.Type.IMAGE, name="素材", duration_ms=None, preview="http://tos/1.png", review_status="active", review_remote_id=None): asset = Asset.objects.create( @@ -155,6 +181,8 @@ class SubmitVideoReplaceTests(TestCase): patch("apps.ai.tasks.poll_free_video_task.apply_async").start() patch("apps.ai.tasks.run_video_replace_digest_task.delay").start() patch("apps.assets.assets_client.is_enabled", return_value=False).start() + # 复刻的商品语义规划属于独立文本调用;其余用例只验证编排与出片边界。 + patch("apps.ai.video_replace.rewrite_product_semantic_remix", return_value=SEMANTIC_SAMPLE).start() self.addCleanup(patch.stopall) self.video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="参考.mp4", duration_ms=8000, preview="http://tos/video.mp4") self.image = _asset(self.team, self.user, name="精华.png", preview="http://tos/product.png") @@ -209,8 +237,8 @@ class SubmitVideoReplaceTests(TestCase): prompt = payload["prompt"] self.assertIn("【镜头 01】", prompt) self.assertIn("【镜头 02】", prompt) - self.assertIn("倒进玻璃杯", prompt) - self.assertIn("转动瓶身", prompt) + self.assertIn("观察包装", prompt) + self.assertIn("转动商品", prompt) self.assertIn("台词/旁白", prompt) self.assertIn("@目标商品", prompt) self.assertIn("净颜精华", prompt) @@ -449,6 +477,23 @@ class SubmitVideoReplaceTests(TestCase): task.refresh_from_db() return task + def test_product_semantic_remix_uses_facts_not_source_product_actions(self): + from apps.ai.video_replace import build_product_semantic_remix_messages, validate_product_semantic_remix + + messages = build_product_semantic_remix_messages( + digest_text=DIGEST_SAMPLE, + product_facts={"商品名称": "蓝牙耳机", "商品品类": "数码家电", "商品描述": "入耳式耳机", "真实卖点": [{"标题": "便携", "说明": ""}]}, + duration=8, + aspect_ratio="9:16", + ) + request = messages[1]["content"] + self.assertIn("蓝牙耳机", request) + self.assertIn("旧牌精华", request) # 参考动作仅作为要剥离的输入 + self.assertIn("商品图片只用于外观", messages[0]["content"]) + self.assertEqual(validate_product_semantic_remix(SEMANTIC_SAMPLE), SEMANTIC_SAMPLE.strip()) + with self.assertRaisesMessage(ValueError, "商品资料不足"): + validate_product_semantic_remix("MISSING_PRODUCT_FACTS:缺少商品品类") + def test_product_triview_is_sent_with_the_video(self): """商品库选商品时,三视图要跟着一起发给火山,并在提示词里被点名。""" tri = self._make_triview() @@ -617,8 +662,8 @@ class SubmitVideoReplaceTests(TestCase): self.assertFalse(task.request_payload.get("shot_plan")) prompt = task.request_payload["prompt"] self.assertIn("【镜头 01】", prompt) - self.assertIn("第四镜收束", prompt) - self.assertIn("省略次要镜头", prompt) + self.assertIn("【镜头 02】", prompt) + self.assertNotIn("第一镜开场", prompt) self.assertEqual(self.provider.create_video_task.call_count, 1) self.assertEqual(self.provider.create_video_task.call_args.kwargs.get("duration"), 10) diff --git a/core/backend/apps/ai/video_replace.py b/core/backend/apps/ai/video_replace.py index d5714ed..dba943c 100644 --- a/core/backend/apps/ai/video_replace.py +++ b/core/backend/apps/ai/video_replace.py @@ -9,10 +9,12 @@ """ from __future__ import annotations +import json import logging import re import uuid from decimal import Decimal +from pathlib import Path from django.conf import settings from django.db import transaction @@ -80,9 +82,8 @@ def get_inflight_video_replace(team): ) PRODUCT_PROMPT = ( - "按下面这份分镜稿生成视频。镜头数量、每镜时长、景别、机位、运镜、人物、场景、口播全部按稿执行," - "不要增删镜头、不要改顺序。" - "把稿里的原商品换成@目标商品,外观、材质、包装以参考图为准。" + "按下面的商品语义重构分镜生成视频。@目标商品只用于锁定外观、材质与包装。" + "严格遵守每镜的新商品动作、场景与口播;不要恢复参考片原商品、原动作、原卖点或原台词。" ) CHARACTER_PROMPT = ( "按下面这份分镜稿生成视频。镜头数量、每镜时长、景别、机位、运镜、商品、场景、口播全部按稿执行," @@ -344,6 +345,99 @@ def _digest_for_seedance(digest_text: str) -> str: return (head if sep else body).strip() +def _semantic_skill_dir() -> Path: + """语义重构 skill 必须在 backend/skills 中,确保镜像内也能加载。""" + base = Path(settings.BASE_DIR) + for candidate in (base / "skills", base.parent.parent / "skills"): + skill = candidate / "product-semantic-video-remix" / "SKILL.md" + if skill.is_file(): + return skill + return base / "skills" / "product-semantic-video-remix" / "SKILL.md" + + +def load_product_semantic_remix_skill() -> str: + path = _semantic_skill_dir() + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + raise ValueError("商品语义重构规则未部署,请联系管理员") from exc + + +def _product_semantic_facts(product: Product) -> dict: + """只取商品库中已填写的事实,绝不从商品图片猜用途或功效。""" + selling_points = [ + {"标题": point.title.strip(), "说明": point.detail.strip()} + for point in product.selling_points.all() + if point.title.strip() or point.detail.strip() + ] + return { + "商品名称": product.title.strip(), + "品牌": product.brand.strip(), + "商品品类": product.category.strip(), + "商品描述": product.description.strip(), + "真实卖点": selling_points, + "目标用户": product.target_audience.strip(), + "规格": product.specs or {}, + "资料状态": "完整" if (product.category.strip() and (product.description.strip() or selling_points)) else "不足", + } + + +def build_product_semantic_remix_messages(*, digest_text: str, product_facts: dict, duration: int, aspect_ratio: str) -> list[dict]: + """参考片只作为结构输入;商品事实和参考片信息严格分栏,防止语义串台。""" + facts = json.dumps(product_facts, ensure_ascii=False, indent=2) + return [ + {"role": "system", "content": load_product_semantic_remix_skill()}, + { + "role": "user", + "content": ( + "请执行商品语义重构,不要直接把参考分镜改几个名词。\n" + f"成片时长:{duration} 秒;画面比例:{aspect_ratio}。\n\n" + "【新商品的唯一事实来源】\n" + f"{facts}\n\n" + "【参考视频拆解稿,仅可继承角色关系、环境、镜头语言、剪辑节奏和叙事功能】\n" + f"{_digest_for_seedance(digest_text)}\n\n" + "输出给视频模型的中文分镜稿:保留【镜头 01】格式和每镜时间、景别、机位、运镜;" + "每镜的画面、人物动作、台词/旁白必须按新商品真实用途重写。" + "不要输出分析过程、分类标签或 Markdown。" + ), + }, + ] + + +def validate_product_semantic_remix(text: str) -> str: + """拒绝模型在事实不足时瞎编,或只给一段泛泛分析而没有可出片分镜。""" + cleaned = (text or "").strip() + if "MISSING_PRODUCT_FACTS" in cleaned: + raise ValueError("商品资料不足:请先在商品库补充品类,以及商品描述或至少一条真实卖点后再复刻") + if len(cleaned) < 120 or "【镜头" not in cleaned: + raise ValueError("商品语义重构结果不完整,请重试") + return cleaned + + +def rewrite_product_semantic_remix(*, task, digest_text: str, product_facts: dict, duration: int, aspect_ratio: str) -> str: + """Gemini 负责重写商品语义,Seedance 只负责按这份新分镜出片。""" + from .video_digest import DIGEST_MAX_TOKENS, resolve_digest_model_config + from .services import _collect_extract_text, get_text_provider + + model_config = resolve_digest_model_config() + if model_config is None: + raise ValueError("商品语义重构模型未配置,请联系管理员") + provider = get_text_provider(model_config) + text, _payload = _collect_extract_text( + provider, + model_config, + build_product_semantic_remix_messages( + digest_text=digest_text, + product_facts=product_facts, + duration=duration, + aspect_ratio=aspect_ratio, + ), + temperature=0.2, + extra_body={"max_tokens": DIGEST_MAX_TOKENS}, + ) + return validate_product_semantic_remix(text) + + def build_character_replace_prompt( digest_text: str, *, @@ -497,11 +591,18 @@ def submit_video_replace(*, team, user, params: dict): if has_product: subject_name, image_refs, subject_source = _product_library_refs(team, product_id) + product_facts = _product_semantic_facts_for_id(team, product_id) elif has_model: subject_name, image_refs, subject_source = _character_library_refs(team, model_id) + product_facts = {} else: noun = "商品" if replace_mode == "product" else "角色" subject_name, image_refs, subject_source = _temporary_image_refs(team, image_ids, noun=noun) + product_facts = { + "商品名称": subject_name, + "资料状态": "不足", + "说明": "临时上传图片只提供外观,未提供品类、用途或真实卖点。", + } duration = _output_duration(params.get("duration"), video_seconds) extra = { @@ -510,6 +611,7 @@ def submit_video_replace(*, team, user, params: dict): "subject_source": subject_source, "product_id": str(product_id) if product_id else "", "model_id": str(model_id) if model_id else "", + "product_facts": product_facts, } base_params = { "mode": "universal", @@ -643,10 +745,15 @@ def run_replace_digest(task) -> None: digest, source_seconds=source_seconds, output_seconds=output_seconds, ) else: - prompt = build_product_replace_prompt( - digest, subject, has_triview=has_triview, - source_seconds=source_seconds, output_seconds=output_seconds, + prompt = rewrite_product_semantic_remix( + task=task, + digest_text=digest, + product_facts=payload.get("product_facts") or {"商品名称": subject, "资料状态": "不足"}, + duration=output_seconds, + aspect_ratio=str(payload.get("aspect_ratio") or "9:16"), ) + if has_triview and TRIVIEW_LABEL not in prompt: + prompt = f"{prompt}\n商品各面与材质细节以@{TRIVIEW_LABEL}为准。" except (VideoDigestError, ValueError) as exc: _fail_reviewing_task(task, str(exc), error_code="processing_failed") return @@ -663,6 +770,7 @@ def run_replace_digest(task) -> None: next_payload.update(meta) next_payload["digest_pending"] = False next_payload["digest_text"] = digest[:32000] + next_payload["semantic_remix"] = replace_mode == "product" next_payload["prompt"] = prompt next_payload["references"] = _image_refs(next_payload.get("references") or []) next_payload.pop("shot_plan", None) @@ -1583,6 +1691,17 @@ def _product_triview_asset(team, product_id: uuid.UUID): ) +def _product_semantic_facts_for_id(team, product_id: uuid.UUID) -> dict: + product = ( + Product.objects.filter(id=product_id, team=team, purged_at__isnull=True, status=Product.Status.ACTIVE) + .prefetch_related("selling_points") + .first() + ) + if product is None: + raise ValueError("商品不存在或已被删除") + return _product_semantic_facts(product) + + def _product_library_refs(team, product_id: uuid.UUID) -> tuple[str, list, str]: product = ( Product.objects.filter(id=product_id, team=team, purged_at__isnull=True, status=Product.Status.ACTIVE) diff --git a/core/backend/skills/product-semantic-video-remix/SKILL.md b/core/backend/skills/product-semantic-video-remix/SKILL.md new file mode 100644 index 0000000..65eb926 --- /dev/null +++ b/core/backend/skills/product-semantic-video-remix/SKILL.md @@ -0,0 +1,46 @@ +--- +name: product-semantic-video-remix +description: 商品语义重构式视频复刻。保留参考片的表达框架,按新商品真实资料重写可执行的视频分镜。 +--- + +# 商品语义重构式视频复刻 + +## 目标 + +参考视频仅提供角色关系、环境氛围、镜头语言、剪辑节奏和叙事结构。必须移除原商品的名称、功能、动作、卖点、台词和因果关系,再依据新商品的真实资料重写每个镜头。 + +## 最高优先级规则 + +1. 复刻镜头意图,不复刻原商品动作。 +2. 新商品真实用途优先于原片视觉相似度。 +3. 商品图片只用于外观、包装、材质和颜色,不得据此编造功效、价格或营销承诺。 +4. 每个动作都必须符合新商品的真实物理用途;原环境冲突时允许最小幅度调整场景。 +5. 信息不足时只输出 `MISSING_PRODUCT_FACTS`,并列出缺少的品类、用途、真实卖点或使用场景;不得猜测后继续写分镜。 + +## 工作方法 + +先从参考分镜中分离四类信息: + +- PRESERVE:角色数量与关系、环境气氛、镜头时长、景别、机位、运镜、剪辑节奏。 +- ADAPT:每镜叙事功能,例如痛点、展示、验证、收尾。 +- DISCARD:原商品、原包装、原功能、原动作、原台词、原卖点、原结果和绑定道具。 +- VALIDATE:原场景与新商品用途是否冲突;冲突时做最小场景调整。 + +然后只根据输入的商品名称、品类、描述、真实卖点、目标用户和规格,建立新商品的能力边界:它是什么、能做什么、不能做什么、谁在何处如何使用。不得把参考片的护肤、饮用、穿戴、清洁、数码操作等动作直接迁移到不同品类。 + +## 输出要求 + +只输出中文导演分镜稿,不输出分析、分类标签、解释或 Markdown。必须保留: + +- `【镜头 01】` 连续编号; +- 每镜的时间、时长、景别、机位、运镜; +- 依据新商品事实重写后的画面、人物动作、人物表情、台词/旁白; +- 角色关系、环境连续性和原片节奏。 + +开头必须有 `【成片规则】`,明确:参考视频不决定新商品发生什么;新商品外观以 `@目标商品` 参考图为准;禁止出现原商品、原品牌、原包装、原功能和原台词。 + +台词只能使用已提供的商品事实和真实卖点。没有可证实的功效时,改写为可见的使用、外观、携带、摆放或操作事实,不得虚构前后效果。 + +## 逐镜校验 + +生成前逐镜确认:新商品能否真的执行该动作、使用者与场景是否合理、动作是否证明真实卖点、前后是否连续、是否残留原商品语义、是否有未经提供依据的功效,以及商品是否保持与参考图一致。任一项不满足时,先重写该镜。 diff --git a/core/frontend/src/account-page.css b/core/frontend/src/account-page.css index 998cb9f..4008c37 100644 --- a/core/frontend/src/account-page.css +++ b/core/frontend/src/account-page.css @@ -444,6 +444,7 @@ .filter-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; } .filter-bar select, .filter-bar input { background: #fff; border: 1px solid var(--st-line); border-radius: 8px; padding: 6px 10px; font-size: 13px; font-family: inherit; color: var(--st-text); } + .filter-bar .rs-select-btn { background: #fff; border-color: var(--st-line); } .filter-bar select:hover, .filter-bar input:hover { border-color: rgba(28, 34, 43, 0.18); } .filter-bar select { padding-right: 24px; } .filter-bar .spacer { flex: 1; } diff --git a/core/frontend/src/components/custom-select.tsx b/core/frontend/src/components/custom-select.tsx new file mode 100644 index 0000000..d04ef60 --- /dev/null +++ b/core/frontend/src/components/custom-select.tsx @@ -0,0 +1,163 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { ChevronDown } from "lucide-react"; + +export type CustomSelectOption = { + value: string; + label: string; + desc?: string; + disabled?: boolean; + group?: string; +}; + +type MenuPos = { + left: number; + width: number; + maxHeight: number; + top?: number; + bottom?: number; +}; + +export function CustomSelect({ + value, + onChange, + options, + disabled, + className, + fill, + align = "left", + size = "md", + placeholder = "请选择", + "aria-label": ariaLabel, +}: { + value: string; + onChange: (value: string) => void; + options: CustomSelectOption[]; + disabled?: boolean; + className?: string; + fill?: boolean; + align?: "left" | "right"; + size?: "sm" | "md"; + placeholder?: string; + "aria-label"?: string; +}) { + const [open, setOpen] = useState(false); + const [pos, setPos] = useState(null); + const btnRef = useRef(null); + const menuRef = useRef(null); + const current = options.find((item) => item.value === value); + + function place() { + const btn = btnRef.current; + if (!btn) return; + const rect = btn.getBoundingClientRect(); + const gutter = 8; + const spaceBelow = window.innerHeight - rect.bottom - gutter; + const spaceAbove = rect.top - gutter; + const up = spaceBelow < 220 && spaceAbove > spaceBelow; + const width = Math.min(rect.width, window.innerWidth - gutter * 2); + let left = align === "right" ? rect.right - width : rect.left; + left = Math.min(Math.max(gutter, left), window.innerWidth - width - gutter); + const next: MenuPos = { + left, + width, + maxHeight: Math.max(120, Math.min(320, (up ? spaceAbove : spaceBelow) - 4)), + }; + if (up) next.bottom = window.innerHeight - rect.top + 6; + else next.top = rect.bottom + 6; + setPos(next); + } + + useLayoutEffect(() => { + if (!open) return; + place(); + }, [open, align, options.length]); + + useEffect(() => { + if (!open) return; + const onDown = (event: MouseEvent) => { + const target = event.target as Node; + if (btnRef.current?.contains(target) || menuRef.current?.contains(target)) return; + setOpen(false); + }; + const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; + const onReposition = () => place(); + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + window.addEventListener("resize", onReposition); + window.addEventListener("scroll", onReposition, true); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + window.removeEventListener("resize", onReposition); + window.removeEventListener("scroll", onReposition, true); + }; + }, [open]); + + useEffect(() => { + if (!open) return; + const menu = menuRef.current; + const active = menu?.querySelector(".rs-select-option.selected"); + if (!menu || !active) return; + menu.scrollTop = active.offsetTop - menu.clientHeight / 2 + active.offsetHeight / 2; + }, [open, value]); + + return ( +
+ + {open && pos && createPortal( +
+ {options.map((item, index) => { + const showGroup = Boolean(item.group && item.group !== options[index - 1]?.group); + return ( +
+ {showGroup ?
{item.group}
: null} + +
+ ); + })} +
, + document.body, + )} +
+ ); +} diff --git a/core/frontend/src/design-restraint.css b/core/frontend/src/design-restraint.css index daa412d..2582e20 100644 --- a/core/frontend/src/design-restraint.css +++ b/core/frontend/src/design-restraint.css @@ -1381,8 +1381,7 @@ body.sidebar-collapsed .user::after { display: none; } } .chip-menu .mi:hover { background: var(--black-alpha-4); } .chip-menu .mi.selected { color: var(--heat); background: var(--heat-12); } -.chip-menu .mi .mi-check { width: 13px; height: 13px; color: var(--heat); visibility: hidden; flex-shrink: 0; } -.chip-menu .mi.selected .mi-check { visibility: visible; } +.chip-menu .mi .mi-check { display: none; } .chip-menu .mi-sep { height: 1px; background: var(--border-faint); margin: 4px 6px; } /* ─── Dropdown unification · 下拉框 / 菜单视觉收口 ─── */ @@ -1454,12 +1453,31 @@ select.duration-select:focus, color: var(--heat) !important; } -:where(.chip-menu, .filter-pop, .pp-menu, .tb-menu, .io-param-menu, .move-menu, .cell-more-menu, .msg-more-menu, .batch-more-menu) { +:where( + .chip-menu, + .filter-pop, + .pp-menu, + .tb-menu, + .io-param-menu, + .ic-param-menu, + .move-menu, + .cell-more-menu, + .msg-more-menu, + .batch-more-menu, + .rs-select-menu, + .pl-select-menu, + .lib-select-menu, + .vc-select-menu, + .af-select-menu, + .pcd-select-menu, + .nw-select-menu, + .fc-dd-menu +) { background: var(--surface) !important; - border: 1px solid var(--border-faint) !important; - border-radius: var(--r-md) !important; - box-shadow: var(--shadow-floating) !important; - padding: 4px !important; + border: 1px solid rgba(34, 42, 54, 0.10) !important; + border-radius: 10px !important; + box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12) !important; + padding: 6px !important; } :where( .chip-menu .mi, @@ -1467,16 +1485,25 @@ select.duration-select:focus, .pp-menu .mi, .tb-menu-item, .io-param-menu .mi, + .ic-param-menu .mi, .move-menu .mv-item, .cell-more-menu button, .msg-more-menu button, - .batch-more-menu button + .batch-more-menu button, + .rs-select-option, + .pl-select-option, + .lib-select-option, + .vc-select-option, + .af-select-option, + .pcd-select-option, + .nw-select-option, + .fc-dd-item ) { - min-height: 32px !important; - padding: 0 10px !important; + min-height: 36px !important; + padding: 7px 10px !important; background: transparent !important; border: 0 !important; - border-radius: var(--r-sm) !important; + border-radius: 8px !important; color: var(--accent-black) !important; display: flex !important; align-items: center !important; @@ -1494,10 +1521,19 @@ select.duration-select:focus, .pp-menu .mi, .tb-menu-item, .io-param-menu .mi, + .ic-param-menu .mi, .move-menu .mv-item, .cell-more-menu button, .msg-more-menu button, - .batch-more-menu button + .batch-more-menu button, + .rs-select-option, + .pl-select-option, + .lib-select-option, + .vc-select-option, + .af-select-option, + .pcd-select-option, + .nw-select-option, + .fc-dd-item ):hover { background: var(--black-alpha-4) !important; color: var(--accent-black) !important; @@ -1507,11 +1543,20 @@ select.duration-select:focus, .filter-pop button.selected, .pp-menu .mi.selected, .tb-menu-item.active, - .io-param-menu .mi.selected + .io-param-menu .mi.selected, + .ic-param-menu .mi.selected, + .rs-select-option.selected, + .pl-select-option.selected, + .lib-select-option.selected, + .vc-select-option.selected, + .af-select-option.selected, + .pcd-select-option.selected, + .nw-select-option.selected, + .fc-dd-item.selected ) { - background: var(--heat-12) !important; + background: var(--heat-8) !important; color: var(--heat) !important; - font-weight: 500 !important; + font-weight: 600 !important; } :where(.cell-more-menu button.danger:hover, .msg-more-menu button.danger:hover, .batch-more-menu button.danger:hover) { background: var(--crimson-bg) !important; @@ -1523,6 +1568,21 @@ select.duration-select:focus, font-size: 12px !important; letter-spacing: .02em !important; } +.rs-select-group { color: var(--black-alpha-48) !important; } +.rs-select-option-desc, +.fc-dd-item .de { color: var(--black-alpha-48) !important; } +.fc-dd-item { + flex-direction: column !important; + align-items: flex-start !important; + justify-content: center !important; + min-height: 44px !important; + padding: 7px 10px !important; +} +.fc-dd-item .ti { color: var(--accent-black) !important; } +.fc-dd-item.selected .ti { color: var(--heat) !important; } +.fc-dd-item.selected .de { color: var(--black-alpha-48) !important; } +.pl-select-option .pl-cat-count { color: var(--black-alpha-48) !important; } +.pl-select-option.selected .pl-cat-count { color: var(--heat) !important; } .rs-select { position: relative; @@ -1532,14 +1592,6 @@ select.duration-select:focus, } .rs-select.rs-select-fill { width: 100%; } .rs-select.rs-select-filter { min-width: 126px; } -.rs-select > select[data-rs-select-bound="1"] { - position: absolute !important; - inset: 0 auto auto 0 !important; - width: 1px !important; - height: 1px !important; - opacity: 0 !important; - pointer-events: none !important; -} .rs-select-btn { width: 100%; height: 36px; @@ -1557,6 +1609,7 @@ select.duration-select:focus, cursor: pointer; transition: background var(--t-base), border-color var(--t-base), box-shadow var(--t-base), color var(--t-base); } +.rs-select-sm .rs-select-btn { height: 32px; font-size: 13px; padding: 0 10px 0 12px; } .rs-select-btn:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); @@ -1564,7 +1617,7 @@ select.duration-select:focus, .rs-select.open .rs-select-btn, .rs-select-btn:focus-visible { border-color: var(--heat-40); - box-shadow: inset 0 0 0 1px var(--heat-40); + box-shadow: 0 0 0 3px var(--heat-8); } .rs-select-btn[disabled] { background: var(--black-alpha-5); @@ -1577,6 +1630,7 @@ select.duration-select:focus, text-overflow: ellipsis; white-space: nowrap; } +.rs-select-label.is-placeholder { color: var(--black-alpha-40); } .rs-select-btn svg { width: 12px; height: 12px; @@ -1590,65 +1644,91 @@ select.duration-select:focus, } .rs-select-menu { position: absolute; - top: calc(100% + 4px); + top: calc(100% + 6px); left: 0; min-width: 100%; width: max-content; - max-width: min(260px, calc(100vw - 24px)); + max-width: min(280px, calc(100vw - 24px)); max-height: 320px; overflow-y: auto; + overscroll-behavior: contain; display: none; z-index: 1600; background: var(--surface); - border: 1px solid var(--border-faint); - border-radius: var(--r-md); - box-shadow: var(--shadow-floating); - padding: 4px; + border: 1px solid rgba(34, 42, 54, 0.10); + border-radius: 10px; + box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12); + padding: 6px; } .rs-select-menu.align-right { left: auto; right: 0; } -.rs-select.open .rs-select-menu { display: block; } +.rs-select.open .rs-select-menu { display: grid; gap: 3px; } +.rs-select-menu-portal { + position: fixed; + top: auto; + min-width: 0; + max-width: none; + display: grid; + gap: 3px; + z-index: calc(var(--z-overlay) + 40); +} +.rs-select-group { + padding: 8px 10px 4px; + color: var(--black-alpha-48); + font-size: 11px; + font-weight: 500; + letter-spacing: .04em; +} .rs-select-option { width: 100%; - min-height: 32px; - padding: 0 10px; + min-height: 38px; + padding: 7px 10px; display: flex; align-items: center; gap: 8px; background: transparent; border: 0; - border-radius: var(--r-sm); + border-radius: 8px; color: var(--accent-black); font-family: inherit; font-size: 13px; text-align: left; - white-space: nowrap; cursor: pointer; transition: background var(--t-base), color var(--t-base); } -.rs-select-option:hover, -.rs-select-option.is-active { - background: var(--black-alpha-4); +.rs-select-option-text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; } -.rs-select-option.selected { - background: var(--heat-12); - color: var(--heat); - font-weight: 500; +.rs-select-option-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } +.rs-select-option-desc { + color: var(--black-alpha-48); + font-size: 11px; + line-height: 1.35; +} +.rs-select-option.selected .rs-select-option-desc { color: var(--black-alpha-48); } .rs-select-option[disabled] { color: var(--black-alpha-32); cursor: not-allowed; } -.rs-select-option .mi-check { - width: 13px; - height: 13px; - color: var(--heat); - visibility: hidden; - flex-shrink: 0; +.rs-select-option[disabled]:hover { background: transparent; } +.rs-select-menu::-webkit-scrollbar { width: 6px; } +.rs-select-menu::-webkit-scrollbar-thumb { + border-radius: 999px; + background: rgba(34, 42, 54, 0.18); } -.rs-select-option.selected .mi-check { visibility: visible; } +.rs-select-menu::-webkit-scrollbar-track { background: transparent; } + +.filter-bar .rs-select { min-width: 132px; } +.filter-bar .rs-select-btn { height: 32px; font-size: 13px; } /* ─── Clear-filters btn · 共享组件 ─── */ .clear-filters { diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index a918ba2..6617e96 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -1480,8 +1480,9 @@ .setup-card .setup-lead { font-size: 13px; line-height: 1.5; color: var(--accent-black); margin-bottom: 6px; } .setup-card .setup-field { display: grid; grid-template-columns: 64px 1fr; align-items: center; gap: 10px; } .setup-card .setup-field .sf-k { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .04em; } - .setup-card .setup-select { height: 32px; width: 100%; padding: 0 10px; font-size: 13px; font-family: inherit; color: var(--accent-black); background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); outline: none; cursor: pointer; } - .setup-card .setup-select:focus { border-color: var(--heat); box-shadow: 0 0 0 3px var(--heat-12); } + .setup-card .setup-select { width: 100%; } + .as-spec-field .rs-select { width: 100%; } + .props-row .rs-select { flex: 1; min-width: 0; } /* 说明行左缩进 = 标签列 64 + gap 10,跟下拉框左边缘对齐 */ .setup-card .setup-rec { font-size: 12px; line-height: 1.5; color: var(--black-alpha-40); margin: 0 0 8px 74px; } .setup-card .setup-rec.warn { color: var(--accent-honey); } diff --git a/core/frontend/src/product-detail-page.css b/core/frontend/src/product-detail-page.css index 1a5ddc6..432e672 100644 --- a/core/frontend/src/product-detail-page.css +++ b/core/frontend/src/product-detail-page.css @@ -249,6 +249,7 @@ .v-edit { display: none; } .ov-card.editing .v-static { display: none; } .ov-card.editing .v-edit { display: block; } + .ov-card.editing .v-edit.rs-select { display: inline-flex; width: 100%; } /* 输入控件 · 对齐新建表单 V2.1 规范 */ .v-input, diff --git a/core/frontend/src/project-wizard-page.css b/core/frontend/src/project-wizard-page.css index 7f8dd20..6bab409 100644 --- a/core/frontend/src/project-wizard-page.css +++ b/core/frontend/src/project-wizard-page.css @@ -506,6 +506,7 @@ font-weight: 500; } .project-wizard-page .nw-field small { color: var(--nw-muted); font-size: 11px; font-weight: 400; } +.project-wizard-page .nw-field .rs-select { width: 100%; } .project-wizard-page .nw-field input, .project-wizard-page .nw-field select { height: 44px; diff --git a/core/frontend/src/quick-create-page.css b/core/frontend/src/quick-create-page.css index 2aeafa6..19faa9c 100644 --- a/core/frontend/src/quick-create-page.css +++ b/core/frontend/src/quick-create-page.css @@ -57,9 +57,8 @@ .quick-create-page .quick-parameter-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; margin-top: 20px; } .quick-create-page .quick-parameter-field { min-width: 0; display: grid; gap: 9px; padding: 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 12px; background: rgba(248,249,252,.72); } .quick-create-page .quick-parameter-field > span { color: #30343a; font-size: 12px; font-weight: 700; } -.quick-create-page .quick-parameter-field select { width: 100%; height: 38px; padding: 0 36px 0 13px; border: 1px solid rgba(34,42,54,.13); border-radius: 10px; outline: none; color: #272a30; background-color: rgba(255,255,255,.92); font: inherit; font-size: 12px; cursor: pointer; } -.quick-create-page .quick-parameter-field select:focus { border-color: rgba(0,47,167,.55); box-shadow: 0 0 0 3px rgba(0,47,167,.08); } -.quick-create-page .quick-parameter-field select:disabled { cursor: not-allowed; opacity: .65; } +.quick-create-page .quick-parameter-field .rs-select { width: 100%; } +.quick-create-page .quick-parameter-field .rs-select-btn { height: 38px; font-size: 12px; } .quick-create-page .quick-form-footer { display: flex; margin-top: 26px; } .quick-create-page .quick-generate-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 12px; color: #fff; background: var(--quick-blue); font: inherit; font-weight: 700; cursor: pointer; box-shadow: 0 12px 24px rgba(0,47,167,.22); }.quick-create-page .quick-generate-button:disabled { color: #99a1ae; background: #e5e8ee; box-shadow: none; cursor: not-allowed; }.quick-create-page .quick-generate-button svg { width: 20px; height: 20px; } .quick-create-page .quick-cancel-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 1px solid rgba(34,42,54,.16); border-radius: 12px; color: #272a30; background: #fff; font: inherit; font-weight: 700; cursor: pointer; }.quick-create-page .quick-cancel-button:hover:not(:disabled) { border-color: rgba(200,61,77,.35); color: #c83d4d; background: rgba(200,61,77,.06); }.quick-create-page .quick-cancel-button:disabled { opacity: .55; cursor: not-allowed; }.quick-create-page .quick-cancel-button svg { width: 18px; height: 18px; } diff --git a/core/frontend/src/routes/account.tsx b/core/frontend/src/routes/account.tsx index 9c79d18..92d4c6e 100644 --- a/core/frontend/src/routes/account.tsx +++ b/core/frontend/src/routes/account.tsx @@ -7,6 +7,7 @@ import type { NavigateFn } from "./route-config"; import { money, pts, stageMeta, yuan } from "./stage-config"; import { pageWindow } from "../components/pager"; import { useBodyScrollLock, useOverlayTransition } from "../components/overlays"; +import { CustomSelect } from "../components/custom-select"; const ROLE_LABEL: Record = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" }; const STATUS_LABEL: Record = { active: "活跃", invited: "待激活", disabled: "已停用" }; @@ -466,19 +467,31 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
- - + { setBillType(next); setBillPage(1); }} + options={[ + { value: "all", label: "全部类型" }, + { value: "charge", label: "扣费" }, + { value: "recharge", label: "充值" }, + { value: "reserve", label: "预扣" }, + { value: "release", label: "释放" }, + { value: "adjustment", label: "调整" }, + { value: "refund", label: "退款" }, + ]} + /> + { setBillMember(next); setBillPage(1); }} + options={[ + { value: "all", label: "全部成员" }, + ...billMemberOptions.map((m) => ({ value: m.id, label: m.label })), + ]} + /> {billFiltered && ( )} @@ -539,12 +552,18 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
- + setProjStatus(next as typeof projStatus)} + options={[ + { value: "all", label: "全部状态" }, + { value: "wip", label: "进行中" }, + { value: "ok", label: "已完成" }, + { value: "fail", label: "失败 · 待重跑" }, + ]} + /> {projFiltered && ( )} @@ -578,12 +597,18 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
- + setMemRole(next as typeof memRole)} + options={[ + { value: "all", label: "全部角色" }, + { value: "owner", label: "超管" }, + { value: "admin", label: "团管" }, + { value: "member", label: "成员" }, + ]} + /> {memFiltered && ( )} diff --git a/core/frontend/src/routes/admin/admin-billing.tsx b/core/frontend/src/routes/admin/admin-billing.tsx index 985482d..96b3a5e 100644 --- a/core/frontend/src/routes/admin/admin-billing.tsx +++ b/core/frontend/src/routes/admin/admin-billing.tsx @@ -4,6 +4,7 @@ import { adminApi } from "../../api"; import { Pager } from "../../components/pager"; import { IconKitSvg } from "../../components/IconKitSvg"; import { SystemLoading } from "../../components/loading"; +import { CustomSelect } from "../../components/custom-select"; import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types"; import { pts } from "../stage-config"; @@ -214,10 +215,13 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {

为团队手动加 / 减额度(争议补偿)。金额可为负;调额后余额不得为负。会落一条调额流水。

- + setForm((f) => ({ ...f, team: next }))} + options={teams.map((t) => ({ value: t.id, label: t.name }))} + />
@@ -372,10 +376,14 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {

各上限留空 = 该维度不限。任一上限触发即拦截生成(在原余额管控之上叠加)。

- + setEditing((p) => ({ ...p, team: next }))} + options={teams.map((t) => ({ value: t.id, label: t.name }))} + />
diff --git a/core/frontend/src/routes/admin/admin-models.tsx b/core/frontend/src/routes/admin/admin-models.tsx index c76bd2c..a1635b0 100644 --- a/core/frontend/src/routes/admin/admin-models.tsx +++ b/core/frontend/src/routes/admin/admin-models.tsx @@ -3,6 +3,7 @@ import { Server, X } from "lucide-react"; import { adminApi } from "../../api"; import { Pager } from "../../components/pager"; import { SystemLoading } from "../../components/loading"; +import { CustomSelect } from "../../components/custom-select"; import type { AdminModel, AdminProvider } from "../../types"; import { pts } from "../stage-config"; @@ -229,10 +230,14 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
- + setModelModal((m) => m && ({ ...m, provider: next }))} + options={providers.map((p) => ({ value: p.id, label: p.display_name }))} + />
@@ -241,9 +246,13 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
- + setModelModal((m) => m && ({ ...m, capability: next }))} + options={CAPABILITIES.map((c) => ({ value: c, label: c }))} + />
diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index d2a4a3f..7f2d552 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -9,6 +9,7 @@ import { isPublicGenerationError, presentGenerationError } from "../generation-e import type { Notice, Page } from "./route-config"; import { stageOrder, statusPill } from "./stage-config"; import { ConfirmModal, MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays"; +import { CustomSelect } from "../components/custom-select"; import { DEFAULT_BILLING_RATES, estimateCost, FC_MODELS, modelLabel } from "../components/free-create/constants"; import { ModelLibrary } from "../components/model-library"; import { ReviewBadge, type ReviewStatus } from "../components/review-badge"; @@ -1198,25 +1199,33 @@ export function PipelinePage(props: { return (
); @@ -3222,27 +3231,42 @@ export function PipelinePage(props: { 沿用模板的镜数、每镜作用和节奏,文案、画面、模特与故事板都会按{setupProduct?.title || "当前商品"}重新生成 —— 不是把上一条片子换个商品名。
)} -