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

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): 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( 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.poll_free_video_task.apply_async").start()
patch("apps.ai.tasks.run_video_replace_digest_task.delay").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.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.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.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") self.image = _asset(self.team, self.user, name="精华.png", preview="http://tos/product.png")
@@ -209,8 +237,8 @@ class SubmitVideoReplaceTests(TestCase):
prompt = payload["prompt"] prompt = payload["prompt"]
self.assertIn("【镜头 01】", prompt) self.assertIn("【镜头 01】", prompt)
self.assertIn("【镜头 02】", 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) self.assertIn("@目标商品", prompt)
self.assertIn("净颜精华", prompt) self.assertIn("净颜精华", prompt)
@@ -449,6 +477,23 @@ class SubmitVideoReplaceTests(TestCase):
task.refresh_from_db() task.refresh_from_db()
return task 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): def test_product_triview_is_sent_with_the_video(self):
"""商品库选商品时,三视图要跟着一起发给火山,并在提示词里被点名。""" """商品库选商品时,三视图要跟着一起发给火山,并在提示词里被点名。"""
tri = self._make_triview() tri = self._make_triview()
@@ -617,8 +662,8 @@ class SubmitVideoReplaceTests(TestCase):
self.assertFalse(task.request_payload.get("shot_plan")) self.assertFalse(task.request_payload.get("shot_plan"))
prompt = task.request_payload["prompt"] prompt = task.request_payload["prompt"]
self.assertIn("【镜头 01】", prompt) self.assertIn("【镜头 01】", prompt)
self.assertIn("第四镜收束", prompt) self.assertIn("【镜头 02】", prompt)
self.assertIn("省略次要镜头", prompt) self.assertNotIn("第一镜开场", prompt)
self.assertEqual(self.provider.create_video_task.call_count, 1) self.assertEqual(self.provider.create_video_task.call_count, 1)
self.assertEqual(self.provider.create_video_task.call_args.kwargs.get("duration"), 10) 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 from __future__ import annotations
import json
import logging import logging
import re import re
import uuid import uuid
from decimal import Decimal from decimal import Decimal
from pathlib import Path
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
@@ -80,9 +82,8 @@ def get_inflight_video_replace(team):
) )
PRODUCT_PROMPT = ( PRODUCT_PROMPT = (
"按下面这份分镜稿生成视频。镜头数量、每镜时长、景别、机位、运镜、人物、场景、口播全部按稿执行," "按下面的商品语义重构分镜生成视频。@目标商品只用于锁定外观、材质与包装。"
"不要增删镜头、不要改顺序" "严格遵守每镜的新商品动作、场景与口播;不要恢复参考片原商品、原动作、原卖点或原台词"
"把稿里的原商品换成@目标商品,外观、材质、包装以参考图为准。"
) )
CHARACTER_PROMPT = ( CHARACTER_PROMPT = (
"按下面这份分镜稿生成视频。镜头数量、每镜时长、景别、机位、运镜、商品、场景、口播全部按稿执行," "按下面这份分镜稿生成视频。镜头数量、每镜时长、景别、机位、运镜、商品、场景、口播全部按稿执行,"
@@ -344,6 +345,99 @@ def _digest_for_seedance(digest_text: str) -> str:
return (head if sep else body).strip() 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( def build_character_replace_prompt(
digest_text: str, digest_text: str,
*, *,
@@ -497,11 +591,18 @@ def submit_video_replace(*, team, user, params: dict):
if has_product: if has_product:
subject_name, image_refs, subject_source = _product_library_refs(team, product_id) 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: elif has_model:
subject_name, image_refs, subject_source = _character_library_refs(team, model_id) subject_name, image_refs, subject_source = _character_library_refs(team, model_id)
product_facts = {}
else: else:
noun = "商品" if replace_mode == "product" else "角色" noun = "商品" if replace_mode == "product" else "角色"
subject_name, image_refs, subject_source = _temporary_image_refs(team, image_ids, noun=noun) 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) duration = _output_duration(params.get("duration"), video_seconds)
extra = { extra = {
@@ -510,6 +611,7 @@ def submit_video_replace(*, team, user, params: dict):
"subject_source": subject_source, "subject_source": subject_source,
"product_id": str(product_id) if product_id else "", "product_id": str(product_id) if product_id else "",
"model_id": str(model_id) if model_id else "", "model_id": str(model_id) if model_id else "",
"product_facts": product_facts,
} }
base_params = { base_params = {
"mode": "universal", "mode": "universal",
@@ -643,10 +745,15 @@ def run_replace_digest(task) -> None:
digest, source_seconds=source_seconds, output_seconds=output_seconds, digest, source_seconds=source_seconds, output_seconds=output_seconds,
) )
else: else:
prompt = build_product_replace_prompt( prompt = rewrite_product_semantic_remix(
digest, subject, has_triview=has_triview, task=task,
source_seconds=source_seconds, output_seconds=output_seconds, 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: except (VideoDigestError, ValueError) as exc:
_fail_reviewing_task(task, str(exc), error_code="processing_failed") _fail_reviewing_task(task, str(exc), error_code="processing_failed")
return return
@@ -663,6 +770,7 @@ def run_replace_digest(task) -> None:
next_payload.update(meta) next_payload.update(meta)
next_payload["digest_pending"] = False next_payload["digest_pending"] = False
next_payload["digest_text"] = digest[:32000] next_payload["digest_text"] = digest[:32000]
next_payload["semantic_remix"] = replace_mode == "product"
next_payload["prompt"] = prompt next_payload["prompt"] = prompt
next_payload["references"] = _image_refs(next_payload.get("references") or []) next_payload["references"] = _image_refs(next_payload.get("references") or [])
next_payload.pop("shot_plan", None) 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]: def _product_library_refs(team, product_id: uuid.UUID) -> tuple[str, list, str]:
product = ( product = (
Product.objects.filter(id=product_id, team=team, purged_at__isnull=True, status=Product.Status.ACTIVE) 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 { 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 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:hover, .filter-bar input:hover { border-color: rgba(28, 34, 43, 0.18); }
.filter-bar select { padding-right: 24px; } .filter-bar select { padding-right: 24px; }
.filter-bar .spacer { flex: 1; } .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:hover { background: var(--black-alpha-4); }
.chip-menu .mi.selected { color: var(--heat); background: var(--heat-12); } .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 .mi-check { display: none; }
.chip-menu .mi.selected .mi-check { visibility: visible; }
.chip-menu .mi-sep { height: 1px; background: var(--border-faint); margin: 4px 6px; } .chip-menu .mi-sep { height: 1px; background: var(--border-faint); margin: 4px 6px; }
/* ─── Dropdown unification · 下拉框 / 菜单视觉收口 ─── */ /* ─── Dropdown unification · 下拉框 / 菜单视觉收口 ─── */
@@ -1454,12 +1453,31 @@ select.duration-select:focus,
color: var(--heat) !important; 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; background: var(--surface) !important;
border: 1px solid var(--border-faint) !important; border: 1px solid rgba(34, 42, 54, 0.10) !important;
border-radius: var(--r-md) !important; border-radius: 10px !important;
box-shadow: var(--shadow-floating) !important; box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12) !important;
padding: 4px !important; padding: 6px !important;
} }
:where( :where(
.chip-menu .mi, .chip-menu .mi,
@@ -1467,16 +1485,25 @@ select.duration-select:focus,
.pp-menu .mi, .pp-menu .mi,
.tb-menu-item, .tb-menu-item,
.io-param-menu .mi, .io-param-menu .mi,
.ic-param-menu .mi,
.move-menu .mv-item, .move-menu .mv-item,
.cell-more-menu button, .cell-more-menu button,
.msg-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; min-height: 36px !important;
padding: 0 10px !important; padding: 7px 10px !important;
background: transparent !important; background: transparent !important;
border: 0 !important; border: 0 !important;
border-radius: var(--r-sm) !important; border-radius: 8px !important;
color: var(--accent-black) !important; color: var(--accent-black) !important;
display: flex !important; display: flex !important;
align-items: center !important; align-items: center !important;
@@ -1494,10 +1521,19 @@ select.duration-select:focus,
.pp-menu .mi, .pp-menu .mi,
.tb-menu-item, .tb-menu-item,
.io-param-menu .mi, .io-param-menu .mi,
.ic-param-menu .mi,
.move-menu .mv-item, .move-menu .mv-item,
.cell-more-menu button, .cell-more-menu button,
.msg-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 { ):hover {
background: var(--black-alpha-4) !important; background: var(--black-alpha-4) !important;
color: var(--accent-black) !important; color: var(--accent-black) !important;
@@ -1507,11 +1543,20 @@ select.duration-select:focus,
.filter-pop button.selected, .filter-pop button.selected,
.pp-menu .mi.selected, .pp-menu .mi.selected,
.tb-menu-item.active, .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; 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) { :where(.cell-more-menu button.danger:hover, .msg-more-menu button.danger:hover, .batch-more-menu button.danger:hover) {
background: var(--crimson-bg) !important; background: var(--crimson-bg) !important;
@@ -1523,6 +1568,21 @@ select.duration-select:focus,
font-size: 12px !important; font-size: 12px !important;
letter-spacing: .02em !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 { .rs-select {
position: relative; position: relative;
@@ -1532,14 +1592,6 @@ select.duration-select:focus,
} }
.rs-select.rs-select-fill { width: 100%; } .rs-select.rs-select-fill { width: 100%; }
.rs-select.rs-select-filter { min-width: 126px; } .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 { .rs-select-btn {
width: 100%; width: 100%;
height: 36px; height: 36px;
@@ -1557,6 +1609,7 @@ select.duration-select:focus,
cursor: pointer; cursor: pointer;
transition: background var(--t-base), border-color var(--t-base), box-shadow var(--t-base), color var(--t-base); 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 { .rs-select-btn:hover {
background: var(--black-alpha-4); background: var(--black-alpha-4);
border-color: var(--black-alpha-24); border-color: var(--black-alpha-24);
@@ -1564,7 +1617,7 @@ select.duration-select:focus,
.rs-select.open .rs-select-btn, .rs-select.open .rs-select-btn,
.rs-select-btn:focus-visible { .rs-select-btn:focus-visible {
border-color: var(--heat-40); 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] { .rs-select-btn[disabled] {
background: var(--black-alpha-5); background: var(--black-alpha-5);
@@ -1577,6 +1630,7 @@ select.duration-select:focus,
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.rs-select-label.is-placeholder { color: var(--black-alpha-40); }
.rs-select-btn svg { .rs-select-btn svg {
width: 12px; width: 12px;
height: 12px; height: 12px;
@@ -1590,65 +1644,91 @@ select.duration-select:focus,
} }
.rs-select-menu { .rs-select-menu {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 6px);
left: 0; left: 0;
min-width: 100%; min-width: 100%;
width: max-content; width: max-content;
max-width: min(260px, calc(100vw - 24px)); max-width: min(280px, calc(100vw - 24px));
max-height: 320px; max-height: 320px;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
display: none; display: none;
z-index: 1600; z-index: 1600;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border-faint); border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: var(--r-md); border-radius: 10px;
box-shadow: var(--shadow-floating); box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
padding: 4px; padding: 6px;
} }
.rs-select-menu.align-right { .rs-select-menu.align-right {
left: auto; left: auto;
right: 0; 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 { .rs-select-option {
width: 100%; width: 100%;
min-height: 32px; min-height: 38px;
padding: 0 10px; padding: 7px 10px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
background: transparent; background: transparent;
border: 0; border: 0;
border-radius: var(--r-sm); border-radius: 8px;
color: var(--accent-black); color: var(--accent-black);
font-family: inherit; font-family: inherit;
font-size: 13px; font-size: 13px;
text-align: left; text-align: left;
white-space: nowrap;
cursor: pointer; cursor: pointer;
transition: background var(--t-base), color var(--t-base); transition: background var(--t-base), color var(--t-base);
} }
.rs-select-option:hover, .rs-select-option-text {
.rs-select-option.is-active { min-width: 0;
background: var(--black-alpha-4); display: flex;
flex-direction: column;
gap: 2px;
} }
.rs-select-option.selected { .rs-select-option-label {
background: var(--heat-12); overflow: hidden;
color: var(--heat); text-overflow: ellipsis;
font-weight: 500; 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] { .rs-select-option[disabled] {
color: var(--black-alpha-32); color: var(--black-alpha-32);
cursor: not-allowed; cursor: not-allowed;
} }
.rs-select-option .mi-check { .rs-select-option[disabled]:hover { background: transparent; }
width: 13px; .rs-select-menu::-webkit-scrollbar { width: 6px; }
height: 13px; .rs-select-menu::-webkit-scrollbar-thumb {
color: var(--heat); border-radius: 999px;
visibility: hidden; background: rgba(34, 42, 54, 0.18);
flex-shrink: 0;
} }
.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 btn · 共享组件 ─── */
.clear-filters { .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-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 { 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-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 { width: 100%; }
.setup-card .setup-select:focus { border-color: var(--heat); box-shadow: 0 0 0 3px var(--heat-12); } .as-spec-field .rs-select { width: 100%; }
.props-row .rs-select { flex: 1; min-width: 0; }
/* 说明行左缩进 = 标签列 64 + gap 10,跟下拉框左边缘对齐 */ /* 说明行左缩进 = 标签列 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 { 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); } .setup-card .setup-rec.warn { color: var(--accent-honey); }
@@ -249,6 +249,7 @@
.v-edit { display: none; } .v-edit { display: none; }
.ov-card.editing .v-static { display: none; } .ov-card.editing .v-static { display: none; }
.ov-card.editing .v-edit { display: block; } .ov-card.editing .v-edit { display: block; }
.ov-card.editing .v-edit.rs-select { display: inline-flex; width: 100%; }
/* 输入控件 · 对齐新建表单 V2.1 规范 */ /* 输入控件 · 对齐新建表单 V2.1 规范 */
.v-input, .v-input,
@@ -506,6 +506,7 @@
font-weight: 500; font-weight: 500;
} }
.project-wizard-page .nw-field small { color: var(--nw-muted); font-size: 11px; font-weight: 400; } .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 input,
.project-wizard-page .nw-field select { .project-wizard-page .nw-field select {
height: 44px; 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-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 { 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 > 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 .rs-select { width: 100%; }
.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 .rs-select-btn { height: 38px; font-size: 12px; }
.quick-create-page .quick-parameter-field select:disabled { cursor: not-allowed; opacity: .65; }
.quick-create-page .quick-form-footer { display: flex; margin-top: 26px; } .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-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; } .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 { money, pts, stageMeta, yuan } from "./stage-config";
import { pageWindow } from "../components/pager"; import { pageWindow } from "../components/pager";
import { useBodyScrollLock, useOverlayTransition } from "../components/overlays"; import { useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
const ROLE_LABEL: Record<string, string> = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" }; const ROLE_LABEL: Record<string, string> = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" };
const STATUS_LABEL: Record<string, string> = { active: "活跃", invited: "待激活", disabled: "已停用" }; 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={`tab-panel ${tab === "bills" ? "active" : ""}`}>
<div className="filter-bar"> <div className="filter-bar">
<select value={billType} onChange={(e) => { setBillType(e.target.value); setBillPage(1); }} aria-label="按类型筛选"> <CustomSelect
<option value="all"></option> size="sm"
<option value="charge"></option> aria-label="按类型筛选"
<option value="recharge"></option> value={billType}
<option value="reserve"></option> onChange={(next) => { setBillType(next); setBillPage(1); }}
<option value="release"></option> options={[
<option value="adjustment"></option> { value: "all", label: "全部类型" },
<option value="refund">退</option> { value: "charge", label: "扣费" },
</select> { value: "recharge", label: "充值" },
<select value={billMember} onChange={(e) => { setBillMember(e.target.value); setBillPage(1); }} aria-label="按成员筛选"> { value: "reserve", label: "预扣" },
<option value="all"></option> { value: "release", label: "释放" },
{billMemberOptions.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)} { value: "adjustment", label: "调整" },
</select> { 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 && ( {billFiltered && (
<button className="filter-reset" type="button" onClick={() => { setBillType("all"); setBillMember("all"); setBillPage(1); }}></button> <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={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
<div className="filter-bar"> <div className="filter-bar">
<select value={projStatus} onChange={(e) => setProjStatus(e.target.value as typeof projStatus)} aria-label="按状态筛选"> <CustomSelect
<option value="all"></option> size="sm"
<option value="wip"></option> aria-label="按状态筛选"
<option value="ok"></option> value={projStatus}
<option value="fail"> · </option> onChange={(next) => setProjStatus(next as typeof projStatus)}
</select> options={[
{ value: "all", label: "全部状态" },
{ value: "wip", label: "进行中" },
{ value: "ok", label: "已完成" },
{ value: "fail", label: "失败 · 待重跑" },
]}
/>
{projFiltered && ( {projFiltered && (
<button className="filter-reset" type="button" onClick={() => setProjStatus("all")}></button> <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={`tab-panel ${tab === "by-member" ? "active" : ""}`}>
<div className="filter-bar"> <div className="filter-bar">
<select value={memRole} onChange={(e) => setMemRole(e.target.value as typeof memRole)} aria-label="按角色筛选"> <CustomSelect
<option value="all"></option> size="sm"
<option value="owner"></option> aria-label="按角色筛选"
<option value="admin"></option> value={memRole}
<option value="member"></option> onChange={(next) => setMemRole(next as typeof memRole)}
</select> options={[
{ value: "all", label: "全部角色" },
{ value: "owner", label: "超管" },
{ value: "admin", label: "团管" },
{ value: "member", label: "成员" },
]}
/>
{memFiltered && ( {memFiltered && (
<button className="filter-reset" type="button" onClick={() => setMemRole("all")}></button> <button className="filter-reset" type="button" onClick={() => setMemRole("all")}></button>
)} )}
@@ -4,6 +4,7 @@ import { adminApi } from "../../api";
import { Pager } from "../../components/pager"; import { Pager } from "../../components/pager";
import { IconKitSvg } from "../../components/IconKitSvg"; import { IconKitSvg } from "../../components/IconKitSvg";
import { SystemLoading } from "../../components/loading"; import { SystemLoading } from "../../components/loading";
import { CustomSelect } from "../../components/custom-select";
import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types"; import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types";
import { pts } from "../stage-config"; import { pts } from "../stage-config";
@@ -214,10 +215,13 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
<p className="admin-modal-desc"> / ();</p> <p className="admin-modal-desc"> / ();</p>
<div className="field"> <div className="field">
<label className="field-label"> <span className="req">*</span></label> <label className="field-label"> <span className="req">*</span></label>
<select className="select" value={form.team} onChange={(e) => setForm((f) => ({ ...f, team: e.target.value }))}> <CustomSelect
<option value=""></option> fill
{teams.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)} value={form.team}
</select> placeholder="选择团队…"
onChange={(next) => setForm((f) => ({ ...f, team: next }))}
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
</div> </div>
<div className="field"> <div className="field">
<label className="field-label"> <span className="field-hint">=,=</span></label> <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> <p className="admin-modal-desc"> = ()</p>
<div className="field"> <div className="field">
<label className="field-label"> <span className="req">*</span></label> <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 }))}> <CustomSelect
<option value=""></option> fill
{teams.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)} value={editing.team}
</select> disabled={Boolean(editing.id)}
placeholder="选择团队…"
onChange={(next) => setEditing((p) => ({ ...p, team: next }))}
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
</div> </div>
<div className="field-row"> <div className="field-row">
<div className="field"> <div className="field">
@@ -3,6 +3,7 @@ import { Server, X } from "lucide-react";
import { adminApi } from "../../api"; import { adminApi } from "../../api";
import { Pager } from "../../components/pager"; import { Pager } from "../../components/pager";
import { SystemLoading } from "../../components/loading"; import { SystemLoading } from "../../components/loading";
import { CustomSelect } from "../../components/custom-select";
import type { AdminModel, AdminProvider } from "../../types"; import type { AdminModel, AdminProvider } from "../../types";
import { pts } from "../stage-config"; import { pts } from "../stage-config";
@@ -229,10 +230,14 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
<div className="modal-b"> <div className="modal-b">
<div className="field"> <div className="field">
<label className="field-label"> <span className="req">*</span></label> <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 }))}> <CustomSelect
<option value=""></option> fill
{providers.map((p) => <option key={p.id} value={p.id}>{p.display_name}</option>)} value={modelModal.provider}
</select> 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>
<div className="field-row"> <div className="field-row">
<div className="field"> <div className="field">
@@ -241,9 +246,13 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
</div> </div>
<div className="field"> <div className="field">
<label className="field-label"></label> <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 }))}> <CustomSelect
{CAPABILITIES.map((c) => <option key={c} value={c}>{c}</option>)} fill
</select> 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> </div>
<div className="field"> <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 type { Notice, Page } from "./route-config";
import { stageOrder, statusPill } from "./stage-config"; import { stageOrder, statusPill } from "./stage-config";
import { ConfirmModal, MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays"; 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 { DEFAULT_BILLING_RATES, estimateCost, FC_MODELS, modelLabel } from "../components/free-create/constants";
import { ModelLibrary } from "../components/model-library"; import { ModelLibrary } from "../components/model-library";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge"; import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
@@ -1198,25 +1199,33 @@ export function PipelinePage(props: {
return ( return (
<div className="as-spec-fields" aria-label="成片规格"> <div className="as-spec-fields" aria-label="成片规格">
<label className="as-spec-field"> <label className="as-spec-field">
<select className="select" value={outputAspect} onChange={(event) => changeOutputSpec({ aspect_ratio: event.target.value })}> <CustomSelect fill size="sm" value={outputAspect} onChange={(next) => changeOutputSpec({ aspect_ratio: next })} options={OUTPUT_RATIOS} />
{OUTPUT_RATIOS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label> </label>
<label className="as-spec-field"> <label className="as-spec-field">
<select className="select" value={outputResolution} onChange={(event) => changeOutputSpec({ resolution: event.target.value })}> <CustomSelect
{OUTPUT_RESOLUTIONS.map((option) => ( fill
<option key={option.value} value={option.value} disabled={Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value))}> size="sm"
{option.label} value={outputResolution}
</option> onChange={(next) => changeOutputSpec({ resolution: next })}
))} options={OUTPUT_RESOLUTIONS.map((option) => ({
</select> ...option,
disabled: Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value)),
}))}
/>
</label> </label>
<label className="as-spec-field"> <label className="as-spec-field">
<select className="select" value={outputModelId} onChange={(event) => changeOutputSpec({ video_model_config_id: event.target.value })} disabled={!videoConfigs.length}> <CustomSelect
{videoConfigs.length ? videoConfigs.map((config) => ( fill
<option key={config.id} value={config.id}>{FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name)}</option> size="sm"
)) : <option value=""></option>} value={outputModelId}
</select> 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> </label>
</div> </div>
); );
@@ -3222,27 +3231,42 @@ export function PipelinePage(props: {
沿,{setupProduct?.title || "当前商品"} 沿,{setupProduct?.title || "当前商品"}
</div> </div>
)} )}
<label className="setup-field"> <div className="setup-field">
<span className="sf-k"></span> <span className="sf-k"></span>
<select className="setup-select" value={setupStructure} onChange={(e) => { setupTouched.current = true; setSetupStructure(e.target.value as VideoStructure); }}> <CustomSelect
{structureOptions.map((k) => <option key={k} value={k}>{VIDEO_STRUCTURES[k]}</option>)} fill
</select> size="sm"
</label> 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> <div className="setup-rec">{STRUCTURE_HINT[setupStructure]}</div>
<label className="setup-field"> <div className="setup-field">
<span className="sf-k"></span> <span className="sf-k"></span>
<select className="setup-select" value={setupPersona} onChange={(e) => { setupTouched.current = true; setSetupPersona(e.target.value); }}> <CustomSelect
{SETUP_PERSONA_KEYS.map((k) => <option key={k} value={k}>{WIZ_PERSONA_LABEL[k]}</option>)} fill
</select> size="sm"
</label> 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> <div className="setup-rec">{recommended.reason} · {WIZ_PERSONA_LABEL[recommended.persona] || recommended.persona}</div>
{/* 时长:15/30/45/60;每镜固定 15 秒 */} {/* 时长:15/30/45/60;每镜固定 15 秒 */}
<label className="setup-field"> <div className="setup-field">
<span className="sf-k"></span> <span className="sf-k"></span>
<select className="setup-select" value={setupDuration} onChange={(e) => { setupTouched.current = true; setSetupDuration(clampDuration(Number(e.target.value))); }}> <CustomSelect
{DURATION_OPTIONS.map((s) => <option key={s} value={s}>{s} </option>)} fill
</select> size="sm"
</label> 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-rec${durationHint ? " warn" : ""}`}>{durationHint || `口播 × ${VIDEO_STRUCTURES[setupStructure]}建议 ${durationSuggest} 秒左右`}</div>
<div className="setup-foot"> <div className="setup-foot">
{/* ← 返回:收起设定卡,回到「脚本辅助生成 / 上传脚本」入口页 */} {/* ← 返回:收起设定卡,回到「脚本辅助生成 / 上传脚本」入口页 */}
@@ -4101,9 +4125,13 @@ export function PipelinePage(props: {
{voStale && <div style={{ fontSize: "12px", color: "#B45309", marginBottom: 6 }}>,,</div>} {voStale && <div style={{ fontSize: "12px", color: "#B45309", marginBottom: 6 }}>,,</div>}
<div className="props-row" style={{ marginBottom: 6 }}> <div className="props-row" style={{ marginBottom: 6 }}>
<span className="k"></span> <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)" }}> <CustomSelect
{VO_VOICES.map((v) => <option key={v.key} value={v.key}>{v.label}</option>)} fill
</select> 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>
<div style={{ display: "flex", gap: 6 }}> <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> <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 { useFileDrop } from "../components/use-file-drop";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge"; import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { ProductCreateDrawer } from "../components/product-create-drawer"; import { ProductCreateDrawer } from "../components/product-create-drawer";
import { CustomSelect } from "../components/custom-select";
import { import {
BUSINESS_TYPES, BUSINESS_TYPES,
BUSINESS_TYPE_KEYS, BUSINESS_TYPE_KEYS,
@@ -527,15 +528,16 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
</div> </div>
<div className="field"> <div className="field">
<label className="field-label"><span className="req">*</span></label> <label className="field-label"><span className="req">*</span></label>
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)} required> <CustomSelect
<option value=""> </option> fill
<optgroup label="常用品类"> value={category}
{PC_CAT_PRIMARY.map((option) => <option key={option}>{option}</option>)} onChange={setCategory}
</optgroup> placeholder="— 选择品类 —"
<optgroup label="更多品类"> options={[
{PC_CAT_MORE.map((option) => <option key={option}>{option}</option>)} ...PC_CAT_PRIMARY.map((option) => ({ value: option, label: option, group: "常用品类" })),
</optgroup> ...PC_CAT_MORE.map((option) => ({ value: option, label: option, group: "更多品类" })),
</select> ]}
/>
</div> </div>
<div className="field field-last"> <div className="field field-last">
<label className="field-label">()</label> <label className="field-label">()</label>
@@ -1069,23 +1071,31 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<div className="k"></div> <div className="k"></div>
<div className="v"> <div className="v">
<span className="v-static">{BUSINESS_TYPES[realType]}</span> <span className="v-static">{BUSINESS_TYPES[realType]}</span>
<select className="v-edit v-select" value={bizType} onChange={(event) => { <CustomSelect
const next = event.target.value as BusinessType; fill
setBizType(next); className="v-edit"
const opts = pdCatChoices(next, ""); value={bizType}
onChange={(next) => {
const typed = next as BusinessType;
setBizType(typed);
const opts = pdCatChoices(typed, "");
if (!opts.includes(cat)) setCat(opts[0]); if (!opts.includes(cat)) setCat(opts[0]);
}}> }}
{BUSINESS_TYPE_KEYS.map((key) => <option key={key} value={key}>{BUSINESS_TYPES[key]}</option>)} options={BUSINESS_TYPE_KEYS.map((key) => ({ value: key, label: BUSINESS_TYPES[key] }))}
</select> />
</div> </div>
</div> </div>
<div className="row" data-field="cat"> <div className="row" data-field="cat">
<div className="k"></div> <div className="k"></div>
<div className="v"> <div className="v">
<span className="v-static">{realCat}</span> <span className="v-static">{realCat}</span>
<select className="v-edit v-select" value={cat} onChange={(event) => setCat(event.target.value)}> <CustomSelect
{pdCatChoices(bizType, cat).map((option) => <option key={option}>{option}</option>)} fill
</select> className="v-edit"
value={cat}
onChange={setCat}
options={pdCatChoices(bizType, cat).map((option) => ({ value: option, label: option }))}
/>
</div> </div>
</div> </div>
<div className="row" data-field="bullets"> <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 { api } from "../api";
import type { Page } from "./route-config"; import type { Page } from "./route-config";
import { ConfirmModal, MediaLightbox } from "../components/overlays"; import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
import { ProductCreateDrawer, type ProductCreatePayload } from "../components/product-create-drawer"; import { ProductCreateDrawer, type ProductCreatePayload } from "../components/product-create-drawer";
import { isLocalLife } from "../product-business"; import { isLocalLife } from "../product-business";
import { Pager } from "../components/pager"; import { Pager } from "../components/pager";
@@ -325,12 +326,19 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
{templates.length > 0 && ( {templates.length > 0 && (
<label className="nw-field"> <label className="nw-field">
<span><small></small></span> <span><small></small></span>
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}> <CustomSelect
<option value=""> · AI </option> fill
{templates.map((item) => ( value={templateId}
<option key={item.id} value={item.id}>{item.name} · {item.total_duration}s{item.usage_count ? ` · 用过 ${item.usage_count}` : ""}</option> onChange={setTemplateId}
))} placeholder="不套用 · 让 AI 按这个商品从头设计"
</select> options={[
{ value: "", label: "不套用 · 让 AI 按这个商品从头设计" },
...templates.map((item) => ({
value: item.id,
label: `${item.name} · ${item.total_duration}s${item.usage_count ? ` · 用过 ${item.usage_count}` : ""}`,
})),
]}
/>
{template && ( {template && (
<div className="nw-tpl"> <div className="nw-tpl">
<strong></strong> <strong></strong>
+30 -18
View File
@@ -20,6 +20,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api, ApiError, type QuickCreateJob } from "../api"; import { api, ApiError, type QuickCreateJob } from "../api";
import { ConfirmModal, OverlayPortal, useBodyScrollLock } from "../components/overlays"; import { ConfirmModal, OverlayPortal, useBodyScrollLock } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
import { useFileDrop } from "../components/use-file-drop"; import { useFileDrop } from "../components/use-file-drop";
import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob, stripQuickCreateSuffix } from "../quick-create-lock"; import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob, stripQuickCreateSuffix } from "../quick-create-lock";
import type { ModelConfig } from "../types"; import type { ModelConfig } from "../types";
@@ -678,33 +679,44 @@ export function QuickCreatePage({
<div className="quick-parameter-grid" aria-label="视频核心参数"> <div className="quick-parameter-grid" aria-label="视频核心参数">
<label className="quick-parameter-field"> <label className="quick-parameter-field">
<span></span> <span></span>
<select value={aspectRatio} onChange={(event) => setAspectRatio(event.target.value)} disabled={isGenerating}> <CustomSelect fill value={aspectRatio} onChange={setAspectRatio} disabled={isGenerating} options={QUICK_RATIOS} />
{QUICK_RATIOS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label> </label>
<label className="quick-parameter-field"> <label className="quick-parameter-field">
<span></span> <span></span>
<select value={resolution} onChange={(event) => setResolution(event.target.value)} disabled={isGenerating}> <CustomSelect
{QUICK_RESOLUTIONS.map((option) => ( fill
<option key={option.value} value={option.value} disabled={Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value))}> value={resolution}
{option.label} onChange={setResolution}
</option> disabled={isGenerating}
))} options={QUICK_RESOLUTIONS.map((option) => ({
</select> ...option,
disabled: Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value)),
}))}
/>
</label> </label>
<label className="quick-parameter-field"> <label className="quick-parameter-field">
<span></span> <span></span>
<select value={totalDuration} onChange={(event) => setTotalDuration(Number(event.target.value))} disabled={isGenerating}> <CustomSelect
{QUICK_DURATIONS.map((duration) => <option key={duration} value={duration}>{duration / 15} {duration}s</option>)} fill
</select> 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>
<label className="quick-parameter-field"> <label className="quick-parameter-field">
<span></span> <span></span>
<select value={videoModelId} onChange={(event) => setVideoModelId(event.target.value)} disabled={isGenerating || !videoConfigs.length}> <CustomSelect
{videoConfigs.length ? videoConfigs.map((config) => ( fill
<option key={config.id} value={config.id}>{FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name)}</option> value={videoModelId}
)) : <option value=""></option>} onChange={setVideoModelId}
</select> 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> </label>
</div> </div>
+27 -26
View File
@@ -10,6 +10,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { LoginSession, Team, User, UserPreference } from "../types"; import type { LoginSession, Team, User, UserPreference } from "../types";
import { TeamModal } from "../components/overlays"; import { TeamModal } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
import { useFileDrop } from "../components/use-file-drop"; import { useFileDrop } from "../components/use-file-drop";
type SectionKey = "profile" | "security" | "notify" | "pref" | "display"; type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
@@ -620,23 +621,23 @@ export function SettingsPage({
<div className="form-row"> <div className="form-row">
<div className="lbl"> BGM </div> <div className="lbl"> BGM </div>
<div className="val"> <div className="val">
<select className="select" value={draft.bgm} onChange={(event) => patchDraft("bgm", event.target.value)}> <CustomSelect fill value={draft.bgm} onChange={(next) => patchDraft("bgm", next)} options={[
<option value="kapian"> Top10 </option> { value: "kapian", label: "抖音 Top10 卡点曲库" },
<option value="emotion"> · /</option> { value: "emotion", label: "情绪向 · 治愈/悬念" },
<option value="urban"> · </option> { value: "urban", label: "都市电子 · 通勤场景" },
<option value="none"> BGM</option> { value: "none", label: "无 BGM" },
</select> ]} />
</div> </div>
</div> </div>
<div className="form-row"> <div className="form-row">
<div className="lbl"></div> <div className="lbl"></div>
<div className="val"> <div className="val">
<select className="select" value={draft.transition} onChange={(event) => patchDraft("transition", event.target.value)}> <CustomSelect fill value={draft.transition} onChange={(next) => patchDraft("transition", next)} options={[
<option value="none"></option> { value: "none", label: "无转场" },
<option value="fade"> · 0.3s</option> { value: "fade", label: "淡入淡出 · 0.3s" },
<option value="slide"> · 0.3s</option> { value: "slide", label: "滑动 · 0.3s" },
<option value="zoom"> · 0.3s</option> { value: "zoom", label: "缩放 · 0.3s" },
</select> ]} />
</div> </div>
</div> </div>
<div className="form-row"> <div className="form-row">
@@ -658,30 +659,30 @@ export function SettingsPage({
<div className="form-row"> <div className="form-row">
<div className="lbl"></div> <div className="lbl"></div>
<div className="val"> <div className="val">
<select className="select" value={draft.appearance} onChange={(event) => patchDraft("appearance", event.target.value)}> <CustomSelect fill value={draft.appearance} onChange={(next) => patchDraft("appearance", next)} options={[
<option value="system"></option> { value: "system", label: "跟随系统" },
<option value="light"></option> { value: "light", label: "浅色" },
<option value="dark" disabled>(V2)</option> { value: "dark", label: "深色(V2)", disabled: true },
</select> ]} />
</div> </div>
</div> </div>
<div className="form-row"> <div className="form-row">
<div className="lbl"></div> <div className="lbl"></div>
<div className="val"> <div className="val">
<select className="select" value={draft.language} onChange={(event) => patchDraft("language", event.target.value)}> <CustomSelect fill value={draft.language} onChange={(next) => patchDraft("language", next)} options={[
<option value="zh"></option> { value: "zh", label: "简体中文" },
<option value="en" disabled>English(V2)</option> { value: "en", label: "English(V2)", disabled: true },
</select> ]} />
</div> </div>
</div> </div>
<div className="form-row"> <div className="form-row">
<div className="lbl"></div> <div className="lbl"></div>
<div className="val"> <div className="val">
<select className="select" value={draft.density} onChange={(event) => patchDraft("density", event.target.value)}> <CustomSelect fill value={draft.density} onChange={(next) => patchDraft("density", next)} options={[
<option value="compact"></option> { value: "compact", label: "紧凑" },
<option value="standard"></option> { value: "standard", label: "标准" },
<option value="loose"></option> { value: "loose", label: "宽松" },
</select> ]} />
</div> </div>
</div> </div>
</section> </section>
+10 -4
View File
@@ -5,6 +5,7 @@ import type { BillingSummary, BillingTrend, Invitation, Notification, Team, Team
import type { Page } from "./route-config"; import type { Page } from "./route-config";
import { money } from "./stage-config"; import { money } from "./stage-config";
import { ConfirmModal, TeamModal } from "../components/overlays"; import { ConfirmModal, TeamModal } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
import { Pager } from "../components/pager"; import { Pager } from "../components/pager";
const MEMBERS_PER_PAGE = 10; const MEMBERS_PER_PAGE = 10;
@@ -608,10 +609,15 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
<> <>
<div className="field"> <div className="field">
<label className="field-label"></label> <label className="field-label"></label>
<select className="input" value={invRole} onChange={(e) => setInvRole(e.target.value)}> <CustomSelect
<option value="member"> · ,</option> fill
<option value="admin"> · //</option> value={invRole}
</select> onChange={setInvRole}
options={[
{ value: "member", label: "成员 · 可用生成,不可管理团队" },
{ value: "admin", label: "团队管理员 · 可管成员/额度/邀请" },
]}
/>
</div> </div>
<div className="field" style={{ marginBottom: 0 }}> <div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">()<span className="lbl-note">(-1 )</span></label> <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 .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 .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 { 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 { 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 .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); } .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); }