使用新的虚拟滚动组件
This commit is contained in:
parent
c9ed299ef6
commit
5cb45dd3b0
|
|
@ -11,71 +11,28 @@ export {}
|
||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
AIgenerate: typeof import('./src/components/AIgenerate/AIgenerate.vue')['default']
|
|
||||||
Canvas: typeof import('./src/components/canvas/index.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']
|
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']
|
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']
|
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']
|
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']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
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']
|
IEpCalendar: typeof import('~icons/ep/calendar')['default']
|
||||||
IEpCirclePlusFilled: typeof import('~icons/ep/circle-plus-filled')['default']
|
|
||||||
IEpClose: typeof import('~icons/ep/close')['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']
|
IEpLoading: typeof import('~icons/ep/loading')['default']
|
||||||
IEpPlus: typeof import('~icons/ep/plus')['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']
|
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']
|
ImageUploader: typeof import('./src/components/dialogBox/imageUploader/index.vue')['default']
|
||||||
Img: typeof import('./src/components/Img/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']
|
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']
|
Proportion: typeof import('./src/components/dialogBox/proportion/index.vue')['default']
|
||||||
Quantity: typeof import('./src/components/dialogBox/quantity/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']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
Select: typeof import('./src/components/Select/index.vue')['default']
|
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']
|
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"
|
class="img-element"
|
||||||
@click="handleImageClick"
|
@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">
|
<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="M7 14H5V19H10V17H7V14Z" fill="currentColor" />
|
||||||
<path d="M5 10H7V7H10V5H5V10Z" fill="currentColor" />
|
<path d="M5 10H7V7H10V5H5V10Z" fill="currentColor" />
|
||||||
<path d="M17 17H14V19H19V14H17V17Z" fill="currentColor" />
|
<path d="M17 17H14V19H19V14H17V17Z" fill="currentColor" />
|
||||||
<path d="M14 5V7H17V10H19V5H14Z" fill="currentColor" />
|
<path d="M14 5V7H17V10H19V5H14Z" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="fade">
|
<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 = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/home'
|
redirect: '/aipaint'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
|
|
@ -13,8 +13,8 @@ const routes = [
|
||||||
component: () => import('@/views/login/index.vue')
|
component: () => import('@/views/login/index.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home',
|
path: '/aipaint',
|
||||||
name: 'home',
|
name: 'aipaint',
|
||||||
component: () => import('@/views/home/index.vue')
|
component: () => import('@/views/home/index.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,13 @@ const DisplayStoreSetup = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (typeof refValue.scrollToBottom === 'function') {
|
||||||
|
console.log('store - 使用新组件 scrollToBottom')
|
||||||
|
await nextTick()
|
||||||
|
refValue.scrollToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const scrollerEl = refValue.$el
|
const scrollerEl = refValue.$el
|
||||||
if (scrollerEl) {
|
if (scrollerEl) {
|
||||||
const viewport = scrollerEl.querySelector('.vue-recycle-scroller__viewport')
|
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-if="props.item.status === 'success'" class="box success-box">
|
||||||
<div v-for="(file, index) in props.item.files" :key="index" class="one-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">
|
||||||
<div class="left-top-btn" @click="downloadImage(file, 'image')"><img src="@/assets/display/download.svg" /></div>
|
<div class="left-top-btn" @click="downloadImage(file, 'image')"><img src="@/assets/display/download.svg" /></div>
|
||||||
|
|
@ -75,13 +76,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
|
||||||
import brush from '@/assets/display/brush.svg'
|
import brush from '@/assets/display/brush.svg'
|
||||||
import { useDisplayStore, useParamStore, useUserStore } from '@/stores'
|
import { useDisplayStore, useParamStore, useUserStore } from '@/stores'
|
||||||
import { downloadImage } from '@/utils/downloadImage.js'
|
import { downloadImage } from '@/utils/downloadImage.js'
|
||||||
import reEditIcon from '@/assets/display/reEdit.svg'
|
import reEditIcon from '@/assets/display/reEdit.svg'
|
||||||
import againGenerateIcon from '@/assets/display/againGenerate.svg'
|
import againGenerateIcon from '@/assets/display/againGenerate.svg'
|
||||||
import deleteImageIcon from '@/assets/display/deleteImage.svg'
|
import deleteImageIcon from '@/assets/display/deleteImage.svg'
|
||||||
|
import Img from '@/components/Img/index.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
item: {
|
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: {
|
build: {
|
||||||
chunkSizeWarningLimit: 2000,
|
chunkSizeWarningLimit: 2000,
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
minify: 'terser',
|
minify: 'esbuild',
|
||||||
terserOptions: {
|
terserOptions: {
|
||||||
compress: {
|
compress: {
|
||||||
keep_infinity: true,
|
keep_infinity: true,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue