From 73da37b6cce05aba0f371b9ac5d8b84295366160 Mon Sep 17 00:00:00 2001 From: Coze Agent Date: Mon, 5 Oct 2026 15:27:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(viral-video):=20=E6=96=87=E6=A1=88?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=97=B6=E9=95=BF=E6=94=B9=E4=B8=BA15-30?= =?UTF-8?q?=E7=A7=92=E6=BB=9A=E8=BD=AEpicker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增通用 DurationWheelPicker 组件(scroll-snap 原生滚动,无额外依赖) * 浅色紫主题:选中行 #7c3aed 紫字加粗+#f5f0ff 浅紫背景条+上下分隔线 * 支持上下滑动/点击/鼠标滚轮,松手自动吸附到最近秒 * 上下两端渐变淡出,移动端流畅 * 可配置 min/max/step/height/unit/disabled - 爆款视频 Step1「文案视频时长」替换原 Select 下拉: * 范围 15–30 秒,步长 1 秒 * 默认值从 15 秒改为 20 秒 * label 右侧实时紫色徽章显示「N秒」当前值 * 底部提示「上下滑动选择 15–30 秒视频时长」 - CSS 新增 .vv-label-value 紫色小徽章样式 - 切换视频模型时仍按模型 max_duration 自动 clamp(seedance-2.5 支持 30s,其他 max=15 模型切回时自动落到 15s) 注:积分 UI 恢复(ENABLE_CREDIT_SYSTEM=true)已在 PR #2193 合入 develop,本 PR 不再包含。 --- .../DurationWheelPicker.css | 110 ++++++++++++ .../common/DurationWheelPicker/index.tsx | 157 ++++++++++++++++++ .../src/pages/viral-video/ViralVideoPage.css | 9 + .../src/pages/viral-video/ViralVideoPage.tsx | 21 ++- 4 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/common/DurationWheelPicker/DurationWheelPicker.css create mode 100644 apps/web/src/components/common/DurationWheelPicker/index.tsx diff --git a/apps/web/src/components/common/DurationWheelPicker/DurationWheelPicker.css b/apps/web/src/components/common/DurationWheelPicker/DurationWheelPicker.css new file mode 100644 index 000000000..93b59a03e --- /dev/null +++ b/apps/web/src/components/common/DurationWheelPicker/DurationWheelPicker.css @@ -0,0 +1,110 @@ +/* DurationWheelPicker —— 浅色紫主题滚轮时长选择器 */ +.dw-picker { + position: relative; + width: 100%; + overflow: hidden; + border-radius: 10px; + 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; + list-style: none; + height: 100%; + overflow-y: scroll; + scroll-snap-type: y mandatory; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} +.dw-picker-list::-webkit-scrollbar { + display: none; +} + +.dw-picker-item { + display: flex; + align-items: baseline; + justify-content: center; + gap: 4px; + scroll-snap-align: center; + cursor: pointer; + font-size: 20px; + color: #9ca3af; + font-weight: 400; + transition: + color 0.15s ease, + transform 0.15s ease, + font-weight 0.15s ease; +} +.dw-picker-item-val { + font-variant-numeric: tabular-nums; + font-size: 22px; +} +.dw-picker-item-unit { + font-size: 16px; + 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-mask { + position: absolute; + left: 8px; + right: 8px; + pointer-events: none; + background: #f5f0ff; + border-radius: 8px; + z-index: 1; +} +.dw-picker-mask::before, +.dw-picker-mask::after { + content: ""; + position: absolute; + left: 0; + right: 0; + height: 1px; + background: #d8c4ff; +} +.dw-picker-mask::before { + top: 0; +} +.dw-picker-mask::after { + bottom: 0; +} + +/* 上下渐变淡出 */ +.dw-picker-fade { + position: absolute; + left: 0; + right: 0; + height: 40%; + pointer-events: none; + z-index: 2; +} +.dw-picker-fade-top { + top: 0; + background: linear-gradient(to bottom, #fafafe 30%, rgba(250, 250, 254, 0)); +} +.dw-picker-fade-bottom { + bottom: 0; + background: linear-gradient(to top, #fafafe 30%, rgba(250, 250, 254, 0)); +} diff --git a/apps/web/src/components/common/DurationWheelPicker/index.tsx b/apps/web/src/components/common/DurationWheelPicker/index.tsx new file mode 100644 index 000000000..a087ca853 --- /dev/null +++ b/apps/web/src/components/common/DurationWheelPicker/index.tsx @@ -0,0 +1,157 @@ +/** + * DurationWheelPicker —— 竖屏滚轮式时长选择器 + * + * 设计要点: + * - 原生 scroll + scroll-snap 模拟移动端滚轮 picker,无需额外依赖 + * - 选中行有紫色背景高亮 + 放大加粗,视觉与爆款视频页紫白主题一致 + * - 支持触摸 / 鼠标滚轮 / 点击跳转;滑动松手后吸附到最近项 + * - 范围 15–30 秒,步长 1 秒 + */ +import React, { useEffect, useRef, useState, useCallback } from "react" +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 + /** 禁用 */ + disabled?: boolean +} + +const ITEM_HEIGHT = 40 + +const DurationWheelPicker: React.FC = ({ + value = 20, + min = 15, + max = 30, + step = 1, + unit = "秒", + onChange, + height = 200, + disabled = false, +}) => { + const options: number[] = [] + for (let v = min; v <= max; v += step) options.push(v) + + 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) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + useEffect(() => { + scrollToValue(active, false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const scrollToValue = useCallback( + (v: number, smooth = true) => { + const list = listRef.current + if (!list) return + const idx = options.indexOf(v) + if (idx < 0) return + const top = idx * ITEM_HEIGHT + list.scrollTo({ top, 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 + if (!list) return + const idx = Math.round(list.scrollTop / ITEM_HEIGHT) + const clamped = Math.max(0, Math.min(options.length - 1, idx)) + const targetTop = clamped * ITEM_HEIGHT + if (Math.abs(list.scrollTop - targetTop) > 1) { + list.scrollTo({ top: targetTop, behavior: "smooth" }) + } + computeActiveFromScroll() + isUserScrollingRef.current = false + }, 120) + } + + const handleItemClick = (v: number) => { + if (disabled) return + setActive(v) + onChange?.(v) + scrollToValue(v, true) + } + + const maskTop = height / 2 - ITEM_HEIGHT / 2 + + return ( +
+
+
+
+ +
    + {options.map((v) => { + const isActive = v === active + return ( +
  • handleItemClick(v)} + aria-selected={isActive} + role="option" + > + {v} + {unit} +
  • + ) + })} +
+
+ ) +} + +export default DurationWheelPicker diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.css b/apps/web/src/pages/viral-video/ViralVideoPage.css index d6d6b6dc1..438494d5b 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.css +++ b/apps/web/src/pages/viral-video/ViralVideoPage.css @@ -393,6 +393,15 @@ 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 29fb3db31..6e4d883f7 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.tsx +++ b/apps/web/src/pages/viral-video/ViralVideoPage.tsx @@ -30,6 +30,7 @@ import { uploadAssetDirect, getAssetLibraries, getAssetsByKind, type AssetItem } import { fetchPresetVoices } from "@/api/voices" import { getVoiceClones, getVoiceClonePreview } from "@/api/voice-clone" import type { VoiceClone } from "@/api/voice-clone" +import DurationWheelPicker from "@/components/common/DurationWheelPicker" import { FUSION_LEVELS, STYLE_STRENGTHS, @@ -218,7 +219,6 @@ const PURPOSES = [ "悬念短剧", "情绪短片", ] -const DURATIONS = [15, 20, 30, 45, 60] const RATIOS = [ { v: "9:16", label: "9:16 竖屏(抖音/视频号)" }, { v: "16:9", label: "16:9 横屏(B站/YouTube)" }, @@ -437,7 +437,7 @@ const emptyTask = (id: string, title: string): TabTask => ({ language: "中文(普通话)", viralStructure: STRUCTURES[0], marketingPurpose: "", - duration: 15, + duration: 20, persona: "", videoRatio: "9:16", videoModel: "seedance-2.5", @@ -2432,14 +2432,19 @@ const ViralVideoPage: React.FC = () => { />
- -