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
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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user