更新
This commit is contained in:
parent
64af489397
commit
17bdec62bc
@ -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>
|
||||
101
src/components/index/StencilTabs.vue
Normal file
101
src/components/index/StencilTabs.vue
Normal 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>
|
||||
@ -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%;
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user