feat(viral-video): #2041 STEP1 UI 重做——按钮模式+参考视频+2x2音频+识别汇览 #2115

Merged
auto-approve-bot merged 1 commits from feat/2041-step1-redo-button-ui into develop 2026-10-01 10:47:46 +08:00
4 changed files with 1729 additions and 763 deletions
+33 -3
View File
@@ -9,7 +9,7 @@ export type StyleStrength = "light" | "medium" | "strict"
export const STYLE_STRENGTHS: { value: StyleStrength; label: string }[] = [
{ value: "light", label: "轻度借鉴" },
{ value: "medium", label: "中度参考" },
{ value: "strict", label: "深度模仿" },
{ value: "strict", label: "像素级复刻" },
]
export type ViralVideoStatus =
@@ -43,11 +43,33 @@ export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
return !!stage && ANALYSIS_STAGES.has(stage)
}
/** 单张图片 VLM 识别出的商品信息 */
export interface ImageProductAnalysis {
/** 商品名称/标题 */
name?: string
/** 净含量/规格 */
spec?: string
/** 视觉特征描述(材质/颜色/文字标签等),可能是拼接字符串或数组 */
features?: string[] | string
/** 卖点 */
selling_points?: string[]
/** 适用场景 */
scene?: string
/** 图片索引 */
image_index?: number
}
/** 图片 VLM 分析结果(后端字段 image_analysis) */
export interface ImageAnalysisResult {
products?: ImageProductAnalysis[]
}
export interface StyleTemplate {
id: string
name: string
description?: string
preview_url?: string
thumbnail_url?: string
style_config?: Record<string, unknown>
tags?: string[]
}
@@ -69,20 +91,25 @@ export interface ViralVideoJob {
reference_video_url?: string
style_strength?: StyleStrength
style_template_id?: string
style_guide?: string
style_guide?: string | Record<string, unknown>
user_copy_text?: string
final_copy_text?: string
fusion_level?: FusionLevel
voice_id?: string
voice_mode?: "global" | "per_video"
voice_source?: "preset" | "library" | "clone" | "upload"
bgm_preference?: string
intent_result?: IntentResult
intent_text?: string
/** 图片 VLM 分析结果(后端字段 image_analysis) */
image_analysis?: ImageAnalysisResult
progress_stage?: ViralVideoStage
progress_percent?: number
progress_message?: string
output_url?: string
result_video_url?: string
error_message?: string
error_msg?: string
credits_cost?: number
created_at?: string
updated_at?: string
@@ -91,11 +118,14 @@ export interface ViralVideoJob {
export interface GenerateViralVideoRequest {
images: string[]
reference_video_url?: string
/** 抖音分享链接(后端解析后下载) */
douyin_url?: string
style_strength?: StyleStrength
style_template_id?: string
user_copy_text?: string
fusion_level?: FusionLevel
voice_id?: string
voice_source?: "preset" | "library" | "clone" | "upload"
bgm_preference?: string
industry?: string
target_customer?: string
+541 -205
View File
@@ -1,12 +1,11 @@
/* ============================================================
爆款视频创作页 - 浅色紫调(对齐 AI 数字人页视觉规范)
布局(参考 ui-ref-step-layout.png 三列等宽 STEP 向导):
布局:三列等宽 STEP 向导
.vv-tabs 顶栏多任务 Tab(生成1 × / + 新建)
.vv-grid 三列等宽 grid(1fr 1fr 1fr,gap 16)
├── .vv-col 左:STEP 1 上传素材(图片+参考视频+配音)
├── .vv-col 中:STEP 2 生成视频文案(融合Tab+参数+文案I/O+AI摘要)
└── .vv-col 右:STEP 3 生成视频(预览+参数+进度+扣点+按钮)
可折叠模块:.vv-section > .vv-section-head[aria-expanded] + .vv-section-body
.vv-grid 三列等宽 grid
├── .vv-col 左:STEP 1 上传图片+参考视频+参考音频+识别汇览
├── .vv-col 中:STEP 2 融合档位+视频参数+AI 文案理解
└── .vv-col 右:STEP 3 视频预览+视频设置+扣点+状态按钮
============================================================ */
.vv-page {
@@ -109,7 +108,7 @@
gap: 14px;
}
/* ── 可折叠模块(对齐 AI 数字人卡块) ────────────────── */
/* ── 模块卡片 ──────────────────────────────────────── */
.vv-section {
background: #fff;
border: 1px solid #e5e7eb;
@@ -124,6 +123,7 @@
cursor: pointer;
user-select: none;
border-bottom: 1px solid #f3f4f6;
gap: 8px;
}
.vv-section.collapsed .vv-section-head {
border-bottom: none;
@@ -136,7 +136,7 @@
font-weight: 600;
color: #111827;
}
.vv-section-title .vv-step-badge {
.vv-step-badge {
width: 22px;
height: 22px;
border-radius: 50%;
@@ -147,14 +147,7 @@
align-items: center;
justify-content: center;
font-weight: 600;
}
.vv-section-arrow {
color: #9ca3af;
font-size: 12px;
transition: transform 0.2s;
}
.vv-section.collapsed .vv-section-arrow {
transform: rotate(-90deg);
flex-shrink: 0;
}
.vv-section-body {
padding: 14px 16px 16px;
@@ -163,7 +156,22 @@
display: none;
}
/* ── 通用表单元素(对齐 AI 数字人样式) ─────────────── */
/* ── STEP 1 标题(不带折叠,与截图一致) ─────────── */
.vv-step1-head {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid #f3f4f6;
}
.vv-step1-title {
font-size: 17px;
font-weight: 700;
color: #111827;
letter-spacing: 0.2px;
}
/* ── 通用表单元素 ─────────────────────────────────── */
.vv-label {
display: block;
font-size: 12px;
@@ -210,7 +218,7 @@
gap: 10px;
}
/* ── Tab 分段(对齐"系统预设/我的音色"样式) ────────── */
/* ── Tab 分段 ─────────────────────────────────────── */
.vv-seg-tabs {
display: flex;
gap: 8px;
@@ -243,7 +251,7 @@
box-shadow: 0 1px 2px rgba(124, 58, 237, 0.06);
}
/* 融合强度大按钮(选中紫色描边+浅紫底) */
/* 融合档位大按钮 */
.vv-fusion-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -282,7 +290,7 @@
color: #7c3aed;
}
/* 风格强度小分段按钮(三档,参考配音风格按钮) */
/* 小标签按钮 */
.vv-pill-row {
display: flex;
gap: 6px;
@@ -309,173 +317,284 @@
color: #7c3aed;
font-weight: 500;
}
/* ── 上传区(浅灰虚线框) ───────────────────────────── */
.vv-upload {
border: 1.5px dashed #d1d5db;
border-radius: 10px;
padding: 20px;
text-align: center;
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
background: #fafbfc;
color: #9ca3af;
}
.vv-upload:hover,
.vv-upload.dragover {
border-color: #7c3aed;
background: #f9f7ff;
color: #7c3aed;
}
.vv-upload-icon {
font-size: 28px;
margin-bottom: 6px;
}
.vv-upload small {
display: block;
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
.vv-pill:disabled,
.vv-fusion-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 图片网格 */
.vv-img-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 8px;
margin-top: 12px;
/* ── STEP1 操作按钮(截图:白底圆角按钮,hover紫边紫底) ── */
.vv-action-row {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.vv-img-item {
position: relative;
aspect-ratio: 1;
.vv-action-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 14px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e5e7eb;
cursor: grab;
background: #fff;
color: #374151;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.vv-action-btn:hover:not(:disabled) {
border-color: #7c3aed;
color: #7c3aed;
background: #f9f7ff;
}
.vv-action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 已选计数 + 清空 */
.vv-count-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 0 10px;
font-size: 13px;
}
/* 文件列表(图片/视频展示,和截图一致:缩略图左 + 文件名/大小 中 + 操作右) */
.vv-file-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.vv-file-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
transition: all 0.15s;
}
.vv-file-item:hover {
border-color: #d8cafc;
}
.vv-file-thumb {
position: relative;
width: 52px;
height: 52px;
border-radius: 6px;
overflow: hidden;
background: #f5f6fa;
flex-shrink: 0;
border: 1px solid #f3f4f6;
}
.vv-img-item.dragging {
opacity: 0.4;
}
.vv-img-item img {
.vv-file-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.vv-img-badge {
.vv-file-thumb .vv-img-badge {
position: absolute;
top: 4px;
left: 4px;
background: rgba(124, 58, 237, 0.9);
top: 2px;
left: 2px;
}
.vv-img-badge {
background: rgba(124, 58, 237, 0.92);
color: #fff;
font-size: 11px;
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
line-height: 1.4;
}
.vv-img-del {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(239, 68, 68, 0.9);
color: #fff;
border: none;
cursor: pointer;
font-size: 12px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s;
.vv-file-info {
flex: 1;
min-width: 0;
}
.vv-img-item:hover .vv-img-del {
opacity: 1;
}
.vv-img-add {
width: 100%;
height: 100%;
border: 1.5px dashed #d1d5db;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #9ca3af;
cursor: pointer;
background: #fafbfc;
font-size: 11px;
gap: 2px;
transition: all 0.15s;
}
.vv-img-add:hover {
border-color: #7c3aed;
color: #7c3aed;
background: #f9f7ff;
}
.vv-progress-mini {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.35);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
}
/* 参考视频预览 */
.vv-video-preview {
width: 100%;
aspect-ratio: 16/9;
border-radius: 8px;
.vv-file-name {
font-size: 13px;
color: #111827;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
background: #000;
margin-top: 10px;
border: 1px solid #e5e7eb;
text-overflow: ellipsis;
}
.vv-video-preview video {
width: 100%;
height: 100%;
object-fit: cover;
}
.vv-video-ph {
width: 100%;
aspect-ratio: 16/9;
border: 1.5px dashed #d1d5db;
border-radius: 8px;
.vv-file-sub {
font-size: 11px;
color: #9ca3af;
margin-top: 3px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.vv-file-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.vv-icon-btn {
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid #e5e7eb;
background: #fff;
color: #6b7280;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: #9ca3af;
cursor: pointer;
background: #fafbfc;
font-size: 12px;
gap: 4px;
margin-top: 10px;
font-size: 11px;
transition: all 0.15s;
}
.vv-video-ph:hover {
.vv-icon-btn:hover:not(:disabled) {
border-color: #7c3aed;
color: #7c3aed;
background: #f9f7ff;
}
.vv-icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.vv-icon-btn-danger:hover:not(:disabled) {
border-color: #ef4444;
color: #ef4444;
background: #fef2f2;
}
/* ── 音色列表(参考 AI 数字人「龙小淳」卡片) ──────── */
.vv-voice-tabs {
/* ── 子区块(参考视频 / 参考音频 带"选填"标签) ──── */
.vv-subblock {
margin-top: 16px;
padding-top: 14px;
border-top: 1px dashed #e5e7eb;
}
.vv-subblock-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.vv-voice-list {
max-height: 280px;
overflow-y: auto;
.vv-subblock-title {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.vv-opt-tag {
font-size: 11px;
color: #9ca3af;
background: #f3f4f6;
border-radius: 4px;
padding: 1px 6px;
}
.vv-subblock-hint {
margin-left: auto;
font-size: 11px;
color: #9ca3af;
}
/* 抖音链接输入 */
.vv-douyin-input {
margin-bottom: 10px;
}
/* 参考视频已选预览 */
.vv-ref-video-box {
margin-top: 10px;
}
.vv-ref-video-el {
width: 100%;
border-radius: 8px;
background: #000;
border: 1px solid #e5e7eb;
}
/* ── 参考音频 2x2 网格 ──────────────────────────── */
.vv-audio-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 10px;
}
.vv-audio-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 16px 10px;
border-radius: 10px;
border: 1.5px solid #e5e7eb;
background: #fff;
color: #374151;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.vv-audio-ic {
font-size: 22px;
color: #9ca3af;
transition: color 0.15s;
}
.vv-audio-cell:hover:not(:disabled) {
border-color: #7c3aed;
color: #7c3aed;
background: #f9f7ff;
}
.vv-audio-cell:hover:not(:disabled) .vv-audio-ic {
color: #7c3aed;
}
.vv-audio-cell.active {
border-color: #7c3aed;
background: #f3f0ff;
color: #7c3aed;
}
.vv-audio-cell.active .vv-audio-ic {
color: #7c3aed;
}
.vv-audio-cell.selected {
border-color: #7c3aed;
background: #f3f0ff;
color: #7c3aed;
}
.vv-audio-cell.selected .vv-audio-ic {
color: #7c3aed;
}
.vv-audio-cell:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.vv-selected-audio {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: #f3f0ff;
border: 1px solid #d8cafc;
border-radius: 8px;
font-size: 12px;
color: #5b21b6;
margin-bottom: 10px;
}
/* 展开的音色列表面板 */
.vv-voice-panel {
border: 1px solid #f3f4f6;
border-radius: 8px;
margin-bottom: 10px;
overflow: hidden;
}
.vv-voice-list {
max-height: 240px;
overflow-y: auto;
}
.vv-voice-list::-webkit-scrollbar {
width: 6px;
@@ -564,13 +683,73 @@
color: #fff;
}
/* ── AI 摘要确认卡(黄色高亮) ─────────────────────── */
/* 折叠提示 */
.vv-tips {
margin-top: 6px;
font-size: 12px;
}
.vv-tips-head {
background: none;
border: none;
padding: 0;
color: #9ca3af;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-family: inherit;
}
.vv-tips-head:hover {
color: #7c3aed;
}
.vv-tips-body {
margin-top: 6px;
padding: 8px 10px;
background: #f9fafb;
border-radius: 6px;
color: #6b7280;
font-size: 11px;
line-height: 1.6;
}
/* ── 识别描述汇览 ─────────────────────────────────── */
.vv-recog {
margin-top: 16px;
padding-top: 14px;
border-top: 1px dashed #e5e7eb;
}
.vv-recog-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #111827;
margin-bottom: 8px;
}
.vv-recog-item {
padding: 10px 12px;
background: #f9fafb;
border-radius: 8px;
font-size: 13px;
line-height: 1.7;
color: #374151;
}
.vv-recog-line + .vv-recog-line {
margin-top: 4px;
}
.vv-recog-k {
color: #6b7280;
font-weight: 500;
}
/* ── AI 摘要确认卡 ────────────────────────────────── */
.vv-intent {
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 10px;
padding: 14px;
margin-top: 12px;
}
.vv-intent-title {
display: flex;
@@ -605,7 +784,7 @@
margin-bottom: 3px;
}
/* ── 9:16 预览区 ────────────────────────────────────── */
/* ── 9:16 预览 ───────────────────────────────────── */
.vv-preview {
width: 100%;
aspect-ratio: 9/16;
@@ -639,13 +818,14 @@
font-size: 12px;
}
/* 上传进度条(保留给视频/音频上传使用) */
.vv-progress-bar {
width: 100%;
height: 6px;
background: #f3f4f6;
border-radius: 3px;
overflow: hidden;
margin-top: 10px;
margin-top: 6px;
}
.vv-progress-fill {
height: 100%;
@@ -653,19 +833,19 @@
border-radius: 3px;
transition: width 0.4s ease;
}
.vv-progress-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #9ca3af;
margin-top: 6px;
}
.vv-progress-pct {
color: #7c3aed;
font-weight: 600;
.vv-progress-mini-inline {
display: inline-block;
width: 10px;
height: 10px;
border: 2px solid rgba(124, 58, 237, 0.25);
border-top-color: #7c3aed;
border-radius: 50%;
animation: vvspin 0.8s linear infinite;
vertical-align: middle;
margin-right: 4px;
}
/* ── 扣点 & 按钮 ────────────────────────────────────── */
/* ── 扣点 & 按钮 ────────────────────────────────── */
.vv-credits {
background: #f9fafb;
border: 1px solid #f3f4f6;
@@ -730,6 +910,9 @@
padding: 6px 12px;
font-size: 12px;
}
.vv-btn:disabled {
cursor: not-allowed;
}
.vv-error {
background: #fef2f2;
@@ -738,8 +921,8 @@
padding: 10px 12px;
color: #dc2626;
font-size: 12px;
margin-top: 10px;
}
.vv-spinner {
width: 14px;
height: 14px;
@@ -759,19 +942,7 @@
color: #9ca3af;
font-size: 12px;
}
.vv-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
.vv-file-name {
font-size: 12px;
color: #374151;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.vv-mt-8 {
margin-top: 8px;
}
.vv-link-btn {
@@ -786,32 +957,197 @@
.vv-link-btn:hover {
text-decoration: underline;
}
.vv-flex {
display: flex;
}
.vv-between {
justify-content: space-between;
align-items: center;
}
.vv-gap-8 {
gap: 8px;
}
.vv-mt-8 {
margin-top: 8px;
}
.vv-mt-12 {
margin-top: 12px;
.vv-link-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
text-decoration: none;
}
/* disabled 状态 */
.vv-pill:disabled,
.vv-fusion-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
/* 状态提示条 */
.vv-hint-gray {
padding: 12px 16px;
background: #f9fafb;
border-radius: 8px;
font-size: 12px;
text-align: center;
color: #6b7280;
}
.vv-btn:disabled {
cursor: not-allowed;
.vv-hint-yellow {
padding: 12px 16px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
font-size: 12px;
text-align: center;
color: #b45309;
}
.vv-section-head {
/* ── 素材选择弹窗 ────────────────────────────────── */
.vv-modal-mask {
position: fixed;
inset: 0;
background: rgba(17, 24, 39, 0.45);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.vv-modal {
width: 720px;
max-width: 100%;
max-height: 82vh;
background: #fff;
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
}
.vv-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f3f4f6;
}
.vv-modal-title {
font-size: 15px;
font-weight: 600;
color: #111827;
}
.vv-modal-close {
width: 28px;
height: 28px;
border-radius: 6px;
border: none;
background: transparent;
color: #9ca3af;
font-size: 18px;
cursor: pointer;
}
.vv-modal-close:hover {
background: #f3f4f6;
color: #374151;
}
.vv-modal-body {
flex: 1;
overflow: hidden;
padding: 14px 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
.vv-asset-search {
display: flex;
gap: 8px;
}
.vv-asset-thumbs {
flex: 1;
overflow-y: auto;
display: grid;
gap: 10px;
padding: 4px;
}
.vv-asset-image {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.vv-asset-video {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.vv-asset-voice {
grid-template-columns: 1fr;
}
.vv-thumb-card {
position: relative;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: #f9fafb;
transition: all 0.15s;
}
.vv-asset-image .vv-thumb-card,
.vv-asset-video .vv-thumb-card {
aspect-ratio: 1;
}
.vv-thumb-card:hover {
border-color: #7c3aed;
}
.vv-thumb-card.selected {
border-color: #7c3aed;
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.2);
}
.vv-thumb-card img,
.vv-thumb-card video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.vv-thumb-ph {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: #9ca3af;
}
.vv-asset-voice .vv-thumb-card {
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
aspect-ratio: auto;
}
.vv-thumb-check {
position: absolute;
top: 6px;
right: 6px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #7c3aed;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.vv-thumb-name {
font-size: 12px;
color: #374151;
padding: 6px 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
background: #fff;
}
.vv-asset-voice .vv-thumb-name {
text-align: left;
padding: 0;
flex: 1;
}
.vv-modal-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #9ca3af;
font-size: 13px;
}
.vv-empty-icon {
font-size: 32px;
opacity: 0.6;
}
.vv-modal-foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px;
border-top: 1px solid #f3f4f6;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,250 @@
/**
* 爆款视频素材选择弹窗(通用版,支持 image/video/voice)
* 基于 ai-avatar 的 ModalAssetPicker 改造:
* - kind 可传 "image" | "video" | "voice"
* - 多图场景 multiple=true 时底部"确认选择"
* - 单选场景点击即回调关闭
*/
import { useEffect, useState } from "react"
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
export interface AssetPickerModalProps {
open: boolean
kind: "image" | "video" | "voice"
multiple?: boolean
title?: string
onClose: () => void
onSelect: (assets: AssetItem[]) => void
}
const KIND_LABEL: Record<AssetPickerModalProps["kind"], string> = {
image: "图片",
video: "视频",
voice: "音频",
}
const MIME_KIND: Record<AssetPickerModalProps["kind"], string> = {
image: "image",
video: "video",
voice: "audio",
}
export default function AssetPickerModal({
open,
kind,
multiple = false,
title,
onClose,
onSelect,
}: AssetPickerModalProps) {
const [keyword, setKeyword] = useState("")
const [libraries, setLibraries] = useState<AssetLibraryItem[]>([])
const [libraryId, setLibraryId] = useState<string>("")
const [assets, setAssets] = useState<AssetItem[]>([])
const [picked, setPicked] = useState<Set<string>>(new Set())
const [loadingLibs, setLoadingLibs] = useState(false)
const [loadingAssets, setLoadingAssets] = useState(false)
const [error, setError] = useState("")
useEffect(() => {
if (!open) return
setKeyword("")
setLibraries([])
setLibraryId("")
setAssets([])
setError("")
setPicked(new Set())
}, [open])
useEffect(() => {
if (!open) return
let cancelled = false
setLoadingLibs(true)
getAssetLibraries(kind)
.then((libs) => {
if (cancelled) return
const list = Array.isArray(libs) ? libs : []
setLibraries(list)
if (list.length > 0) setLibraryId(list[0].id)
})
.catch(() => {
if (!cancelled) setError("素材库加载失败,请重试")
})
.finally(() => {
if (!cancelled) setLoadingLibs(false)
})
return () => {
cancelled = true
}
}, [open, kind])
useEffect(() => {
if (!open || !libraryId) return
let cancelled = false
setLoadingAssets(true)
const load = async () => {
try {
const { items } = await getAssets(libraryId, { page_size: 100 })
if (cancelled) return
let list = Array.isArray(items) ? items : []
const mimePrefix = MIME_KIND[kind]
list = list.filter((a) => !a.mime_type || a.mime_type.startsWith(mimePrefix))
const kw = keyword.trim()
if (kw) list = list.filter((a) => a.name?.includes(kw))
setAssets(list)
setError("")
} catch {
if (!cancelled) {
setError("素材加载失败,请重试")
setAssets([])
}
} finally {
if (!cancelled) setLoadingAssets(false)
}
}
const timer = window.setTimeout(load, 250)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [open, libraryId, keyword, kind])
const thumbFor = (a: AssetItem) => {
if (kind === "image") return a.thumbnail_url || a.file_url
if (kind === "video") return a.thumbnail_url
return ""
}
const togglePick = (id: string) => {
if (multiple) {
setPicked((prev) => {
const n = new Set(prev)
if (n.has(id)) n.delete(id)
else n.add(id)
return n
})
} else {
const asset = assets.find((a) => a.id === id)
if (asset) {
onSelect([asset])
onClose()
}
}
}
const handleConfirm = () => {
const list = assets.filter((a) => picked.has(a.id))
if (list.length > 0) onSelect(list)
onClose()
}
if (!open) return null
return (
<div className="vv-modal-mask" onClick={onClose}>
<div className="vv-modal" onClick={(e) => e.stopPropagation()}>
<div className="vv-modal-head">
<span className="vv-modal-title">{title || `选择${KIND_LABEL[kind]}素材`}</span>
<button className="vv-modal-close" onClick={onClose} aria-label="关闭">
×
</button>
</div>
<div className="vv-modal-body">
<div className="vv-asset-search">
<select
className="vv-input"
style={{ width: 170, flex: "0 0 auto" }}
value={libraryId}
onChange={(e) => setLibraryId(e.target.value)}
disabled={loadingLibs || libraries.length === 0}
>
{libraries.length === 0 ? (
<option value="">
{loadingLibs ? "加载中…" : `暂无${KIND_LABEL[kind]}素材库`}
</option>
) : (
libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
📁 {lib.name}
</option>
))
)}
</select>
<input
className="vv-input"
type="text"
placeholder={`搜索${KIND_LABEL[kind]}名称…`}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
</div>
{libraries.length === 0 && !loadingLibs ? (
<div className="vv-modal-empty">
<div className="vv-empty-icon">📁</div>
暂无{KIND_LABEL[kind]}素材库,请先在「素材库」中创建并上传
</div>
) : loadingAssets ? (
<div className="vv-modal-empty">
<div className="vv-empty-icon">⏳</div>
素材加载中…
</div>
) : error ? (
<div className="vv-modal-empty">
<div className="vv-empty-icon">⚠️</div>
{error}
</div>
) : assets.length === 0 ? (
<div className="vv-modal-empty">
<div className="vv-empty-icon">
{kind === "image" ? "🖼️" : kind === "video" ? "🎬" : "🎵"}
</div>
该素材库暂无{KIND_LABEL[kind]}素材
</div>
) : (
<div className={`vv-asset-thumbs vv-asset-${kind}`}>
{assets.map((asset) => {
const active = picked.has(asset.id)
const thumb = thumbFor(asset)
return (
<div
key={asset.id}
className={`vv-thumb-card${active ? " selected" : ""}`}
onClick={() => togglePick(asset.id)}
>
{thumb ? (
<img src={thumb} alt={asset.name} />
) : kind === "video" ? (
<video src={asset.file_url} muted preload="metadata" />
) : (
<div className="vv-thumb-ph">{kind === "voice" ? "🎵" : "📄"}</div>
)}
{active && <div className="vv-thumb-check">✓</div>}
<div className="vv-thumb-name" title={asset.name}>
{asset.name}
</div>
</div>
)
})}
</div>
)}
</div>
{multiple && (
<div className="vv-modal-foot">
<button className="vv-btn vv-btn-ghost vv-btn-sm" onClick={onClose}>
取消
</button>
<button
className="vv-btn vv-btn-primary"
style={{ width: "auto", marginTop: 0, padding: "8px 18px" }}
onClick={handleConfirm}
disabled={picked.size === 0}
>
确认选择({picked.size})
</button>
</div>
)}
</div>
</div>
)
}