优化复刻视频脚本和下拉框样式

This commit is contained in:
Azmat@qq.com
2026-08-31 10:59:54 +08:00
parent 284748eeff
commit f59e9d0418
20 changed files with 775 additions and 212 deletions
+49 -4
View File
@@ -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)
+125 -6
View File
@@ -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)
@@ -0,0 +1,46 @@
---
name: product-semantic-video-remix
description: 商品语义重构式视频复刻。保留参考片的表达框架,按新商品真实资料重写可执行的视频分镜。
---
# 商品语义重构式视频复刻
## 目标
参考视频仅提供角色关系、环境氛围、镜头语言、剪辑节奏和叙事结构。必须移除原商品的名称、功能、动作、卖点、台词和因果关系,再依据新商品的真实资料重写每个镜头。
## 最高优先级规则
1. 复刻镜头意图,不复刻原商品动作。
2. 新商品真实用途优先于原片视觉相似度。
3. 商品图片只用于外观、包装、材质和颜色,不得据此编造功效、价格或营销承诺。
4. 每个动作都必须符合新商品的真实物理用途;原环境冲突时允许最小幅度调整场景。
5. 信息不足时只输出 `MISSING_PRODUCT_FACTS`,并列出缺少的品类、用途、真实卖点或使用场景;不得猜测后继续写分镜。
## 工作方法
先从参考分镜中分离四类信息:
- PRESERVE:角色数量与关系、环境气氛、镜头时长、景别、机位、运镜、剪辑节奏。
- ADAPT:每镜叙事功能,例如痛点、展示、验证、收尾。
- DISCARD:原商品、原包装、原功能、原动作、原台词、原卖点、原结果和绑定道具。
- VALIDATE:原场景与新商品用途是否冲突;冲突时做最小场景调整。
然后只根据输入的商品名称、品类、描述、真实卖点、目标用户和规格,建立新商品的能力边界:它是什么、能做什么、不能做什么、谁在何处如何使用。不得把参考片的护肤、饮用、穿戴、清洁、数码操作等动作直接迁移到不同品类。
## 输出要求
只输出中文导演分镜稿,不输出分析、分类标签、解释或 Markdown。必须保留:
- `【镜头 01】` 连续编号;
- 每镜的时间、时长、景别、机位、运镜;
- 依据新商品事实重写后的画面、人物动作、人物表情、台词/旁白;
- 角色关系、环境连续性和原片节奏。
开头必须有 `【成片规则】`,明确:参考视频不决定新商品发生什么;新商品外观以 `@目标商品` 参考图为准;禁止出现原商品、原品牌、原包装、原功能和原台词。
台词只能使用已提供的商品事实和真实卖点。没有可证实的功效时,改写为可见的使用、外观、携带、摆放或操作事实,不得虚构前后效果。
## 逐镜校验
生成前逐镜确认:新商品能否真的执行该动作、使用者与场景是否合理、动作是否证明真实卖点、前后是否连续、是否残留原商品语义、是否有未经提供依据的功效,以及商品是否保持与参考图一致。任一项不满足时,先重写该镜。
+1
View File
@@ -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; }
@@ -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<MenuPos | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(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<HTMLElement>(".rs-select-option.selected");
if (!menu || !active) return;
menu.scrollTop = active.offsetTop - menu.clientHeight / 2 + active.offsetHeight / 2;
}, [open, value]);
return (
<div className={`rs-select${fill ? " rs-select-fill" : ""}${open ? " open" : ""}${size === "sm" ? " rs-select-sm" : ""}${className ? ` ${className}` : ""}`}>
<button
ref={btnRef}
type="button"
className="rs-select-btn"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
onClick={() => setOpen((v) => !v)}
>
<span className={`rs-select-label${current ? "" : " is-placeholder"}`}>{current?.label || placeholder}</span>
<ChevronDown />
</button>
{open && pos && createPortal(
<div
ref={menuRef}
className="rs-select-menu rs-select-menu-portal"
role="listbox"
style={{
left: pos.left,
width: pos.width,
maxHeight: pos.maxHeight,
top: pos.top,
bottom: pos.bottom,
}}
>
{options.map((item, index) => {
const showGroup = Boolean(item.group && item.group !== options[index - 1]?.group);
return (
<div key={`${item.group || ""}:${item.value}`}>
{showGroup ? <div className="rs-select-group">{item.group}</div> : null}
<button
type="button"
role="option"
aria-selected={item.value === value}
className={`rs-select-option${item.value === value ? " selected" : ""}`}
disabled={item.disabled}
onClick={() => {
if (item.disabled) return;
onChange(item.value);
setOpen(false);
}}
>
<span className="rs-select-option-text">
<span className="rs-select-option-label">{item.label}</span>
{item.desc ? <span className="rs-select-option-desc">{item.desc}</span> : null}
</span>
</button>
</div>
);
})}
</div>,
document.body,
)}
</div>
);
}
+129 -49
View File
@@ -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 {
+3 -2
View File
@@ -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); }
@@ -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,
@@ -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;
+2 -3
View File
@@ -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; }
+50 -25
View File
@@ -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<string, string> = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" };
const STATUS_LABEL: Record<string, string> = { active: "活跃", invited: "待激活", disabled: "已停用" };
@@ -466,19 +467,31 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<div className={`tab-panel ${tab === "bills" ? "active" : ""}`}>
<div className="filter-bar">
<select value={billType} onChange={(e) => { setBillType(e.target.value); setBillPage(1); }} aria-label="按类型筛选">
<option value="all"></option>
<option value="charge"></option>
<option value="recharge"></option>
<option value="reserve"></option>
<option value="release"></option>
<option value="adjustment"></option>
<option value="refund">退</option>
</select>
<select value={billMember} onChange={(e) => { setBillMember(e.target.value); setBillPage(1); }} aria-label="按成员筛选">
<option value="all"></option>
{billMemberOptions.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
<CustomSelect
size="sm"
aria-label="按类型筛选"
value={billType}
onChange={(next) => { 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: "退款" },
]}
/>
<CustomSelect
size="sm"
aria-label="按成员筛选"
value={billMember}
onChange={(next) => { setBillMember(next); setBillPage(1); }}
options={[
{ value: "all", label: "全部成员" },
...billMemberOptions.map((m) => ({ value: m.id, label: m.label })),
]}
/>
{billFiltered && (
<button className="filter-reset" type="button" onClick={() => { setBillType("all"); setBillMember("all"); setBillPage(1); }}></button>
)}
@@ -539,12 +552,18 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<div className={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
<div className="filter-bar">
<select value={projStatus} onChange={(e) => setProjStatus(e.target.value as typeof projStatus)} aria-label="按状态筛选">
<option value="all"></option>
<option value="wip"></option>
<option value="ok"></option>
<option value="fail"> · </option>
</select>
<CustomSelect
size="sm"
aria-label="按状态筛选"
value={projStatus}
onChange={(next) => setProjStatus(next as typeof projStatus)}
options={[
{ value: "all", label: "全部状态" },
{ value: "wip", label: "进行中" },
{ value: "ok", label: "已完成" },
{ value: "fail", label: "失败 · 待重跑" },
]}
/>
{projFiltered && (
<button className="filter-reset" type="button" onClick={() => setProjStatus("all")}></button>
)}
@@ -578,12 +597,18 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<div className={`tab-panel ${tab === "by-member" ? "active" : ""}`}>
<div className="filter-bar">
<select value={memRole} onChange={(e) => setMemRole(e.target.value as typeof memRole)} aria-label="按角色筛选">
<option value="all"></option>
<option value="owner"></option>
<option value="admin"></option>
<option value="member"></option>
</select>
<CustomSelect
size="sm"
aria-label="按角色筛选"
value={memRole}
onChange={(next) => setMemRole(next as typeof memRole)}
options={[
{ value: "all", label: "全部角色" },
{ value: "owner", label: "超管" },
{ value: "admin", label: "团管" },
{ value: "member", label: "成员" },
]}
/>
{memFiltered && (
<button className="filter-reset" type="button" onClick={() => setMemRole("all")}></button>
)}
@@ -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 }) {
<p className="admin-modal-desc"> / ();</p>
<div className="field">
<label className="field-label"> <span className="req">*</span></label>
<select className="select" value={form.team} onChange={(e) => setForm((f) => ({ ...f, team: e.target.value }))}>
<option value=""></option>
{teams.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<CustomSelect
fill
value={form.team}
placeholder="选择团队…"
onChange={(next) => setForm((f) => ({ ...f, team: next }))}
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
</div>
<div className="field">
<label className="field-label"> <span className="field-hint">=,=</span></label>
@@ -372,10 +376,14 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
<p className="admin-modal-desc"> = ()</p>
<div className="field">
<label className="field-label"> <span className="req">*</span></label>
<select className="select" value={editing.team} disabled={Boolean(editing.id)} onChange={(e) => setEditing((p) => ({ ...p, team: e.target.value }))}>
<option value=""></option>
{teams.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<CustomSelect
fill
value={editing.team}
disabled={Boolean(editing.id)}
placeholder="选择团队…"
onChange={(next) => setEditing((p) => ({ ...p, team: next }))}
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
</div>
<div className="field-row">
<div className="field">
@@ -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 }) {
<div className="modal-b">
<div className="field">
<label className="field-label"> <span className="req">*</span></label>
<select className="select" value={modelModal.provider} disabled={Boolean(modelModal.id)} onChange={(e) => setModelModal((m) => m && ({ ...m, provider: e.target.value }))}>
<option value=""></option>
{providers.map((p) => <option key={p.id} value={p.id}>{p.display_name}</option>)}
</select>
<CustomSelect
fill
value={modelModal.provider}
disabled={Boolean(modelModal.id)}
placeholder="选择供应商…"
onChange={(next) => setModelModal((m) => m && ({ ...m, provider: next }))}
options={providers.map((p) => ({ value: p.id, label: p.display_name }))}
/>
</div>
<div className="field-row">
<div className="field">
@@ -241,9 +246,13 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
</div>
<div className="field">
<label className="field-label"></label>
<select className="select" value={modelModal.capability} disabled={Boolean(modelModal.id)} onChange={(e) => setModelModal((m) => m && ({ ...m, capability: e.target.value }))}>
{CAPABILITIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<CustomSelect
fill
value={modelModal.capability}
disabled={Boolean(modelModal.id)}
onChange={(next) => setModelModal((m) => m && ({ ...m, capability: next }))}
options={CAPABILITIES.map((c) => ({ value: c, label: c }))}
/>
</div>
</div>
<div className="field">
+61 -33
View File
@@ -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 (
<div className="as-spec-fields" aria-label="成片规格">
<label className="as-spec-field">
<select className="select" value={outputAspect} onChange={(event) => changeOutputSpec({ aspect_ratio: event.target.value })}>
{OUTPUT_RATIOS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<CustomSelect fill size="sm" value={outputAspect} onChange={(next) => changeOutputSpec({ aspect_ratio: next })} options={OUTPUT_RATIOS} />
</label>
<label className="as-spec-field">
<select className="select" value={outputResolution} onChange={(event) => changeOutputSpec({ resolution: event.target.value })}>
{OUTPUT_RESOLUTIONS.map((option) => (
<option key={option.value} value={option.value} disabled={Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value))}>
{option.label}
</option>
))}
</select>
<CustomSelect
fill
size="sm"
value={outputResolution}
onChange={(next) => changeOutputSpec({ resolution: next })}
options={OUTPUT_RESOLUTIONS.map((option) => ({
...option,
disabled: Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value)),
}))}
/>
</label>
<label className="as-spec-field">
<select className="select" value={outputModelId} onChange={(event) => changeOutputSpec({ video_model_config_id: event.target.value })} disabled={!videoConfigs.length}>
{videoConfigs.length ? videoConfigs.map((config) => (
<option key={config.id} value={config.id}>{FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name)}</option>
)) : <option value=""></option>}
</select>
<CustomSelect
fill
size="sm"
value={outputModelId}
disabled={!videoConfigs.length}
placeholder="暂无模型"
onChange={(next) => changeOutputSpec({ video_model_config_id: next })}
options={videoConfigs.map((config) => ({
value: config.id,
label: FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name),
}))}
/>
</label>
</div>
);
@@ -3222,27 +3231,42 @@ export function PipelinePage(props: {
沿,{setupProduct?.title || "当前商品"}
</div>
)}
<label className="setup-field">
<div className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupStructure} onChange={(e) => { setupTouched.current = true; setSetupStructure(e.target.value as VideoStructure); }}>
{structureOptions.map((k) => <option key={k} value={k}>{VIDEO_STRUCTURES[k]}</option>)}
</select>
</label>
<CustomSelect
fill
size="sm"
className="setup-select"
value={setupStructure}
onChange={(next) => { setupTouched.current = true; setSetupStructure(next as VideoStructure); }}
options={structureOptions.map((k) => ({ value: k, label: VIDEO_STRUCTURES[k] }))}
/>
</div>
<div className="setup-rec">{STRUCTURE_HINT[setupStructure]}</div>
<label className="setup-field">
<div className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupPersona} onChange={(e) => { setupTouched.current = true; setSetupPersona(e.target.value); }}>
{SETUP_PERSONA_KEYS.map((k) => <option key={k} value={k}>{WIZ_PERSONA_LABEL[k]}</option>)}
</select>
</label>
<CustomSelect
fill
size="sm"
className="setup-select"
value={setupPersona}
onChange={(next) => { setupTouched.current = true; setSetupPersona(next); }}
options={SETUP_PERSONA_KEYS.map((k) => ({ value: k, label: WIZ_PERSONA_LABEL[k] }))}
/>
</div>
<div className="setup-rec">{recommended.reason} · {WIZ_PERSONA_LABEL[recommended.persona] || recommended.persona}</div>
{/* 时长:15/30/45/60;每镜固定 15 秒 */}
<label className="setup-field">
<div className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupDuration} onChange={(e) => { setupTouched.current = true; setSetupDuration(clampDuration(Number(e.target.value))); }}>
{DURATION_OPTIONS.map((s) => <option key={s} value={s}>{s} </option>)}
</select>
</label>
<CustomSelect
fill
size="sm"
className="setup-select"
value={String(setupDuration)}
onChange={(next) => { setupTouched.current = true; setSetupDuration(clampDuration(Number(next))); }}
options={DURATION_OPTIONS.map((s) => ({ value: String(s), label: `${s}` }))}
/>
</div>
<div className={`setup-rec${durationHint ? " warn" : ""}`}>{durationHint || `口播 × ${VIDEO_STRUCTURES[setupStructure]}建议 ${durationSuggest} 秒左右`}</div>
<div className="setup-foot">
{/* ← 返回:收起设定卡,回到「脚本辅助生成 / 上传脚本」入口页 */}
@@ -4101,9 +4125,13 @@ export function PipelinePage(props: {
{voStale && <div style={{ fontSize: "12px", color: "#B45309", marginBottom: 6 }}>,,</div>}
<div className="props-row" style={{ marginBottom: 6 }}>
<span className="k"></span>
<select value={voVoicePick || voInfo?.voice_type || VO_VOICES[0].key} onChange={(e) => setVoVoicePick(e.target.value)} style={{ flex: 1, fontSize: "12px", padding: "4px 6px", border: "1px solid var(--border-faint)", borderRadius: "6px", background: "var(--surface)", color: "var(--accent-black)" }}>
{VO_VOICES.map((v) => <option key={v.key} value={v.key}>{v.label}</option>)}
</select>
<CustomSelect
fill
size="sm"
value={voVoicePick || voInfo?.voice_type || VO_VOICES[0].key}
onChange={setVoVoicePick}
options={VO_VOICES.map((v) => ({ value: v.key, label: v.label }))}
/>
</div>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-sm" type="button" disabled={loading || !edState.clips.some((c) => c.subtitle.trim())} onClick={() => onGenerateVoiceover({ items: edState.clips.map((c, idx) => ({ index: idx, text: c.subtitle.trim() })).filter((item) => item.text), voice_type: voVoicePick || voInfo?.voice_type || VO_VOICES[0].key })}>{voInfo ? "重新生成配音" : `生成配音 · ${pts(10)} 积分/500字`}</button>
+29 -19
View File
@@ -6,6 +6,7 @@ import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlay
import { useFileDrop } from "../components/use-file-drop";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { ProductCreateDrawer } from "../components/product-create-drawer";
import { CustomSelect } from "../components/custom-select";
import {
BUSINESS_TYPES,
BUSINESS_TYPE_KEYS,
@@ -527,15 +528,16 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)} required>
<option value=""> </option>
<optgroup label="常用品类">
{PC_CAT_PRIMARY.map((option) => <option key={option}>{option}</option>)}
</optgroup>
<optgroup label="更多品类">
{PC_CAT_MORE.map((option) => <option key={option}>{option}</option>)}
</optgroup>
</select>
<CustomSelect
fill
value={category}
onChange={setCategory}
placeholder="— 选择品类 —"
options={[
...PC_CAT_PRIMARY.map((option) => ({ value: option, label: option, group: "常用品类" })),
...PC_CAT_MORE.map((option) => ({ value: option, label: option, group: "更多品类" })),
]}
/>
</div>
<div className="field field-last">
<label className="field-label">()</label>
@@ -1069,23 +1071,31 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<div className="k"></div>
<div className="v">
<span className="v-static">{BUSINESS_TYPES[realType]}</span>
<select className="v-edit v-select" value={bizType} onChange={(event) => {
const next = event.target.value as BusinessType;
setBizType(next);
const opts = pdCatChoices(next, "");
<CustomSelect
fill
className="v-edit"
value={bizType}
onChange={(next) => {
const typed = next as BusinessType;
setBizType(typed);
const opts = pdCatChoices(typed, "");
if (!opts.includes(cat)) setCat(opts[0]);
}}>
{BUSINESS_TYPE_KEYS.map((key) => <option key={key} value={key}>{BUSINESS_TYPES[key]}</option>)}
</select>
}}
options={BUSINESS_TYPE_KEYS.map((key) => ({ value: key, label: BUSINESS_TYPES[key] }))}
/>
</div>
</div>
<div className="row" data-field="cat">
<div className="k"></div>
<div className="v">
<span className="v-static">{realCat}</span>
<select className="v-edit v-select" value={cat} onChange={(event) => setCat(event.target.value)}>
{pdCatChoices(bizType, cat).map((option) => <option key={option}>{option}</option>)}
</select>
<CustomSelect
fill
className="v-edit"
value={cat}
onChange={setCat}
options={pdCatChoices(bizType, cat).map((option) => ({ value: option, label: option }))}
/>
</div>
</div>
<div className="row" data-field="bullets">
+14 -6
View File
@@ -5,6 +5,7 @@ import type { Asset, Product, Project, ScriptTemplate } from "../types";
import { api } from "../api";
import type { Page } from "./route-config";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
import { ProductCreateDrawer, type ProductCreatePayload } from "../components/product-create-drawer";
import { isLocalLife } from "../product-business";
import { Pager } from "../components/pager";
@@ -325,12 +326,19 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
{templates.length > 0 && (
<label className="nw-field">
<span><small></small></span>
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
<option value=""> · AI </option>
{templates.map((item) => (
<option key={item.id} value={item.id}>{item.name} · {item.total_duration}s{item.usage_count ? ` · 用过 ${item.usage_count}` : ""}</option>
))}
</select>
<CustomSelect
fill
value={templateId}
onChange={setTemplateId}
placeholder="不套用 · 让 AI 按这个商品从头设计"
options={[
{ value: "", label: "不套用 · 让 AI 按这个商品从头设计" },
...templates.map((item) => ({
value: item.id,
label: `${item.name} · ${item.total_duration}s${item.usage_count ? ` · 用过 ${item.usage_count}` : ""}`,
})),
]}
/>
{template && (
<div className="nw-tpl">
<strong></strong>
+30 -18
View File
@@ -20,6 +20,7 @@ import {
} from "lucide-react";
import { api, ApiError, type QuickCreateJob } from "../api";
import { ConfirmModal, OverlayPortal, useBodyScrollLock } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
import { useFileDrop } from "../components/use-file-drop";
import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob, stripQuickCreateSuffix } from "../quick-create-lock";
import type { ModelConfig } from "../types";
@@ -678,33 +679,44 @@ export function QuickCreatePage({
<div className="quick-parameter-grid" aria-label="视频核心参数">
<label className="quick-parameter-field">
<span></span>
<select value={aspectRatio} onChange={(event) => setAspectRatio(event.target.value)} disabled={isGenerating}>
{QUICK_RATIOS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<CustomSelect fill value={aspectRatio} onChange={setAspectRatio} disabled={isGenerating} options={QUICK_RATIOS} />
</label>
<label className="quick-parameter-field">
<span></span>
<select value={resolution} onChange={(event) => setResolution(event.target.value)} disabled={isGenerating}>
{QUICK_RESOLUTIONS.map((option) => (
<option key={option.value} value={option.value} disabled={Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value))}>
{option.label}
</option>
))}
</select>
<CustomSelect
fill
value={resolution}
onChange={setResolution}
disabled={isGenerating}
options={QUICK_RESOLUTIONS.map((option) => ({
...option,
disabled: Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value)),
}))}
/>
</label>
<label className="quick-parameter-field">
<span></span>
<select value={totalDuration} onChange={(event) => setTotalDuration(Number(event.target.value))} disabled={isGenerating}>
{QUICK_DURATIONS.map((duration) => <option key={duration} value={duration}>{duration / 15} {duration}s</option>)}
</select>
<CustomSelect
fill
value={String(totalDuration)}
onChange={(next) => setTotalDuration(Number(next))}
disabled={isGenerating}
options={QUICK_DURATIONS.map((duration) => ({ value: String(duration), label: `${duration / 15} 场(${duration}s` }))}
/>
</label>
<label className="quick-parameter-field">
<span></span>
<select value={videoModelId} onChange={(event) => setVideoModelId(event.target.value)} disabled={isGenerating || !videoConfigs.length}>
{videoConfigs.length ? videoConfigs.map((config) => (
<option key={config.id} value={config.id}>{FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name)}</option>
)) : <option value=""></option>}
</select>
<CustomSelect
fill
value={videoModelId}
onChange={setVideoModelId}
disabled={isGenerating || !videoConfigs.length}
placeholder="暂无可用视频模型"
options={videoConfigs.map((config) => ({
value: config.id,
label: FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name),
}))}
/>
</label>
</div>
+27 -26
View File
@@ -10,6 +10,7 @@ import {
} from "lucide-react";
import type { LoginSession, Team, User, UserPreference } from "../types";
import { TeamModal } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
import { useFileDrop } from "../components/use-file-drop";
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
@@ -620,23 +621,23 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"> BGM </div>
<div className="val">
<select className="select" value={draft.bgm} onChange={(event) => patchDraft("bgm", event.target.value)}>
<option value="kapian"> Top10 </option>
<option value="emotion"> · /</option>
<option value="urban"> · </option>
<option value="none"> BGM</option>
</select>
<CustomSelect fill value={draft.bgm} onChange={(next) => patchDraft("bgm", next)} options={[
{ value: "kapian", label: "抖音 Top10 卡点曲库" },
{ value: "emotion", label: "情绪向 · 治愈/悬念" },
{ value: "urban", label: "都市电子 · 通勤场景" },
{ value: "none", label: "无 BGM" },
]} />
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" value={draft.transition} onChange={(event) => patchDraft("transition", event.target.value)}>
<option value="none"></option>
<option value="fade"> · 0.3s</option>
<option value="slide"> · 0.3s</option>
<option value="zoom"> · 0.3s</option>
</select>
<CustomSelect fill value={draft.transition} onChange={(next) => patchDraft("transition", next)} options={[
{ value: "none", label: "无转场" },
{ value: "fade", label: "淡入淡出 · 0.3s" },
{ value: "slide", label: "滑动 · 0.3s" },
{ value: "zoom", label: "缩放 · 0.3s" },
]} />
</div>
</div>
<div className="form-row">
@@ -658,30 +659,30 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" value={draft.appearance} onChange={(event) => patchDraft("appearance", event.target.value)}>
<option value="system"></option>
<option value="light"></option>
<option value="dark" disabled>(V2)</option>
</select>
<CustomSelect fill value={draft.appearance} onChange={(next) => patchDraft("appearance", next)} options={[
{ value: "system", label: "跟随系统" },
{ value: "light", label: "浅色" },
{ value: "dark", label: "深色(V2)", disabled: true },
]} />
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" value={draft.language} onChange={(event) => patchDraft("language", event.target.value)}>
<option value="zh"></option>
<option value="en" disabled>English(V2)</option>
</select>
<CustomSelect fill value={draft.language} onChange={(next) => patchDraft("language", next)} options={[
{ value: "zh", label: "简体中文" },
{ value: "en", label: "English(V2)", disabled: true },
]} />
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" value={draft.density} onChange={(event) => patchDraft("density", event.target.value)}>
<option value="compact"></option>
<option value="standard"></option>
<option value="loose"></option>
</select>
<CustomSelect fill value={draft.density} onChange={(next) => patchDraft("density", next)} options={[
{ value: "compact", label: "紧凑" },
{ value: "standard", label: "标准" },
{ value: "loose", label: "宽松" },
]} />
</div>
</div>
</section>
+10 -4
View File
@@ -5,6 +5,7 @@ import type { BillingSummary, BillingTrend, Invitation, Notification, Team, Team
import type { Page } from "./route-config";
import { money } from "./stage-config";
import { ConfirmModal, TeamModal } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
import { Pager } from "../components/pager";
const MEMBERS_PER_PAGE = 10;
@@ -608,10 +609,15 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
<>
<div className="field">
<label className="field-label"></label>
<select className="input" value={invRole} onChange={(e) => setInvRole(e.target.value)}>
<option value="member"> · ,</option>
<option value="admin"> · //</option>
</select>
<CustomSelect
fill
value={invRole}
onChange={setInvRole}
options={[
{ value: "member", label: "成员 · 可用生成,不可管理团队" },
{ value: "admin", label: "团队管理员 · 可管成员/额度/邀请" },
]}
/>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">()<span className="lbl-note">(-1 )</span></label>
+1 -1
View File
@@ -267,7 +267,7 @@
.form-row .lbl .req { color: var(--accent-crimson); margin-left: 2px; }
.form-row .lbl-sub { font-size: 12px; color: var(--black-alpha-48); margin-top: 2px; }
.form-row .val { display: flex; align-items: center; gap: 10px; min-width: 0; }
.form-row .val .input, .form-row .val .select { width: 100%; max-width: 380px; }
.form-row .val .input, .form-row .val .select, .form-row .val .rs-select { width: 100%; max-width: 380px; }
.form-row .val .static { font-size: 13px; color: var(--accent-black); font-variant-numeric: tabular-nums; }
.form-row .val .static.mono { font-family: var(--font-mono); font-size: 13px; color: var(--black-alpha-56); }
.form-row .val .role-tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 12px; font-weight: 500; background: var(--heat-12); color: var(--heat); }