fix(ui): 模板选择预览条宽度缩短至1/3,素材卡片改为点击播放交互
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m45s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m6s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m26s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m49s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m40s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m50s
AI Code Review / AI Code Review (pull_request) Successful in 7m54s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m28s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 9m22s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m25s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 11m18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m10s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 11m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m56s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m55s
CI/CD Pipeline / Integration Tests (push) Successful in 7m8s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 4m1s
CI/CD Pipeline / Unit Tests (push) Successful in 16m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m53s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 16m48s
CI/CD Pipeline / CI Gate (pull_request) Successful in 24s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 479h15m12s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 479h15m13s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 479h15m14s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 479h15m15s
CI/CD Pipeline / Production Browser E2E (push) Failing after 479h15m56s
CI/CD Pipeline / Canary Release to Production (push) Failing after 479h15m57s
CI/CD Pipeline / Deploy Production (push) Failing after 479h15m58s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 479h16m2s
CI/CD Pipeline / CI Gate (push) Failing after 479h16m38s
CI/CD Pipeline / Build Production Web Image (push) Failing after 479h16m4s
CI/CD Pipeline / Build Production API Image (push) Failing after 479h16m6s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 479h23m28s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 479h23m30s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 479h23m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 479h28m57s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 479h32m0s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 479h32m2s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 479h32m48s
CI/CD Pipeline / PR Build Web Image (push) Failing after 479h32m50s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 479h33m12s
CI/CD Pipeline / PR Build API Image (push) Failing after 479h32m52s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 479h33m14s
CI/CD Pipeline / Frontend Lint (push) Failing after 479h33m10s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 479h33m34s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 479h33m16s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 479h38m20s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 479h49m12s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 479h49m15s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 480h3m10s

- generate.css: xx-choice-thumb 宽度从 100% 改为 33%(max-width 80px),居中显示
- ManualMaterialList.tsx: 重构为独立 MaterialCard 组件
  - 移除 hover 自动播放逻辑
  - 默认显示视频封面/缩略图(非黑色)
  - 点击播放按钮开始播放,播放时隐藏按钮
  - 播放中点击视频区域可暂停并恢复按钮
  - 卡片内嵌播放,无弹窗
This commit is contained in:
xiaoxia
2026-08-27 15:47:28 +08:00
parent 195339d0f8
commit c2fe02cf06
2 changed files with 231 additions and 204 deletions
@@ -1,7 +1,8 @@
/**
* 手动选择素材列表 — 竖屏 9:16 卡片网格
* 交互:默认显示封面,点击播放按钮播放,播放中隐藏按钮,点击视频区域暂停
*/
import React, { useRef, useCallback } from "react"
import React, { useRef, useState, useCallback } from "react"
import { Typography } from "antd"
import type { AssetItem } from "@/api/assets"
@@ -22,37 +23,230 @@ const fmtDuration = (seconds?: number): string => {
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
}
/** 单个素材卡片 */
const MaterialCard: React.FC<{
asset: AssetItem
checked: boolean
onToggle: () => void
}> = ({ asset, checked, onToggle }) => {
const videoRef = useRef<HTMLVideoElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const isVideo = asset.mime_type?.startsWith("video/") ?? false
const thumbSrc = asset.thumbnail_url || undefined
const handlePlayToggle = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const video = videoRef.current
if (!video || !isVideo) return
if (isPlaying) {
video.pause()
setIsPlaying(false)
} else {
video.play().catch(() => {})
setIsPlaying(true)
}
},
[isPlaying, isVideo],
)
const handleVideoEnded = useCallback(() => {
setIsPlaying(false)
}, [])
const handleCardClick = useCallback(() => {
// 如果视频正在播放,点击卡片空白区域暂停视频
if (isPlaying) {
const video = videoRef.current
if (video) {
video.pause()
setIsPlaying(false)
}
return
}
onToggle()
}, [isPlaying, onToggle])
return (
<div
onClick={handleCardClick}
style={{
position: "relative",
aspectRatio: "9 / 16",
borderRadius: 10,
overflow: "hidden",
cursor: "pointer",
border: checked ? "2px solid var(--primary-color, #4f46e5)" : "2px solid transparent",
boxShadow: checked ? "0 0 0 2px rgba(79, 70, 229, 0.2)" : "0 1px 3px rgba(0, 0, 0, 0.1)",
background: "#1e293b",
transition: "all 0.15s ease",
}}
>
{/* 视频元素 */}
{isVideo && asset.file_url ? (
<video
ref={videoRef}
src={asset.file_url}
poster={thumbSrc}
muted
loop
playsInline
preload="metadata"
onEnded={handleVideoEnded}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : thumbSrc ? (
<img
src={thumbSrc}
alt={asset.name}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = "none"
}}
/>
) : (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #334155, #1e293b)",
color: "rgba(255,255,255,0.5)",
fontSize: 28,
}}
>
{isVideo ? "🎬" : "🎵"}
</div>
)}
{/* 底部渐变遮罩 */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: "50%",
background: "linear-gradient(0deg, rgba(0,0,0,0.6) 0%, transparent 100%)",
pointerEvents: "none",
}}
/>
{/* 播放按钮 — 播放中隐藏 */}
{!isPlaying && (
<div
onClick={handlePlayToggle}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 36,
height: 36,
borderRadius: "50%",
background: "rgba(99, 102, 241, 0.85)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 3,
transition: "opacity 0.2s ease",
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
<path d="M8 5v14l11-7z" />
</svg>
</div>
)}
{/* 文件名(左下角) */}
<div
style={{
position: "absolute",
bottom: 6,
left: 6,
right: 50,
color: "white",
fontSize: 11,
fontWeight: 500,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
textShadow: "0 1px 2px rgba(0,0,0,0.5)",
pointerEvents: "none",
zIndex: 1,
}}
>
{asset.name}
</div>
{/* 时长(右下角) */}
<div
style={{
position: "absolute",
bottom: 6,
right: 6,
background: "rgba(0, 0, 0, 0.7)",
color: "white",
padding: "1px 5px",
borderRadius: 3,
fontSize: 10,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
pointerEvents: "none",
zIndex: 1,
}}
>
{fmtDuration(asset.duration)}
</div>
{/* 选中勾选标记(左上角) */}
{checked && (
<div
style={{
position: "absolute",
top: 6,
left: 6,
width: 20,
height: 20,
borderRadius: "50%",
background: "var(--primary-color, #4f46e5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: 12,
fontWeight: 700,
zIndex: 2,
pointerEvents: "none",
}}
>
✓
</div>
)}
</div>
)
}
const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
materials,
materialsLoading,
selectedMaterials,
onToggle,
}) => {
const activeVideoRef = useRef<HTMLVideoElement | null>(null)
const handleVideoMouseEnter = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
const video = e.currentTarget
if (
activeVideoRef.current &&
activeVideoRef.current !== video &&
document.body.contains(activeVideoRef.current)
) {
activeVideoRef.current.pause()
activeVideoRef.current.currentTime = 0
}
activeVideoRef.current = video
video.play().catch(() => {})
}, [])
const handleVideoMouseLeave = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
const video = e.currentTarget
video.pause()
video.currentTime = 0
if (activeVideoRef.current === video) {
activeVideoRef.current = null
}
}, [])
return (
<div style={{ marginTop: 14 }}>
{materialsLoading ? (
@@ -69,183 +263,14 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
gap: 10,
}}
>
{materials.items.map((m) => {
const checked = selectedMaterials.includes(m.id)
const isVideo = m.mime_type?.startsWith("video/") ?? false
const thumbSrc = m.thumbnail_url || undefined
return (
<div
key={m.id}
onClick={() => onToggle(m.id)}
style={{
position: "relative",
aspectRatio: "9 / 16",
borderRadius: 10,
overflow: "hidden",
cursor: "pointer",
border: checked
? "2px solid var(--primary-color, #4f46e5)"
: "2px solid transparent",
boxShadow: checked
? "0 0 0 2px rgba(79, 70, 229, 0.2)"
: "0 1px 3px rgba(0, 0, 0, 0.1)",
background: "#1e293b",
transition: "all 0.15s ease",
}}
>
{/* 视频/图片封面 */}
{isVideo && m.file_url ? (
<video
src={m.file_url}
poster={m.thumbnail_url || undefined}
muted
loop
playsInline
preload="none"
onMouseEnter={handleVideoMouseEnter}
onMouseLeave={handleVideoMouseLeave}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : thumbSrc ? (
<img
src={thumbSrc}
alt={m.name}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = "none"
}}
/>
) : (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #334155, #1e293b)",
color: "rgba(255,255,255,0.5)",
fontSize: 28,
}}
>
{isVideo ? "🎬" : "🎵"}
</div>
)}
{/* 底部渐变遮罩 */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: "50%",
background: "linear-gradient(0deg, rgba(0,0,0,0.6) 0%, transparent 100%)",
pointerEvents: "none",
}}
/>
{/* 播放按钮(中央) */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 32,
height: 32,
borderRadius: "50%",
background: "rgba(99, 102, 241, 0.85)",
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="white">
<path d="M8 5v14l11-7z" />
</svg>
</div>
{/* 文件名(左下角) */}
<div
style={{
position: "absolute",
bottom: 6,
left: 6,
right: 50,
color: "white",
fontSize: 11,
fontWeight: 500,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
textShadow: "0 1px 2px rgba(0,0,0,0.5)",
pointerEvents: "none",
zIndex: 1,
}}
>
{m.name}
</div>
{/* 时长(右下角) */}
<div
style={{
position: "absolute",
bottom: 6,
right: 6,
background: "rgba(0, 0, 0, 0.7)",
color: "white",
padding: "1px 5px",
borderRadius: 3,
fontSize: 10,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
pointerEvents: "none",
zIndex: 1,
}}
>
{fmtDuration(m.duration)}
</div>
{/* 选中勾选标记(左上角) */}
{checked && (
<div
style={{
position: "absolute",
top: 6,
left: 6,
width: 20,
height: 20,
borderRadius: "50%",
background: "var(--primary-color, #4f46e5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: 12,
fontWeight: 700,
zIndex: 2,
pointerEvents: "none",
}}
>
✓
</div>
)}
</div>
)
})}
{materials.items.map((asset) => (
<MaterialCard
key={asset.id}
asset={asset}
checked={selectedMaterials.includes(asset.id)}
onToggle={() => onToggle(asset.id)}
/>
))}
</div>
)}
</div>
+3 -1
View File
@@ -219,6 +219,8 @@
}
.xx-choice-thumb {
width: 33%;
max-width: 80px;
height: 36px;
border-radius: var(--radius-sm);
display: grid;
@@ -226,7 +228,7 @@
color: var(--text-inverse);
font-size: 16px;
font-weight: 700;
margin-bottom: 6px;
margin: 0 auto 6px;
}
.xx-choice-item h4 {