feat(viral-video): #2041 STEP1 UI 重做——按钮模式+参考视频+2x2音频+识别汇览 #2115
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user