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"> <div class="top">
<el-checkbox v-model="showProcessing" @change="handleFilterChange" label="进行中" size="large" /> <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="" />
<el-option label="已创建" value="created" /> <el-option label="已创建" value="created" />
<el-option label="处理中" value="processing" /> <el-option label="处理中" value="processing" />
@ -29,10 +29,8 @@
<div class="content"> <div class="content">
<!-- 加载中 --> <!-- 加载中 -->
<div v-if="isLoading" class="loading-container"> <div v-if="isLoading" class="loading-container">
<el-icon class="is-loading" :size="40"> <el-icon class="is-loading" :size="40" color="#ffffff">
<svg viewBox="0 0 1024 1024"> <Loading />
<path fill="currentColor" d="M512 64a448 448 0 1 1 0 896A448 448 0 0 1 0 64z"></path>
</svg>
</el-icon> </el-icon>
<div class="loading-text">加载中...</div> <div class="loading-text">加载中...</div>
</div> </div>
@ -546,7 +544,6 @@ defineExpose({
refreshTaskList: loadTaskList // 刷新任务列表 refreshTaskList: loadTaskList // 刷新任务列表
}); });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
@ -616,3 +613,24 @@ defineExpose({
font-weight: 500; font-weight: 500;
} }
</style> </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> <template>
<div class="content"> <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="{ <div ref="container" class="waterfall-container" :style="{
width: '100%', width: '100%',
height: 'auto', height: 'auto',
@ -51,19 +28,18 @@
top: `${item.top}px`, top: `${item.top}px`,
left: `${item.left}px`, left: `${item.left}px`,
}"> }">
<!-- 卡片内容保持不变 -->
<div class="card-wrapper"> <div class="card-wrapper">
<div class="video-container" :style="{ aspectRatio: item.aspectRatio }" <div class="video-container" :style="{ aspectRatio: item.aspectRatio }"
@mouseenter="handleMouseEnter(index)" @mouseleave="handleMouseLeave(index)"> @mouseenter="handleMouseEnter(index)" @mouseleave="handleMouseLeave(index)">
<video :ref="(el) => setVideoRef(index, el)" :src="item.src" :poster="item.poster" preload="none" <video :ref="(el) => setVideoRef(index, el as HTMLVideoElement | null)" :src="item.src"
class="video-element" :class="{ 'playing': isVideoPlaying[index] }" loop muted playsinline /> :poster="item.poster" preload="none" class="video-element" :class="{ playing: isVideoPlaying[index] }"
<!-- 底部信息栏 --> loop muted playsinline />
<div class="card-info-overlay"> <div class="card-info-overlay">
<!-- 左侧:用户信息 -->
<div class="user-info"> <div class="user-info">
<img v-if="item.avatar" :src="item.avatar" class="user-avatar" alt="avatar" /> <img v-if="item.avatar" :src="item.avatar" class="user-avatar" alt="avatar" />
<span v-if="item.userName" class="user-name">{{ item.userName }}</span> <span v-if="item.userName" class="user-name">{{ item.userName }}</span>
</div> </div>
<!-- 右侧:点赞和收藏 -->
<div class="interaction-info"> <div class="interaction-info">
<div v-if="item.likes > 0" class="interaction-item"> <div v-if="item.likes > 0" class="interaction-item">
<span class="icon-heart">♡</span> <span class="icon-heart">♡</span>
@ -76,7 +52,6 @@
</div> </div>
</div> </div>
<!-- 底部加载指示器 -->
<div ref="loadMoreTrigger" class="load-more-trigger"> <div ref="loadMoreTrigger" class="load-more-trigger">
<span v-if="loading" class="loading-spinner"></span> <span v-if="loading" class="loading-spinner"></span>
<span v-else-if="hasMore">加载更多...</span> <span v-else-if="hasMore">加载更多...</span>
@ -85,29 +60,19 @@
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue' import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { getVideos } from '@/apis/home' import { getVideos } from '@/apis/home'
import { transformFeedItem, transformShareElementItem } from '@/utlis/transform' import { transformFeedItem, transformShareElementItem } from '@/utlis/transform'
import type { FeedResponse, ShareElementsResponse } from '@/types/home' import type { FeedResponse, ShareElementsResponse } from '@/types/home'
import type { StencilItem } 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
}
const props = defineProps<{ const props = defineProps<{
gap?: number // 卡片间距,默认 16px activeTab: string // 由父组件传入当前激活的 Tab
columnCount?: number // 列数,默认根据屏幕宽度自动计算 gap?: number
items?: StencilItem[] // 外部传入的卡片数据 columnCount?: number
items?: StencilItem[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -116,24 +81,6 @@ const emit = defineEmits<{
(e: 'loadMore'): void (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 items = ref<StencilItem[]>([])
const loading = ref(false) const loading = ref(false)
@ -144,28 +91,23 @@ const pageToken = ref<string | null>(null)
const videoRefs = ref<(HTMLVideoElement | null)[]>([]) const videoRefs = ref<(HTMLVideoElement | null)[]>([])
const isVideoPlaying = ref<Record<number, boolean>>({}) const isVideoPlaying = ref<Record<number, boolean>>({})
// 设置视频引用
const setVideoRef = (index: number, el: HTMLVideoElement | null) => { const setVideoRef = (index: number, el: HTMLVideoElement | null) => {
videoRefs.value[index] = el videoRefs.value[index] = el
} }
// 鼠标移入:播放视频
const handleMouseEnter = (index: number) => { const handleMouseEnter = (index: number) => {
const video = videoRefs.value[index] const video = videoRefs.value[index]
if (video) { if (video) {
video.play().catch(err => { video.play().catch(err => console.log('播放失败:', err))
console.log('播放失败:', err)
})
isVideoPlaying.value[index] = true isVideoPlaying.value[index] = true
} }
} }
// 鼠标移出:暂停视频
const handleMouseLeave = (index: number) => { const handleMouseLeave = (index: number) => {
const video = videoRefs.value[index] const video = videoRefs.value[index]
if (video) { if (video) {
video.pause() video.pause()
video.currentTime = 0 // 重置到开头 video.currentTime = 0
isVideoPlaying.value[index] = false isVideoPlaying.value[index] = false
} }
} }
@ -183,18 +125,16 @@ let resizeObserver: ResizeObserver | null = null
let isLayoutPending = false let isLayoutPending = false
let intersectionObserver: IntersectionObserver | null = null let intersectionObserver: IntersectionObserver | null = null
// ==================== 辅助函数 ====================
// 获取当前列数(优先使用 props,否则根据屏幕宽度)
const getColumnCount = (): number => { const getColumnCount = (): number => {
if (props.columnCount !== undefined) return props.columnCount if (props.columnCount !== undefined) return props.columnCount
const width = window.innerWidth const width = window.innerWidth
if (width >= 1280) return 4 // PC if (width >= 1280) return 4
if (width >= 768) return 3 // 平板 if (width >= 768) return 3
return 2 // 手机 return 2
} }
/* 核心布局算法 */
const layout = async () => { const layout = async () => {
// ... 布局算法保持不变,参考原文件 ...
if (!container.value || !innerScrollContainer.value || items.value.length === 0) return if (!container.value || !innerScrollContainer.value || items.value.length === 0) return
if (isLayoutPending) return if (isLayoutPending) return
isLayoutPending = true isLayoutPending = true
@ -203,29 +143,21 @@ const layout = async () => {
const containerWidth = container.value.clientWidth const containerWidth = container.value.clientWidth
const columnCount = getColumnCount() const columnCount = getColumnCount()
const gap = 4 const gap = 4
// 计算卡片宽度
cardWidth.value = (containerWidth - gap * (columnCount - 1)) / columnCount cardWidth.value = (containerWidth - gap * (columnCount - 1)) / columnCount
// 重置列高度
columnHeights = new Array(columnCount).fill(0) columnHeights = new Array(columnCount).fill(0)
// 降级:根据宽高比估算高度(更可靠)
const heights: number[] = [] const heights: number[] = []
for (const item of items.value) { for (const item of items.value) {
const ratio = parseFloat(item.aspectRatio.split('/')[0]) / parseFloat(item.aspectRatio.split('/')[1]) const [num, den] = item.aspectRatio.split('/').map(Number)
heights.push(cardWidth.value / ratio) heights.push(cardWidth.value / (num / den))
} }
// 计算每个卡片的位置
const newItems = items.value.map((item, idx) => { const newItems = items.value.map((item, idx) => {
const height = heights[idx] || 0 const height = heights[idx] || 0
// 找到当前最短列
const minHeight = Math.min(...columnHeights) const minHeight = Math.min(...columnHeights)
const minIndex = columnHeights.indexOf(minHeight) const minIndex = columnHeights.indexOf(minHeight)
const left = minIndex * (cardWidth.value + gap) const left = minIndex * (cardWidth.value + gap)
const top = columnHeights[minIndex] const top = columnHeights[minIndex]
// 更新该列高度
columnHeights[minIndex] += height + gap columnHeights[minIndex] += height + gap
return { ...item, top, left, height } return { ...item, top, left, height }
}) })
@ -237,22 +169,17 @@ const layout = async () => {
} }
} }
// 媒体加载完成后重新布局
// ==================== 数据加载 ==================== // ==================== 数据加载 ====================
// 加载第一页
const loadFirstPage = async () => { const loadFirstPage = async () => {
if (loading.value) return if (loading.value) return
loading.value = true loading.value = true
try { try {
const res: FeedResponse | ShareElementsResponse = await getVideos({ const res: FeedResponse | ShareElementsResponse = await getVideos({
channel_type: activeTab.value // 自动使用当前 Tab 类型 channel_type: props.activeTab
}) })
const cards: StencilItem[] = [] const cards: StencilItem[] = []
if (props.activeTab === 'share_elements') {
// 根据不同的数据格式进行转换
if (activeTab.value === 'share_elements') {
// 使用新的 share_elements 数据格式
const shareRes = res as ShareElementsResponse const shareRes = res as ShareElementsResponse
for (const item of shareRes.share_elements) { for (const item of shareRes.share_elements) {
const card = transformShareElementItem(item) const card = transformShareElementItem(item)
@ -260,7 +187,6 @@ const loadFirstPage = async () => {
} }
pageToken.value = shareRes.next_page_token ?? null pageToken.value = shareRes.next_page_token ?? null
} else { } else {
// 使用原有的数据格式
const feedRes = res as FeedResponse const feedRes = res as FeedResponse
for (const item of feedRes.list) { for (const item of feedRes.list) {
const card = transformFeedItem(item) const card = transformFeedItem(item)
@ -282,59 +208,28 @@ const loadFirstPage = async () => {
} }
} }
// 加载更多
const loadMore = async () => { const loadMore = async () => {
console.log('--- loadMore 开始 ---') if (loading.value || !hasMore.value || !pageToken.value) return
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
}
loading.value = true loading.value = true
try { try {
// 动态构建参数:只在 pageToken 有值时才传递 page_token const params: { channel_type: string; page_token?: string } = {
const params: { channel_type: props.activeTab,
channel_type: string;
page_token?: string;
} = {
channel_type: activeTab.value
} }
if (pageToken.value) { if (pageToken.value) params.page_token = pageToken.value
params.page_token = pageToken.value
}
console.log('请求参数:', params)
console.log('准备调用 getVideos...')
const res: FeedResponse | ShareElementsResponse = await getVideos(params) const res: FeedResponse | ShareElementsResponse = await getVideos(params)
console.log('getVideos 调用成功!')
console.log('接口返回数据:', res)
const newCards: StencilItem[] = [] const newCards: StencilItem[] = []
if (props.activeTab === 'share_elements') {
// 根据不同的数据格式进行转换
if (activeTab.value === 'share_elements') {
// 使用新的 share_elements 数据格式
const shareRes = res as ShareElementsResponse 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) { for (const item of shareRes.share_elements) {
const card = transformShareElementItem(item) const card = transformShareElementItem(item)
if (card) newCards.push(card) if (card) newCards.push(card)
} }
pageToken.value = shareRes.next_page_token ?? null pageToken.value = shareRes.next_page_token ?? null
} else { } else {
// 使用原有的数据格式
const feedRes = res as FeedResponse 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) { for (const item of feedRes.list) {
const card = transformFeedItem(item) const card = transformFeedItem(item)
if (card) newCards.push(card) if (card) newCards.push(card)
@ -342,41 +237,20 @@ const loadMore = async () => {
pageToken.value = feedRes.next_page_token ?? null pageToken.value = feedRes.next_page_token ?? null
} }
console.log('转换后的卡片数量:', newCards.length)
items.value = [...items.value, ...newCards] items.value = [...items.value, ...newCards]
hasMore.value = !!pageToken.value hasMore.value = !!pageToken.value
console.log('更新后 - items 长度:', items.value.length, 'pageToken:', pageToken.value, 'hasMore:', hasMore.value)
} catch (error) { } catch (error) {
console.error('加载更多失败,错误详情:', 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))
hasMore.value = false hasMore.value = false
} finally { } finally {
loading.value = false loading.value = false
} }
console.log('--- loadMore 结束 ---')
} }
// ==================== 滚动监听 ====================
// 设置 IntersectionObserver 监听底部
const setupObserver = () => { const setupObserver = () => {
if (intersectionObserver) { if (intersectionObserver) intersectionObserver.disconnect()
intersectionObserver.disconnect() if (!loadMoreTrigger.value) return
}
if (!loadMoreTrigger.value) {
console.warn('loadMoreTrigger 元素不存在')
return
}
intersectionObserver = new IntersectionObserver( intersectionObserver = new IntersectionObserver(
(entries) => { (entries) => {
const entry = entries[0] const entry = entries[0]
@ -385,54 +259,51 @@ const setupObserver = () => {
loadMore() loadMore()
} }
}, },
{ { root: container.value, threshold: 0.1 }
root: container.value,
threshold: 0.1,
}
) )
intersectionObserver.observe(loadMoreTrigger.value) intersectionObserver.observe(loadMoreTrigger.value)
} }
// ==================== 响应式监听 ==================== // ==================== 响应式监听 ====================
// 响应窗口尺寸变化 // 监听 activeTab 变化,重置并重新加载
let resizeTimer: number
const handleResize = () => {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = window.setTimeout(() => {
layout()
}, 100)
}
// 监听内部 items 变化,重新布局
watch( watch(
() => items.value, () => props.activeTab,
() => { () => {
nextTick(() => layout()) items.value = []
}, pageToken.value = null
{ deep: true } hasMore.value = true
) containerHeight.value = 0
itemsWithPosition.value = []
// 监听列数变化 loadFirstPage()
watch( if (container.value) container.value.scrollTop = 0
() => getColumnCount(),
() => {
layout()
} }
) )
// ==================== 生命周期 ==================== 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(() => { onMounted(() => {
loadFirstPage() loadFirstPage()
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
if (window.ResizeObserver && container.value) { if (window.ResizeObserver && container.value) {
resizeObserver = new ResizeObserver(() => layout()) resizeObserver = new ResizeObserver(() => layout())
resizeObserver.observe(container.value) resizeObserver.observe(container.value)
} }
nextTick(() => setupObserver())
nextTick(() => {
setupObserver()
})
}) })
onUnmounted(() => { onUnmounted(() => {
@ -446,10 +317,16 @@ defineExpose({ refreshLayout: layout })
</script> </script>
<style scoped> <style scoped>
.user-avatar{ .content {
width: 20px!important; width: 100%;
height: 20px!important; height: 100%;
} }
.user-avatar {
width: 20px !important;
height: 20px !important;
}
/* 把原来的 .tab 相关样式删掉,换成下面这些 */ /* 把原来的 .tab 相关样式删掉,换成下面这些 */
.stencil-container { .stencil-container {
width: 100%; width: 100%;

View File

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

View File

@ -13,7 +13,7 @@
placeholder="订单状态" placeholder="订单状态"
size="default" size="default"
@change="handleFilterChange" @change="handleFilterChange"
popper-class="custom-select-popper" popper-class="custom-select-popup"
class="status-select" class="status-select"
> >
<el-option label="全部状态" value="" /> <el-option label="全部状态" value="" />
@ -764,20 +764,22 @@ onMounted(() => {
} }
</style> </style>
<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; color: #ffffff !important;
background-color: transparent !important;
font-size: 14px !important;
} }
.dark-popper .el-select-dropdown__item:hover { .custom-select-popup .el-select-dropdown__item.selected,
background-color: #3a3d44 !important; .custom-select-popup .el-select-dropdown__item:hover {
} background-color: #3a3d46 !important;
.dark-popper .el-select-dropdown__item.is-selected {
background-color: #2c2f36 !important;
color: #409eff !important;
} }
</style> </style>

View File

@ -1,9 +1,18 @@
<template> <template>
<div> <div class="index" ref="indexContainer">
<div class="top"><Top /></div> <div class="top"><Top /></div>
<div class="AI"><AI /></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"> <div class="stencil">
<Stencil <StencilWaterfall
ref="waterfallRef"
:activeTab="activeTab"
:gap="16" :gap="16"
@like="handleLike" @like="handleLike"
@make-same="handleMakeSame" @make-same="handleMakeSame"
@ -13,16 +22,84 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Top from '@/components/index/top.vue'; import { ref, onMounted, onUnmounted } from 'vue'
import AI from '@/components/index/AIMode.vue'; import Top from '@/components/index/top.vue'
import Stencil from '@/components/index/Stencil.vue'; import AI from '@/components/index/AIMode.vue'
import type { StencilItem } from '../../types/home'; 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) => { const handleLike = (item: StencilItem) => {
console.log('点赞', item); console.log('点赞', item)
}; }
const handleMakeSame = (item: StencilItem) => { const handleMakeSame = (item: StencilItem) => {
console.log('做同款', item); 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> </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>