feat(viral-video): 文案视频时长改为15-30秒滚轮picker
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m25s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m20s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 3m51s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m9s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 7m0s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled

- 新增通用 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 不再包含。
This commit is contained in:
Coze Agent
2026-10-05 15:27:55 +08:00
parent b3b5dbd459
commit 73da37b6cc
4 changed files with 289 additions and 8 deletions
@@ -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));
}
@@ -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<DurationWheelPickerProps> = ({
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<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)
}
// 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 (
<div
className={`dw-picker${disabled ? " dw-picker-disabled" : ""}`}
style={{ height }}
aria-label="视频时长选择"
aria-disabled={disabled}
>
<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,
}}
>
{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>
)
}
export default DurationWheelPicker
@@ -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;
@@ -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 = () => {
/>
</div>
<div className="vv-form-row" style={{ gridColumn: "1 / -1" }}>
<label className="vv-label">文案视频时长</label>
<Select
className="vv-select"
style={{ width: "100%" }}
<label className="vv-label">
文案视频时长
<span className="vv-label-value">{task.duration}秒</span>
</label>
<DurationWheelPicker
value={task.duration}
onChange={(v) => setTask({ duration: v })}
options={DURATIONS.map((n) => ({ value: n, label: `${n}秒` }))}
min={15}
max={30}
step={1}
height={200}
onChange={(v: number) => setTask({ duration: v })}
/>
<div className="vv-form-hint">上下滑动选择 15–30 秒视频时长</div>
</div>
</div>