使用新的虚拟滚动组件
This commit is contained in:
parent
c9ed299ef6
commit
5cb45dd3b0
|
|
@ -11,71 +11,28 @@ export {}
|
|||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AIgenerate: typeof import('./src/components/AIgenerate/AIgenerate.vue')['default']
|
||||
Canvas: typeof import('./src/components/canvas/index.vue')['default']
|
||||
Collection: typeof import('./src/components/collection/index.vue')['default']
|
||||
copy: typeof import('./src/components/ModelDescription copy.vue')['default']
|
||||
DeepseekPopover: typeof import('./src/components/AIgenerate/DeepseekPopover.vue')['default']
|
||||
DialogBox: typeof import('./src/components/dialogBox/index.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
IEpArrow: typeof import('~icons/ep/arrow')['default']
|
||||
IEpArrowDown: typeof import('~icons/ep/arrow-down')['default']
|
||||
IEpBack: typeof import('~icons/ep/back')['default']
|
||||
IEpCalendar: typeof import('~icons/ep/calendar')['default']
|
||||
IEpCirclePlusFilled: typeof import('~icons/ep/circle-plus-filled')['default']
|
||||
IEpClose: typeof import('~icons/ep/close')['default']
|
||||
IEpDelete: typeof import('~icons/ep/delete')['default']
|
||||
IEpDownload: typeof import('~icons/ep/download')['default']
|
||||
IEpEdit: typeof import('~icons/ep/edit')['default']
|
||||
IEpHouse: typeof import('~icons/ep/house')['default']
|
||||
IEpLoading: typeof import('~icons/ep/loading')['default']
|
||||
IEpPlus: typeof import('~icons/ep/plus')['default']
|
||||
IEpRefresh: typeof import('~icons/ep/refresh')['default']
|
||||
IEpRefreshRight: typeof import('~icons/ep/refresh-right')['default']
|
||||
IEpRight: typeof import('~icons/ep/right')['default']
|
||||
IEpStar: typeof import('~icons/ep/star')['default']
|
||||
IEpStarFilled: typeof import('~icons/ep/star-filled')['default']
|
||||
IEpTop: typeof import('~icons/ep/top')['default']
|
||||
IEpUploadFilled: typeof import('~icons/ep/upload-filled')['default']
|
||||
ImageUploader: typeof import('./src/components/dialogBox/imageUploader/index.vue')['default']
|
||||
Img: typeof import('./src/components/Img/index.vue')['default']
|
||||
'Index(1)': typeof import('./src/components/canvas/index(1).vue')['default']
|
||||
Library: typeof import('./src/components/Library/index.vue')['default']
|
||||
Model: typeof import('./src/components/dialogBox/model/index.vue')['default']
|
||||
ModelParam: typeof import('./src/components/AIgenerate/components/modelParam.vue')['default']
|
||||
Official: typeof import('./src/components/Library/official.vue')['default']
|
||||
Private: typeof import('./src/components/Library/private.vue')['default']
|
||||
Proportion: typeof import('./src/components/dialogBox/proportion/index.vue')['default']
|
||||
Quantity: typeof import('./src/components/dialogBox/quantity/index.vue')['default']
|
||||
Recommend: typeof import('./src/components/AIgenerate/components/recommend.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Select: typeof import('./src/components/Select/index.vue')['default']
|
||||
Subpage: typeof import('./src/components/subpage.vue')['default']
|
||||
UploadPicture: typeof import('./src/components/UploadPicture.vue')['default']
|
||||
VirtualScroller: typeof import('./src/components/virtual-scroller/VirtualScroller.vue')['default']
|
||||
VirtualScrollerItem: typeof import('./src/components/virtual-scroller/VirtualScrollerItem.vue')['default']
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -6,14 +6,14 @@
|
|||
class="img-element"
|
||||
@click="handleImageClick"
|
||||
/>
|
||||
<div class="fullscreen-icon" @click="toggleFullscreen">
|
||||
<!-- <div class="fullscreen-icon" @click="toggleFullscreen">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 14H5V19H10V17H7V14Z" fill="currentColor" />
|
||||
<path d="M5 10H7V7H10V5H5V10Z" fill="currentColor" />
|
||||
<path d="M17 17H14V19H19V14H17V17Z" fill="currentColor" />
|
||||
<path d="M14 5V7H17V10H19V5H14Z" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
# 高性能虚拟滚动组件
|
||||
|
||||
一个基于 Vue 3 Composition API 开发的高性能虚拟滚动组件,支持从底部开始渲染(容器向上增大),适用于聊天记录、历史列表等场景。
|
||||
|
||||
## 特性
|
||||
|
||||
### 核心功能
|
||||
- ✅ 虚拟滚动核心,只渲染可视区域内容
|
||||
- ✅ 支持未知高度内容,自动测量和缓存高度
|
||||
- ✅ 插槽式组件插入,灵活易用
|
||||
- ✅ 两种渲染模式:顶部模式(向下滚动)和底部模式(向上滚动)
|
||||
- ✅ 滚动触发加载更多事件
|
||||
- ✅ 性能优化,防止滚动抖动
|
||||
|
||||
### 补充功能
|
||||
- ✅ 自定义渲染函数
|
||||
- ✅ 动态数据更新
|
||||
- ✅ 滚动位置保存和恢复
|
||||
- ✅ 自定义滚动阈值
|
||||
|
||||
### 外部接口
|
||||
- ✅ 丰富的 Props 配置
|
||||
- ✅ 完整的方法暴露
|
||||
|
||||
## 安装
|
||||
|
||||
组件位于 `@/components/virtual-scroller`,无需额外安装依赖。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 基本使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<VirtualScroller
|
||||
:data="list"
|
||||
:item-height="100"
|
||||
:estimated-height="100"
|
||||
render-mode="bottom"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div>{{ item.text }}</div>
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { VirtualScroller } from '@/components/virtual-scroller'
|
||||
|
||||
const list = ref([
|
||||
{ id: 1, text: '消息 1' },
|
||||
{ id: 2, text: '消息 2' }
|
||||
])
|
||||
</script>
|
||||
```
|
||||
|
||||
### Props 说明
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| data | Array | [] | 列表数据 |
|
||||
| itemHeight | Number | 80 | 预估列表项高度 |
|
||||
| estimatedHeight | Number | 80 | 未知高度内容的预估高度 |
|
||||
| renderMode | String | 'bottom' | 渲染模式,'top' \| 'bottom' |
|
||||
| scrollThreshold | Number | 100 | 滚动触发阈值 |
|
||||
| onLoadMore | Function | null | 加载更多回调函数 |
|
||||
| renderItem | Function | null | 自定义渲染函数 |
|
||||
| keyExtractor | Function | (item, index) => index | 列表项 key 生成函数 |
|
||||
| cacheHeight | Boolean | true | 是否缓存已测量的高度 |
|
||||
| buffer | Number | 5 | 预渲染缓冲区数量 |
|
||||
|
||||
### 事件说明
|
||||
|
||||
| 事件名 | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| scroll | event | 滚动事件 |
|
||||
| load-more | - | 加载更多事件 |
|
||||
| item-height-change | index, height | 列表项高度变化时触发 |
|
||||
|
||||
### 暴露方法
|
||||
|
||||
通过 `ref` 可以调用以下方法:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<VirtualScroller ref="scrollerRef" :data="list" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const scrollerRef = ref(null)
|
||||
|
||||
// 滚动到底部
|
||||
scrollerRef.value?.scrollToBottom()
|
||||
|
||||
// 滚动到顶部
|
||||
scrollerRef.value?.scrollToTop()
|
||||
|
||||
// 滚动到指定索引
|
||||
scrollerRef.value?.scrollToIndex(10)
|
||||
|
||||
// 更新数据
|
||||
scrollerRef.value?.updateData(newData)
|
||||
|
||||
// 获取当前滚动位置
|
||||
const position = scrollerRef.value?.getScrollPosition()
|
||||
|
||||
// 设置滚动位置
|
||||
scrollerRef.value?.setScrollPosition(500)
|
||||
|
||||
// 更新指定项高度
|
||||
scrollerRef.value?.updateItemHeight(5, 200)
|
||||
|
||||
// 清除高度缓存
|
||||
scrollerRef.value?.clearHeightCache()
|
||||
</script>
|
||||
```
|
||||
|
||||
## 渲染模式说明
|
||||
|
||||
### 顶部模式 (renderMode: 'top')
|
||||
|
||||
内容从顶部开始显示,向下滚动加载更多。适用于普通列表。
|
||||
|
||||
```vue
|
||||
<VirtualScroller
|
||||
:data="list"
|
||||
render-mode="top"
|
||||
:on-load-more="loadMore"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div>{{ item.text }}</div>
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
```
|
||||
|
||||
### 底部模式 (renderMode: 'bottom') ⭐ 核心特性
|
||||
|
||||
内容从底部开始显示,向上滚动加载更多,**容器垂直向上增大**。适用于聊天记录、历史列表等场景。
|
||||
|
||||
```vue
|
||||
<VirtualScroller
|
||||
:data="list"
|
||||
render-mode="bottom"
|
||||
:on-load-more="loadMoreHistory"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<Message :item="item" />
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
- 使用 `requestAnimationFrame` 优化渲染
|
||||
- 节流处理滚动事件(16ms)
|
||||
- 高度缓存机制,避免重复测量
|
||||
- 使用 Intersection Observer 优化可见性检测
|
||||
- 只渲染可视区域 + 缓冲区内容
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. `renderMode: 'bottom'` 模式下,数据建议按时间倒序排列(最新的在最后)
|
||||
2. 列表项需要有明确的高度或能被正确测量
|
||||
3. 使用图片等异步内容时,建议设置 `min-height` 防止布局抖动
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
virtual-scroller/
|
||||
├── index.js # 组件入口
|
||||
├── VirtualScroller.vue # 主组件
|
||||
├── VirtualScrollerItem.vue # 列表项组件
|
||||
├── useVirtualScroller.js # 组合式 API 钩子
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted, defineExpose } from 'vue'
|
||||
import useVirtualScroller from './useVirtualScroller'
|
||||
import VirtualScrollerItem from './VirtualScrollerItem.vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
itemHeight: {
|
||||
type: Number,
|
||||
default: 80
|
||||
},
|
||||
renderMode: {
|
||||
type: String,
|
||||
default: 'bottom',
|
||||
validator: (value) => ['top', 'bottom'].includes(value)
|
||||
},
|
||||
scrollThreshold: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
onLoadMore: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
renderItem: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
keyExtractor: {
|
||||
type: Function,
|
||||
default: (item, index) => index
|
||||
},
|
||||
estimatedHeight: {
|
||||
type: Number,
|
||||
default: 80
|
||||
},
|
||||
cacheHeight: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
buffer: {
|
||||
type: Number,
|
||||
default: 5
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['scroll', 'load-more', 'item-height-change'])
|
||||
|
||||
const scrollerRef = ref(null)
|
||||
const {
|
||||
visibleData,
|
||||
totalHeight,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
scrollToIndex,
|
||||
updateData,
|
||||
getScrollPosition,
|
||||
setScrollPosition,
|
||||
updateItemHeight,
|
||||
clearHeightCache,
|
||||
handleScroll: _handleScroll,
|
||||
initScroll
|
||||
} = useVirtualScroller(props, emit, scrollerRef)
|
||||
|
||||
const handleScroll = (event) => {
|
||||
_handleScroll(event)
|
||||
emit('scroll', event)
|
||||
}
|
||||
|
||||
const handleItemHeightChange = (index, height) => {
|
||||
updateItemHeight(index, height)
|
||||
emit('item-height-change', index, height)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
scrollToIndex,
|
||||
updateData,
|
||||
getScrollPosition,
|
||||
setScrollPosition,
|
||||
updateItemHeight,
|
||||
clearHeightCache
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initScroll()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="scrollerRef"
|
||||
class="virtual-scroller"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div
|
||||
class="virtual-scroller-content"
|
||||
:style="{ height: `${totalHeight}px`, position: 'relative' }"
|
||||
>
|
||||
<VirtualScrollerItem
|
||||
v-for="(item, index) in visibleData"
|
||||
:key="keyExtractor(item.item, item.index)"
|
||||
:item="item.item"
|
||||
:index="item.index"
|
||||
:top="item.top"
|
||||
:estimated-height="estimatedHeight"
|
||||
:cache-height="cacheHeight"
|
||||
@height-change="(height) => handleItemHeightChange(item.index, height)"
|
||||
>
|
||||
<template v-if="renderItem">
|
||||
<component :is="renderItem(item.item, item.index)" />
|
||||
</template>
|
||||
<slot v-else :item="item.item" :index="item.index">
|
||||
<div>{{ item.item }}</div>
|
||||
</slot>
|
||||
</VirtualScrollerItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.virtual-scroller {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.virtual-scroller-content {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: [Object, String, Number],
|
||||
default: null
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
top: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
estimatedHeight: {
|
||||
type: Number,
|
||||
default: 80
|
||||
},
|
||||
cacheHeight: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['height-change'])
|
||||
|
||||
const itemRef = ref(null)
|
||||
const measuredHeight = ref(props.estimatedHeight)
|
||||
let heightCache = props.estimatedHeight
|
||||
let resizeObserver = null
|
||||
|
||||
const measureHeight = () => {
|
||||
if (itemRef.value) {
|
||||
const rect = itemRef.value.getBoundingClientRect()
|
||||
const newHeight = rect.height
|
||||
if (newHeight !== measuredHeight.value && newHeight > 0) {
|
||||
measuredHeight.value = newHeight
|
||||
emit('height-change', newHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const initResizeObserver = () => {
|
||||
if (typeof ResizeObserver !== 'undefined' && itemRef.value) {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const newHeight = entry.contentRect.height
|
||||
if (newHeight !== measuredHeight.value && newHeight > 0) {
|
||||
measuredHeight.value = newHeight
|
||||
emit('height-change', newHeight)
|
||||
}
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(itemRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
measureHeight()
|
||||
initResizeObserver()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="itemRef"
|
||||
class="virtual-scroller-item"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: `${top}px`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
minHeight: `${estimatedHeight}px`
|
||||
}"
|
||||
>
|
||||
<slot :item="item" :index="index"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.virtual-scroller-item {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import VirtualScroller from './VirtualScroller.vue'
|
||||
import VirtualScrollerItem from './VirtualScrollerItem.vue'
|
||||
import useVirtualScroller from './useVirtualScroller'
|
||||
|
||||
export {
|
||||
VirtualScroller,
|
||||
VirtualScrollerItem,
|
||||
useVirtualScroller
|
||||
}
|
||||
|
||||
export default VirtualScroller
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
|
||||
export default function useVirtualScroller(props, emit, scrollerRef) {
|
||||
const heights = ref(new Map())
|
||||
const positions = ref([])
|
||||
const scrollTop = ref(0)
|
||||
const clientHeight = ref(0)
|
||||
const isLoading = ref(false)
|
||||
let lastScrollTime = 0
|
||||
let animationFrameId = null
|
||||
const savedScrollPosition = ref(null)
|
||||
|
||||
const getHeight = (index) => {
|
||||
return heights.value.get(index) || props.estimatedHeight
|
||||
}
|
||||
|
||||
const initPositions = () => {
|
||||
const data = props.data || []
|
||||
positions.value = []
|
||||
let top = 0
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const height = getHeight(i)
|
||||
positions.value.push({
|
||||
index: i,
|
||||
top,
|
||||
bottom: top + height,
|
||||
height
|
||||
})
|
||||
top += height
|
||||
}
|
||||
}
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
if (positions.value.length === 0) return 0
|
||||
return positions.value[positions.value.length - 1].bottom
|
||||
})
|
||||
|
||||
const getVisibleRange = () => {
|
||||
const data = props.data || []
|
||||
if (data.length === 0) return { start: 0, end: 0 }
|
||||
|
||||
let start = 0
|
||||
let end = data.length
|
||||
|
||||
const currentScrollTop = scrollTop.value
|
||||
const currentClientHeight = clientHeight.value
|
||||
|
||||
for (let i = 0; i < positions.value.length; i++) {
|
||||
if (positions.value[i].bottom > currentScrollTop) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = start; i < positions.value.length; i++) {
|
||||
if (positions.value[i].top > currentScrollTop + currentClientHeight) {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
start = Math.max(0, start - props.buffer)
|
||||
end = Math.min(data.length, end + props.buffer)
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
const visibleData = computed(() => {
|
||||
const { start, end } = getVisibleRange()
|
||||
const data = props.data || []
|
||||
const result = []
|
||||
for (let i = start; i < end; i++) {
|
||||
result.push({
|
||||
item: data[i],
|
||||
index: i,
|
||||
top: positions.value[i]?.top || 0
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const handleScroll = (event) => {
|
||||
const now = Date.now()
|
||||
const throttleTime = 16
|
||||
|
||||
if (now - lastScrollTime < throttleTime) return
|
||||
lastScrollTime = now
|
||||
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(() => {
|
||||
const target = event.target
|
||||
scrollTop.value = target.scrollTop
|
||||
clientHeight.value = target.clientHeight
|
||||
|
||||
checkLoadMore(target)
|
||||
})
|
||||
}
|
||||
|
||||
const checkLoadMore = (target) => {
|
||||
if (isLoading.value || !props.onLoadMore) return
|
||||
|
||||
const { scrollTop: st, scrollHeight, clientHeight: ch } = target
|
||||
const distanceToTop = st
|
||||
const distanceToBottom = scrollHeight - st - ch
|
||||
|
||||
if (props.renderMode === 'bottom') {
|
||||
if (distanceToTop <= props.scrollThreshold) {
|
||||
loadMore()
|
||||
}
|
||||
} else {
|
||||
if (distanceToBottom <= props.scrollThreshold) {
|
||||
loadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (isLoading.value || !props.onLoadMore) return
|
||||
isLoading.value = true
|
||||
emit('load-more')
|
||||
try {
|
||||
await props.onLoadMore()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (scrollerRef.value) {
|
||||
scrollerRef.value.scrollTop = scrollerRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToTop = () => {
|
||||
nextTick(() => {
|
||||
if (scrollerRef.value) {
|
||||
scrollerRef.value.scrollTop = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToIndex = (index) => {
|
||||
nextTick(() => {
|
||||
if (scrollerRef.value && positions.value[index]) {
|
||||
scrollerRef.value.scrollTop = positions.value[index].top
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateData = (newData) => {
|
||||
initPositions()
|
||||
}
|
||||
|
||||
const getScrollPosition = () => {
|
||||
if (scrollerRef.value) {
|
||||
return scrollerRef.value.scrollTop
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const setScrollPosition = (position) => {
|
||||
nextTick(() => {
|
||||
if (scrollerRef.value) {
|
||||
scrollerRef.value.scrollTop = position
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateItemHeight = (index, height) => {
|
||||
if (!props.cacheHeight) return
|
||||
|
||||
const oldHeight = heights.value.get(index) || props.estimatedHeight
|
||||
if (oldHeight === height) return
|
||||
|
||||
heights.value.set(index, height)
|
||||
|
||||
const oldScrollTop = scrollerRef.value?.scrollTop || 0
|
||||
const oldScrollHeight = scrollerRef.value?.scrollHeight || 0
|
||||
|
||||
initPositions()
|
||||
|
||||
nextTick(() => {
|
||||
if (scrollerRef.value && props.renderMode === 'bottom') {
|
||||
const newScrollHeight = scrollerRef.value.scrollHeight
|
||||
const heightDiff = newScrollHeight - oldScrollHeight
|
||||
if (heightDiff > 0) {
|
||||
scrollerRef.value.scrollTop = oldScrollTop + heightDiff
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const clearHeightCache = () => {
|
||||
heights.value.clear()
|
||||
initPositions()
|
||||
}
|
||||
|
||||
const initScroll = () => {
|
||||
initPositions()
|
||||
nextTick(() => {
|
||||
if (scrollerRef.value) {
|
||||
clientHeight.value = scrollerRef.value.clientHeight
|
||||
if (props.renderMode === 'bottom') {
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.data, () => {
|
||||
const oldScrollTop = scrollerRef.value?.scrollTop || 0
|
||||
const oldScrollHeight = scrollerRef.value?.scrollHeight || 0
|
||||
|
||||
initPositions()
|
||||
|
||||
nextTick(() => {
|
||||
if (scrollerRef.value && props.renderMode === 'bottom') {
|
||||
const newScrollHeight = scrollerRef.value.scrollHeight
|
||||
const heightDiff = newScrollHeight - oldScrollHeight
|
||||
if (heightDiff > 0) {
|
||||
scrollerRef.value.scrollTop = oldScrollTop + heightDiff
|
||||
}
|
||||
}
|
||||
})
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
return {
|
||||
visibleData,
|
||||
totalHeight,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
scrollToIndex,
|
||||
updateData,
|
||||
getScrollPosition,
|
||||
setScrollPosition,
|
||||
updateItemHeight,
|
||||
clearHeightCache,
|
||||
handleScroll,
|
||||
initScroll
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { getToken, setToken } from '@/utils/auth'
|
|||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/home'
|
||||
redirect: '/aipaint'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
|
|
@ -13,8 +13,8 @@ const routes = [
|
|||
component: () => import('@/views/login/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
name: 'home',
|
||||
path: '/aipaint',
|
||||
name: 'aipaint',
|
||||
component: () => import('@/views/home/index.vue')
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -41,6 +41,13 @@ const DisplayStoreSetup = () => {
|
|||
}
|
||||
|
||||
try {
|
||||
if (typeof refValue.scrollToBottom === 'function') {
|
||||
console.log('store - 使用新组件 scrollToBottom')
|
||||
await nextTick()
|
||||
refValue.scrollToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
const scrollerEl = refValue.$el
|
||||
if (scrollerEl) {
|
||||
const viewport = scrollerEl.querySelector('.vue-recycle-scroller__viewport')
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@
|
|||
<!-- 已完成 -->
|
||||
<div v-if="props.item.status === 'success'" class="box success-box">
|
||||
<div v-for="(file, index) in props.item.files" :key="index" class="one-box">
|
||||
<img :src="file" alt="index" class="img" />
|
||||
<!-- <img :src="file" alt="index" class="img" /> -->
|
||||
<Img :src="file" alt="index" class="img" />
|
||||
|
||||
<div class="left-top">
|
||||
<div class="left-top-btn" @click="downloadImage(file, 'image')"><img src="@/assets/display/download.svg" /></div>
|
||||
|
|
@ -75,13 +76,13 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import brush from '@/assets/display/brush.svg'
|
||||
import { useDisplayStore, useParamStore, useUserStore } from '@/stores'
|
||||
import { downloadImage } from '@/utils/downloadImage.js'
|
||||
import reEditIcon from '@/assets/display/reEdit.svg'
|
||||
import againGenerateIcon from '@/assets/display/againGenerate.svg'
|
||||
import deleteImageIcon from '@/assets/display/deleteImage.svg'
|
||||
import Img from '@/components/Img/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,350 @@
|
|||
<template>
|
||||
<div id="display" class="content-area">
|
||||
<RefreshOverlay :visible="refreshing" />
|
||||
|
||||
<div class="back">
|
||||
<img src="@/assets/display/back.svg" alt="">
|
||||
<span class="title-text">退出</span>
|
||||
</div>
|
||||
|
||||
<div v-if="props.if" class="btn-container">
|
||||
<div class="btn">
|
||||
<!-- <span class="btn-text">全部</span> -->
|
||||
<img src="@/assets/display/search.svg" alt="">
|
||||
</div>
|
||||
<span class="line"></span>
|
||||
<div class="btn">
|
||||
<Select v-model="selectedTime" :options="timeOptions" width="auto">
|
||||
<template #prefix>
|
||||
<i-ep-Calendar />
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<div class="header">
|
||||
<el-date-picker
|
||||
v-model="value1"
|
||||
type="daterange"
|
||||
start-placeholder="Start date"
|
||||
end-placeholder="End date"
|
||||
:size="size"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
<span class="line"></span>
|
||||
<div class="btn">
|
||||
<Select v-model="selectedFavorite" :options="favoriteOptions" width="auto" >
|
||||
<template #prefix>
|
||||
<i-ep-Star />
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.if" ref="scrollerRef" class="scroller" @scroll="handleScroll">
|
||||
<div v-for="(item, index) in list" :key="item.id" class="item-wrapper">
|
||||
<Set :key="`${item.id}`" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDisplayStore, useParamStore, useUserStore } from '@/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Set from './components/set.vue'
|
||||
import RefreshOverlay from './components/RefreshOverlay.vue'
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import { getGenerateHistoryList } from '@/apis/display'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
if: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const useDisplay = useDisplayStore()
|
||||
const useParams = useParamStore()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const refreshing = ref(false)
|
||||
const scrollerRef = ref(null)
|
||||
const isLoadingMore = ref(false)
|
||||
const activeTab = ref('all')
|
||||
const isInitializing = ref(true)
|
||||
let total = 0
|
||||
|
||||
const timeOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '最近一周', value: 'week' },
|
||||
{ label: '最近一个月', value: 'month' },
|
||||
{ label: '最近三个月', value: 'quarter' }
|
||||
]
|
||||
|
||||
const favoriteOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '已收藏', value: 'favorite' }
|
||||
]
|
||||
|
||||
const selectedTime = ref('all')
|
||||
const selectedFavorite = ref('all')
|
||||
const { tempList } = storeToRefs(useDisplay)
|
||||
|
||||
const activeFilter = ref('all')
|
||||
const list = computed(() => {
|
||||
const data = tempList.value || []
|
||||
if (activeFilter.value === 'all') {
|
||||
return data
|
||||
}
|
||||
return data.filter((item) => item.type === activeFilter.value)
|
||||
})
|
||||
|
||||
const page = ref(1)
|
||||
const toggleDisplay = (newValue, oldValue) => {
|
||||
activeFilter.value = newValue
|
||||
}
|
||||
|
||||
const conversion = (newlist) => {
|
||||
const temp = newlist.data.records.map((item) => {
|
||||
return {
|
||||
id: item.taskId,
|
||||
collection: item.collection,
|
||||
status: 'success',
|
||||
prompt: item.prompt,
|
||||
params: item.params,
|
||||
time: item.createTime,
|
||||
files: [item.fileUrl]
|
||||
}
|
||||
})
|
||||
return temp
|
||||
}
|
||||
|
||||
const fetchHistory= async (isScrollTopLoad = false) => {
|
||||
try {
|
||||
if (isScrollTopLoad) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await getGenerateHistoryList({ userId: userStore.userInfo.id, chargeType: 1 })
|
||||
total = result.data ? result.data.length : 0
|
||||
if (total === 0) {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
|
||||
const wrappedData = {
|
||||
data: {
|
||||
records: result.data
|
||||
}
|
||||
}
|
||||
const convertedList = conversion(wrappedData)
|
||||
|
||||
const adaptedList = convertedList.map((item, index) => {
|
||||
const originalItem = result.data[index]
|
||||
return {
|
||||
...item,
|
||||
text: originalItem?.title || item.prompt || '生成图片',
|
||||
name: originalItem?.title || item.prompt || '生成图片',
|
||||
type: 'image',
|
||||
title: originalItem?.title || '生成图片'
|
||||
}
|
||||
})
|
||||
|
||||
if (!isScrollTopLoad && adaptedList.length > 0) {
|
||||
useDisplay.initHistoryList(adaptedList)
|
||||
|
||||
await nextTick()
|
||||
|
||||
const scrollToBottomDirect = (force = false) => {
|
||||
if (scrollerRef.value) {
|
||||
console.log('直接滚动 - scrollHeight:', scrollerRef.value.scrollHeight, 'force:', force)
|
||||
if (force) {
|
||||
scrollerRef.value.scrollTop = scrollerRef.value.scrollHeight + 1000
|
||||
} else {
|
||||
scrollerRef.value.scrollTop = scrollerRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
setTimeout(() => {
|
||||
scrollToBottomDirect(i >= 15)
|
||||
}, 60 * i)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToBottomDirect(true)
|
||||
setTimeout(() => {
|
||||
refreshing.value = false
|
||||
isInitializing.value = false
|
||||
useDisplay.scrollToBottom()
|
||||
}, 600)
|
||||
}, 1500)
|
||||
} else {
|
||||
useDisplay.initHistoryList(adaptedList)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取历史失败:', error)
|
||||
ElMessage({
|
||||
message: '获取历史失败',
|
||||
type: 'warning'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getList = async () => {
|
||||
if (isLoadingMore.value) return
|
||||
isLoadingMore.value = true
|
||||
try {
|
||||
await fetchHistory(true)
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = (event) => {
|
||||
if (isInitializing.value) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.target
|
||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight
|
||||
|
||||
if (distanceToBottom <= 50) {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
} else if (distanceToBottom >= 350) {
|
||||
useDisplay.Sender_variant = 'default'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('display 组件已挂载')
|
||||
if (!props.loading) return
|
||||
refreshing.value = true
|
||||
|
||||
nextTick(() => {
|
||||
console.log('设置 scrollerRef 到 store')
|
||||
useDisplay.scrollerRef = scrollerRef.value
|
||||
fetchHistory()
|
||||
})
|
||||
|
||||
page.value++
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.content-area {
|
||||
width: 100%;
|
||||
min-width: 750px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.back{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
left: 30px;
|
||||
top: 22px;
|
||||
z-index: 3;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
// background-color: #FAFBFC;
|
||||
}
|
||||
|
||||
.back:hover{
|
||||
background-color: #e4e7ed;
|
||||
}
|
||||
|
||||
.btn-container{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: auto;
|
||||
padding: 4px;
|
||||
right: 30px;
|
||||
top: 22px;
|
||||
z-index: 3;
|
||||
|
||||
border-radius: 10px;
|
||||
background-color: #FAFBFC;
|
||||
position: absolute;
|
||||
|
||||
.btn{
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn-text{
|
||||
color: #000;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.line{
|
||||
width: 1px;
|
||||
height: 8px;
|
||||
background-color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.scroller {
|
||||
height: 100%;
|
||||
padding: 30px 0px 350px 0px;
|
||||
overflow-y: auto;
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.option-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.option-item:hover) {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
:deep(.option-item.selected) {
|
||||
color: #000F33;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.option-text) {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:deep(.option-check) {
|
||||
margin-left: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.item-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
<template>
|
||||
<div id="display" class="content-area">
|
||||
<RefreshOverlay :visible="refreshing" />
|
||||
|
||||
<div class="back">
|
||||
<img src="@/assets/display/back.svg" alt="">
|
||||
<span class="title-text">退出</span>
|
||||
</div>
|
||||
|
||||
<div v-if="props.if" class="btn-container">
|
||||
<div class="btn">
|
||||
<!-- <span class="btn-text">全部</span> -->
|
||||
<img src="@/assets/display/search.svg" alt="">
|
||||
</div>
|
||||
<span class="line"></span>
|
||||
<div class="btn">
|
||||
<Select v-model="selectedTime" :options="timeOptions" width="auto">
|
||||
<template #prefix>
|
||||
<i-ep-Calendar />
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<div class="header">
|
||||
<el-date-picker
|
||||
v-model="value1"
|
||||
type="daterange"
|
||||
start-placeholder="Start date"
|
||||
end-placeholder="End date"
|
||||
:size="size"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
<span class="line"></span>
|
||||
<div class="btn">
|
||||
<Select v-model="selectedFavorite" :options="favoriteOptions" width="auto" >
|
||||
<template #prefix>
|
||||
<i-ep-Star />
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DynamicScroller
|
||||
ref="scrollerRef"
|
||||
v-if="props.if"
|
||||
:items="list"
|
||||
:min-item-size="800"
|
||||
class="scroller"
|
||||
:buffer="50"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<template #default="{ item, index, active }">
|
||||
<DynamicScrollerItem
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:active="active"
|
||||
:index="index"
|
||||
data-index="index"
|
||||
>
|
||||
<Set :key="`${item.id}`" :item="item" />
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDisplayStore, useParamStore, useUserStore } from '@/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Set from './components/set.vue'
|
||||
import RefreshOverlay from './components/RefreshOverlay.vue'
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import { getGenerateHistoryList } from '@/apis/display'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
if: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const useDisplay = useDisplayStore()
|
||||
const useParams = useParamStore()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const refreshing = ref(false)
|
||||
const scrollerRef = ref(null)
|
||||
const isLoadingMore = ref(false)
|
||||
const activeTab = ref('all')
|
||||
const isInitializing = ref(true)
|
||||
let total = 0
|
||||
|
||||
const timeOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '最近一周', value: 'week' },
|
||||
{ label: '最近一个月', value: 'month' },
|
||||
{ label: '最近三个月', value: 'quarter' }
|
||||
]
|
||||
|
||||
const favoriteOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '已收藏', value: 'favorite' }
|
||||
]
|
||||
|
||||
const selectedTime = ref('all')
|
||||
const selectedFavorite = ref('all')
|
||||
const { tempList } = storeToRefs(useDisplay)
|
||||
|
||||
// const tempList = ref([
|
||||
// { id: 0, type: 'image', status: 'none', name: '局部重绘', time: '2025-12-01 18:26', files: [] },
|
||||
// { id: 1, type: 'image', status: 'success', name: '局部重绘', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] },
|
||||
// { id: 2, type: 'image', status: 'success', name: '局部重绘', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] },
|
||||
// { id: 3, type: 'image', status: 'success', name: '局部重绘', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] },
|
||||
// { id: 4, type: 'image', status: 'success', name: '局部重绘', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] }
|
||||
|
||||
|
||||
// ])
|
||||
const activeFilter = ref('all')
|
||||
const list = computed(() => {
|
||||
const data = tempList.value || []
|
||||
if (activeFilter.value === 'all') {
|
||||
return data
|
||||
}
|
||||
return data.filter((item) => item.type === activeFilter.value)
|
||||
})
|
||||
|
||||
const page = ref(1)
|
||||
// 筛选列表里的不同生成类型: 图片,视频
|
||||
const toggleDisplay = (newValue, oldValue) => {
|
||||
activeFilter.value = newValue
|
||||
}
|
||||
|
||||
// 转换数据
|
||||
const conversion = (newlist) => {
|
||||
const temp = newlist.data.records.map((item) => {
|
||||
return {
|
||||
id: item.taskId,
|
||||
collection: item.collection,
|
||||
status: 'success',
|
||||
prompt: item.prompt,
|
||||
params: item.params,
|
||||
time: item.createTime,
|
||||
files: [item.fileUrl]
|
||||
}
|
||||
})
|
||||
return temp
|
||||
}
|
||||
|
||||
// 获取历史列表
|
||||
const fetchHistory= async (isScrollTopLoad = false) => {
|
||||
try {
|
||||
if (isScrollTopLoad) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await getGenerateHistoryList({ userId: userStore.userInfo.id, chargeType: 1 })
|
||||
total = result.data ? result.data.length : 0
|
||||
if (total === 0) {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
|
||||
const wrappedData = {
|
||||
data: {
|
||||
records: result.data
|
||||
}
|
||||
}
|
||||
const convertedList = conversion(wrappedData)
|
||||
|
||||
const adaptedList = convertedList.map((item, index) => {
|
||||
const originalItem = result.data[index]
|
||||
return {
|
||||
...item,
|
||||
text: originalItem?.title || item.prompt || '生成图片',
|
||||
name: originalItem?.title || item.prompt || '生成图片',
|
||||
type: 'image',
|
||||
title: originalItem?.title || '生成图片'
|
||||
}
|
||||
})
|
||||
|
||||
if (!isScrollTopLoad && adaptedList.length > 0) {
|
||||
useDisplay.initHistoryList(adaptedList)
|
||||
|
||||
await nextTick()
|
||||
|
||||
const scrollToBottomDirect = (force = false) => {
|
||||
if (scrollerRef.value) {
|
||||
const el = scrollerRef.value.$el
|
||||
if (el) {
|
||||
const viewport = el.querySelector('.vue-recycle-scroller__viewport')
|
||||
if (viewport) {
|
||||
console.log('直接滚动 - scrollHeight:', viewport.scrollHeight, 'force:', force)
|
||||
if (force) {
|
||||
viewport.scrollTop = viewport.scrollHeight + 1000
|
||||
} else {
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof scrollerRef.value.scrollToItem === 'function') {
|
||||
console.log('直接 scrollToItem')
|
||||
scrollerRef.value.scrollToItem(list.value.length - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
setTimeout(() => {
|
||||
scrollToBottomDirect(i >= 15)
|
||||
}, 60 * i)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToBottomDirect(true)
|
||||
setTimeout(() => {
|
||||
refreshing.value = false
|
||||
isInitializing.value = false
|
||||
useDisplay.scrollToBottom()
|
||||
}, 600)
|
||||
}, 1500)
|
||||
} else {
|
||||
useDisplay.initHistoryList(adaptedList)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取历史失败:', error)
|
||||
ElMessage({
|
||||
message: '获取历史失败',
|
||||
type: 'warning'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 获取下一页数据
|
||||
const getList = async () => {
|
||||
if (isLoadingMore.value) return
|
||||
isLoadingMore.value = true
|
||||
try {
|
||||
await fetchHistory(true)
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理滚动事件
|
||||
const handleScroll = (event) => {
|
||||
if (isInitializing.value) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.target
|
||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight
|
||||
|
||||
// 临时禁用滚动到顶部获取历史记录
|
||||
// if (scrollTop <= 50 && !isLoadingMore.value) {
|
||||
// getList()
|
||||
// }
|
||||
|
||||
if (distanceToBottom <= 50) {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
} else if (distanceToBottom >= 350) {
|
||||
useDisplay.Sender_variant = 'default'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('display 组件已挂载')
|
||||
if (!props.loading) return
|
||||
refreshing.value = true
|
||||
|
||||
nextTick(() => {
|
||||
console.log('设置 scrollerRef 到 store')
|
||||
useDisplay.scrollerRef = scrollerRef.value
|
||||
fetchHistory()
|
||||
})
|
||||
|
||||
page.value++
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.content-area {
|
||||
width: 100%;
|
||||
min-width: 750px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.back{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
left: 30px;
|
||||
top: 22px;
|
||||
z-index: 3;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
// background-color: #FAFBFC;
|
||||
}
|
||||
|
||||
.back:hover{
|
||||
background-color: #e4e7ed;
|
||||
}
|
||||
|
||||
.btn-container{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: auto;
|
||||
padding: 4px;
|
||||
right: 30px;
|
||||
top: 22px;
|
||||
z-index: 3;
|
||||
|
||||
border-radius: 10px;
|
||||
background-color: #FAFBFC;
|
||||
position: absolute;
|
||||
|
||||
.btn{
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn-text{
|
||||
color: #000;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.line{
|
||||
width: 1px;
|
||||
height: 8px;
|
||||
background-color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.scroller {
|
||||
height: 100%;
|
||||
padding: 30px 0px 350px 0px;
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch; /* iOS Safari */
|
||||
scroll-behavior: smooth; /* 平滑滚动 */
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent; /* 轨道透明 */
|
||||
}
|
||||
|
||||
:deep(.option-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.option-item:hover) {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
:deep(.option-item.selected) {
|
||||
color: #000F33;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.option-text) {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:deep(.option-check) {
|
||||
margin-left: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,370 @@
|
|||
<template>
|
||||
<div id="display" class="content-area">
|
||||
<RefreshOverlay :visible="refreshing" />
|
||||
|
||||
<div class="back">
|
||||
<img src="@/assets/display/back.svg" alt="">
|
||||
<span class="title-text">退出</span>
|
||||
</div>
|
||||
|
||||
<div v-if="props.if" class="btn-container">
|
||||
<div class="btn">
|
||||
<!-- <span class="btn-text">全部</span> -->
|
||||
<img src="@/assets/display/search.svg" alt="">
|
||||
</div>
|
||||
<span class="line"></span>
|
||||
<div class="btn">
|
||||
<Select v-model="selectedTime" :options="timeOptions" width="auto">
|
||||
<template #prefix>
|
||||
<i-ep-Calendar />
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<div class="header">
|
||||
<el-date-picker
|
||||
v-model="value1"
|
||||
type="daterange"
|
||||
start-placeholder="Start date"
|
||||
end-placeholder="End date"
|
||||
:size="size"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
<span class="line"></span>
|
||||
<div class="btn">
|
||||
<Select v-model="selectedFavorite" :options="favoriteOptions" width="auto" >
|
||||
<template #prefix>
|
||||
<i-ep-Star />
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VirtualScroller
|
||||
ref="scrollerRef"
|
||||
v-if="props.if"
|
||||
:data="list"
|
||||
:item-height="200"
|
||||
:estimated-height="350"
|
||||
:render-mode="'bottom'"
|
||||
:buffer="15"
|
||||
class="scroller"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<Set :key="`${item.id}`" :item="item" />
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDisplayStore, useParamStore, useUserStore } from '@/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Set from './components/set.vue'
|
||||
import RefreshOverlay from './components/RefreshOverlay.vue'
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import { VirtualScroller } from '@/components/virtual-scroller'
|
||||
import { getGenerateHistoryList } from '@/apis/display'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
if: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const useDisplay = useDisplayStore()
|
||||
const useParams = useParamStore()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const refreshing = ref(false)
|
||||
const scrollerRef = ref(null)
|
||||
const isLoadingMore = ref(false)
|
||||
const activeTab = ref('all')
|
||||
const isInitializing = ref(true)
|
||||
let total = 0
|
||||
|
||||
const timeOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '最近一周', value: 'week' },
|
||||
{ label: '最近一个月', value: 'month' },
|
||||
{ label: '最近三个月', value: 'quarter' }
|
||||
]
|
||||
|
||||
const favoriteOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '已收藏', value: 'favorite' }
|
||||
]
|
||||
|
||||
const selectedTime = ref('all')
|
||||
const selectedFavorite = ref('all')
|
||||
const { tempList } = storeToRefs(useDisplay)
|
||||
|
||||
// const tempList = ref([
|
||||
// { id: 0, type: 'image', status: 'none', name: '局部重绘', time: '2025-12-01 18:26', files: [] },
|
||||
// { id: 1, type: 'image', status: 'success', name: '局部重绘', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] },
|
||||
// { id: 2, type: 'image', status: 'success', name: '局部重绘', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] },
|
||||
// { id: 3, type: 'image', status: 'success', name: '局部重绘', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] },
|
||||
// { id: 4, type: 'image', status: 'success', name: '局部重绘', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] }
|
||||
|
||||
|
||||
// ])
|
||||
const activeFilter = ref('all')
|
||||
const list = computed(() => {
|
||||
const data = tempList.value || []
|
||||
if (activeFilter.value === 'all') {
|
||||
return data
|
||||
}
|
||||
return data.filter((item) => item.type === activeFilter.value)
|
||||
})
|
||||
|
||||
const page = ref(1)
|
||||
// 筛选列表里的不同生成类型: 图片,视频
|
||||
const toggleDisplay = (newValue, oldValue) => {
|
||||
activeFilter.value = newValue
|
||||
}
|
||||
|
||||
// 转换数据
|
||||
const conversion = (newlist) => {
|
||||
const temp = newlist.data.records.map((item) => {
|
||||
return {
|
||||
id: item.taskId,
|
||||
collection: item.collection,
|
||||
status: 'success',
|
||||
prompt: item.prompt,
|
||||
params: item.params,
|
||||
time: item.createTime,
|
||||
files: [item.fileUrl]
|
||||
}
|
||||
})
|
||||
return temp
|
||||
}
|
||||
|
||||
// 获取历史列表
|
||||
const fetchHistory= async (isScrollTopLoad = false) => {
|
||||
try {
|
||||
if (isScrollTopLoad) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await getGenerateHistoryList({ userId: userStore.userInfo.id, chargeType: 1 })
|
||||
total = result.data ? result.data.length : 0
|
||||
if (total === 0) {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
|
||||
const wrappedData = {
|
||||
data: {
|
||||
records: result.data
|
||||
}
|
||||
}
|
||||
const convertedList = conversion(wrappedData)
|
||||
|
||||
const adaptedList = convertedList.map((item, index) => {
|
||||
const originalItem = result.data[index]
|
||||
return {
|
||||
...item,
|
||||
text: originalItem?.title || item.prompt || '生成图片',
|
||||
name: originalItem?.title || item.prompt || '生成图片',
|
||||
type: 'image',
|
||||
title: originalItem?.title || '生成图片'
|
||||
}
|
||||
})
|
||||
|
||||
if (!isScrollTopLoad && adaptedList.length > 0) {
|
||||
useDisplay.initHistoryList(adaptedList)
|
||||
|
||||
await nextTick()
|
||||
|
||||
const scrollToBottomDirect = () => {
|
||||
if (scrollerRef.value && typeof scrollerRef.value.scrollToBottom === 'function') {
|
||||
scrollerRef.value.scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setTimeout(() => {
|
||||
scrollToBottomDirect()
|
||||
}, 100 * i)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToBottomDirect()
|
||||
setTimeout(() => {
|
||||
refreshing.value = false
|
||||
isInitializing.value = false
|
||||
useDisplay.scrollToBottom()
|
||||
}, 300)
|
||||
}, 600)
|
||||
} else {
|
||||
useDisplay.initHistoryList(adaptedList)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取历史失败:', error)
|
||||
ElMessage({
|
||||
message: '获取历史失败',
|
||||
type: 'warning'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 获取下一页数据
|
||||
const getList = async () => {
|
||||
if (isLoadingMore.value) return
|
||||
isLoadingMore.value = true
|
||||
try {
|
||||
await fetchHistory(true)
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理滚动事件
|
||||
const handleScroll = (event) => {
|
||||
if (isInitializing.value) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.target
|
||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight
|
||||
|
||||
// 临时禁用滚动到顶部获取历史记录
|
||||
// if (scrollTop <= 50 && !isLoadingMore.value) {
|
||||
// getList()
|
||||
// }
|
||||
|
||||
if (distanceToBottom <= 50) {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
} else if (distanceToBottom >= 350) {
|
||||
useDisplay.Sender_variant = 'default'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('display 组件已挂载')
|
||||
if (!props.loading) return
|
||||
refreshing.value = true
|
||||
|
||||
nextTick(() => {
|
||||
console.log('设置 scrollerRef 到 store')
|
||||
useDisplay.scrollerRef = scrollerRef.value
|
||||
fetchHistory()
|
||||
})
|
||||
|
||||
page.value++
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.content-area {
|
||||
width: 100%;
|
||||
min-width: 750px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.back{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
left: 30px;
|
||||
top: 22px;
|
||||
z-index: 3;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
// background-color: #FAFBFC;
|
||||
}
|
||||
|
||||
.back:hover{
|
||||
background-color: #e4e7ed;
|
||||
}
|
||||
|
||||
.btn-container{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: auto;
|
||||
padding: 4px;
|
||||
right: 30px;
|
||||
top: 22px;
|
||||
z-index: 3;
|
||||
|
||||
border-radius: 10px;
|
||||
background-color: #FAFBFC;
|
||||
position: absolute;
|
||||
|
||||
.btn{
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn-text{
|
||||
color: #000;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.line{
|
||||
width: 1px;
|
||||
height: 8px;
|
||||
background-color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.scroller {
|
||||
height: 100%;
|
||||
padding: 30px 0px 350px 0px;
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch; /* iOS Safari */
|
||||
scroll-behavior: smooth; /* 平滑滚动 */
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent; /* 轨道透明 */
|
||||
}
|
||||
|
||||
:deep(.option-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.option-item:hover) {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
:deep(.option-item.selected) {
|
||||
color: #000F33;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.option-text) {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:deep(.option-check) {
|
||||
margin-left: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -30,7 +30,7 @@ export default defineConfig(({ _command, mode }) => {
|
|||
build: {
|
||||
chunkSizeWarningLimit: 2000,
|
||||
outDir: 'dist',
|
||||
minify: 'terser',
|
||||
minify: 'esbuild',
|
||||
terserOptions: {
|
||||
compress: {
|
||||
keep_infinity: true,
|
||||
|
|
|
|||
Loading…
Reference in New Issue