From 14078ab85c38964865750476030eca88d4445e06 Mon Sep 17 00:00:00 2001 From: Coze Agent Date: Mon, 5 Oct 2026 16:49:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(viral-video):=20=E6=97=B6=E9=95=BF=E6=BB=9A?= =?UTF-8?q?=E8=BD=AE=E6=94=B9=E4=B8=BA=E5=BC=B9=E5=B1=82=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=E8=A1=A8=E5=8D=95=E5=A4=96=E8=A7=82=E4=B8=8E?= =?UTF-8?q?=E5=85=B6=E4=BB=96=E5=AD=97=E6=AE=B5=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DurationWheelPicker 改为 Popover 弹层式:触发按钮外观与 .vv-select 下拉完全一致 (白底+1px #e0e0e8 边+hover 深灰边+focus 紫色 ring+下拉箭头) - 点击触发按钮弹出滚轮面板(白卡片+浅紫滚轮),含「取消/确认」按钮 - 滚轮内部交互不变:scroll-snap、触摸/滚轮/点击、选中紫字加粗+浅紫高亮条 - 移除常驻大滚轮块、label 右侧紫色徽章、底部提示文字 - 移除 .vv-label-value 样式(已不再使用) - 默认 20s、15-30s步长1s、模型切换clamp 逻辑保持 --- .../DurationWheelPicker.css | 132 +++++++++--- .../common/DurationWheelPicker/index.tsx | 196 ++++++++++-------- .../src/pages/viral-video/ViralVideoPage.css | 9 - .../src/pages/viral-video/ViralVideoPage.tsx | 11 +- 4 files changed, 213 insertions(+), 135 deletions(-) diff --git a/apps/web/src/components/common/DurationWheelPicker/DurationWheelPicker.css b/apps/web/src/components/common/DurationWheelPicker/DurationWheelPicker.css index 93b59a03e..610357bb6 100644 --- a/apps/web/src/components/common/DurationWheelPicker/DurationWheelPicker.css +++ b/apps/web/src/components/common/DurationWheelPicker/DurationWheelPicker.css @@ -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; } diff --git a/apps/web/src/components/common/DurationWheelPicker/index.tsx b/apps/web/src/components/common/DurationWheelPicker/index.tsx index 09faa4245..55d4c0dee 100644 --- a/apps/web/src/components/common/DurationWheelPicker/index.tsx +++ b/apps/web/src/components/common/DurationWheelPicker/index.tsx @@ -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 = ({ value = 20, @@ -38,33 +37,31 @@ const DurationWheelPicker: React.FC = ({ 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(value) const listRef = useRef(null) - const [active, setActive] = useState(value) - const isUserScrollingRef = useRef(false) const scrollTimerRef = useRef | 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 = ({ 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 = ({ 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 = ( +
+
+
+
+
+
    + {options.map((v) => { + const isActive = v === draft + return ( +
  • handleItemClick(v)} + aria-selected={isActive} + role="option" + > + {v} + {unit} +
  • + ) + })} +
+
+
+ + +
+
+ ) return ( -
setOpen(v)} + content={wheel} + trigger="click" + placement="bottomLeft" + overlayClassName="dw-popover" + overlayStyle={{ padding: 0 }} + overlayInnerStyle={{ padding: 0, borderRadius: 10 }} + destroyTooltipOnHide > -
-
-
- -
    - {options.map((v) => { - const isActive = v === active - return ( -
  • handleItemClick(v)} - aria-selected={isActive} - role="option" - > - {v} - {unit} -
  • - ) - })} -
-
+ + {value != null ? `${value}${unit}` : placeholder} + + +
+ ) } diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.css b/apps/web/src/pages/viral-video/ViralVideoPage.css index 438494d5b..d6d6b6dc1 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.css +++ b/apps/web/src/pages/viral-video/ViralVideoPage.css @@ -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; diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.tsx b/apps/web/src/pages/viral-video/ViralVideoPage.tsx index 6e4d883f7..07cfe8dd2 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.tsx +++ b/apps/web/src/pages/viral-video/ViralVideoPage.tsx @@ -2431,20 +2431,15 @@ const ViralVideoPage: React.FC = () => { options={PURPOSES.map((i) => ({ value: i, label: i }))} />
-
- +
+ setTask({ duration: v })} + onChange={(v) => setTask({ duration: v })} /> -
上下滑动选择 15–30 秒视频时长
-- 2.54.0