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

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
+1
View File
@@ -444,6 +444,7 @@
.filter-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
.filter-bar select, .filter-bar input { background: #fff; border: 1px solid var(--st-line); border-radius: 8px; padding: 6px 10px; font-size: 13px; font-family: inherit; color: var(--st-text); }
.filter-bar .rs-select-btn { background: #fff; border-color: var(--st-line); }
.filter-bar select:hover, .filter-bar input:hover { border-color: rgba(28, 34, 43, 0.18); }
.filter-bar select { padding-right: 24px; }
.filter-bar .spacer { flex: 1; }
@@ -0,0 +1,163 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronDown } from "lucide-react";
export type CustomSelectOption = {
value: string;
label: string;
desc?: string;
disabled?: boolean;
group?: string;
};
type MenuPos = {
left: number;
width: number;
maxHeight: number;
top?: number;
bottom?: number;
};
export function CustomSelect({
value,
onChange,
options,
disabled,
className,
fill,
align = "left",
size = "md",
placeholder = "请选择",
"aria-label": ariaLabel,
}: {
value: string;
onChange: (value: string) => void;
options: CustomSelectOption[];
disabled?: boolean;
className?: string;
fill?: boolean;
align?: "left" | "right";
size?: "sm" | "md";
placeholder?: string;
"aria-label"?: string;
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<MenuPos | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const current = options.find((item) => item.value === value);
function place() {
const btn = btnRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
const gutter = 8;
const spaceBelow = window.innerHeight - rect.bottom - gutter;
const spaceAbove = rect.top - gutter;
const up = spaceBelow < 220 && spaceAbove > spaceBelow;
const width = Math.min(rect.width, window.innerWidth - gutter * 2);
let left = align === "right" ? rect.right - width : rect.left;
left = Math.min(Math.max(gutter, left), window.innerWidth - width - gutter);
const next: MenuPos = {
left,
width,
maxHeight: Math.max(120, Math.min(320, (up ? spaceAbove : spaceBelow) - 4)),
};
if (up) next.bottom = window.innerHeight - rect.top + 6;
else next.top = rect.bottom + 6;
setPos(next);
}
useLayoutEffect(() => {
if (!open) return;
place();
}, [open, align, options.length]);
useEffect(() => {
if (!open) return;
const onDown = (event: MouseEvent) => {
const target = event.target as Node;
if (btnRef.current?.contains(target) || menuRef.current?.contains(target)) return;
setOpen(false);
};
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); };
const onReposition = () => place();
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
window.addEventListener("resize", onReposition);
window.addEventListener("scroll", onReposition, true);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
window.removeEventListener("resize", onReposition);
window.removeEventListener("scroll", onReposition, true);
};
}, [open]);
useEffect(() => {
if (!open) return;
const menu = menuRef.current;
const active = menu?.querySelector<HTMLElement>(".rs-select-option.selected");
if (!menu || !active) return;
menu.scrollTop = active.offsetTop - menu.clientHeight / 2 + active.offsetHeight / 2;
}, [open, value]);
return (
<div className={`rs-select${fill ? " rs-select-fill" : ""}${open ? " open" : ""}${size === "sm" ? " rs-select-sm" : ""}${className ? ` ${className}` : ""}`}>
<button
ref={btnRef}
type="button"
className="rs-select-btn"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
onClick={() => setOpen((v) => !v)}
>
<span className={`rs-select-label${current ? "" : " is-placeholder"}`}>{current?.label || placeholder}</span>
<ChevronDown />
</button>
{open && pos && createPortal(
<div
ref={menuRef}
className="rs-select-menu rs-select-menu-portal"
role="listbox"
style={{
left: pos.left,
width: pos.width,
maxHeight: pos.maxHeight,
top: pos.top,
bottom: pos.bottom,
}}
>
{options.map((item, index) => {
const showGroup = Boolean(item.group && item.group !== options[index - 1]?.group);
return (
<div key={`${item.group || ""}:${item.value}`}>
{showGroup ? <div className="rs-select-group">{item.group}</div> : null}
<button
type="button"
role="option"
aria-selected={item.value === value}
className={`rs-select-option${item.value === value ? " selected" : ""}`}
disabled={item.disabled}
onClick={() => {
if (item.disabled) return;
onChange(item.value);
setOpen(false);
}}
>
<span className="rs-select-option-text">
<span className="rs-select-option-label">{item.label}</span>
{item.desc ? <span className="rs-select-option-desc">{item.desc}</span> : null}
</span>
</button>
</div>
);
})}
</div>,
document.body,
)}
</div>
);
}
+129 -49
View File
@@ -1381,8 +1381,7 @@ body.sidebar-collapsed .user::after { display: none; }
}
.chip-menu .mi:hover { background: var(--black-alpha-4); }
.chip-menu .mi.selected { color: var(--heat); background: var(--heat-12); }
.chip-menu .mi .mi-check { width: 13px; height: 13px; color: var(--heat); visibility: hidden; flex-shrink: 0; }
.chip-menu .mi.selected .mi-check { visibility: visible; }
.chip-menu .mi .mi-check { display: none; }
.chip-menu .mi-sep { height: 1px; background: var(--border-faint); margin: 4px 6px; }
/* ─── Dropdown unification · 下拉框 / 菜单视觉收口 ─── */
@@ -1454,12 +1453,31 @@ select.duration-select:focus,
color: var(--heat) !important;
}
:where(.chip-menu, .filter-pop, .pp-menu, .tb-menu, .io-param-menu, .move-menu, .cell-more-menu, .msg-more-menu, .batch-more-menu) {
:where(
.chip-menu,
.filter-pop,
.pp-menu,
.tb-menu,
.io-param-menu,
.ic-param-menu,
.move-menu,
.cell-more-menu,
.msg-more-menu,
.batch-more-menu,
.rs-select-menu,
.pl-select-menu,
.lib-select-menu,
.vc-select-menu,
.af-select-menu,
.pcd-select-menu,
.nw-select-menu,
.fc-dd-menu
) {
background: var(--surface) !important;
border: 1px solid var(--border-faint) !important;
border-radius: var(--r-md) !important;
box-shadow: var(--shadow-floating) !important;
padding: 4px !important;
border: 1px solid rgba(34, 42, 54, 0.10) !important;
border-radius: 10px !important;
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12) !important;
padding: 6px !important;
}
:where(
.chip-menu .mi,
@@ -1467,16 +1485,25 @@ select.duration-select:focus,
.pp-menu .mi,
.tb-menu-item,
.io-param-menu .mi,
.ic-param-menu .mi,
.move-menu .mv-item,
.cell-more-menu button,
.msg-more-menu button,
.batch-more-menu button
.batch-more-menu button,
.rs-select-option,
.pl-select-option,
.lib-select-option,
.vc-select-option,
.af-select-option,
.pcd-select-option,
.nw-select-option,
.fc-dd-item
) {
min-height: 32px !important;
padding: 0 10px !important;
min-height: 36px !important;
padding: 7px 10px !important;
background: transparent !important;
border: 0 !important;
border-radius: var(--r-sm) !important;
border-radius: 8px !important;
color: var(--accent-black) !important;
display: flex !important;
align-items: center !important;
@@ -1494,10 +1521,19 @@ select.duration-select:focus,
.pp-menu .mi,
.tb-menu-item,
.io-param-menu .mi,
.ic-param-menu .mi,
.move-menu .mv-item,
.cell-more-menu button,
.msg-more-menu button,
.batch-more-menu button
.batch-more-menu button,
.rs-select-option,
.pl-select-option,
.lib-select-option,
.vc-select-option,
.af-select-option,
.pcd-select-option,
.nw-select-option,
.fc-dd-item
):hover {
background: var(--black-alpha-4) !important;
color: var(--accent-black) !important;
@@ -1507,11 +1543,20 @@ select.duration-select:focus,
.filter-pop button.selected,
.pp-menu .mi.selected,
.tb-menu-item.active,
.io-param-menu .mi.selected
.io-param-menu .mi.selected,
.ic-param-menu .mi.selected,
.rs-select-option.selected,
.pl-select-option.selected,
.lib-select-option.selected,
.vc-select-option.selected,
.af-select-option.selected,
.pcd-select-option.selected,
.nw-select-option.selected,
.fc-dd-item.selected
) {
background: var(--heat-12) !important;
background: var(--heat-8) !important;
color: var(--heat) !important;
font-weight: 500 !important;
font-weight: 600 !important;
}
:where(.cell-more-menu button.danger:hover, .msg-more-menu button.danger:hover, .batch-more-menu button.danger:hover) {
background: var(--crimson-bg) !important;
@@ -1523,6 +1568,21 @@ select.duration-select:focus,
font-size: 12px !important;
letter-spacing: .02em !important;
}
.rs-select-group { color: var(--black-alpha-48) !important; }
.rs-select-option-desc,
.fc-dd-item .de { color: var(--black-alpha-48) !important; }
.fc-dd-item {
flex-direction: column !important;
align-items: flex-start !important;
justify-content: center !important;
min-height: 44px !important;
padding: 7px 10px !important;
}
.fc-dd-item .ti { color: var(--accent-black) !important; }
.fc-dd-item.selected .ti { color: var(--heat) !important; }
.fc-dd-item.selected .de { color: var(--black-alpha-48) !important; }
.pl-select-option .pl-cat-count { color: var(--black-alpha-48) !important; }
.pl-select-option.selected .pl-cat-count { color: var(--heat) !important; }
.rs-select {
position: relative;
@@ -1532,14 +1592,6 @@ select.duration-select:focus,
}
.rs-select.rs-select-fill { width: 100%; }
.rs-select.rs-select-filter { min-width: 126px; }
.rs-select > select[data-rs-select-bound="1"] {
position: absolute !important;
inset: 0 auto auto 0 !important;
width: 1px !important;
height: 1px !important;
opacity: 0 !important;
pointer-events: none !important;
}
.rs-select-btn {
width: 100%;
height: 36px;
@@ -1557,6 +1609,7 @@ select.duration-select:focus,
cursor: pointer;
transition: background var(--t-base), border-color var(--t-base), box-shadow var(--t-base), color var(--t-base);
}
.rs-select-sm .rs-select-btn { height: 32px; font-size: 13px; padding: 0 10px 0 12px; }
.rs-select-btn:hover {
background: var(--black-alpha-4);
border-color: var(--black-alpha-24);
@@ -1564,7 +1617,7 @@ select.duration-select:focus,
.rs-select.open .rs-select-btn,
.rs-select-btn:focus-visible {
border-color: var(--heat-40);
box-shadow: inset 0 0 0 1px var(--heat-40);
box-shadow: 0 0 0 3px var(--heat-8);
}
.rs-select-btn[disabled] {
background: var(--black-alpha-5);
@@ -1577,6 +1630,7 @@ select.duration-select:focus,
text-overflow: ellipsis;
white-space: nowrap;
}
.rs-select-label.is-placeholder { color: var(--black-alpha-40); }
.rs-select-btn svg {
width: 12px;
height: 12px;
@@ -1590,65 +1644,91 @@ select.duration-select:focus,
}
.rs-select-menu {
position: absolute;
top: calc(100% + 4px);
top: calc(100% + 6px);
left: 0;
min-width: 100%;
width: max-content;
max-width: min(260px, calc(100vw - 24px));
max-width: min(280px, calc(100vw - 24px));
max-height: 320px;
overflow-y: auto;
overscroll-behavior: contain;
display: none;
z-index: 1600;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: var(--shadow-floating);
padding: 4px;
border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 10px;
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
padding: 6px;
}
.rs-select-menu.align-right {
left: auto;
right: 0;
}
.rs-select.open .rs-select-menu { display: block; }
.rs-select.open .rs-select-menu { display: grid; gap: 3px; }
.rs-select-menu-portal {
position: fixed;
top: auto;
min-width: 0;
max-width: none;
display: grid;
gap: 3px;
z-index: calc(var(--z-overlay) + 40);
}
.rs-select-group {
padding: 8px 10px 4px;
color: var(--black-alpha-48);
font-size: 11px;
font-weight: 500;
letter-spacing: .04em;
}
.rs-select-option {
width: 100%;
min-height: 32px;
padding: 0 10px;
min-height: 38px;
padding: 7px 10px;
display: flex;
align-items: center;
gap: 8px;
background: transparent;
border: 0;
border-radius: var(--r-sm);
border-radius: 8px;
color: var(--accent-black);
font-family: inherit;
font-size: 13px;
text-align: left;
white-space: nowrap;
cursor: pointer;
transition: background var(--t-base), color var(--t-base);
}
.rs-select-option:hover,
.rs-select-option.is-active {
background: var(--black-alpha-4);
.rs-select-option-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.rs-select-option.selected {
background: var(--heat-12);
color: var(--heat);
font-weight: 500;
.rs-select-option-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rs-select-option-desc {
color: var(--black-alpha-48);
font-size: 11px;
line-height: 1.35;
}
.rs-select-option.selected .rs-select-option-desc { color: var(--black-alpha-48); }
.rs-select-option[disabled] {
color: var(--black-alpha-32);
cursor: not-allowed;
}
.rs-select-option .mi-check {
width: 13px;
height: 13px;
color: var(--heat);
visibility: hidden;
flex-shrink: 0;
.rs-select-option[disabled]:hover { background: transparent; }
.rs-select-menu::-webkit-scrollbar { width: 6px; }
.rs-select-menu::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(34, 42, 54, 0.18);
}
.rs-select-option.selected .mi-check { visibility: visible; }
.rs-select-menu::-webkit-scrollbar-track { background: transparent; }
.filter-bar .rs-select { min-width: 132px; }
.filter-bar .rs-select-btn { height: 32px; font-size: 13px; }
/* ─── Clear-filters btn · 共享组件 ─── */
.clear-filters {
+3 -2
View File
@@ -1480,8 +1480,9 @@
.setup-card .setup-lead { font-size: 13px; line-height: 1.5; color: var(--accent-black); margin-bottom: 6px; }
.setup-card .setup-field { display: grid; grid-template-columns: 64px 1fr; align-items: center; gap: 10px; }
.setup-card .setup-field .sf-k { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .04em; }
.setup-card .setup-select { height: 32px; width: 100%; padding: 0 10px; font-size: 13px; font-family: inherit; color: var(--accent-black); background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); outline: none; cursor: pointer; }
.setup-card .setup-select:focus { border-color: var(--heat); box-shadow: 0 0 0 3px var(--heat-12); }
.setup-card .setup-select { width: 100%; }
.as-spec-field .rs-select { width: 100%; }
.props-row .rs-select { flex: 1; min-width: 0; }
/* 说明行左缩进 = 标签列 64 + gap 10,跟下拉框左边缘对齐 */
.setup-card .setup-rec { font-size: 12px; line-height: 1.5; color: var(--black-alpha-40); margin: 0 0 8px 74px; }
.setup-card .setup-rec.warn { color: var(--accent-honey); }
@@ -249,6 +249,7 @@
.v-edit { display: none; }
.ov-card.editing .v-static { display: none; }
.ov-card.editing .v-edit { display: block; }
.ov-card.editing .v-edit.rs-select { display: inline-flex; width: 100%; }
/* 输入控件 · 对齐新建表单 V2.1 规范 */
.v-input,
@@ -506,6 +506,7 @@
font-weight: 500;
}
.project-wizard-page .nw-field small { color: var(--nw-muted); font-size: 11px; font-weight: 400; }
.project-wizard-page .nw-field .rs-select { width: 100%; }
.project-wizard-page .nw-field input,
.project-wizard-page .nw-field select {
height: 44px;
+2 -3
View File
@@ -57,9 +57,8 @@
.quick-create-page .quick-parameter-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; margin-top: 20px; }
.quick-create-page .quick-parameter-field { min-width: 0; display: grid; gap: 9px; padding: 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 12px; background: rgba(248,249,252,.72); }
.quick-create-page .quick-parameter-field > span { color: #30343a; font-size: 12px; font-weight: 700; }
.quick-create-page .quick-parameter-field select { width: 100%; height: 38px; padding: 0 36px 0 13px; border: 1px solid rgba(34,42,54,.13); border-radius: 10px; outline: none; color: #272a30; background-color: rgba(255,255,255,.92); font: inherit; font-size: 12px; cursor: pointer; }
.quick-create-page .quick-parameter-field select:focus { border-color: rgba(0,47,167,.55); box-shadow: 0 0 0 3px rgba(0,47,167,.08); }
.quick-create-page .quick-parameter-field select:disabled { cursor: not-allowed; opacity: .65; }
.quick-create-page .quick-parameter-field .rs-select { width: 100%; }
.quick-create-page .quick-parameter-field .rs-select-btn { height: 38px; font-size: 12px; }
.quick-create-page .quick-form-footer { display: flex; margin-top: 26px; }
.quick-create-page .quick-generate-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 12px; color: #fff; background: var(--quick-blue); font: inherit; font-weight: 700; cursor: pointer; box-shadow: 0 12px 24px rgba(0,47,167,.22); }.quick-create-page .quick-generate-button:disabled { color: #99a1ae; background: #e5e8ee; box-shadow: none; cursor: not-allowed; }.quick-create-page .quick-generate-button svg { width: 20px; height: 20px; }
.quick-create-page .quick-cancel-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 1px solid rgba(34,42,54,.16); border-radius: 12px; color: #272a30; background: #fff; font: inherit; font-weight: 700; cursor: pointer; }.quick-create-page .quick-cancel-button:hover:not(:disabled) { border-color: rgba(200,61,77,.35); color: #c83d4d; background: rgba(200,61,77,.06); }.quick-create-page .quick-cancel-button:disabled { opacity: .55; cursor: not-allowed; }.quick-create-page .quick-cancel-button svg { width: 18px; height: 18px; }
+50 -25
View File
@@ -7,6 +7,7 @@ import type { NavigateFn } from "./route-config";
import { money, pts, stageMeta, yuan } from "./stage-config";
import { pageWindow } from "../components/pager";
import { useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
const ROLE_LABEL: Record<string, string> = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" };
const STATUS_LABEL: Record<string, string> = { active: "活跃", invited: "待激活", disabled: "已停用" };
@@ -466,19 +467,31 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<div className={`tab-panel ${tab === "bills" ? "active" : ""}`}>
<div className="filter-bar">
<select value={billType} onChange={(e) => { setBillType(e.target.value); setBillPage(1); }} aria-label="按类型筛选">
<option value="all"></option>
<option value="charge"></option>
<option value="recharge"></option>
<option value="reserve"></option>
<option value="release"></option>
<option value="adjustment"></option>
<option value="refund">退</option>
</select>
<select value={billMember} onChange={(e) => { setBillMember(e.target.value); setBillPage(1); }} aria-label="按成员筛选">
<option value="all"></option>
{billMemberOptions.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
<CustomSelect
size="sm"
aria-label="按类型筛选"
value={billType}
onChange={(next) => { setBillType(next); setBillPage(1); }}
options={[
{ value: "all", label: "全部类型" },
{ value: "charge", label: "扣费" },
{ value: "recharge", label: "充值" },
{ value: "reserve", label: "预扣" },
{ value: "release", label: "释放" },
{ value: "adjustment", label: "调整" },
{ value: "refund", label: "退款" },
]}
/>
<CustomSelect
size="sm"
aria-label="按成员筛选"
value={billMember}
onChange={(next) => { setBillMember(next); setBillPage(1); }}
options={[
{ value: "all", label: "全部成员" },
...billMemberOptions.map((m) => ({ value: m.id, label: m.label })),
]}
/>
{billFiltered && (
<button className="filter-reset" type="button" onClick={() => { setBillType("all"); setBillMember("all"); setBillPage(1); }}></button>
)}
@@ -539,12 +552,18 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<div className={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
<div className="filter-bar">
<select value={projStatus} onChange={(e) => setProjStatus(e.target.value as typeof projStatus)} aria-label="按状态筛选">
<option value="all"></option>
<option value="wip"></option>
<option value="ok"></option>
<option value="fail"> · </option>
</select>
<CustomSelect
size="sm"
aria-label="按状态筛选"
value={projStatus}
onChange={(next) => setProjStatus(next as typeof projStatus)}
options={[
{ value: "all", label: "全部状态" },
{ value: "wip", label: "进行中" },
{ value: "ok", label: "已完成" },
{ value: "fail", label: "失败 · 待重跑" },
]}
/>
{projFiltered && (
<button className="filter-reset" type="button" onClick={() => setProjStatus("all")}></button>
)}
@@ -578,12 +597,18 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<div className={`tab-panel ${tab === "by-member" ? "active" : ""}`}>
<div className="filter-bar">
<select value={memRole} onChange={(e) => setMemRole(e.target.value as typeof memRole)} aria-label="按角色筛选">
<option value="all"></option>
<option value="owner"></option>
<option value="admin"></option>
<option value="member"></option>
</select>
<CustomSelect
size="sm"
aria-label="按角色筛选"
value={memRole}
onChange={(next) => setMemRole(next as typeof memRole)}
options={[
{ value: "all", label: "全部角色" },
{ value: "owner", label: "超管" },
{ value: "admin", label: "团管" },
{ value: "member", label: "成员" },
]}
/>
{memFiltered && (
<button className="filter-reset" type="button" onClick={() => setMemRole("all")}></button>
)}
@@ -4,6 +4,7 @@ import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { IconKitSvg } from "../../components/IconKitSvg";
import { SystemLoading } from "../../components/loading";
import { CustomSelect } from "../../components/custom-select";
import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types";
import { pts } from "../stage-config";
@@ -214,10 +215,13 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
<p className="admin-modal-desc"> / ();</p>
<div className="field">
<label className="field-label"> <span className="req">*</span></label>
<select className="select" value={form.team} onChange={(e) => setForm((f) => ({ ...f, team: e.target.value }))}>
<option value=""></option>
{teams.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<CustomSelect
fill
value={form.team}
placeholder="选择团队…"
onChange={(next) => setForm((f) => ({ ...f, team: next }))}
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
</div>
<div className="field">
<label className="field-label"> <span className="field-hint">=,=</span></label>
@@ -372,10 +376,14 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
<p className="admin-modal-desc"> = ()</p>
<div className="field">
<label className="field-label"> <span className="req">*</span></label>
<select className="select" value={editing.team} disabled={Boolean(editing.id)} onChange={(e) => setEditing((p) => ({ ...p, team: e.target.value }))}>
<option value=""></option>
{teams.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<CustomSelect
fill
value={editing.team}
disabled={Boolean(editing.id)}
placeholder="选择团队…"
onChange={(next) => setEditing((p) => ({ ...p, team: next }))}
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
</div>
<div className="field-row">
<div className="field">
@@ -3,6 +3,7 @@ import { Server, X } from "lucide-react";
import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { SystemLoading } from "../../components/loading";
import { CustomSelect } from "../../components/custom-select";
import type { AdminModel, AdminProvider } from "../../types";
import { pts } from "../stage-config";
@@ -229,10 +230,14 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
<div className="modal-b">
<div className="field">
<label className="field-label"> <span className="req">*</span></label>
<select className="select" value={modelModal.provider} disabled={Boolean(modelModal.id)} onChange={(e) => setModelModal((m) => m && ({ ...m, provider: e.target.value }))}>
<option value=""></option>
{providers.map((p) => <option key={p.id} value={p.id}>{p.display_name}</option>)}
</select>
<CustomSelect
fill
value={modelModal.provider}
disabled={Boolean(modelModal.id)}
placeholder="选择供应商…"
onChange={(next) => setModelModal((m) => m && ({ ...m, provider: next }))}
options={providers.map((p) => ({ value: p.id, label: p.display_name }))}
/>
</div>
<div className="field-row">
<div className="field">
@@ -241,9 +246,13 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
</div>
<div className="field">
<label className="field-label"></label>
<select className="select" value={modelModal.capability} disabled={Boolean(modelModal.id)} onChange={(e) => setModelModal((m) => m && ({ ...m, capability: e.target.value }))}>
{CAPABILITIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<CustomSelect
fill
value={modelModal.capability}
disabled={Boolean(modelModal.id)}
onChange={(next) => setModelModal((m) => m && ({ ...m, capability: next }))}
options={CAPABILITIES.map((c) => ({ value: c, label: c }))}
/>
</div>
</div>
<div className="field">
+61 -33
View File
@@ -9,6 +9,7 @@ import { isPublicGenerationError, presentGenerationError } from "../generation-e
import type { Notice, Page } from "./route-config";
import { stageOrder, statusPill } from "./stage-config";
import { ConfirmModal, MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
import { DEFAULT_BILLING_RATES, estimateCost, FC_MODELS, modelLabel } from "../components/free-create/constants";
import { ModelLibrary } from "../components/model-library";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
@@ -1198,25 +1199,33 @@ export function PipelinePage(props: {
return (
<div className="as-spec-fields" aria-label="成片规格">
<label className="as-spec-field">
<select className="select" value={outputAspect} onChange={(event) => changeOutputSpec({ aspect_ratio: event.target.value })}>
{OUTPUT_RATIOS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<CustomSelect fill size="sm" value={outputAspect} onChange={(next) => changeOutputSpec({ aspect_ratio: next })} options={OUTPUT_RATIOS} />
</label>
<label className="as-spec-field">
<select className="select" value={outputResolution} onChange={(event) => changeOutputSpec({ resolution: event.target.value })}>
{OUTPUT_RESOLUTIONS.map((option) => (
<option key={option.value} value={option.value} disabled={Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value))}>
{option.label}
</option>
))}
</select>
<CustomSelect
fill
size="sm"
value={outputResolution}
onChange={(next) => changeOutputSpec({ resolution: next })}
options={OUTPUT_RESOLUTIONS.map((option) => ({
...option,
disabled: Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value)),
}))}
/>
</label>
<label className="as-spec-field">
<select className="select" value={outputModelId} onChange={(event) => changeOutputSpec({ video_model_config_id: event.target.value })} disabled={!videoConfigs.length}>
{videoConfigs.length ? videoConfigs.map((config) => (
<option key={config.id} value={config.id}>{FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name)}</option>
)) : <option value=""></option>}
</select>
<CustomSelect
fill
size="sm"
value={outputModelId}
disabled={!videoConfigs.length}
placeholder="暂无模型"
onChange={(next) => changeOutputSpec({ video_model_config_id: next })}
options={videoConfigs.map((config) => ({
value: config.id,
label: FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name),
}))}
/>
</label>
</div>
);
@@ -3222,27 +3231,42 @@ export function PipelinePage(props: {
沿,{setupProduct?.title || "当前商品"}
</div>
)}
<label className="setup-field">
<div className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupStructure} onChange={(e) => { setupTouched.current = true; setSetupStructure(e.target.value as VideoStructure); }}>
{structureOptions.map((k) => <option key={k} value={k}>{VIDEO_STRUCTURES[k]}</option>)}
</select>
</label>
<CustomSelect
fill
size="sm"
className="setup-select"
value={setupStructure}
onChange={(next) => { setupTouched.current = true; setSetupStructure(next as VideoStructure); }}
options={structureOptions.map((k) => ({ value: k, label: VIDEO_STRUCTURES[k] }))}
/>
</div>
<div className="setup-rec">{STRUCTURE_HINT[setupStructure]}</div>
<label className="setup-field">
<div className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupPersona} onChange={(e) => { setupTouched.current = true; setSetupPersona(e.target.value); }}>
{SETUP_PERSONA_KEYS.map((k) => <option key={k} value={k}>{WIZ_PERSONA_LABEL[k]}</option>)}
</select>
</label>
<CustomSelect
fill
size="sm"
className="setup-select"
value={setupPersona}
onChange={(next) => { setupTouched.current = true; setSetupPersona(next); }}
options={SETUP_PERSONA_KEYS.map((k) => ({ value: k, label: WIZ_PERSONA_LABEL[k] }))}
/>
</div>
<div className="setup-rec">{recommended.reason} · {WIZ_PERSONA_LABEL[recommended.persona] || recommended.persona}</div>
{/* 时长:15/30/45/60;每镜固定 15 秒 */}
<label className="setup-field">
<div className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupDuration} onChange={(e) => { setupTouched.current = true; setSetupDuration(clampDuration(Number(e.target.value))); }}>
{DURATION_OPTIONS.map((s) => <option key={s} value={s}>{s} </option>)}
</select>
</label>
<CustomSelect
fill
size="sm"
className="setup-select"
value={String(setupDuration)}
onChange={(next) => { setupTouched.current = true; setSetupDuration(clampDuration(Number(next))); }}
options={DURATION_OPTIONS.map((s) => ({ value: String(s), label: `${s}` }))}
/>
</div>
<div className={`setup-rec${durationHint ? " warn" : ""}`}>{durationHint || `口播 × ${VIDEO_STRUCTURES[setupStructure]}建议 ${durationSuggest} 秒左右`}</div>
<div className="setup-foot">
{/* ← 返回:收起设定卡,回到「脚本辅助生成 / 上传脚本」入口页 */}
@@ -4101,9 +4125,13 @@ export function PipelinePage(props: {
{voStale && <div style={{ fontSize: "12px", color: "#B45309", marginBottom: 6 }}>,,</div>}
<div className="props-row" style={{ marginBottom: 6 }}>
<span className="k"></span>
<select value={voVoicePick || voInfo?.voice_type || VO_VOICES[0].key} onChange={(e) => setVoVoicePick(e.target.value)} style={{ flex: 1, fontSize: "12px", padding: "4px 6px", border: "1px solid var(--border-faint)", borderRadius: "6px", background: "var(--surface)", color: "var(--accent-black)" }}>
{VO_VOICES.map((v) => <option key={v.key} value={v.key}>{v.label}</option>)}
</select>
<CustomSelect
fill
size="sm"
value={voVoicePick || voInfo?.voice_type || VO_VOICES[0].key}
onChange={setVoVoicePick}
options={VO_VOICES.map((v) => ({ value: v.key, label: v.label }))}
/>
</div>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-sm" type="button" disabled={loading || !edState.clips.some((c) => c.subtitle.trim())} onClick={() => onGenerateVoiceover({ items: edState.clips.map((c, idx) => ({ index: idx, text: c.subtitle.trim() })).filter((item) => item.text), voice_type: voVoicePick || voInfo?.voice_type || VO_VOICES[0].key })}>{voInfo ? "重新生成配音" : `生成配音 · ${pts(10)} 积分/500字`}</button>
+30 -20
View File
@@ -6,6 +6,7 @@ import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlay
import { useFileDrop } from "../components/use-file-drop";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { ProductCreateDrawer } from "../components/product-create-drawer";
import { CustomSelect } from "../components/custom-select";
import {
BUSINESS_TYPES,
BUSINESS_TYPE_KEYS,
@@ -527,15 +528,16 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)} required>
<option value=""> </option>
<optgroup label="常用品类">
{PC_CAT_PRIMARY.map((option) => <option key={option}>{option}</option>)}
</optgroup>
<optgroup label="更多品类">
{PC_CAT_MORE.map((option) => <option key={option}>{option}</option>)}
</optgroup>
</select>
<CustomSelect
fill
value={category}
onChange={setCategory}
placeholder="— 选择品类 —"
options={[
...PC_CAT_PRIMARY.map((option) => ({ value: option, label: option, group: "常用品类" })),
...PC_CAT_MORE.map((option) => ({ value: option, label: option, group: "更多品类" })),
]}
/>
</div>
<div className="field field-last">
<label className="field-label">()</label>
@@ -1069,23 +1071,31 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<div className="k"></div>
<div className="v">
<span className="v-static">{BUSINESS_TYPES[realType]}</span>
<select className="v-edit v-select" value={bizType} onChange={(event) => {
const next = event.target.value as BusinessType;
setBizType(next);
const opts = pdCatChoices(next, "");
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">
+14 -6
View File
@@ -5,6 +5,7 @@ import type { Asset, Product, Project, ScriptTemplate } from "../types";
import { api } from "../api";
import type { Page } from "./route-config";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
import { ProductCreateDrawer, type ProductCreatePayload } from "../components/product-create-drawer";
import { isLocalLife } from "../product-business";
import { Pager } from "../components/pager";
@@ -325,12 +326,19 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
{templates.length > 0 && (
<label className="nw-field">
<span><small></small></span>
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
<option value=""> · AI </option>
{templates.map((item) => (
<option key={item.id} value={item.id}>{item.name} · {item.total_duration}s{item.usage_count ? ` · 用过 ${item.usage_count}` : ""}</option>
))}
</select>
<CustomSelect
fill
value={templateId}
onChange={setTemplateId}
placeholder="不套用 · 让 AI 按这个商品从头设计"
options={[
{ value: "", label: "不套用 · 让 AI 按这个商品从头设计" },
...templates.map((item) => ({
value: item.id,
label: `${item.name} · ${item.total_duration}s${item.usage_count ? ` · 用过 ${item.usage_count}` : ""}`,
})),
]}
/>
{template && (
<div className="nw-tpl">
<strong></strong>
+30 -18
View File
@@ -20,6 +20,7 @@ import {
} from "lucide-react";
import { api, ApiError, type QuickCreateJob } from "../api";
import { ConfirmModal, OverlayPortal, useBodyScrollLock } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
import { useFileDrop } from "../components/use-file-drop";
import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob, stripQuickCreateSuffix } from "../quick-create-lock";
import type { ModelConfig } from "../types";
@@ -678,33 +679,44 @@ export function QuickCreatePage({
<div className="quick-parameter-grid" aria-label="视频核心参数">
<label className="quick-parameter-field">
<span></span>
<select value={aspectRatio} onChange={(event) => setAspectRatio(event.target.value)} disabled={isGenerating}>
{QUICK_RATIOS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<CustomSelect fill value={aspectRatio} onChange={setAspectRatio} disabled={isGenerating} options={QUICK_RATIOS} />
</label>
<label className="quick-parameter-field">
<span></span>
<select value={resolution} onChange={(event) => setResolution(event.target.value)} disabled={isGenerating}>
{QUICK_RESOLUTIONS.map((option) => (
<option key={option.value} value={option.value} disabled={Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value))}>
{option.label}
</option>
))}
</select>
<CustomSelect
fill
value={resolution}
onChange={setResolution}
disabled={isGenerating}
options={QUICK_RESOLUTIONS.map((option) => ({
...option,
disabled: Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value)),
}))}
/>
</label>
<label className="quick-parameter-field">
<span></span>
<select value={totalDuration} onChange={(event) => setTotalDuration(Number(event.target.value))} disabled={isGenerating}>
{QUICK_DURATIONS.map((duration) => <option key={duration} value={duration}>{duration / 15} {duration}s</option>)}
</select>
<CustomSelect
fill
value={String(totalDuration)}
onChange={(next) => setTotalDuration(Number(next))}
disabled={isGenerating}
options={QUICK_DURATIONS.map((duration) => ({ value: String(duration), label: `${duration / 15} 场(${duration}s` }))}
/>
</label>
<label className="quick-parameter-field">
<span></span>
<select value={videoModelId} onChange={(event) => setVideoModelId(event.target.value)} disabled={isGenerating || !videoConfigs.length}>
{videoConfigs.length ? videoConfigs.map((config) => (
<option key={config.id} value={config.id}>{FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name)}</option>
)) : <option value=""></option>}
</select>
<CustomSelect
fill
value={videoModelId}
onChange={setVideoModelId}
disabled={isGenerating || !videoConfigs.length}
placeholder="暂无可用视频模型"
options={videoConfigs.map((config) => ({
value: config.id,
label: FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name),
}))}
/>
</label>
</div>
+27 -26
View File
@@ -10,6 +10,7 @@ import {
} from "lucide-react";
import type { LoginSession, Team, User, UserPreference } from "../types";
import { TeamModal } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
import { useFileDrop } from "../components/use-file-drop";
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
@@ -620,23 +621,23 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"> BGM </div>
<div className="val">
<select className="select" value={draft.bgm} onChange={(event) => patchDraft("bgm", event.target.value)}>
<option value="kapian"> Top10 </option>
<option value="emotion"> · /</option>
<option value="urban"> · </option>
<option value="none"> BGM</option>
</select>
<CustomSelect fill value={draft.bgm} onChange={(next) => patchDraft("bgm", next)} options={[
{ value: "kapian", label: "抖音 Top10 卡点曲库" },
{ value: "emotion", label: "情绪向 · 治愈/悬念" },
{ value: "urban", label: "都市电子 · 通勤场景" },
{ value: "none", label: "无 BGM" },
]} />
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" value={draft.transition} onChange={(event) => patchDraft("transition", event.target.value)}>
<option value="none"></option>
<option value="fade"> · 0.3s</option>
<option value="slide"> · 0.3s</option>
<option value="zoom"> · 0.3s</option>
</select>
<CustomSelect fill value={draft.transition} onChange={(next) => patchDraft("transition", next)} options={[
{ value: "none", label: "无转场" },
{ value: "fade", label: "淡入淡出 · 0.3s" },
{ value: "slide", label: "滑动 · 0.3s" },
{ value: "zoom", label: "缩放 · 0.3s" },
]} />
</div>
</div>
<div className="form-row">
@@ -658,30 +659,30 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" value={draft.appearance} onChange={(event) => patchDraft("appearance", event.target.value)}>
<option value="system"></option>
<option value="light"></option>
<option value="dark" disabled>(V2)</option>
</select>
<CustomSelect fill value={draft.appearance} onChange={(next) => patchDraft("appearance", next)} options={[
{ value: "system", label: "跟随系统" },
{ value: "light", label: "浅色" },
{ value: "dark", label: "深色(V2)", disabled: true },
]} />
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" value={draft.language} onChange={(event) => patchDraft("language", event.target.value)}>
<option value="zh"></option>
<option value="en" disabled>English(V2)</option>
</select>
<CustomSelect fill value={draft.language} onChange={(next) => patchDraft("language", next)} options={[
{ value: "zh", label: "简体中文" },
{ value: "en", label: "English(V2)", disabled: true },
]} />
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" value={draft.density} onChange={(event) => patchDraft("density", event.target.value)}>
<option value="compact"></option>
<option value="standard"></option>
<option value="loose"></option>
</select>
<CustomSelect fill value={draft.density} onChange={(next) => patchDraft("density", next)} options={[
{ value: "compact", label: "紧凑" },
{ value: "standard", label: "标准" },
{ value: "loose", label: "宽松" },
]} />
</div>
</div>
</section>
+10 -4
View File
@@ -5,6 +5,7 @@ import type { BillingSummary, BillingTrend, Invitation, Notification, Team, Team
import type { Page } from "./route-config";
import { money } from "./stage-config";
import { ConfirmModal, TeamModal } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
import { Pager } from "../components/pager";
const MEMBERS_PER_PAGE = 10;
@@ -608,10 +609,15 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
<>
<div className="field">
<label className="field-label"></label>
<select className="input" value={invRole} onChange={(e) => setInvRole(e.target.value)}>
<option value="member"> · ,</option>
<option value="admin"> · //</option>
</select>
<CustomSelect
fill
value={invRole}
onChange={setInvRole}
options={[
{ value: "member", label: "成员 · 可用生成,不可管理团队" },
{ value: "admin", label: "团队管理员 · 可管成员/额度/邀请" },
]}
/>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">()<span className="lbl-note">(-1 )</span></label>
+1 -1
View File
@@ -267,7 +267,7 @@
.form-row .lbl .req { color: var(--accent-crimson); margin-left: 2px; }
.form-row .lbl-sub { font-size: 12px; color: var(--black-alpha-48); margin-top: 2px; }
.form-row .val { display: flex; align-items: center; gap: 10px; min-width: 0; }
.form-row .val .input, .form-row .val .select { width: 100%; max-width: 380px; }
.form-row .val .input, .form-row .val .select, .form-row .val .rs-select { width: 100%; max-width: 380px; }
.form-row .val .static { font-size: 13px; color: var(--accent-black); font-variant-numeric: tabular-nums; }
.form-row .val .static.mono { font-family: var(--font-mono); font-size: 13px; color: var(--black-alpha-56); }
.form-row .val .role-tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 12px; font-weight: 500; background: var(--heat-12); color: var(--heat); }