feat: Q5 智能匹配前端简化 — 一键AI选素材 (#1241) (#1241)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 57s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m27s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m29s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m12s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m48s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI/CD Pipeline / Unit Tests (push) Successful in 8m38s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m24s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m40s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped

Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
This commit was merged in pull request #1241.
This commit is contained in:
2026-08-05 00:36:00 +08:00
committed by auto-approve-bot
parent ffebaa7249
commit 4e35a2c855
10 changed files with 119 additions and 185 deletions
+11
View File
@@ -49,6 +49,17 @@ export const getAssetsByKind = async (
return response.data.items || []
}
/**
* 智能匹配素材(后端 AI 选素材)
* 调用后端 smart-match 端点,由后端根据素材库内容智能选择素材
*/
export const smartMatchAssets = async (libraryId: string): Promise<{ items: AssetItem[] }> => {
const response = await apiClient.post("/assets/smart-match", {
library_id: libraryId,
})
return response.data
}
/** 创建素材(上传文件后调用,附带 metadata) */
export const createAsset = async (data: {
library_id: string
+2 -1
View File
@@ -29,10 +29,11 @@ export {
deleteAssetLibrary,
} from "./libraries"
// 素材 CRUD
// 素材 CRUD + 智能匹配
export {
getAssets,
getAssetsByKind,
smartMatchAssets,
createAsset,
updateAsset,
updateAssetReviewStatus,
@@ -65,8 +65,6 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
{m.materialMode === "auto" && (
<div className="xx-smart-match-section">
<SmartMatchInput
inputValue={m.smartMatchInput}
onInputChange={m.setSmartMatchInput}
matching={m.smartMatching}
onMatch={m.handleSmartMatch}
hasMatched={m.hasMatched}
@@ -76,7 +74,7 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
/>
<SmartMatchResults
results={m.smartMatchedResults}
matchedAssets={m.smartMatchedResults}
selectedIds={m.smartSelectedIds}
matching={m.smartMatching}
hasMatched={m.hasMatched}
@@ -1,30 +1,24 @@
/**
* 单个智能匹配卡片
* 智能匹配卡片Q5 简化版)
* 只展示素材缩略图、名称、时长,无匹配分数
*/
import React from "react"
import { PlayCircleOutlined, CheckCircleFilled } from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
interface SmartMatchResultItem {
asset: AssetItem
matchScore: number
matchReason: string
}
interface SmartMatchCardProps {
result: SmartMatchResultItem
asset: AssetItem
selected: boolean
onClick: () => void
formatDuration: (seconds: number) => string
}
const SmartMatchCard: React.FC<SmartMatchCardProps> = ({
result,
asset,
selected,
onClick,
formatDuration,
}) => {
const { asset, matchScore, matchReason } = result
return (
<div className={`xx-smart-match-card ${selected ? "selected" : ""}`} onClick={onClick}>
{/* 缩略图 */}
@@ -36,7 +30,6 @@ const SmartMatchCard: React.FC<SmartMatchCardProps> = ({
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
</div>
)}
<div className="xx-smart-match-score">{matchScore}%</div>
{selected && (
<div className="xx-smart-match-check">
<CheckCircleFilled style={{ fontSize: 20, color: "#fff" }} />
@@ -46,12 +39,11 @@ const SmartMatchCard: React.FC<SmartMatchCardProps> = ({
<div className="xx-smart-match-duration">{formatDuration(asset.duration)}</div>
)}
</div>
{/* 信息区 */}
{/* 名称 */}
<div className="xx-smart-match-info">
<div className="xx-smart-match-name" title={asset.name}>
{asset.name}
</div>
<div className="xx-smart-match-reason">🎯 {matchReason}</div>
</div>
</div>
)
@@ -1,13 +1,11 @@
/**
* 智能匹配输入区
* textarea + 提示 + 按钮组
* 智能匹配操作区(Q5 简化版)
* 一键触发 AI 选素材,无需输入描述
*/
import React from "react"
import { LoadingOutlined } from "@ant-design/icons"
import { LoadingOutlined, ThunderboltOutlined } from "@ant-design/icons"
interface SmartMatchInputProps {
inputValue: string
onInputChange: (value: string) => void
matching: boolean
onMatch: () => void
hasMatched: boolean
@@ -17,8 +15,6 @@ interface SmartMatchInputProps {
}
const SmartMatchInput: React.FC<SmartMatchInputProps> = ({
inputValue,
onInputChange,
matching,
onMatch,
hasMatched,
@@ -28,22 +24,9 @@ const SmartMatchInput: React.FC<SmartMatchInputProps> = ({
}) => {
return (
<div className="xx-smart-match-input-area">
<label className="xx-smart-match-label">🤖 </label>
<textarea
className="xx-smart-match-input"
placeholder="例如:一个科技感十足的产品宣传视频,画面要有现代办公场景、团队协作、数据分析图表…"
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
rows={3}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
onMatch()
}
}}
/>
<div className="xx-smart-match-input-footer">
<div className="xx-smart-match-action-row">
<span className="xx-smart-match-tip">
{loading ? "扫描视频库中…" : `当前视频库共 ${materialsCount} 个素材可供匹配`}
{loading ? "扫描视频库中…" : `当前视频库共 ${materialsCount} 个素材`}
</span>
<div style={{ display: "flex", gap: 8 }}>
{hasMatched && (
@@ -60,15 +43,18 @@ const SmartMatchInput: React.FC<SmartMatchInputProps> = ({
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={onMatch}
disabled={matching || loading || !inputValue.trim()}
disabled={matching || loading}
>
{matching ? (
<>
<LoadingOutlined style={{ marginRight: 6 }} />
AI
</>
) : (
"✨ 智能匹配"
<>
<ThunderboltOutlined style={{ marginRight: 6 }} />
{hasMatched ? "重新选择" : "让 AI 帮你选"}
</>
)}
</button>
</div>
@@ -1,19 +1,14 @@
/**
* 智能匹配结果区(含加载/空状态/已选汇总
* 智能匹配结果区(Q5 简化版
* 直接展示 AI 选中的素材,无匹配分数和理由
*/
import React from "react"
import { LoadingOutlined } from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import SmartMatchCard from "./SmartMatchCard"
interface SmartMatchResultItem {
asset: AssetItem
matchScore: number
matchReason: string
}
interface SmartMatchResultsProps {
results: SmartMatchResultItem[]
matchedAssets: AssetItem[]
selectedIds: string[]
matching: boolean
hasMatched: boolean
@@ -25,7 +20,7 @@ interface SmartMatchResultsProps {
}
const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
results,
matchedAssets,
selectedIds,
matching,
hasMatched,
@@ -42,9 +37,9 @@ const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
<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-primary)", fontSize: 14 }}>AI </div>
<div style={{ color: "var(--text-tertiary)", fontSize: 12, marginTop: 4 }}>
</div>
</div>
)
@@ -56,20 +51,22 @@ const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
<div className="xx-smart-match-empty">
<div style={{ fontSize: 36, marginBottom: 8 }}>💡</div>
<div style={{ color: "var(--text-secondary)", fontSize: 13 }}>
AI
AI
</div>
</div>
)
}
// 无结果
if (results.length === 0) return null
if (matchedAssets.length === 0) return null
return (
<>
<div className="xx-smart-match-results">
<div className="xx-smart-match-results-header">
<span className="xx-smart-match-results-title"> ({results.length})</span>
<span className="xx-smart-match-results-title">
AI ({matchedAssets.length})
</span>
<div className="xx-smart-match-results-actions">
<button type="button" className="xx-link-btn" onClick={onSelectAll}>
@@ -81,14 +78,14 @@ const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
</div>
</div>
<div className="xx-smart-match-grid">
{results.map((result) => {
const isSelected = selectedIds.includes(result.asset.id)
{matchedAssets.map((asset) => {
const isSelected = selectedIds.includes(asset.id)
return (
<SmartMatchCard
key={result.asset.id}
result={result}
key={asset.id}
asset={asset}
selected={isSelected}
onClick={() => onToggle(result.asset.id)}
onClick={() => onToggle(asset.id)}
formatDuration={formatDuration}
/>
)
+9 -36
View File
@@ -1433,47 +1433,20 @@
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 {
.xx-smart-match-action-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
}
/* textarea removed in Q5 */
.xx-smart-match-tip {
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
@@ -1,75 +1,88 @@
import { useState, useCallback, useMemo } from "react"
import { message } from "antd"
import type { AssetItem } from "@/api/assets"
import { SMART_MATCH_REASONS } from "../../constants"
interface SmartMatchedResult {
asset: AssetItem
matchScore: number
matchReason: string
}
import { smartMatchAssets } from "@/api/assets"
interface UseSmartMatchOptions {
libraryId: string
materials: { items: AssetItem[]; total: number }
smartSelectedIds: string[]
onSmartSelectedIdsChange: (ids: string[]) => void
}
/**
* 智能素材匹配 Hook
* 封装 AI 匹配、换一批、全选/清空等逻辑
* 智能素材匹配 HookQ5 简化版)
* 用户不手动选素材时,一键调用后端 AI 选素材
* 后端统一选素材逻辑后续完善,当前先走前端流程简化
*/
export function useSmartMatch({
libraryId,
materials,
smartSelectedIds,
onSmartSelectedIdsChange,
}: UseSmartMatchOptions) {
const [smartMatchInput, setSmartMatchInput] = useState("")
const [smartMatching, setSmartMatching] = useState(false)
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
const [hasMatched, setHasMatched] = useState(false)
const [smartMatchedResults, setSmartMatchedResults] = useState<AssetItem[]>([])
/* ── 智能素材匹配 ── */
/* ── 一键智能匹配 ── */
const handleSmartMatch = useCallback(async () => {
if (!smartMatchInput.trim()) {
message.warning("请先输入视频内容描述")
if (!libraryId) {
message.warning("请先选择视频库")
return
}
if (materials.items.length === 0) {
message.warning("当前视频库暂无素材")
return
}
setSmartMatching(true)
setHasMatched(true)
// 模拟 AI 匹配延迟
await new Promise((resolve) => setTimeout(resolve, 1500))
try {
// 调用后端智能匹配 API
const result = await smartMatchAssets(libraryId)
const matchedIds = result.items?.map((a: AssetItem) => a.id) ?? []
// 从素材库中随机选取 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)
if (matchedIds.length > 0) {
onSmartSelectedIdsChange(matchedIds)
// 保存 API 返回的完整素材列表
const resolved = result.items?.length
? result.items
: materials.items.filter((a) => matchedIds.includes(a.id))
setSmartMatchedResults(resolved)
setHasMatched(true)
message.success(`AI 已为你选择 ${matchedIds.length} 个素材`)
} else {
// 后端返回空结果,回退到全选
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
setSmartMatchedResults(materials.items)
setHasMatched(true)
message.info("AI 暂未找到匹配素材,已全选当前库素材")
}
} catch {
// 后端 API 尚未就绪时,回退到全选当前库素材
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
setSmartMatchedResults(materials.items)
setHasMatched(true)
message.info("已为你全选当前库素材(智能匹配功能即将上线)")
} finally {
setSmartMatching(false)
}
}, [libraryId, materials.items, onSmartSelectedIdsChange])
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 ? ",画面质感优秀" : ""),
}))
const handleRefreshMatch = useCallback(async () => {
// 换一批 = 重新触发智能匹配
await handleSmartMatch()
}, [handleSmartMatch])
// 按匹配度从高到低排序
results.sort((a, b) => b.matchScore - a.matchScore)
const handleSelectAllMatched = useCallback(() => {
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
}, [materials.items, onSmartSelectedIdsChange])
setSmartMatchedResults(results)
// 默认选中匹配度 >= 90 的素材
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
onSmartSelectedIdsChange(
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
)
setSmartMatching(false)
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
const handleClearSmartSelect = useCallback(() => {
onSmartSelectedIdsChange([])
}, [onSmartSelectedIdsChange])
const handleToggleSmartSelect = useCallback(
(assetId: string) => {
@@ -82,57 +95,20 @@ export function useSmartMatch({
[smartSelectedIds, onSmartSelectedIdsChange],
)
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)
onSmartSelectedIdsChange([])
setSmartMatching(false)
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
const handleSelectAllMatched = useCallback(() => {
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
}, [smartMatchedResults, onSmartSelectedIdsChange])
const handleClearSmartSelect = useCallback(() => {
onSmartSelectedIdsChange([])
}, [onSmartSelectedIdsChange])
/* ── 计算已选智能匹配素材的总时长 ── */
const smartSelectedTotalDuration = useMemo(() => {
return smartMatchedResults
.filter((r) => smartSelectedIds.includes(r.asset.id))
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
}, [smartMatchedResults, smartSelectedIds])
/* ── 计算已选素材总时长 ── */
const smartSelectedTotalDuration = useMemo(
() =>
materials.items
.filter((a) => smartSelectedIds.includes(a.id))
.reduce((sum, a) => sum + (a.duration || 0), 0),
[materials.items, smartSelectedIds],
)
return {
smartMatchInput,
setSmartMatchInput,
smartMatching,
smartMatchedResults,
hasMatched,
smartSelectedIds,
smartMatchedResults,
handleSmartMatch,
handleToggleSmartSelect,
handleRefreshMatch,
@@ -28,6 +28,7 @@ export function useStep2Materials({
useMaterialLibrary()
const smartMatch = useSmartMatch({
libraryId: selectedLibraryId,
materials,
smartSelectedIds,
onSmartSelectedIdsChange,
@@ -58,13 +59,11 @@ export function useStep2Materials({
// 手动选择
selectedMaterials,
handleToggleMaterial,
// 智能匹配
smartMatchInput: smartMatch.smartMatchInput,
setSmartMatchInput: smartMatch.setSmartMatchInput,
// 智能匹配(简化版)
smartMatching: smartMatch.smartMatching,
smartMatchedResults: smartMatch.smartMatchedResults,
hasMatched: smartMatch.hasMatched,
smartSelectedIds: smartMatch.smartSelectedIds,
smartMatchedResults: smartMatch.smartMatchedResults,
handleSmartMatch: smartMatch.handleSmartMatch,
handleToggleSmartSelect: smartMatch.handleToggleSmartSelect,
handleRefreshMatch: smartMatch.handleRefreshMatch,
@@ -179,6 +179,7 @@ vi.mock("@/api/assets", () => ({
createAssetLibrary: vi.fn().mockResolvedValue({}),
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
smartMatchAssets: vi.fn().mockResolvedValue({ items: [] }),
createAsset: vi.fn().mockResolvedValue({}),
updateAsset: vi.fn().mockResolvedValue({}),
deleteAsset: vi.fn().mockResolvedValue({}),