feat(#584): 智能素材匹配前端页面框架 #618

Merged
auto-approve-bot merged 2 commits from feat/584-smart-material-match into develop 2026-07-20 08:23:50 +08:00
2 changed files with 527 additions and 36 deletions
+282 -36
View File
@@ -238,6 +238,21 @@ function getActivePreset(settings: TitleSettings): string | null {
return null
}
/* ================================================================
常量
================================================================ */
const SMART_MATCH_REASONS = [
"画面清晰度高,构图专业",
"与描述场景高度契合",
"时长适中,适合剪辑节奏",
"色彩风格统一",
"包含关键动作镜头",
"镜头运动流畅自然",
"光影效果出色",
"人物表情生动",
]
/* ================================================================
组件
================================================================ */
@@ -266,6 +281,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 +498,92 @@ const GeneratePage: React.FC = () => {
enabled: !!selectedLibraryId,
})
/* ── 智能素材匹配 ── */
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 +813,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 +1018,7 @@ const GeneratePage: React.FC = () => {
generateCount,
materialMode,
coverSettings,
smartSelectedIds,
])
/* ── 下载视频 ── */
@@ -953,6 +1067,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 +1078,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 +1301,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 +1476,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 +2266,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;
}
/* ============================================================
标题设置(选择标题步骤)
============================================================ */