feat(#584): 智能素材匹配前端页面框架
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 52s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m43s
AI Code Review / AI Code Review (pull_request) Successful in 3m59s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1407h32m8s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1407h32m11s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1407h32m31s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1407h32m34s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1407h32m34s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1407h32m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1407h32m34s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1407h32m35s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1407h32m35s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1407h32m36s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1407h32m36s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1408h4m40s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1408h4m41s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1408h4m42s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1408h4m17s

- 智能剪辑新增智能素材匹配交互:用户输入描述后AI推荐素材
- 支持推荐素材列表展示(匹配度+匹配理由+缩略图+时长)
- 支持勾选/取消勾选、全选/清空、换一批等交互
- auto模式下使用智能匹配选中的素材生成视频
- 生成预览和确认步骤显示智能匹配的素材数量
- 下一步验证:auto模式需选中素材才能继续
- 暂用模拟数据,后端LLM接入后替换
This commit is contained in:
2026-07-19 22:40:13 +08:00
parent dbe8580f85
commit 856647a4e5
2 changed files with 522 additions and 36 deletions
+277 -36
View File
@@ -266,6 +266,18 @@ const GeneratePage: React.FC = () => {
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
/* 素材选择模式:手动选择 / 自动匹配 */
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
/* 智能素材匹配:用户描述输入 */
const [smartMatchInput, setSmartMatchInput] = useState("")
/* 智能素材匹配:是否正在匹配中 */
const [smartMatching, setSmartMatching] = useState(false)
/* 智能素材匹配:推荐结果列表 */
const [smartMatchedResults, setSmartMatchedResults] = useState<
Array<{ asset: AssetItem; matchScore: number; matchReason: string }>
>([])
/* 智能素材匹配:已选择的素材ID(auto模式下使用) */
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
/* 智能素材匹配:是否已执行过匹配 */
const [hasMatched, setHasMatched] = useState(false)
/* ── 标题设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
@@ -471,6 +483,102 @@ const GeneratePage: React.FC = () => {
enabled: !!selectedLibraryId,
})
/* ── 智能素材匹配 ── */
const SMART_MATCH_REASONS = [
"画面清晰度高,构图专业",
"与描述场景高度契合",
"时长适中,适合剪辑节奏",
"色彩风格统一",
"包含关键动作镜头",
"镜头运动流畅自然",
"光影效果出色",
"人物表情生动",
]
const handleSmartMatch = useCallback(async () => {
if (!smartMatchInput.trim()) {
message.warning("请先输入视频内容描述")
return
}
if (materials.items.length === 0) {
message.warning("当前视频库暂无素材")
return
}
setSmartMatching(true)
setHasMatched(true)
// 模拟 AI 匹配延迟
await new Promise((resolve) => setTimeout(resolve, 1500))
// 从素材库中随机选取 5-8 个作为推荐结果
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
const picked = shuffled.slice(0, count)
const results = picked.map((asset, idx) => ({
asset,
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
matchReason:
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
}))
// 按匹配度从高到低排序
results.sort((a, b) => b.matchScore - a.matchScore)
setSmartMatchedResults(results)
// 默认选中匹配度 >= 90 的素材
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
setSmartSelectedIds(
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
)
setSmartMatching(false)
}, [smartMatchInput, materials.items])
const handleToggleSmartSelect = useCallback((assetId: string) => {
setSmartSelectedIds((prev) =>
prev.includes(assetId) ? prev.filter((id) => id !== assetId) : [...prev, assetId],
)
}, [])
const handleRefreshMatch = useCallback(async () => {
if (materials.items.length <= 5) {
message.info("素材库素材较少,无法换一批")
return
}
setSmartMatching(true)
await new Promise((resolve) => setTimeout(resolve, 800))
const remaining = materials.items.filter(
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
)
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
const picked = shuffled.slice(0, count)
const results = picked.map((asset, idx) => ({
asset,
matchScore: Math.round(80 + Math.random() * 19),
matchReason:
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
(Math.random() > 0.5 ? ",节奏明快" : ""),
}))
results.sort((a, b) => b.matchScore - a.matchScore)
setSmartMatchedResults(results)
setSmartSelectedIds([])
setSmartMatching(false)
}, [materials.items, smartMatchedResults])
const handleSelectAllMatched = useCallback(() => {
setSmartSelectedIds(smartMatchedResults.map((r) => r.asset.id))
}, [smartMatchedResults])
const handleClearSmartSelect = useCallback(() => {
setSmartSelectedIds([])
}, [])
const handleCloneSuccess = useCallback(
(voice: VoiceClone) => {
addClone(voice)
@@ -700,7 +808,7 @@ const GeneratePage: React.FC = () => {
template_id: selectedTemplate,
name: titleSettings.title.trim(),
config: {
asset_ids: selectedMaterials,
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
@@ -905,6 +1013,7 @@ const GeneratePage: React.FC = () => {
generateCount,
materialMode,
coverSettings,
smartSelectedIds,
])
/* ── 下载视频 ── */
@@ -953,6 +1062,10 @@ const GeneratePage: React.FC = () => {
message.warning("请至少选择一个素材")
return
}
if (currentStep === 2 && materialMode === "auto" && smartSelectedIds.length === 0) {
message.warning("请先进行智能匹配并选择素材")
return
}
if (currentStep === 4 && !titleSettings.title.trim()) {
message.warning("请选择或输入标题")
return
@@ -960,7 +1073,14 @@ const GeneratePage: React.FC = () => {
if (currentStep < 7) {
setCurrentStep((s) => s + 1)
}
}, [currentStep, selectedTemplate, selectedMaterials.length, titleSettings, materialMode])
}, [
currentStep,
selectedTemplate,
selectedMaterials.length,
titleSettings,
materialMode,
smartSelectedIds.length,
])
const goPrev = useCallback(() => {
if (currentStep > 1) {
@@ -1176,40 +1296,157 @@ const GeneratePage: React.FC = () => {
{/* ── 自动匹配模式 ── */}
{materialMode === "auto" && (
<div className="xx-auto-match-card">
<div className="xx-auto-match-icon">🤖</div>
<div className="xx-auto-match-body">
<h4 className="xx-auto-match-title">智能素材匹配</h4>
<p className="xx-auto-match-desc">
系统将根据所选模板和标题,从视频库中自动分析并匹配最合适的素材进行视频生成。
无需手动挑选,AI 会综合素材质量、时长、内容相关性等维度进行智能筛选。
</p>
<div className="xx-auto-match-features">
<span className="xx-auto-match-feature">📊 质量评分筛选</span>
<span className="xx-auto-match-feature">🎯 内容相关性匹配</span>
<span className="xx-auto-match-feature">⏱️ 时长智能分配</span>
<div className="xx-smart-match-section">
{/* 描述输入区 */}
<div className="xx-smart-match-input-area">
<label className="xx-smart-match-label">🤖 描述你想要的视频内容</label>
<textarea
className="xx-smart-match-input"
placeholder="例如:一个科技感十足的产品宣传视频,画面要有现代办公场景、团队协作、数据分析图表…"
value={smartMatchInput}
onChange={(e) => setSmartMatchInput(e.target.value)}
rows={3}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
handleSmartMatch()
}
}}
/>
<div className="xx-smart-match-input-footer">
<span className="xx-smart-match-tip">
{materialsLoading
? "扫描视频库中…"
: `当前视频库共 ${materials.items.length} 个素材可供匹配`}
</span>
<div style={{ display: "flex", gap: 8 }}>
{hasMatched && (
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={handleRefreshMatch}
disabled={smartMatching || materialsLoading}
>
🔄 换一批
</button>
)}
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={handleSmartMatch}
disabled={smartMatching || materialsLoading || !smartMatchInput.trim()}
>
{smartMatching ? (
<>
<LoadingOutlined style={{ marginRight: 6 }} />
匹配中…
</>
) : (
"✨ 智能匹配"
)}
</button>
</div>
</div>
</div>
{materialsLoading ? (
<Text
style={{
color: "var(--text-secondary)",
fontSize: 12,
marginTop: 8,
}}
>
扫描视频库中…
</Text>
) : (
<Text
style={{
color: "var(--text-tertiary, #94a3b8)",
fontSize: 12,
marginTop: 8,
}}
>
当前视频库共 {materials.items.length} 个素材可供匹配
</Text>
{/* 推荐结果区 */}
{hasMatched && !smartMatching && smartMatchedResults.length > 0 && (
<div className="xx-smart-match-results">
<div className="xx-smart-match-results-header">
<span className="xx-smart-match-results-title">
推荐素材 ({smartMatchedResults.length}个)
</span>
<div className="xx-smart-match-results-actions">
<button type="button" className="xx-link-btn" onClick={handleSelectAllMatched}>
全选
</button>
<span style={{ color: "var(--border-color)" }}>|</span>
<button type="button" className="xx-link-btn" onClick={handleClearSmartSelect}>
清空
</button>
</div>
</div>
<div className="xx-smart-match-grid">
{smartMatchedResults.map((result) => {
const isSelected = smartSelectedIds.includes(result.asset.id)
return (
<div
key={result.asset.id}
className={`xx-smart-match-card ${isSelected ? "selected" : ""}`}
onClick={() => handleToggleSmartSelect(result.asset.id)}
>
{/* 缩略图 */}
<div className="xx-smart-match-thumb">
{result.asset.thumbnail_url ? (
<img src={result.asset.thumbnail_url} alt={result.asset.name} />
) : (
<div className="xx-smart-match-thumb-placeholder">
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
</div>
)}
<div className="xx-smart-match-score">{result.matchScore}%</div>
{isSelected && (
<div className="xx-smart-match-check">
<CheckCircleFilled style={{ fontSize: 20, color: "#fff" }} />
</div>
)}
{result.asset.duration && (
<div className="xx-smart-match-duration">
{formatDuration(result.asset.duration)}
</div>
)}
</div>
{/* 信息区 */}
<div className="xx-smart-match-info">
<div className="xx-smart-match-name" title={result.asset.name}>
{result.asset.name}
</div>
<div className="xx-smart-match-reason">🎯 {result.matchReason}</div>
</div>
</div>
)
})}
</div>
</div>
)}
{/* 匹配中状态 */}
{smartMatching && (
<div className="xx-smart-match-loading">
<LoadingOutlined
style={{ fontSize: 32, color: "var(--primary-color)", marginBottom: 12 }}
/>
<div style={{ color: "var(--text-primary)", fontSize: 14 }}>AI 正在分析素材…</div>
<div style={{ color: "var(--text-tertiary)", fontSize: 12, marginTop: 4 }}>
正在根据描述从视频库中匹配最合适的素材
</div>
</div>
)}
{/* 未匹配状态提示 */}
{!hasMatched && !smartMatching && (
<div className="xx-smart-match-empty">
<div style={{ fontSize: 36, marginBottom: 8 }}>💡</div>
<div style={{ color: "var(--text-secondary)", fontSize: 13 }}>
输入视频内容描述,点击「智能匹配」让 AI 帮你选素材
</div>
</div>
)}
{/* 已选素材汇总 */}
{hasMatched && !smartMatching && smartSelectedIds.length > 0 && (
<div className="xx-smart-match-summary">
<div className="xx-smart-match-summary-header">
<span className="xx-pill xx-pill-ok">已选 {smartSelectedIds.length} 个素材</span>
<span style={{ color: "var(--text-tertiary)", fontSize: 12 }}>
预计总时长约{" "}
{smartMatchedResults
.filter((r) => smartSelectedIds.includes(r.asset.id))
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
.toFixed(0)}{" "}
秒
</span>
</div>
</div>
)}
</div>
)}
@@ -1234,7 +1471,9 @@ const GeneratePage: React.FC = () => {
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">素材数量</span>
<span className="xx-preview-plan-value">
{materialMode === "auto" ? "AI自动匹配" : `${selectedMaterials.length} 个素材`}
{materialMode === "auto"
? `${smartSelectedIds.length} 个素材(智能匹配)`
: `${selectedMaterials.length} 个素材`}
</span>
</div>
<div className="xx-preview-plan-row">
@@ -2022,7 +2261,9 @@ const GeneratePage: React.FC = () => {
<div className="xx-summary-row">
<span className="xx-summary-label">素材</span>
<span className="xx-summary-value">
{materialMode === "auto" ? "自动匹配" : `${selectedMaterials.length} 个素材`}
{materialMode === "auto"
? `${smartSelectedIds.length} 个素材(智能匹配)`
: `${selectedMaterials.length} 个素材`}
</span>
</div>
<div className="xx-summary-row">
+245
View File
@@ -1137,6 +1137,251 @@
border-radius: 20px;
}
/* ── 智能素材匹配 ── */
.xx-smart-match-section {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 16px;
}
.xx-smart-match-input-area {
padding: 16px;
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 14px;
}
.xx-smart-match-label {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1e293b);
margin-bottom: 10px;
}
.xx-smart-match-input {
width: 100%;
min-height: 80px;
padding: 12px;
font-size: 13px;
line-height: 1.6;
color: var(--text-primary, #1e293b);
background: #fff;
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 10px;
resize: vertical;
box-sizing: border-box;
transition: border-color 0.2s;
font-family: inherit;
}
.xx-smart-match-input:focus {
outline: none;
border-color: var(--primary-color, #4f46e5);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.xx-smart-match-input::placeholder {
color: var(--text-tertiary, #94a3b8);
}
.xx-smart-match-input-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
}
.xx-smart-match-tip {
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
}
.xx-smart-match-results {
display: flex;
flex-direction: column;
gap: 12px;
}
.xx-smart-match-results-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.xx-smart-match-results-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.xx-smart-match-results-actions {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.xx-link-btn {
background: none;
border: none;
color: var(--primary-color, #4f46e5);
font-size: 12px;
cursor: pointer;
padding: 2px 4px;
}
.xx-link-btn:hover {
text-decoration: underline;
}
.xx-smart-match-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
max-height: 420px;
overflow-y: auto;
padding-right: 4px;
}
.xx-smart-match-card {
background: #fff;
border: 2px solid var(--border-primary, #e2e8f0);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
}
.xx-smart-match-card:hover {
border-color: var(--primary-color, #4f46e5);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.xx-smart-match-card.selected {
border-color: var(--primary-color, #4f46e5);
background: var(--primary-soft, #eef2ff);
}
.xx-smart-match-thumb {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #f1f5f9;
overflow: hidden;
}
.xx-smart-match-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.xx-smart-match-thumb-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary, #94a3b8);
}
.xx-smart-match-score {
position: absolute;
top: 8px;
left: 8px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
border-radius: 12px;
}
.xx-smart-match-check {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
background: var(--primary-color, #4f46e5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.xx-smart-match-duration {
position: absolute;
bottom: 8px;
right: 8px;
padding: 2px 6px;
font-size: 11px;
color: #fff;
background: rgba(0, 0, 0, 0.6);
border-radius: 4px;
}
.xx-smart-match-info {
padding: 10px 12px;
}
.xx-smart-match-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #1e293b);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.xx-smart-match-reason {
font-size: 11px;
color: var(--text-secondary, #64748b);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.xx-smart-match-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
background: #f8fafc;
border-radius: 12px;
}
.xx-smart-match-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 30px 20px;
background: #f8fafc;
border-radius: 12px;
text-align: center;
}
.xx-smart-match-summary {
padding: 12px 16px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 12px;
}
.xx-smart-match-summary-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* ============================================================
标题设置(选择标题步骤)
============================================================ */