This commit is contained in:
hrx 2026-04-09 11:26:08 +08:00
parent 64af489397
commit 17bdec62bc
6 changed files with 426 additions and 303 deletions

View File

@ -16,7 +16,7 @@
<div class="top">
<el-checkbox v-model="showProcessing" @change="handleFilterChange" label="进行中" size="large" />
<el-select v-model="statusFilter" placeholder="全部状态" style="width: 240px" @change="handleFilterChange">
<el-select v-model="statusFilter" placeholder="全部状态" style="width: 240px" popper-class="custom-select-popup" @change="handleFilterChange">
<el-option label="全部状态" value="" />
<el-option label="已创建" value="created" />
<el-option label="处理中" value="processing" />
@ -29,10 +29,8 @@
<div class="content">
<!-- 加载中 -->
<div v-if="isLoading" class="loading-container">
<el-icon class="is-loading" :size="40">
<svg viewBox="0 0 1024 1024">
<path fill="currentColor" d="M512 64a448 448 0 1 1 0 896A448 448 0 0 1 0 64z"></path>
</svg>
<el-icon class="is-loading" :size="40" color="#ffffff">
<Loading />
</el-icon>
<div class="loading-text">加载中...</div>
</div>
@ -546,7 +544,6 @@ defineExpose({
refreshTaskList: loadTaskList //
});
</script>
<style lang="less" scoped>
@ -615,4 +612,25 @@ defineExpose({
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
</style>
<style>
/* 全局样式(保留原有下拉框样式) */
.custom-select-popup {
background-color: #2A2D34 !important;
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
border: none !important;
}
.custom-select-popup .el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
font-size: 14px !important;
}
.custom-select-popup .el-select-dropdown__item.selected,
.custom-select-popup .el-select-dropdown__item:hover {
background-color: #3a3d46 !important;
}
</style>

View File

@ -0,0 +1,101 @@
<template>
<div class="stencil-container">
<div class="stencil-top">
<div
class="stencil-item"
:class="{ active: activeTab === 'mixed' }"
@click="handleTabClick('mixed')"
>
<div class="item-title">推荐</div>
</div>
<div
class="stencil-item"
:class="{ active: activeTab === 'share_elements' }"
@click="handleTabClick('share_elements')"
>
<div class="item-title reference-title">
<img
src="https://sfzas1.vidu.cn/2.17.51-cn/_next/static/media/refhub_zh.4622df68.svg"
alt="reference"
class="reference-icon"
/>
<img
src="https://sfzas1.vidu.cn/2.17.51-cn/_next/static/media/reference_2.0.bbfb4cad.svg"
alt="reference_2.0"
class="reference-sub-icon"
/>
</div>
</div>
<div
class="stencil-item"
:class="{ active: activeTab === 'short_film' }"
@click="handleTabClick('short_film')"
>
<div class="item-title">短片</div>
</div>
<div
class="stencil-item"
:class="{ active: activeTab === 'tutorial' }"
@click="handleTabClick('tutorial')"
>
<div class="item-title">教程</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
activeTab: string
}>()
const emit = defineEmits<{
(e: 'tab-change', tabType: string): void
}>()
const handleTabClick = (type: string) => {
emit('tab-change', type)
}
</script>
<style scoped>
.stencil-container {
width: 100%;
}
.stencil-top {
display: flex;
align-items: center;
gap: 24px;
padding: 12px 0;
}
.stencil-item {
cursor: pointer;
}
.stencil-item.active .item-title {
color: #fff;
font-weight: bold;
}
.item-title {
font-size: 16px;
color: rgba(255, 255, 255, 0.7);
white-space: nowrap;
}
.reference-title {
display: flex;
align-items: center;
gap: 4px;
}
.reference-icon,
.reference-sub-icon {
height: 20px;
width: auto;
object-fit: contain;
}
</style>

View File

@ -1,29 +1,6 @@
<!-- components/index/StencilWaterfall.vue -->
<template>
<div class="content">
<!-- 只替换这里的 Tab 结构其他全部不动 -->
<div class="stencil-container">
<div class="stencil-top">
<div class="stencil-item" :class="{ active: activeTab === 'mixed' }" @click="switchTab('mixed')">
<div class="item-title">推荐</div>
</div>
<div class="stencil-item" :class="{ active: activeTab === 'share_elements' }"
@click="switchTab('share_elements')">
<div class="item-title reference-title">
<img src="https://sfzas1.vidu.cn/2.17.51-cn/_next/static/media/refhub_zh.4622df68.svg" alt="reference"
class="reference-icon" />
<img src="https://sfzas1.vidu.cn/2.17.51-cn/_next/static/media/reference_2.0.bbfb4cad.svg"
alt="reference_2.0" class="reference-sub-icon" />
</div>
</div>
<div class="stencil-item" :class="{ active: activeTab === 'short_film' }" @click="switchTab('short_film')">
<div class="item-title">短片</div>
</div>
<div class="stencil-item" :class="{ active: activeTab === 'tutorial' }" @click="switchTab('tutorial')">
<div class="item-title">教程</div>
</div>
</div>
</div>
<div ref="container" class="waterfall-container" :style="{
width: '100%',
height: 'auto',
@ -51,19 +28,18 @@
top: `${item.top}px`,
left: `${item.left}px`,
}">
<!-- 卡片内容保持不变 -->
<div class="card-wrapper">
<div class="video-container" :style="{ aspectRatio: item.aspectRatio }"
@mouseenter="handleMouseEnter(index)" @mouseleave="handleMouseLeave(index)">
<video :ref="(el) => setVideoRef(index, el)" :src="item.src" :poster="item.poster" preload="none"
class="video-element" :class="{ 'playing': isVideoPlaying[index] }" loop muted playsinline />
<!-- 底部信息栏 -->
<video :ref="(el) => setVideoRef(index, el as HTMLVideoElement | null)" :src="item.src"
:poster="item.poster" preload="none" class="video-element" :class="{ playing: isVideoPlaying[index] }"
loop muted playsinline />
<div class="card-info-overlay">
<!-- 左侧用户信息 -->
<div class="user-info">
<img v-if="item.avatar" :src="item.avatar" class="user-avatar" alt="avatar" />
<span v-if="item.userName" class="user-name">{{ item.userName }}</span>
</div>
<!-- 右侧点赞和收藏 -->
<div class="interaction-info">
<div v-if="item.likes > 0" class="interaction-item">
<span class="icon-heart"></span>
@ -76,7 +52,6 @@
</div>
</div>
<!-- 底部加载指示器 -->
<div ref="loadMoreTrigger" class="load-more-trigger">
<span v-if="loading" class="loading-spinner"></span>
<span v-else-if="hasMore">加载更多...</span>
@ -85,29 +60,19 @@
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { getVideos } from '@/apis/home'
import { transformFeedItem, transformShareElementItem } from '@/utlis/transform'
import type { FeedResponse, ShareElementsResponse } from '@/types/home'
//
export interface StencilItem {
id: string
type: 'video'
src: string
poster: string
aspectRatio: string // e.g., "1.77778/1"
avatar: string
userName: string
likes: number
averageColor?: string
}
import type { StencilItem } from '@/types/home'
const props = defineProps<{
gap?: number // 16px
columnCount?: number //
items?: StencilItem[] //
activeTab: string // Tab
gap?: number
columnCount?: number
items?: StencilItem[]
}>()
const emit = defineEmits<{
@ -116,24 +81,6 @@ const emit = defineEmits<{
(e: 'loadMore'): void
}>()
// ==================== Tab ====================
const activeTab = ref('mixed') //
// Tab
const switchTab = (type: string) => {
if (activeTab.value === type) return
activeTab.value = type
// &
items.value = []
pageToken.value = null
hasMore.value = true
containerHeight.value = 0
itemsWithPosition.value = []
loadFirstPage()
//
if (container.value) container.value.scrollTop = 0
}
// ==================== ====================
const items = ref<StencilItem[]>([])
const loading = ref(false)
@ -144,28 +91,23 @@ const pageToken = ref<string | null>(null)
const videoRefs = ref<(HTMLVideoElement | null)[]>([])
const isVideoPlaying = ref<Record<number, boolean>>({})
//
const setVideoRef = (index: number, el: HTMLVideoElement | null) => {
videoRefs.value[index] = el
}
//
const handleMouseEnter = (index: number) => {
const video = videoRefs.value[index]
if (video) {
video.play().catch(err => {
console.log('播放失败:', err)
})
video.play().catch(err => console.log('播放失败:', err))
isVideoPlaying.value[index] = true
}
}
//
const handleMouseLeave = (index: number) => {
const video = videoRefs.value[index]
if (video) {
video.pause()
video.currentTime = 0 //
video.currentTime = 0
isVideoPlaying.value[index] = false
}
}
@ -183,18 +125,16 @@ let resizeObserver: ResizeObserver | null = null
let isLayoutPending = false
let intersectionObserver: IntersectionObserver | null = null
// ==================== ====================
// 使 props
const getColumnCount = (): number => {
if (props.columnCount !== undefined) return props.columnCount
const width = window.innerWidth
if (width >= 1280) return 4 // PC
if (width >= 768) return 3 //
return 2 //
if (width >= 1280) return 4
if (width >= 768) return 3
return 2
}
/* 核心布局算法 */
const layout = async () => {
// ... ...
if (!container.value || !innerScrollContainer.value || items.value.length === 0) return
if (isLayoutPending) return
isLayoutPending = true
@ -203,29 +143,21 @@ const layout = async () => {
const containerWidth = container.value.clientWidth
const columnCount = getColumnCount()
const gap = 4
//
cardWidth.value = (containerWidth - gap * (columnCount - 1)) / columnCount
//
columnHeights = new Array(columnCount).fill(0)
//
const heights: number[] = []
for (const item of items.value) {
const ratio = parseFloat(item.aspectRatio.split('/')[0]) / parseFloat(item.aspectRatio.split('/')[1])
heights.push(cardWidth.value / ratio)
const [num, den] = item.aspectRatio.split('/').map(Number)
heights.push(cardWidth.value / (num / den))
}
//
const newItems = items.value.map((item, idx) => {
const height = heights[idx] || 0
//
const minHeight = Math.min(...columnHeights)
const minIndex = columnHeights.indexOf(minHeight)
const left = minIndex * (cardWidth.value + gap)
const top = columnHeights[minIndex]
//
columnHeights[minIndex] += height + gap
return { ...item, top, left, height }
})
@ -237,22 +169,17 @@ const layout = async () => {
}
}
//
// ==================== ====================
//
const loadFirstPage = async () => {
if (loading.value) return
loading.value = true
try {
const res: FeedResponse | ShareElementsResponse = await getVideos({
channel_type: activeTab.value // 使 Tab
channel_type: props.activeTab
})
const cards: StencilItem[] = []
//
if (activeTab.value === 'share_elements') {
// 使 share_elements
if (props.activeTab === 'share_elements') {
const shareRes = res as ShareElementsResponse
for (const item of shareRes.share_elements) {
const card = transformShareElementItem(item)
@ -260,7 +187,6 @@ const loadFirstPage = async () => {
}
pageToken.value = shareRes.next_page_token ?? null
} else {
// 使
const feedRes = res as FeedResponse
for (const item of feedRes.list) {
const card = transformFeedItem(item)
@ -282,59 +208,28 @@ const loadFirstPage = async () => {
}
}
//
const loadMore = async () => {
console.log('--- loadMore 开始 ---')
console.log('loading:', loading.value, 'hasMore:', hasMore.value, 'pageToken:', pageToken.value)
if (loading.value || !hasMore.value) return
if (!pageToken.value) {
console.log('pageToken 为空,停止加载')
hasMore.value = false
return
}
if (loading.value || !hasMore.value || !pageToken.value) return
loading.value = true
try {
// pageToken page_token
const params: {
channel_type: string;
page_token?: string;
} = {
channel_type: activeTab.value
const params: { channel_type: string; page_token?: string } = {
channel_type: props.activeTab,
}
if (pageToken.value) {
params.page_token = pageToken.value
}
console.log('请求参数:', params)
console.log('准备调用 getVideos...')
if (pageToken.value) params.page_token = pageToken.value
const res: FeedResponse | ShareElementsResponse = await getVideos(params)
console.log('getVideos 调用成功!')
console.log('接口返回数据:', res)
const newCards: StencilItem[] = []
//
if (activeTab.value === 'share_elements') {
// 使 share_elements
if (props.activeTab === 'share_elements') {
const shareRes = res as ShareElementsResponse
console.log('返回的 share_elements 长度:', shareRes.share_elements?.length)
console.log('返回的 next_page_token:', shareRes.next_page_token)
for (const item of shareRes.share_elements) {
const card = transformShareElementItem(item)
if (card) newCards.push(card)
}
pageToken.value = shareRes.next_page_token ?? null
} else {
// 使
const feedRes = res as FeedResponse
console.log('返回的 list 长度:', feedRes.list?.length)
console.log('返回的 next_page_token:', feedRes.next_page_token)
for (const item of feedRes.list) {
const card = transformFeedItem(item)
if (card) newCards.push(card)
@ -342,41 +237,20 @@ const loadMore = async () => {
pageToken.value = feedRes.next_page_token ?? null
}
console.log('转换后的卡片数量:', newCards.length)
items.value = [...items.value, ...newCards]
hasMore.value = !!pageToken.value
console.log('更新后 - items 长度:', items.value.length, 'pageToken:', pageToken.value, 'hasMore:', hasMore.value)
} catch (error) {
console.error('加载更多失败,错误详情:', error)
console.error('错误类型:', typeof error)
if (error instanceof Error) {
console.error('错误消息:', error.message)
console.error('错误堆栈:', error.stack)
} else if (error && typeof error === 'object') {
const errObj = error as Record<string, unknown>;
console.error('错误响应:', errObj.response)
console.error('错误请求:', errObj.request)
}
console.error('完整错误对象:', JSON.stringify(error))
console.error('加载更多失败', error)
hasMore.value = false
} finally {
loading.value = false
}
console.log('--- loadMore 结束 ---')
}
// ==================== ====================
// IntersectionObserver
const setupObserver = () => {
if (intersectionObserver) {
intersectionObserver.disconnect()
}
if (!loadMoreTrigger.value) {
console.warn('loadMoreTrigger 元素不存在')
return
}
if (intersectionObserver) intersectionObserver.disconnect()
if (!loadMoreTrigger.value) return
intersectionObserver = new IntersectionObserver(
(entries) => {
const entry = entries[0]
@ -385,54 +259,51 @@ const setupObserver = () => {
loadMore()
}
},
{
root: container.value,
threshold: 0.1,
}
{ root: container.value, threshold: 0.1 }
)
intersectionObserver.observe(loadMoreTrigger.value)
}
// ==================== ====================
//
let resizeTimer: number
const handleResize = () => {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = window.setTimeout(() => {
layout()
}, 100)
}
// items
// activeTab
watch(
() => items.value,
() => props.activeTab,
() => {
nextTick(() => layout())
},
{ deep: true }
)
//
watch(
() => getColumnCount(),
() => {
layout()
items.value = []
pageToken.value = null
hasMore.value = true
containerHeight.value = 0
itemsWithPosition.value = []
loadFirstPage()
if (container.value) container.value.scrollTop = 0
}
)
// ==================== ====================
watch(
() => items.value,
() => nextTick(() => layout()),
{ deep: true }
)
watch(
() => getColumnCount(),
() => layout()
)
let resizeTimer: number
const handleResize = () => {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = window.setTimeout(() => layout(), 100)
}
onMounted(() => {
loadFirstPage()
window.addEventListener('resize', handleResize)
if (window.ResizeObserver && container.value) {
resizeObserver = new ResizeObserver(() => layout())
resizeObserver.observe(container.value)
}
nextTick(() => {
setupObserver()
})
nextTick(() => setupObserver())
})
onUnmounted(() => {
@ -446,10 +317,16 @@ defineExpose({ refreshLayout: layout })
</script>
<style scoped>
.user-avatar{
width: 20px!important;
height: 20px!important;
.content {
width: 100%;
height: 100%;
}
.user-avatar {
width: 20px !important;
height: 20px !important;
}
/* 把原来的 .tab 相关样式删掉,换成下面这些 */
.stencil-container {
width: 100%;

View File

@ -1,16 +1,17 @@
<template>
<div class="reference-to-video">
<!-- 顶部卡片图片上传和提示词输入区 -->
<!-- 顶部卡片图片上传和提示词输入区 -->
<el-card class="card">
<div class="reference-container">
<!-- 动态上传区域根据模型生成图片上传项 -->
<!-- 动态上传区域根据当前模型的 image 字段动态渲染上传项 -->
<div v-if="currentModel" class="upload-section">
<div v-for="field in imageFields" :key="field.name" class="upload-item">
<div class="upload-label">
{{ field.label }}
<span v-if="field.required" class="required-star">*</span>
</div>
<!-- 已上传图片时显示预览 -->
<!-- 已上传图片时显示预览卡片 -->
<div v-if="uploadedFiles[field.name]" class="image-preview-container">
<img :src="uploadedFiles[field.name]" :alt="field.label" class="preview-image" />
<div class="preview-actions">
@ -22,7 +23,8 @@
</span>
</div>
</div>
<!-- 未上传时显示上传框已有图片时禁用上传 -->
<!-- 未上传时显示上传按钮生成中或已有图片时禁用 -->
<el-upload
v-else
action="#"
@ -39,7 +41,7 @@
</div>
</div>
<!-- 文本输入框绑定 prompt 字段 -->
<!-- 文本输入框仅当模型包含 prompt 字段时显示 -->
<div v-if="hasPromptField" class="input">
<el-input
type="textarea"
@ -59,10 +61,10 @@
<img w-full :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
<!-- 底部配置卡片 -->
<!-- 底部配置卡片模型选择及其他动态配置项 -->
<el-card class="btmcard">
<div class="select">
<!-- 模型选择动态从接口加载 -->
<!-- 模型选择下拉框动态从接口加载 -->
<div class="select-item">
<div class="select-title">模型</div>
<div class="select-content">
@ -83,7 +85,7 @@
</div>
</div>
<!-- 动态生成其他配置项排除 prompt 和图片字段 -->
<!-- 动态渲染其他配置字段排除图片和 prompt 字段 -->
<template v-for="field in visibleFields" :key="field.name">
<div class="select-item">
<div class="select-title">
@ -131,7 +133,7 @@
:disabled="generating"
/>
<!-- 音频上传 -->
<!-- 音频上传示例 -->
<div v-else-if="field.uitype === 'audio'" class="audio-wrapper">
<el-input
v-model="configData[field.name]"
@ -152,7 +154,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { ElMessage } from 'element-plus';
import { Delete, Plus, ZoomIn } from '@element-plus/icons-vue';
import type { UploadFile } from 'element-plus';
@ -166,33 +168,51 @@ import type {
ModelParams,
} from '../../types/model';
// ==================== ====================
// ==================== ====================
const emit = defineEmits<{
/** 价格更新事件,携带当前计算出的价格(单位:分/元?) */
(e: 'price-update', price: number): void;
/** 提示词/图片内容变更事件,用于告知父组件是否有有效输入 */
(e: 'prompt-change', isEmpty: boolean): void;
/** 任务创建成功事件,携带返回的 taskid */
(e: 'task-created', taskid: string): void;
}>();
// ==================== ====================
/** 配置项可能的值类型(用于表单双向绑定) */
type ConfigValue = string | number | boolean;
// ==================== ====================
// ==================== ====================
/** 模型列表 */
const models = ref<Model[]>([]);
/** 当前选中的模型 ID */
const currentModelId = ref('');
/** 当前选中的模型对象(从 models 中查找) */
const currentModel = ref<Model | null>(null);
/** 提示词内容 */
const prompt = ref('');
/** 已上传图片的 base64 数据映射(字段名 -> base64 字符串) */
const uploadedFiles = ref<Record<string, string>>({});
/** 已上传图片的原始文件名映射(用于验证存在性) */
const uploadedFileNames = ref<Record<string, string>>({});
/** 其他配置项数据(除 prompt 和图片外) */
const configData = ref<Record<string, ConfigValue>>({});
/** 是否正在生成视频(用于禁用交互) */
const generating = ref(false);
/** 当前预估价格 */
const price = ref(0);
/** 图片预览对话框相关 */
const dialogImageUrl = ref('');
const dialogVisible = ref(false);
/** 价格查询防抖计时器 */
let priceTimer: ReturnType<typeof setTimeout> | null = null;
// ==================== ====================
/** 图片类型的字段列表 */
const imageFields = computed(() => {
if (!currentModel.value) return [];
return currentModel.value.input_fields.filter(
@ -200,11 +220,13 @@ const imageFields = computed(() => {
);
});
/** 是否存在 prompt 字段 */
const hasPromptField = computed(() => {
if (!currentModel.value) return false;
return currentModel.value.input_fields.some(f => f.name === 'prompt');
});
/** 需要显示的其他配置字段(排除图片和 prompt */
const visibleFields = computed(() => {
if (!currentModel.value) return [];
return currentModel.value.input_fields.filter(
@ -212,6 +234,7 @@ const visibleFields = computed(() => {
);
});
/** prompt 输入框的占位文本(根据必填状态动态生成) */
const promptPlaceholder = computed(() => {
if (!currentModel.value) return '请输入提示词';
const promptField = currentModel.value.input_fields.find(f => f.name === 'prompt');
@ -219,11 +242,18 @@ const promptPlaceholder = computed(() => {
});
// ==================== ====================
/**
* 根据当前模型初始化 configData 对象
* - 对于 code 类型若无默认值则取第一个选项
* - 对于 int 类型默认值为 1
* - 其他类型默认空字符串
*/
const initFormData = () => {
if (!currentModel.value) return;
const newConfigData: Record<string, ConfigValue> = {};
currentModel.value.input_fields.forEach(field => {
// prompt
if (field.uitype === 'image' || field.name === 'prompt') {
return;
}
@ -245,12 +275,17 @@ const initFormData = () => {
configData.value = newConfigData;
};
// ==================== ====================
// ==================== ====================
/**
* 构建价格查询所需的参数对象
* @returns 价格查询参数若模型未加载则返回 null
*/
const buildPriceParams = (): PriceQueryParams | null => {
if (!currentModel.value) return null;
const configDataForPrice: PriceQueryParams['config_data'] = { action: '' };
// prompt
currentModel.value.input_fields.forEach(field => {
if (field.uitype === 'image' || field.name === 'prompt') {
return;
@ -267,20 +302,20 @@ const buildPriceParams = (): PriceQueryParams | null => {
configDataForPrice.action = currentModel.value.apiname;
const priceParams = {
const priceParams: PriceQueryParams = {
product_type: 'llm',
product_id: currentModel.value.id,
config_data: configDataForPrice,
};
console.log('=== 构建价格查询参数 ===');
console.log('价格查询参数:', JSON.stringify(priceParams, null, 2));
console.log('=== 构建价格查询参数 ===', priceParams);
return priceParams;
};
let priceTimer: ReturnType<typeof setTimeout> | null = null;
/**
* 执行价格查询防抖处理
* - 先检查必填内容是否满足若不满足则价格置零
*/
const fetchPrice = () => {
if (priceTimer) clearTimeout(priceTimer);
@ -314,6 +349,10 @@ const fetchPrice = () => {
}, 500);
};
/**
* 检查必填内容是否存在提示词或图片至少有一个
* @returns 是否有有效内容
*/
const checkRequiredFields = (): boolean => {
if (!currentModel.value) return false;
@ -321,10 +360,13 @@ const checkRequiredFields = (): boolean => {
const hasUploads = imageFields.value.length > 0 &&
imageFields.value.some(f => uploadedFileNames.value[f.name]?.trim());
return hasPrompt || hasUploads;
return !!(hasPrompt || hasUploads);
};
// ==================== ====================
// ==================== ====================
/**
* 加载模型列表仅过滤 type '图生视频'
*/
const loadModels = async () => {
try {
const params: ModelParams = { type: '图生视频' };
@ -332,6 +374,7 @@ const loadModels = async () => {
if (res.status === 'ok' && Array.isArray(res.data)) {
models.value = res.data;
if (models.value.length > 0) {
//
currentModelId.value = models.value[0].id;
currentModel.value = models.value[0];
initFormData();
@ -346,14 +389,17 @@ const loadModels = async () => {
}
};
/**
* 模型切换处理
* - 清空已上传图片
* - 重置表单配置
*/
const handleModelChange = (modelId: string) => {
const selected = models.value.find(m => m.id === modelId);
if (selected) {
console.log('=== 切换模型 ===');
console.log('新模型:', selected.name);
console.log('清空的文件:', uploadedFileNames.value);
console.log('=== 切换模型 ===', selected.name);
currentModel.value = selected;
//
uploadedFiles.value = {};
uploadedFileNames.value = {};
initFormData();
@ -362,6 +408,7 @@ const handleModelChange = (modelId: string) => {
};
// ==================== ====================
//
watch(
() => [configData.value, currentModelId.value],
() => {
@ -370,6 +417,7 @@ watch(
{ deep: true }
);
//
watch(
() => [prompt.value, uploadedFileNames.value],
() => {
@ -385,9 +433,14 @@ watch(
{ deep: true, immediate: true }
);
// ==================== ====================
// ==================== ====================
/**
* 图片上传变更回调
* @param file Element Plus 上传文件对象
* @param fieldName 对应的字段名
*/
const handleFileChange = (file: UploadFile, fieldName: string) => {
//
//
if (uploadedFiles.value[fieldName]) {
ElMessage.warning('每个字段只能上传一张图片,请先删除已有图片再上传');
return;
@ -402,51 +455,56 @@ const handleFileChange = (file: UploadFile, fieldName: string) => {
uploadedFiles.value[fieldName] = base64;
uploadedFileNames.value[fieldName] = fileName;
console.log('=== 上传图片 ===');
console.log('字段名:', fieldName);
console.log('文件名:', fileName);
console.log('Base64长度:', base64.length);
console.log('Base64前100字符:', base64.substring(0, 100));
console.log('当前所有上传文件:', Object.keys(uploadedFileNames.value));
console.log('=== 上传图片 ===', { fieldName, fileName, base64Length: base64.length });
ElMessage.success('上传成功');
};
reader.readAsDataURL(rawFile);
};
/**
* 移除已上传的图片
* @param fieldName 字段名
*/
const handleRemove = (fieldName: string) => {
uploadedFiles.value[fieldName] = '';
uploadedFileNames.value[fieldName] = '';
console.log('=== 删除图片 ===');
console.log('字段名:', fieldName);
console.log('剩余上传文件:', uploadedFileNames.value);
console.log('=== 删除图片 ===', fieldName);
ElMessage.info('已移除图片');
};
/**
* 图片预览
* @param file 包含 url 的文件对象
*/
const handlePictureCardPreview = (file: UploadFile) => {
dialogImageUrl.value = file.url!;
dialogVisible.value = true;
};
/**
* 模拟音频上传示例
*/
const simulateUpload = () => {
ElMessage.info('此处可对接实际音频上传服务');
configData.value.audio_file = 'https://example.com/demo_audio.mp3';
};
// ==================== ====================
// ==================== ====================
/**
* 构建提交给后端的参数对象
*/
const buildSubmitParams = (): Record<string, unknown> => {
const baseParams: Record<string, unknown> = {
llmid: currentModel.value!.id,
};
//
//
if (hasPromptField.value && prompt.value) {
baseParams.prompt = prompt.value;
}
// 使base64
// base64
Object.keys(uploadedFiles.value).forEach(key => {
if (uploadedFiles.value[key]) {
baseParams[key] = uploadedFiles.value[key];
@ -466,25 +524,21 @@ const buildSubmitParams = (): Record<string, unknown> => {
}
});
console.log('=== 构建提交参数 ===');
console.log('完整参数:', JSON.stringify(baseParams, null, 2));
console.log('上传的图片字段:', Object.keys(uploadedFiles.value));
console.log('=== 构建提交参数 ===', baseParams);
return baseParams;
};
/**
* 生成视频入口供父组件通过 ref 调用
* @returns 是否成功创建任务
*/
const generate = async (): Promise<boolean> => {
if (!currentModel.value) {
ElMessage.error('请选择模型');
return false;
}
console.log('=== 开始验证必填字段 ===');
console.log('当前模型:', currentModel.value.name);
console.log('图片文件:', uploadedFileNames.value);
console.log('提示词:', prompt.value);
console.log('配置数据:', configData.value);
//
const missingFields: string[] = [];
currentModel.value.input_fields.forEach(field => {
if (field.required) {
@ -500,53 +554,41 @@ const generate = async (): Promise<boolean> => {
});
if (missingFields.length > 0) {
console.error('=== 必填字段验证失败 ===');
console.error('缺失的字段:', missingFields);
console.error('必填字段缺失:', missingFields);
ElMessage.error(`请填写必填项:${missingFields.join('、')}`);
return false;
}
console.log('=== 必填字段验证通过 ===');
generating.value = true;
try {
const params = buildSubmitParams();
console.log('=== 准备提交接口 ===');
console.log('接口路径: /api/llmage/vidu_inference.dspy');
console.log('请求参数:', JSON.stringify(params, null, 2));
const res = (await imagetovideo(params)) as TextVideoResponse;
console.log('=== 图生视频接口响应 ===');
console.log('响应状态:', res.status);
console.log('响应数据:', res.data);
console.log('完整响应:', JSON.stringify(res, null, 2));
console.log('=== 图生视频接口响应 ===', res);
let taskid = res.data?.taskid || res.taskid;
console.log('提取的 taskid:', taskid);
const taskid = res.data?.taskid || res.taskid;
if (res.status === 'created' || res.status === 'processing' || res.status === 'queueing') {
const statusMsg = res.status === 'created' ? '视频生成任务已提交' : (res.status === 'processing' ? '进行中' : '排队中');
console.log('任务状态:', statusMsg);
console.log('任务创建成功,等待处理...');
if (res.status === 'created' || res.status === 'processing' || res.status === 'queueing' || res.status === 'CREATED') {
const statusMsg =
res.status === 'created'
? '视频生成任务已提交'
: res.status === 'processing'
? '进行中'
: '排队中';
ElMessage.success(`${statusMsg},请稍后在任务列表中查看`);
if (taskid) {
console.log('向上传递 task-created 事件taskid:', taskid);
emit('task-created', taskid);
}
resetForm();
return true;
} else {
console.error('任务创建失败,状态:', res.status);
ElMessage.error(res.message || '生成失败,请重试');
return false;
}
} catch (error) {
console.error('=== 生成失败 ===');
console.error('错误详情:', error);
console.error('生成失败:', error);
ElMessage.error(error instanceof Error ? error.message : '生成失败,请重试');
return false;
} finally {
@ -554,10 +596,11 @@ const generate = async (): Promise<boolean> => {
}
};
/**
* 重置表单清空输入图片配置恢复默认
*/
const resetForm = () => {
console.log('=== 重置表单 ===');
console.log('清空的图片文件:', uploadedFileNames.value);
prompt.value = '';
uploadedFiles.value = {};
uploadedFileNames.value = {};
@ -567,7 +610,7 @@ const resetForm = () => {
emit('prompt-change', true);
};
// ==================== ====================
// ==================== ====================
defineExpose({
generate,
resetForm,
@ -577,24 +620,30 @@ defineExpose({
onMounted(() => {
loadModels();
});
//
onBeforeUnmount(() => {
if (priceTimer) {
clearTimeout(priceTimer);
priceTimer = null;
}
});
</script>
<style scoped lang="less">
@import url('../../assets/less/Video/text/text.less');
// +
.reference-to-video {
height: 100vh; //
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto; //
// padding: 16px;
overflow-y: auto;
box-sizing: border-box;
background-color: #0f1114; //
background-color: #0f1114;
.card {
margin-bottom: 16px;
flex-shrink: 0; //
flex-shrink: 0;
}
.btmcard {
@ -602,7 +651,6 @@ onMounted(() => {
}
}
//
.image-preview-container {
position: relative;
width: 148px;
@ -661,7 +709,7 @@ onMounted(() => {
</style>
<style>
/* 全局样式(保留原有下拉框样式) */
/* 全局样式:自定义下拉框弹出层 */
.custom-select-popup {
background-color: #2A2D34 !important;
border-radius: 12px !important;

View File

@ -13,7 +13,7 @@
placeholder="订单状态"
size="default"
@change="handleFilterChange"
popper-class="custom-select-popper"
popper-class="custom-select-popup"
class="status-select"
>
<el-option label="全部状态" value="" />
@ -764,20 +764,22 @@ onMounted(() => {
}
</style>
<style>
.dark-popper .el-select-dropdown__list {
background-color: #1e2024 !important;
/* 全局样式(保留原有下拉框样式) */
.custom-select-popup {
background-color: #2A2D34 !important;
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
border: none !important;
}
.dark-popper .el-select-dropdown__item {
.custom-select-popup .el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
font-size: 14px !important;
}
.dark-popper .el-select-dropdown__item:hover {
background-color: #3a3d44 !important;
}
.dark-popper .el-select-dropdown__item.is-selected {
background-color: #2c2f36 !important;
color: #409eff !important;
.custom-select-popup .el-select-dropdown__item.selected,
.custom-select-popup .el-select-dropdown__item:hover {
background-color: #3a3d46 !important;
}
</style>

View File

@ -1,9 +1,18 @@
<template>
<div>
<div class="index" ref="indexContainer">
<div class="top"><Top /></div>
<div class="AI"><AI /></div>
<!-- Tab 后续通过添加 sticky 类实现吸顶 -->
<div class="sticky-tabs" :class="{ 'is-sticky': isTabSticky }">
<StencilTabs :activeTab="activeTab" @tab-change="handleTabChange" />
</div>
<!-- 瀑布流内容 -->
<div class="stencil">
<Stencil
<StencilWaterfall
ref="waterfallRef"
:activeTab="activeTab"
:gap="16"
@like="handleLike"
@make-same="handleMakeSame"
@ -13,16 +22,84 @@
</template>
<script setup lang="ts">
import Top from '@/components/index/top.vue';
import AI from '@/components/index/AIMode.vue';
import Stencil from '@/components/index/Stencil.vue';
import type { StencilItem } from '../../types/home';
import { ref, onMounted, onUnmounted } from 'vue'
import Top from '@/components/index/top.vue'
import AI from '@/components/index/AIMode.vue'
import StencilTabs from '@/components/index/StencilTabs.vue'
import StencilWaterfall from '@/components/index/StencilWaterfall.vue'
import type { StencilItem } from '@/types/home'
const activeTab = ref('mixed') //
const handleTabChange = (tabType: string) => {
activeTab.value = tabType
}
const handleLike = (item: StencilItem) => {
console.log('点赞', item);
};
console.log('点赞', item)
}
const handleMakeSame = (item: StencilItem) => {
console.log('做同款', item);
};
</script>
console.log('做同款', item)
}
// ===== =====
const indexContainer = ref<HTMLElement>()
const isTabSticky = ref(false)
const tabsOffsetTop = ref(0)
const handleScroll = () => {
if (!indexContainer.value) return
const scrollTop = indexContainer.value.scrollTop
// Tab sticky
isTabSticky.value = scrollTop >= tabsOffsetTop.value
}
onMounted(() => {
const container = indexContainer.value
if (container) {
container.addEventListener('scroll', handleScroll)
// Tab
const tabsEl = container.querySelector('.sticky-tabs') as HTMLElement
if (tabsEl) {
tabsOffsetTop.value = tabsEl.offsetTop
}
}
})
onUnmounted(() => {
indexContainer.value?.removeEventListener('scroll', handleScroll)
})
</script>
<style lang="less" scoped>
.index {
width: 100%;
height: 100%;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.3);
border-radius: 10px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
}
.sticky-tabs {
position: relative;
z-index: 10;
background-color: #0f0f0f; /* 与页面背景一致,吸顶时遮盖内容 */
transition: box-shadow 0.2s;
&.is-sticky {
position: sticky;
top: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
</style>