优化复刻视频脚本和下拉框样式
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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】` 连续编号;
|
||||
- 每镜的时间、时长、景别、机位、运镜;
|
||||
- 依据新商品事实重写后的画面、人物动作、人物表情、台词/旁白;
|
||||
- 角色关系、环境连续性和原片节奏。
|
||||
|
||||
开头必须有 `【成片规则】`,明确:参考视频不决定新商品发生什么;新商品外观以 `@目标商品` 参考图为准;禁止出现原商品、原品牌、原包装、原功能和原台词。
|
||||
|
||||
台词只能使用已提供的商品事实和真实卖点。没有可证实的功效时,改写为可见的使用、外观、携带、摆放或操作事实,不得虚构前后效果。
|
||||
|
||||
## 逐镜校验
|
||||
|
||||
生成前逐镜确认:新商品能否真的执行该动作、使用者与场景是否合理、动作是否证明真实卖点、前后是否连续、是否残留原商品语义、是否有未经提供依据的功效,以及商品是否保持与参考图一致。任一项不满足时,先重写该镜。
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, "");
|
||||
if (!opts.includes(cat)) setCat(opts[0]);
|
||||
}}>
|
||||
{BUSINESS_TYPE_KEYS.map((key) => <option key={key} value={key}>{BUSINESS_TYPES[key]}</option>)}
|
||||
</select>
|
||||
<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]);
|
||||
}}
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user