fix(viral-video): 时长滚轮改为弹层式,保持与其他表单项外观一致 #2199

Merged
xiaoxia merged 1 commits from fix/vv-duration-picker-popup into develop 2026-10-05 17:09:06 +08:00
4 changed files with 213 additions and 135 deletions
@@ -1,20 +1,70 @@
/* DurationWheelPicker —— 浅色紫主题滚轮时长选择器 */
/* DurationWheelPicker —— 弹层式滚轮选择器(样式与表单一致) */
/* 触发按钮:外观复用 .vv-select 风格 */
.dw-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 36px;
padding: 0 12px;
background: #fff;
border: 1px solid #e0e0e8;
border-radius: 8px;
font-size: 13px;
color: #1f2937;
cursor: pointer;
box-sizing: border-box;
transition: all 0.15s;
user-select: none;
}
.dw-trigger:hover {
border-color: #c0c0d0;
}
.dw-trigger-open,
.dw-trigger:focus-within {
border-color: #7c3aed !important;
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.12);
}
.dw-trigger-disabled {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed;
}
.dw-trigger-val {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dw-trigger-placeholder {
color: #9ca3af;
}
.dw-trigger-arrow {
font-size: 10px;
color: #9ca3af;
margin-left: 8px;
transition: transform 0.2s;
}
.dw-trigger-arrow-up {
transform: rotate(180deg);
}
/* 弹层容器 */
.dw-popup {
padding: 8px;
min-width: 140px;
}
/* 滚轮 */
.dw-picker {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 10px;
border-radius: 8px;
background: #fafafe;
border: 1px solid #e5e7eb;
user-select: none;
-webkit-user-select: none;
}
.dw-picker-disabled {
opacity: 0.5;
pointer-events: none;
}
.dw-picker-list {
margin: 0;
padding: 0;
@@ -28,51 +78,47 @@
.dw-picker-list::-webkit-scrollbar {
display: none;
}
.dw-picker-item {
display: flex;
align-items: baseline;
justify-content: center;
gap: 4px;
gap: 3px;
scroll-snap-align: center;
cursor: pointer;
font-size: 20px;
font-size: 15px;
color: #9ca3af;
font-weight: 400;
transition:
color 0.15s ease,
transform 0.15s ease,
font-weight 0.15s ease;
color 0.15s,
transform 0.15s,
font-weight 0.15s;
}
.dw-picker-item-val {
font-variant-numeric: tabular-nums;
font-size: 22px;
}
.dw-picker-item-unit {
font-size: 16px;
font-size: 13px;
color: inherit;
}
.dw-picker-item-active {
color: #7c3aed;
font-weight: 600;
transform: scale(1.12);
}
.dw-picker-item-active .dw-picker-item-val {
font-size: 28px;
}
.dw-picker-item-active .dw-picker-item-unit {
font-size: 18px;
}
.dw-picker-item-active .dw-picker-item-unit {
font-size: 14px;
}
/* 中心选中条:浅紫背景 + 上下分隔线 */
/* 中心选中条 */
.dw-picker-mask {
position: absolute;
left: 8px;
right: 8px;
left: 6px;
right: 6px;
pointer-events: none;
background: #f5f0ff;
border-radius: 8px;
border-radius: 6px;
z-index: 1;
}
.dw-picker-mask::before,
@@ -91,7 +137,7 @@
bottom: 0;
}
/* 上下渐变淡出 */
/* 上下渐变 */
.dw-picker-fade {
position: absolute;
left: 0;
@@ -102,9 +148,35 @@
}
.dw-picker-fade-top {
top: 0;
background: linear-gradient(to bottom, #fafafe 30%, rgba(250, 250, 254, 0));
background: linear-gradient(to bottom, #fafafe 25%, rgba(250, 250, 254, 0));
}
.dw-picker-fade-bottom {
bottom: 0;
background: linear-gradient(to top, #fafafe 30%, rgba(250, 250, 254, 0));
background: linear-gradient(to top, #fafafe 25%, rgba(250, 250, 254, 0));
}
/* 弹层按钮区 */
.dw-popup-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
.dw-popup-actions .ant-btn {
border-radius: 6px;
}
.dw-popup-actions .ant-btn-primary {
background: #7c3aed;
}
.dw-popup-actions .ant-btn-primary:hover {
background: #6d28d9 !important;
}
/* 覆盖 antd Popover 默认内边距 */
.dw-popover .ant-popover-inner {
padding: 0 !important;
overflow: hidden;
}
.dw-popover .ant-popover-arrow {
display: none;
}
@@ -1,35 +1,34 @@
/**
* DurationWheelPicker —— 竖屏滚轮式时长选择器
* DurationWheelPicker —— 竖屏滚轮式时长选择器(弹层版)
*
* 设计要点:
* - 原生 scroll + scroll-snap 模拟移动端滚轮 picker,无需额外依赖
* - 选中行有紫色背景高亮 + 放大加粗,视觉与爆款视频页紫白主题一致
* - 支持触摸 / 鼠标滚轮 / 点击跳转;滑动松手后吸附到最近项
* - 范围 15–30 秒,步长 1 秒
* 设计:
* - 外观是和其他表单 Select 一致的输入框(白色底+1px灰边+紫色focus ring)
* - 点击输入框弹出 Popover,内部是滚轮 picker(原生 scroll-snap,零依赖)
* - 滚轮样式:白底容器,选中行 #7c3aed 紫字加粗+浅紫背景条
* - 支持触摸/鼠标滚轮/点击;松手吸附;底部"确认/取消"按钮
* - 默认范围 15–30 秒,步长 1 秒
*/
import React, { useEffect, useRef, useState, useCallback, useMemo } from "react"
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react"
import { Popover, Button } from "antd"
import { DownOutlined } from "@ant-design/icons"
import "./DurationWheelPicker.css"
export interface DurationWheelPickerProps {
/** 当前选中值(秒) */
value?: number
/** 最小值,默认 15 */
min?: number
/** 最大值,默认 30 */
max?: number
/** 步长,默认 1 */
step?: number
/** 单位文案,默认 "秒" */
unit?: string
/** 选择回调 */
onChange?: (value: number) => void
/** 容器高度,默认 200px(约 5 行可见) */
height?: number
/** 禁用 */
placeholder?: string
disabled?: boolean
/** 弹层宽度,默认 160px */
popupWidth?: number
/** 弹层内滚轮高度,默认 180px */
wheelHeight?: number
}
const ITEM_HEIGHT = 40
const ITEM_HEIGHT = 36
const DurationWheelPicker: React.FC<DurationWheelPickerProps> = ({
value = 20,
@@ -38,33 +37,31 @@ const DurationWheelPicker: React.FC<DurationWheelPickerProps> = ({
step = 1,
unit = "秒",
onChange,
height = 200,
placeholder = "请选择时长",
disabled = false,
popupWidth = 160,
wheelHeight = 180,
}) => {
const options: number[] = useMemo(() => {
const options = useMemo(() => {
const arr: number[] = []
for (let v = min; v <= max; v += step) arr.push(v)
return arr
}, [min, max, step])
const [open, setOpen] = useState(false)
// 弹层内暂存值,点确认才提交
const [draft, setDraft] = useState<number>(value)
const listRef = useRef<HTMLUListElement>(null)
const [active, setActive] = useState<number>(value)
const isUserScrollingRef = useRef(false)
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (isUserScrollingRef.current) return
if (value !== active) {
setActive(value)
scrollToValue(value, false)
if (open) {
setDraft(value)
// 下一帧滚到当前值
requestAnimationFrame(() => scrollToValue(value, false))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
useEffect(() => {
scrollToValue(active, false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [open])
const scrollToValue = useCallback(
(v: number, smooth = true) => {
@@ -72,26 +69,12 @@ const DurationWheelPicker: React.FC<DurationWheelPickerProps> = ({
if (!list) return
const idx = options.indexOf(v)
if (idx < 0) return
const top = idx * ITEM_HEIGHT
list.scrollTo({ top, behavior: smooth ? "smooth" : "auto" })
list.scrollTo({ top: idx * ITEM_HEIGHT, behavior: smooth ? "smooth" : "auto" })
},
[options],
)
const computeActiveFromScroll = useCallback(() => {
const list = listRef.current
if (!list) return
const idx = Math.round(list.scrollTop / ITEM_HEIGHT)
const clamped = Math.max(0, Math.min(options.length - 1, idx))
const v = options[clamped]
if (v !== active) {
setActive(v)
onChange?.(v)
}
}, [active, onChange, options])
const handleScroll = () => {
isUserScrollingRef.current = true
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current)
scrollTimerRef.current = setTimeout(() => {
const list = listRef.current
@@ -102,58 +85,95 @@ const DurationWheelPicker: React.FC<DurationWheelPickerProps> = ({
if (Math.abs(list.scrollTop - targetTop) > 1) {
list.scrollTo({ top: targetTop, behavior: "smooth" })
}
computeActiveFromScroll()
isUserScrollingRef.current = false
}, 120)
setDraft(options[clamped])
}, 100)
}
const handleConfirm = () => {
onChange?.(draft)
setOpen(false)
}
const handleCancel = () => {
setOpen(false)
}
const handleItemClick = (v: number) => {
if (disabled) return
setActive(v)
onChange?.(v)
setDraft(v)
scrollToValue(v, true)
}
const maskTop = height / 2 - ITEM_HEIGHT / 2
const maskTop = wheelHeight / 2 - ITEM_HEIGHT / 2
const wheel = (
<div className="dw-popup">
<div
className="dw-picker"
style={{ height: wheelHeight, width: popupWidth - 24 /* padding */ }}
>
<div className="dw-picker-mask" style={{ top: maskTop, height: ITEM_HEIGHT }} aria-hidden />
<div className="dw-picker-fade dw-picker-fade-top" aria-hidden />
<div className="dw-picker-fade dw-picker-fade-bottom" aria-hidden />
<ul
ref={listRef}
className="dw-picker-list"
onScroll={handleScroll}
style={{
paddingTop: wheelHeight / 2 - ITEM_HEIGHT / 2,
paddingBottom: wheelHeight / 2 - ITEM_HEIGHT / 2,
}}
>
{options.map((v) => {
const isActive = v === draft
return (
<li
key={v}
className={`dw-picker-item${isActive ? " dw-picker-item-active" : ""}`}
style={{ height: ITEM_HEIGHT, lineHeight: `${ITEM_HEIGHT}px` }}
onClick={() => handleItemClick(v)}
aria-selected={isActive}
role="option"
>
<span className="dw-picker-item-val">{v}</span>
<span className="dw-picker-item-unit">{unit}</span>
</li>
)
})}
</ul>
</div>
<div className="dw-popup-actions">
<Button size="small" onClick={handleCancel}>
取消
</Button>
<Button size="small" type="primary" onClick={handleConfirm}>
确认
</Button>
</div>
</div>
)
return (
<div
className={`dw-picker${disabled ? " dw-picker-disabled" : ""}`}
style={{ height }}
aria-label="视频时长选择"
aria-disabled={disabled}
<Popover
open={!disabled && open}
onOpenChange={(v) => setOpen(v)}
content={wheel}
trigger="click"
placement="bottomLeft"
overlayClassName="dw-popover"
overlayStyle={{ padding: 0 }}
overlayInnerStyle={{ padding: 0, borderRadius: 10 }}
destroyTooltipOnHide
>
<div className="dw-picker-mask" style={{ top: maskTop, height: ITEM_HEIGHT }} aria-hidden />
<div className="dw-picker-fade dw-picker-fade-top" aria-hidden />
<div className="dw-picker-fade dw-picker-fade-bottom" aria-hidden />
<ul
ref={listRef}
className="dw-picker-list"
onScroll={handleScroll}
style={{
paddingTop: height / 2 - ITEM_HEIGHT / 2,
paddingBottom: height / 2 - ITEM_HEIGHT / 2,
}}
<div
className={`dw-trigger${disabled ? " dw-trigger-disabled" : ""}${open ? " dw-trigger-open" : ""}`}
style={{ height: 36 }}
>
{options.map((v) => {
const isActive = v === active
return (
<li
key={v}
className={`dw-picker-item${isActive ? " dw-picker-item-active" : ""}`}
style={{ height: ITEM_HEIGHT, lineHeight: `${ITEM_HEIGHT}px` }}
onClick={() => handleItemClick(v)}
aria-selected={isActive}
role="option"
>
<span className="dw-picker-item-val">{v}</span>
<span className="dw-picker-item-unit">{unit}</span>
</li>
)
})}
</ul>
</div>
<span className={`dw-trigger-val${value != null ? "" : " dw-trigger-placeholder"}`}>
{value != null ? `${value}${unit}` : placeholder}
</span>
<DownOutlined className={`dw-trigger-arrow${open ? " dw-trigger-arrow-up" : ""}`} />
</div>
</Popover>
)
}
@@ -393,15 +393,6 @@
margin-bottom: 6px;
font-weight: 500;
}
.vv-label-value {
margin-left: 8px;
padding: 2px 8px;
border-radius: 6px;
background: #f5f0ff;
color: #7c3aed;
font-size: 12px;
font-weight: 600;
}
.vv-label-optional {
color: #9ca3af;
font-weight: 400;
@@ -2431,20 +2431,15 @@ const ViralVideoPage: React.FC = () => {
options={PURPOSES.map((i) => ({ value: i, label: i }))}
/>
</div>
<div className="vv-form-row" style={{ gridColumn: "1 / -1" }}>
<label className="vv-label">
文案视频时长
<span className="vv-label-value">{task.duration}秒</span>
</label>
<div className="vv-form-row">
<label className="vv-label">文案视频时长</label>
<DurationWheelPicker
value={task.duration}
min={15}
max={30}
step={1}
height={200}
onChange={(v: number) => setTask({ duration: v })}
onChange={(v) => setTask({ duration: v })}
/>
<div className="vv-form-hint">上下滑动选择 15–30 秒视频时长</div>
</div>
</div>