优化滚动 组件
This commit is contained in:
parent
6ff21efb5f
commit
ff4ae2bdc8
|
|
@ -8,6 +8,7 @@ export {}
|
||||||
declare global {
|
declare global {
|
||||||
const EffectScope: typeof import('vue').EffectScope
|
const EffectScope: typeof import('vue').EffectScope
|
||||||
const ElMessage: typeof import('element-plus/es').ElMessage
|
const ElMessage: typeof import('element-plus/es').ElMessage
|
||||||
|
const ElMessageBox: typeof import('element-plus/es').ElMessageBox
|
||||||
const ElNotification: typeof import('element-plus/es').ElNotification
|
const ElNotification: typeof import('element-plus/es').ElNotification
|
||||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||||
const computed: typeof import('vue').computed
|
const computed: typeof import('vue').computed
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export {}
|
||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
2: typeof import('./src/components/virtual-scroller/VirtualScroller copy 2.vue')['default']
|
||||||
Canvas: typeof import('./src/components/canvas/index.vue')['default']
|
Canvas: typeof import('./src/components/canvas/index.vue')['default']
|
||||||
copy: typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default']
|
copy: typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default']
|
||||||
DialogBox: typeof import('./src/components/dialogBox/index.vue')['default']
|
DialogBox: typeof import('./src/components/dialogBox/index.vue')['default']
|
||||||
|
|
@ -35,5 +36,7 @@ declare module 'vue' {
|
||||||
Select: typeof import('./src/components/Select/index.vue')['default']
|
Select: typeof import('./src/components/Select/index.vue')['default']
|
||||||
Time: typeof import('./src/components/dialogBox/Time/index.vue')['default']
|
Time: typeof import('./src/components/dialogBox/Time/index.vue')['default']
|
||||||
VirtualScroller: typeof import('./src/components/virtual-scroller/VirtualScroller.vue')['default']
|
VirtualScroller: typeof import('./src/components/virtual-scroller/VirtualScroller.vue')['default']
|
||||||
|
'VirtualScroller copy': typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default']
|
||||||
|
'VirtualScroller copy 2': typeof import('./src/components/virtual-scroller/VirtualScroller copy 2.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,8 @@ export function getGenerateHistoryList(query) {
|
||||||
export function cancelOrCollect(query) {
|
export function cancelOrCollect(query) {
|
||||||
return service.post('/collect/toggle', null, { params: query })
|
return service.post('/collect/toggle', null, { params: query })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除生成历史
|
||||||
|
export function deleteGenerateHistory(query) {
|
||||||
|
return service.delete('/taskRecordHistory/delete', { params: query })
|
||||||
|
}
|
||||||
|
|
@ -75,7 +75,6 @@ const props = defineProps({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['open-canvas'])
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const useDisplay = useDisplayStore()
|
const useDisplay = useDisplayStore()
|
||||||
|
|
||||||
|
|
@ -122,6 +121,20 @@ const handleStart = async () => {
|
||||||
imgs.push({ name: `image_${index + 1}`, url: img.url })
|
imgs.push({ name: `image_${index + 1}`, url: img.url })
|
||||||
})
|
})
|
||||||
console.log('imgs', imgs)
|
console.log('imgs', imgs)
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
type: props.type,
|
||||||
|
model: model.value,
|
||||||
|
modelType: modelType.value,
|
||||||
|
prompt: prompt.value,
|
||||||
|
proportion: proportion.value,
|
||||||
|
referenceImages: referenceImages.value,
|
||||||
|
quantity: quantity.value,
|
||||||
|
resolution: resolution.value,
|
||||||
|
time: time.value,
|
||||||
|
videoPattern: videoPattern.value
|
||||||
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
AIGC: 'Painting',
|
AIGC: 'Painting',
|
||||||
platform: 'runninghub',
|
platform: 'runninghub',
|
||||||
|
|
@ -133,12 +146,32 @@ const handleStart = async () => {
|
||||||
{ name: 'aspect_ratio', data: proportion.value},
|
{ name: 'aspect_ratio', data: proportion.value},
|
||||||
{ name: 'resolution', data: resolution.value},
|
{ name: 'resolution', data: resolution.value},
|
||||||
],
|
],
|
||||||
imgs
|
imgs,
|
||||||
|
result
|
||||||
}
|
}
|
||||||
await generate(modelType.value, data)
|
await generate(modelType.value, data)
|
||||||
console.log('生成中', isgerenate.value)
|
console.log('生成中', isgerenate.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fillParamsFromResult = (resultData) => {
|
||||||
|
if (!resultData) return
|
||||||
|
|
||||||
|
if (resultData.model !== undefined) model.value = resultData.model
|
||||||
|
if (resultData.modelType !== undefined) modelType.value = resultData.modelType
|
||||||
|
if (resultData.prompt !== undefined) prompt.value = resultData.prompt
|
||||||
|
if (resultData.proportion !== undefined) proportion.value = resultData.proportion
|
||||||
|
if (resultData.referenceImages !== undefined) referenceImages.value = resultData.referenceImages
|
||||||
|
if (resultData.quantity !== undefined) quantity.value = resultData.quantity
|
||||||
|
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
|
||||||
|
if (resultData.time !== undefined) time.value = resultData.time
|
||||||
|
if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
fillParamsFromResult,
|
||||||
|
handleStart
|
||||||
|
})
|
||||||
|
|
||||||
const handleContainerClick = () => {
|
const handleContainerClick = () => {
|
||||||
if (useDisplay.Sender_variant === 'default') {
|
if (useDisplay.Sender_variant === 'default') {
|
||||||
useDisplay.Sender_variant = 'updown'
|
useDisplay.Sender_variant = 'updown'
|
||||||
|
|
@ -151,7 +184,7 @@ const handleScrollToBottom = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenCanvas = (data) => {
|
const handleOpenCanvas = (data) => {
|
||||||
emit('open-canvas', data)
|
useDisplay.openCanvas(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => useDisplay.isSubGerenate, (newValue) => {
|
watch(() => useDisplay.isSubGerenate, (newValue) => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,486 @@
|
||||||
|
<template>
|
||||||
|
<div class="virtual-scroller" :style="containerStyle">
|
||||||
|
<div
|
||||||
|
ref="scrollContainerRef"
|
||||||
|
class="virtual-scroller-container"
|
||||||
|
:style="scrollContainerStyle"
|
||||||
|
@scroll.passive="handleScroll"
|
||||||
|
@wheel="handleWheel"
|
||||||
|
>
|
||||||
|
<div class="virtual-scroller-spacer" :style="spacerStyle"></div>
|
||||||
|
<div class="virtual-scroller-placeholder" :style="placeholderStyle">
|
||||||
|
<slot name="bottom-placeholder" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="item in visibleItems"
|
||||||
|
:key="item.key"
|
||||||
|
:ref="el => setItemRef(el, item.key)"
|
||||||
|
class="virtual-scroller-item"
|
||||||
|
:style="getItemStyle(item)"
|
||||||
|
:data-index="item.index"
|
||||||
|
:data-key="item.key"
|
||||||
|
>
|
||||||
|
<slot name="default" :item="item.data" :index="item.index" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
itemKey: {
|
||||||
|
type: [String, Function],
|
||||||
|
default: 'id'
|
||||||
|
},
|
||||||
|
estimatedHeight: {
|
||||||
|
type: Number,
|
||||||
|
default: 100
|
||||||
|
},
|
||||||
|
buffer: {
|
||||||
|
type: Number,
|
||||||
|
default: 5
|
||||||
|
},
|
||||||
|
placeholderHeight: {
|
||||||
|
type: Number,
|
||||||
|
default: 350
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end'])
|
||||||
|
|
||||||
|
const scrollContainerRef = ref(null)
|
||||||
|
const itemRefs = new Map()
|
||||||
|
const itemHeights = ref(new Map())
|
||||||
|
const resizeObserver = ref(null)
|
||||||
|
const isScrolling = ref(false)
|
||||||
|
const scrollTimeout = ref(null)
|
||||||
|
|
||||||
|
const getKey = (item, index) => {
|
||||||
|
if (typeof props.itemKey === 'function') {
|
||||||
|
return props.itemKey(item, index)
|
||||||
|
}
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
return item[props.itemKey] ?? index
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
const getItemHeight = (key) => {
|
||||||
|
return itemHeights.value.get(key) ?? props.estimatedHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerStyle = computed(() => ({
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const scrollContainerStyle = computed(() => ({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
overflowX: 'hidden',
|
||||||
|
overflowY: 'auto',
|
||||||
|
direction: 'rtl',
|
||||||
|
transform: 'rotate(180deg)'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const totalDataHeight = computed(() => {
|
||||||
|
let height = 0
|
||||||
|
for (let i = 0; i < props.data.length; i++) {
|
||||||
|
const key = getKey(props.data[i], i)
|
||||||
|
height += getItemHeight(key)
|
||||||
|
}
|
||||||
|
return height
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalHeight = computed(() => {
|
||||||
|
return props.placeholderHeight + totalDataHeight.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const spacerStyle = computed(() => ({
|
||||||
|
height: `${totalHeight.value}px`,
|
||||||
|
width: '100%',
|
||||||
|
flexShrink: 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
const placeholderStyle = computed(() => ({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: `${props.placeholderHeight}px`,
|
||||||
|
zIndex: 1,
|
||||||
|
direction: 'ltr'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const visibleRange = computed(() => {
|
||||||
|
const count = props.data.length
|
||||||
|
if (count === 0) {
|
||||||
|
return { start: 0, end: 0, offset: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = scrollContainerRef.value
|
||||||
|
if (!el) {
|
||||||
|
return { start: 0, end: Math.min(count - 1, 9), offset: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollTop = el.scrollTop
|
||||||
|
const viewportHeight = el.clientHeight || 600
|
||||||
|
const bufferCount = props.buffer
|
||||||
|
|
||||||
|
// In inverted scroll (180deg rotation):
|
||||||
|
// - scrollTop = 0: visual BOTTOM (shows newer data, lower index)
|
||||||
|
// - scrollTop = max: visual TOP (shows older data, higher index)
|
||||||
|
// - visibleStart/visibleEnd are offsets in the data area (after placeholder)
|
||||||
|
const visibleStart = Math.max(0, scrollTop - props.placeholderHeight)
|
||||||
|
const visibleEnd = visibleStart + viewportHeight
|
||||||
|
|
||||||
|
let startIndex = 0
|
||||||
|
let endIndex = count - 1
|
||||||
|
let startOffset = 0
|
||||||
|
let currentOffset = 0
|
||||||
|
|
||||||
|
// Find startIndex: first item that ends after visibleStart
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const key = getKey(props.data[i], i)
|
||||||
|
const height = getItemHeight(key)
|
||||||
|
const itemEnd = currentOffset + height
|
||||||
|
|
||||||
|
if (itemEnd > visibleStart) {
|
||||||
|
startIndex = Math.max(0, i - bufferCount)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
currentOffset += height
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate startOffset for startIndex
|
||||||
|
startOffset = 0
|
||||||
|
for (let i = 0; i < startIndex; i++) {
|
||||||
|
const key = getKey(props.data[i], i)
|
||||||
|
startOffset += getItemHeight(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find endIndex: last item that starts before visibleEnd
|
||||||
|
currentOffset = startOffset
|
||||||
|
for (let i = startIndex; i < count; i++) {
|
||||||
|
const key = getKey(props.data[i], i)
|
||||||
|
const height = getItemHeight(key)
|
||||||
|
|
||||||
|
// Check if this item is visible (item starts before visibleEnd)
|
||||||
|
if (currentOffset >= visibleEnd) {
|
||||||
|
// This item starts after visibleEnd, so previous item is the last visible
|
||||||
|
endIndex = Math.min(count - 1, Math.max(startIndex, i - 1 + bufferCount))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
endIndex = i
|
||||||
|
currentOffset += height
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start: startIndex, end: endIndex, offset: startOffset }
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleItems = computed(() => {
|
||||||
|
const { start, end, offset } = visibleRange.value
|
||||||
|
const items = []
|
||||||
|
const count = props.data.length
|
||||||
|
|
||||||
|
if (count === 0) return items
|
||||||
|
|
||||||
|
const safeStart = Math.max(0, start)
|
||||||
|
const safeEnd = Math.min(count - 1, end)
|
||||||
|
|
||||||
|
if (safeStart > safeEnd) return items
|
||||||
|
|
||||||
|
let currentOffset = offset + props.placeholderHeight
|
||||||
|
const seenKeys = new Set()
|
||||||
|
|
||||||
|
for (let i = safeStart; i <= safeEnd; i++) {
|
||||||
|
const data = props.data[i]
|
||||||
|
if (!data) continue
|
||||||
|
|
||||||
|
const key = getKey(data, i)
|
||||||
|
|
||||||
|
// Deduplicate by key
|
||||||
|
if (seenKeys.has(key)) continue
|
||||||
|
seenKeys.add(key)
|
||||||
|
|
||||||
|
const height = getItemHeight(key)
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
data,
|
||||||
|
index: i,
|
||||||
|
key,
|
||||||
|
offset: currentOffset,
|
||||||
|
height
|
||||||
|
})
|
||||||
|
|
||||||
|
currentOffset += height
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const getItemStyle = (item) => ({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
transform: `translateY(${item.offset}px)`,
|
||||||
|
direction: 'ltr',
|
||||||
|
willChange: 'transform'
|
||||||
|
})
|
||||||
|
|
||||||
|
const setItemRef = (el, key) => {
|
||||||
|
if (el) {
|
||||||
|
itemRefs.set(key, el)
|
||||||
|
} else {
|
||||||
|
itemRefs.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const measureItem = (key, element) => {
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
const target = element.firstElementChild || element
|
||||||
|
const height = target.getBoundingClientRect().height
|
||||||
|
|
||||||
|
if (height > 0 && height !== itemHeights.value.get(key)) {
|
||||||
|
const newHeights = new Map(itemHeights.value)
|
||||||
|
newHeights.set(key, height)
|
||||||
|
itemHeights.value = newHeights
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupResizeObserver = () => {
|
||||||
|
if (resizeObserver.value) {
|
||||||
|
resizeObserver.value.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver.value = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const key = entry.target.dataset.key
|
||||||
|
if (key !== undefined) {
|
||||||
|
measureItem(key, entry.target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const observeItems = () => {
|
||||||
|
if (!resizeObserver.value) return
|
||||||
|
|
||||||
|
resizeObserver.value.disconnect()
|
||||||
|
|
||||||
|
for (const [key, element] of itemRefs) {
|
||||||
|
if (element) {
|
||||||
|
resizeObserver.value.observe(element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWheel = (event) => {
|
||||||
|
if (!scrollContainerRef.value) return
|
||||||
|
|
||||||
|
scrollContainerRef.value.scrollBy({
|
||||||
|
top: -event.deltaY,
|
||||||
|
behavior: 'instant'
|
||||||
|
})
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = (event) => {
|
||||||
|
const target = event.target
|
||||||
|
const scrollHeight = target.scrollHeight
|
||||||
|
const scrollTop = target.scrollTop
|
||||||
|
const clientHeight = target.clientHeight
|
||||||
|
|
||||||
|
isScrolling.value = true
|
||||||
|
|
||||||
|
if (scrollTimeout.value) {
|
||||||
|
clearTimeout(scrollTimeout.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTimeout.value = setTimeout(() => {
|
||||||
|
isScrolling.value = false
|
||||||
|
}, 150)
|
||||||
|
|
||||||
|
// In inverted scroll:
|
||||||
|
// - distanceToTop (visual top) = scrollHeight - scrollTop - clientHeight
|
||||||
|
// - distanceToBottom (visual bottom) = scrollTop
|
||||||
|
// - isAtTop (visual top, older data) = distanceToTop <= threshold
|
||||||
|
// - isAtBottom (visual bottom, newer data) = distanceToBottom <= threshold
|
||||||
|
const distanceToTop = scrollHeight - scrollTop - clientHeight
|
||||||
|
const distanceToBottom = scrollTop
|
||||||
|
const threshold = 5
|
||||||
|
|
||||||
|
const isAtTop = distanceToTop <= threshold
|
||||||
|
const isAtBottom = distanceToBottom <= threshold
|
||||||
|
|
||||||
|
emit('scroll', {
|
||||||
|
scrollTop,
|
||||||
|
scrollHeight,
|
||||||
|
clientHeight,
|
||||||
|
distanceToTop,
|
||||||
|
distanceToBottom,
|
||||||
|
isAtTop,
|
||||||
|
isAtBottom
|
||||||
|
})
|
||||||
|
|
||||||
|
// scroll-start: reached visual top (older data, need to load more)
|
||||||
|
if (isAtTop) {
|
||||||
|
emit('scroll-start')
|
||||||
|
}
|
||||||
|
|
||||||
|
// scroll-end: reached visual bottom (newer data)
|
||||||
|
if (isAtBottom) {
|
||||||
|
emit('scroll-end')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToIndex = (index, behavior = 'auto') => {
|
||||||
|
if (!scrollContainerRef.value || index < 0 || index >= props.data.length) return
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
const key = getKey(props.data[i], i)
|
||||||
|
offset += getItemHeight(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetScrollTop = offset + props.placeholderHeight
|
||||||
|
|
||||||
|
scrollContainerRef.value.scrollTo({
|
||||||
|
top: targetScrollTop,
|
||||||
|
behavior
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = (behavior = 'smooth') => {
|
||||||
|
if (!scrollContainerRef.value) return
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!scrollContainerRef.value) return
|
||||||
|
// In inverted scroll, bottom is scrollTop = 0
|
||||||
|
scrollContainerRef.value.scrollTo({ top: 0, behavior })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToTop = (behavior = 'smooth') => {
|
||||||
|
if (!scrollContainerRef.value) return
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!scrollContainerRef.value) return
|
||||||
|
// In inverted scroll, top is scrollTop = max
|
||||||
|
scrollContainerRef.value.scrollTo({
|
||||||
|
top: scrollContainerRef.value.scrollHeight,
|
||||||
|
behavior
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getScrollElement = () => scrollContainerRef.value
|
||||||
|
|
||||||
|
const isAtTop = () => {
|
||||||
|
if (!scrollContainerRef.value) return false
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.value
|
||||||
|
return scrollHeight - scrollTop - clientHeight <= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAtBottom = () => {
|
||||||
|
if (!scrollContainerRef.value) return false
|
||||||
|
return scrollContainerRef.value.scrollTop <= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
itemHeights.value = new Map()
|
||||||
|
itemRefs.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.data, (newData, oldData) => {
|
||||||
|
const newLength = newData?.length || 0
|
||||||
|
const oldLength = oldData?.length || 0
|
||||||
|
|
||||||
|
if (newLength < oldLength) {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(observeItems)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(visibleItems, () => {
|
||||||
|
nextTick(observeItems)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupResizeObserver()
|
||||||
|
nextTick(observeItems)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (resizeObserver.value) {
|
||||||
|
resizeObserver.value.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollTimeout.value) {
|
||||||
|
clearTimeout(scrollTimeout.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
itemRefs.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
scrollToIndex,
|
||||||
|
scrollToBottom,
|
||||||
|
scrollToTop,
|
||||||
|
getScrollElement,
|
||||||
|
isAtTop,
|
||||||
|
isAtBottom,
|
||||||
|
reset,
|
||||||
|
scrollContainerRef
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.virtual-scroller {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
|
||||||
|
&-container {
|
||||||
|
contain: layout style;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-spacer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
contain: layout style;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-placeholder {
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,518 @@
|
||||||
|
<template>
|
||||||
|
<div class="virtual-scroller" :style="containerStyle">
|
||||||
|
<div class="virtual-scroller-wrapper" :style="wrapperStyle">
|
||||||
|
<div
|
||||||
|
ref="scrollContainerRef"
|
||||||
|
class="virtual-scroller-container"
|
||||||
|
:style="containerInnerStyle"
|
||||||
|
@scroll.passive="handleScroll"
|
||||||
|
@wheel="handleWheel"
|
||||||
|
>
|
||||||
|
<div class="virtual-scroller-spacer" :style="spacerStyle"></div>
|
||||||
|
<div class="virtual-scroller-placeholder" :style="placeholderStyle">
|
||||||
|
<slot name="bottom-placeholder" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="item in visibleItems"
|
||||||
|
:key="item.key"
|
||||||
|
:ref="el => setItemRef(el, item.key)"
|
||||||
|
class="virtual-scroller-item"
|
||||||
|
:style="getItemStyle(item)"
|
||||||
|
:data-index="item.index"
|
||||||
|
:data-key="item.key"
|
||||||
|
>
|
||||||
|
<slot name="default" :item="item.data" :index="item.index" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
itemKey: {
|
||||||
|
type: [String, Function],
|
||||||
|
default: 'id'
|
||||||
|
},
|
||||||
|
estimatedHeight: {
|
||||||
|
type: Number,
|
||||||
|
default: 100
|
||||||
|
},
|
||||||
|
buffer: {
|
||||||
|
type: Number,
|
||||||
|
default: 5
|
||||||
|
},
|
||||||
|
placeholderHeight: {
|
||||||
|
type: Number,
|
||||||
|
default: 350
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end'])
|
||||||
|
|
||||||
|
const scrollContainerRef = ref(null)
|
||||||
|
const itemRefs = new Map()
|
||||||
|
const itemHeights = ref(new Map())
|
||||||
|
const resizeObserver = ref(null)
|
||||||
|
const isScrolling = ref(false)
|
||||||
|
const scrollTimeout = ref(null)
|
||||||
|
|
||||||
|
const getKey = (item, index) => {
|
||||||
|
if (typeof props.itemKey === 'function') {
|
||||||
|
return props.itemKey(item, index)
|
||||||
|
}
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
return item[props.itemKey] ?? index
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
const getItemHeight = (key) => {
|
||||||
|
return itemHeights.value.get(key) ?? props.estimatedHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerStyle = computed(() => ({
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const wrapperStyle = computed(() => ({
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transform: 'rotate(180deg)',
|
||||||
|
direction: 'rtl'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const containerInnerStyle = computed(() => ({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
overflowX: 'hidden',
|
||||||
|
overflowY: 'auto',
|
||||||
|
direction: 'ltr'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const totalDataHeight = computed(() => {
|
||||||
|
let height = 0
|
||||||
|
for (let i = 0; i < props.data.length; i++) {
|
||||||
|
const key = getKey(props.data[i], i)
|
||||||
|
height += getItemHeight(key)
|
||||||
|
}
|
||||||
|
return height
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalHeight = computed(() => {
|
||||||
|
return props.placeholderHeight + totalDataHeight.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const spacerStyle = computed(() => ({
|
||||||
|
height: `${totalHeight.value}px`,
|
||||||
|
width: '100%',
|
||||||
|
flexShrink: 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
const placeholderStyle = computed(() => ({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: `${props.placeholderHeight}px`,
|
||||||
|
zIndex: 1
|
||||||
|
}))
|
||||||
|
|
||||||
|
const getItemOffsets = () => {
|
||||||
|
const offsets = []
|
||||||
|
let offset = 0
|
||||||
|
for (let i = 0; i < props.data.length; i++) {
|
||||||
|
offsets.push(offset)
|
||||||
|
const key = getKey(props.data[i], i)
|
||||||
|
offset += getItemHeight(key)
|
||||||
|
}
|
||||||
|
return offsets
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleRange = computed(() => {
|
||||||
|
const count = props.data.length
|
||||||
|
if (count === 0) {
|
||||||
|
return { start: 0, end: 0, offset: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = scrollContainerRef.value
|
||||||
|
if (!el) {
|
||||||
|
return { start: 0, end: Math.min(count - 1, 9), offset: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollTop = el.scrollTop
|
||||||
|
const viewportHeight = el.clientHeight || 600
|
||||||
|
const bufferCount = props.buffer
|
||||||
|
|
||||||
|
// In inverted scroll (180deg rotation):
|
||||||
|
// - scrollTop = 0: visual BOTTOM (shows newer data, lower index)
|
||||||
|
// - scrollTop = max: visual TOP (shows older data, higher index)
|
||||||
|
// - Items are positioned from top: placeholderHeight, then data items
|
||||||
|
// - visibleStart/visibleEnd are offsets in the data area (after placeholder)
|
||||||
|
|
||||||
|
// When scrollTop = 0, we're at visual bottom, showing items near the START of data
|
||||||
|
// When scrollTop = max, we're at visual top, showing items near the END of data
|
||||||
|
|
||||||
|
// The visible area in data coordinates:
|
||||||
|
// - scrollTop 0 means we see items at offset 0 (start of data)
|
||||||
|
// - scrollTop increases means we see items at higher offsets (end of data)
|
||||||
|
|
||||||
|
const visibleStart = Math.max(0, scrollTop - props.placeholderHeight)
|
||||||
|
const visibleEnd = visibleStart + viewportHeight
|
||||||
|
|
||||||
|
let startIndex = 0
|
||||||
|
let endIndex = count - 1
|
||||||
|
let startOffset = 0
|
||||||
|
let currentOffset = 0
|
||||||
|
|
||||||
|
// Find startIndex: first item that ends after visibleStart
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const key = getKey(props.data[i], i)
|
||||||
|
const height = getItemHeight(key)
|
||||||
|
const itemEnd = currentOffset + height
|
||||||
|
|
||||||
|
if (itemEnd > visibleStart) {
|
||||||
|
startIndex = Math.max(0, i - bufferCount)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
currentOffset += height
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate startOffset for startIndex
|
||||||
|
startOffset = 0
|
||||||
|
for (let i = 0; i < startIndex; i++) {
|
||||||
|
const key = getKey(props.data[i], i)
|
||||||
|
startOffset += getItemHeight(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find endIndex: last item that starts before visibleEnd
|
||||||
|
currentOffset = startOffset
|
||||||
|
for (let i = startIndex; i < count; i++) {
|
||||||
|
const key = getKey(props.data[i], i)
|
||||||
|
const height = getItemHeight(key)
|
||||||
|
|
||||||
|
// Check if this item is visible (item starts before visibleEnd)
|
||||||
|
if (currentOffset >= visibleEnd) {
|
||||||
|
// This item starts after visibleEnd, so previous item is the last visible
|
||||||
|
endIndex = Math.min(count - 1, Math.max(startIndex, i - 1 + bufferCount))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
endIndex = i
|
||||||
|
currentOffset += height
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start: startIndex, end: endIndex, offset: startOffset }
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleItems = computed(() => {
|
||||||
|
const { start, end, offset } = visibleRange.value
|
||||||
|
const items = []
|
||||||
|
const count = props.data.length
|
||||||
|
|
||||||
|
if (count === 0) return items
|
||||||
|
|
||||||
|
const safeStart = Math.max(0, start)
|
||||||
|
const safeEnd = Math.min(count - 1, end)
|
||||||
|
|
||||||
|
if (safeStart > safeEnd) return items
|
||||||
|
|
||||||
|
let currentOffset = offset + props.placeholderHeight
|
||||||
|
const seenKeys = new Set()
|
||||||
|
|
||||||
|
for (let i = safeStart; i <= safeEnd; i++) {
|
||||||
|
const data = props.data[i]
|
||||||
|
if (!data) continue
|
||||||
|
|
||||||
|
const key = getKey(data, i)
|
||||||
|
|
||||||
|
// Deduplicate by key
|
||||||
|
if (seenKeys.has(key)) continue
|
||||||
|
seenKeys.add(key)
|
||||||
|
|
||||||
|
const height = getItemHeight(key)
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
data,
|
||||||
|
index: i,
|
||||||
|
key,
|
||||||
|
offset: currentOffset,
|
||||||
|
height
|
||||||
|
})
|
||||||
|
|
||||||
|
currentOffset += height
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const getItemStyle = (item) => ({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
transform: `translateY(${item.offset}px)`,
|
||||||
|
willChange: 'transform'
|
||||||
|
})
|
||||||
|
|
||||||
|
const setItemRef = (el, key) => {
|
||||||
|
if (el) {
|
||||||
|
itemRefs.set(key, el)
|
||||||
|
} else {
|
||||||
|
itemRefs.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const measureItem = (key, element) => {
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
const target = element.firstElementChild || element
|
||||||
|
const height = target.getBoundingClientRect().height
|
||||||
|
|
||||||
|
if (height > 0 && height !== itemHeights.value.get(key)) {
|
||||||
|
const newHeights = new Map(itemHeights.value)
|
||||||
|
newHeights.set(key, height)
|
||||||
|
itemHeights.value = newHeights
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupResizeObserver = () => {
|
||||||
|
if (resizeObserver.value) {
|
||||||
|
resizeObserver.value.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver.value = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const key = entry.target.dataset.key
|
||||||
|
if (key !== undefined) {
|
||||||
|
measureItem(key, entry.target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const observeItems = () => {
|
||||||
|
if (!resizeObserver.value) return
|
||||||
|
|
||||||
|
resizeObserver.value.disconnect()
|
||||||
|
|
||||||
|
for (const [key, element] of itemRefs) {
|
||||||
|
if (element) {
|
||||||
|
resizeObserver.value.observe(element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWheel = (event) => {
|
||||||
|
if (!scrollContainerRef.value) return
|
||||||
|
|
||||||
|
scrollContainerRef.value.scrollBy({
|
||||||
|
top: -event.deltaY,
|
||||||
|
behavior: 'instant'
|
||||||
|
})
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = (event) => {
|
||||||
|
const target = event.target
|
||||||
|
const scrollHeight = target.scrollHeight
|
||||||
|
const scrollTop = target.scrollTop
|
||||||
|
const clientHeight = target.clientHeight
|
||||||
|
|
||||||
|
isScrolling.value = true
|
||||||
|
|
||||||
|
if (scrollTimeout.value) {
|
||||||
|
clearTimeout(scrollTimeout.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTimeout.value = setTimeout(() => {
|
||||||
|
isScrolling.value = false
|
||||||
|
}, 150)
|
||||||
|
|
||||||
|
// In inverted scroll:
|
||||||
|
// - distanceToTop (visual top) = scrollHeight - scrollTop - clientHeight
|
||||||
|
// - distanceToBottom (visual bottom) = scrollTop
|
||||||
|
// - isAtTop (visual top, older data) = distanceToTop <= threshold
|
||||||
|
// - isAtBottom (visual bottom, newer data) = distanceToBottom <= threshold
|
||||||
|
const distanceToTop = scrollHeight - scrollTop - clientHeight
|
||||||
|
const distanceToBottom = scrollTop
|
||||||
|
const threshold = 5
|
||||||
|
|
||||||
|
const isAtTop = distanceToTop <= threshold
|
||||||
|
const isAtBottom = distanceToBottom <= threshold
|
||||||
|
|
||||||
|
emit('scroll', {
|
||||||
|
scrollTop,
|
||||||
|
scrollHeight,
|
||||||
|
clientHeight,
|
||||||
|
distanceToTop,
|
||||||
|
distanceToBottom,
|
||||||
|
isAtTop,
|
||||||
|
isAtBottom
|
||||||
|
})
|
||||||
|
|
||||||
|
// scroll-start: reached visual top (older data, need to load more)
|
||||||
|
if (isAtTop) {
|
||||||
|
emit('scroll-start')
|
||||||
|
}
|
||||||
|
|
||||||
|
// scroll-end: reached visual bottom (newer data)
|
||||||
|
if (isAtBottom) {
|
||||||
|
emit('scroll-end')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToIndex = (index, behavior = 'auto') => {
|
||||||
|
if (!scrollContainerRef.value || index < 0 || index >= props.data.length) return
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
const key = getKey(props.data[i], i)
|
||||||
|
offset += getItemHeight(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetScrollTop = offset + props.placeholderHeight
|
||||||
|
|
||||||
|
scrollContainerRef.value.scrollTo({
|
||||||
|
top: targetScrollTop,
|
||||||
|
behavior
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = (behavior = 'smooth') => {
|
||||||
|
if (!scrollContainerRef.value) return
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!scrollContainerRef.value) return
|
||||||
|
// In inverted scroll, bottom is scrollTop = 0
|
||||||
|
scrollContainerRef.value.scrollTo({ top: 0, behavior })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToTop = (behavior = 'smooth') => {
|
||||||
|
if (!scrollContainerRef.value) return
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!scrollContainerRef.value) return
|
||||||
|
// In inverted scroll, top is scrollTop = max
|
||||||
|
scrollContainerRef.value.scrollTo({
|
||||||
|
top: scrollContainerRef.value.scrollHeight,
|
||||||
|
behavior
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getScrollElement = () => scrollContainerRef.value
|
||||||
|
|
||||||
|
const isAtTop = () => {
|
||||||
|
if (!scrollContainerRef.value) return false
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.value
|
||||||
|
return scrollHeight - scrollTop - clientHeight <= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAtBottom = () => {
|
||||||
|
if (!scrollContainerRef.value) return false
|
||||||
|
return scrollContainerRef.value.scrollTop <= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
itemHeights.value = new Map()
|
||||||
|
itemRefs.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.data, (newData, oldData) => {
|
||||||
|
const newLength = newData?.length || 0
|
||||||
|
const oldLength = oldData?.length || 0
|
||||||
|
|
||||||
|
if (newLength < oldLength) {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(observeItems)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(visibleItems, () => {
|
||||||
|
nextTick(observeItems)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupResizeObserver()
|
||||||
|
nextTick(observeItems)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (resizeObserver.value) {
|
||||||
|
resizeObserver.value.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollTimeout.value) {
|
||||||
|
clearTimeout(scrollTimeout.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
itemRefs.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
scrollToIndex,
|
||||||
|
scrollToBottom,
|
||||||
|
scrollToTop,
|
||||||
|
getScrollElement,
|
||||||
|
isAtTop,
|
||||||
|
isAtBottom,
|
||||||
|
reset,
|
||||||
|
scrollContainerRef
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.virtual-scroller {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
|
||||||
|
&-wrapper {
|
||||||
|
contain: content;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-container {
|
||||||
|
contain: layout style;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-spacer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
contain: layout style;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-placeholder {
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,613 +1,379 @@
|
||||||
<template>
|
<template>
|
||||||
|
<div class="virtual-scroller" :style="containerStyle">
|
||||||
<div
|
<div
|
||||||
ref="containerRef"
|
ref="scrollContainerRef"
|
||||||
class="virtual-scroller"
|
class="virtual-scroller-container"
|
||||||
:style="containerStyle"
|
:style="scrollContainerStyle"
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref="wrapperRef"
|
|
||||||
class="virtual-scroller-wrapper"
|
|
||||||
:style="wrapperStyle"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref="renderContainerRef"
|
|
||||||
class="virtual-scroller-render-container"
|
|
||||||
:style="renderContainerStyle"
|
|
||||||
@scroll.passive="handleScroll"
|
@scroll.passive="handleScroll"
|
||||||
@wheel="handleWheel"
|
|
||||||
>
|
|
||||||
<div class="virtual-scroller-spacer" :style="{ height: `${totalHeight}px` }"></div>
|
|
||||||
<div
|
|
||||||
class="virtual-scroller-bottom-placeholder"
|
|
||||||
:style="bottomPlaceholderStyle"
|
|
||||||
>
|
>
|
||||||
|
<div class="virtual-scroller-spacer" :style="spacerStyle"></div>
|
||||||
|
<div class="virtual-scroller-placeholder" :style="placeholderStyle">
|
||||||
<slot name="bottom-placeholder" />
|
<slot name="bottom-placeholder" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="renderItem in visibleItems"
|
v-for="item in visibleItems"
|
||||||
:key="renderItem.key"
|
:key="item.key"
|
||||||
:ref="el => setItemRef(el, renderItem.key)"
|
:ref="el => setItemRef(el, item.key)"
|
||||||
class="virtual-scroller-item"
|
class="virtual-scroller-item"
|
||||||
:style="getItemStyle(renderItem)"
|
:style="getItemStyle(item)"
|
||||||
:data-index="renderItem.index"
|
:data-index="item.index"
|
||||||
:data-key="renderItem.key"
|
:data-key="item.key"
|
||||||
>
|
>
|
||||||
<slot
|
<slot name="default" :item="item.data" :index="item.index" />
|
||||||
name="default"
|
|
||||||
:item="renderItem.item"
|
|
||||||
:index="renderItem.index"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick, type CSSProperties } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
interface VirtualScrollerProps {
|
||||||
data: {
|
items: any[]
|
||||||
type: Array,
|
estimatedHeight?: number
|
||||||
required: true,
|
bufferSize?: number
|
||||||
default: () => []
|
keyField?: string
|
||||||
},
|
height?: string | number
|
||||||
itemKey: {
|
direction?: 'normal' | 'reverse'
|
||||||
type: [String, Function],
|
|
||||||
default: 'id'
|
|
||||||
},
|
|
||||||
estimatedHeight: {
|
|
||||||
type: Number,
|
|
||||||
default: 100
|
|
||||||
},
|
|
||||||
buffer: {
|
|
||||||
type: Number,
|
|
||||||
default: 3
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
type: [String, Number],
|
|
||||||
default: '100%'
|
|
||||||
},
|
|
||||||
width: {
|
|
||||||
type: [String, Number],
|
|
||||||
default: '100%'
|
|
||||||
},
|
|
||||||
renderMode: {
|
|
||||||
type: String,
|
|
||||||
default: 'default',
|
|
||||||
validator: (value) => ['default', 'top'].includes(value)
|
|
||||||
},
|
|
||||||
bottomPlaceholderHeight: {
|
|
||||||
type: Number,
|
|
||||||
default: 350
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<VirtualScrollerProps>(), {
|
||||||
|
estimatedHeight: 100,
|
||||||
|
bufferSize: 3,
|
||||||
|
keyField: 'id',
|
||||||
|
height: '100%',
|
||||||
|
direction: 'reverse'
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end'])
|
const emit = defineEmits<{
|
||||||
|
scroll: [scrollTop: number, scrollInfo: {
|
||||||
|
isAtTop: boolean
|
||||||
|
isAtBottom: boolean
|
||||||
|
distanceToTop: number
|
||||||
|
distanceToBottom: number
|
||||||
|
}]
|
||||||
|
'visible-change': [startIndex: number, endIndex: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
const containerRef = ref(null)
|
const scrollContainerRef = ref<HTMLElement | null>(null)
|
||||||
const wrapperRef = ref(null)
|
|
||||||
const renderContainerRef = ref(null)
|
|
||||||
const itemRefs = new Map()
|
|
||||||
const itemHeights = ref(new Map())
|
|
||||||
const resizeObserver = ref(null)
|
|
||||||
const scrollTop = ref(0)
|
const scrollTop = ref(0)
|
||||||
const isScrolling = ref(false)
|
const itemRefs = new Map<string, HTMLElement>()
|
||||||
const scrollTimeout = ref(null)
|
const isReverseMode = computed(() => props.direction === 'reverse')
|
||||||
|
const isInitializing = ref(true)
|
||||||
|
|
||||||
const containerStyle = computed(() => ({
|
const itemHeights = ref(new Map<string | number, number>())
|
||||||
height: '100%',
|
const itemOffsets = ref(new Map<string | number, number>())
|
||||||
width: '100%',
|
let resizeObserver: ResizeObserver | null = null
|
||||||
position: 'relative'
|
|
||||||
}))
|
|
||||||
|
|
||||||
const getItemKey = (item, index) => {
|
function getItemKey(item: any, index: number): string | number {
|
||||||
if (typeof props.itemKey === 'function') {
|
return item[props.keyField] ?? index
|
||||||
return props.itemKey(item, index)
|
|
||||||
}
|
}
|
||||||
if (typeof props.itemKey === 'string' && item && typeof item === 'object') {
|
|
||||||
return item[props.itemKey] ?? index
|
function getItemHeight(key: string | number): number {
|
||||||
|
return itemHeights.value.get(key) ?? props.estimatedHeight
|
||||||
}
|
}
|
||||||
return index
|
|
||||||
|
function calculateOffsets() {
|
||||||
|
let offset = 0
|
||||||
|
const newOffsets = new Map<string | number, number>()
|
||||||
|
|
||||||
|
for (let i = 0; i < props.items.length; i++) {
|
||||||
|
const key = getItemKey(props.items[i], i)
|
||||||
|
newOffsets.set(key, offset)
|
||||||
|
offset += getItemHeight(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
itemOffsets.value = newOffsets
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalHeight = computed(() => {
|
const totalHeight = computed(() => {
|
||||||
let height = props.bottomPlaceholderHeight
|
let height = 0
|
||||||
const len = props.data.length
|
for (let i = 0; i < props.items.length; i++) {
|
||||||
|
const key = getItemKey(props.items[i], i)
|
||||||
for (let i = 0; i < len; i++) {
|
height += getItemHeight(key)
|
||||||
const key = getItemKey(props.data[i], i)
|
|
||||||
const cachedHeight = itemHeights.value.get(key)
|
|
||||||
height += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return height
|
return height
|
||||||
})
|
})
|
||||||
|
|
||||||
const getItemPosition = (index) => {
|
const containerStyle = computed<CSSProperties>(() => ({
|
||||||
let offset = 0
|
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}))
|
||||||
|
|
||||||
for (let i = 0; i < index; i++) {
|
const scrollContainerStyle = computed<CSSProperties>(() => ({
|
||||||
const key = getItemKey(props.data[i], i)
|
height: '100%',
|
||||||
const cachedHeight = itemHeights.value.get(key)
|
overflowY: 'auto',
|
||||||
offset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
overflowX: 'hidden',
|
||||||
}
|
transform: 'rotate(180deg)',
|
||||||
|
position: 'relative'
|
||||||
|
}))
|
||||||
|
|
||||||
const key = getItemKey(props.data[index], index)
|
const spacerStyle = computed<CSSProperties>(() => ({
|
||||||
const height = itemHeights.value.get(key) ?? props.estimatedHeight
|
height: `${totalHeight.value}px`,
|
||||||
|
width: '1px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0
|
||||||
|
}))
|
||||||
|
|
||||||
return { offset, height }
|
const placeholderStyle = computed<CSSProperties>(() => ({
|
||||||
}
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
const containerHeight = computed(() => {
|
left: 0,
|
||||||
if (!renderContainerRef.value) return 0
|
right: 0,
|
||||||
return renderContainerRef.value.clientHeight
|
pointerEvents: 'none'
|
||||||
})
|
}))
|
||||||
|
|
||||||
const visibleRange = computed(() => {
|
const visibleRange = computed(() => {
|
||||||
if (props.data.length === 0) {
|
if (!scrollContainerRef.value || props.items.length === 0) {
|
||||||
return { start: 0, end: 0, offset: 0 }
|
return { start: 0, end: Math.min(props.bufferSize * 2, props.items.length - 1) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = renderContainerRef.value
|
const containerHeight = scrollContainerRef.value.clientHeight
|
||||||
if (!el) {
|
const scrollTopValue = scrollTop.value
|
||||||
const count = props.data.length
|
|
||||||
return {
|
|
||||||
start: Math.max(0, count - 10),
|
|
||||||
end: count - 1,
|
|
||||||
offset: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentScrollTop = el.scrollTop
|
|
||||||
const viewportHeight = containerHeight.value || el.clientHeight || 600
|
|
||||||
const bufferCount = props.buffer
|
|
||||||
const count = props.data.length
|
|
||||||
|
|
||||||
const extraBuffer = Math.ceil(viewportHeight / props.estimatedHeight)
|
|
||||||
|
|
||||||
const placeholderHeight = props.bottomPlaceholderHeight
|
|
||||||
const scrollHeight = el.scrollHeight
|
|
||||||
|
|
||||||
const maxScrollTop = Math.max(0, scrollHeight - viewportHeight)
|
|
||||||
const normalizedScrollTop = Math.min(Math.max(0, currentScrollTop), maxScrollTop)
|
|
||||||
|
|
||||||
const visibleStart = Math.max(0, normalizedScrollTop)
|
|
||||||
const visibleEnd = visibleStart + viewportHeight
|
|
||||||
|
|
||||||
let startIndex = 0
|
let startIndex = 0
|
||||||
let endIndex = count - 1
|
let endIndex = props.items.length - 1
|
||||||
let startOffset = 0
|
|
||||||
let currentOffset = 0
|
let currentOffset = 0
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < props.items.length; i++) {
|
||||||
const key = getItemKey(props.data[i], i)
|
const key = getItemKey(props.items[i], i)
|
||||||
const height = itemHeights.value.get(key) ?? props.estimatedHeight
|
const itemHeight = getItemHeight(key)
|
||||||
|
|
||||||
const itemEnd = currentOffset + height
|
if (currentOffset + itemHeight > scrollTopValue - props.bufferSize * props.estimatedHeight) {
|
||||||
|
startIndex = Math.max(0, i - props.bufferSize)
|
||||||
if (itemEnd > visibleStart) {
|
|
||||||
startIndex = Math.max(0, i - bufferCount - extraBuffer)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
currentOffset += itemHeight
|
||||||
currentOffset += height
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startOffset = 0
|
currentOffset = 0
|
||||||
for (let i = 0; i < startIndex; i++) {
|
for (let i = 0; i < props.items.length; i++) {
|
||||||
const key = getItemKey(props.data[i], i)
|
const key = getItemKey(props.items[i], i)
|
||||||
startOffset += itemHeights.value.get(key) ?? props.estimatedHeight
|
const itemHeight = getItemHeight(key)
|
||||||
}
|
|
||||||
|
|
||||||
currentOffset = startOffset
|
if (currentOffset > scrollTopValue + containerHeight + props.bufferSize * props.estimatedHeight) {
|
||||||
for (let i = startIndex; i < count; i++) {
|
endIndex = Math.min(props.items.length - 1, i + props.bufferSize)
|
||||||
const key = getItemKey(props.data[i], i)
|
|
||||||
const height = itemHeights.value.get(key) ?? props.estimatedHeight
|
|
||||||
|
|
||||||
if (currentOffset > visibleEnd) {
|
|
||||||
endIndex = Math.min(count - 1, i - 1 + bufferCount + extraBuffer)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
currentOffset += itemHeight
|
||||||
currentOffset += height
|
|
||||||
endIndex = i
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { start: startIndex, end: endIndex, offset: startOffset }
|
return { start: startIndex, end: endIndex }
|
||||||
})
|
})
|
||||||
|
|
||||||
const visibleItems = computed(() => {
|
const visibleItems = computed(() => {
|
||||||
const { start, end, offset } = visibleRange.value
|
const { start, end } = visibleRange.value
|
||||||
const items = []
|
const items: Array<{ key: string | number; data: any; index: number; offset: number }> = []
|
||||||
|
const currentRenderedKeys = new Set<string | number>()
|
||||||
|
|
||||||
if (!props.data || props.data.length === 0) {
|
for (let i = start; i <= end; i++) {
|
||||||
return []
|
const item = props.items[i]
|
||||||
}
|
|
||||||
|
|
||||||
const safeStart = Math.max(0, start)
|
|
||||||
const safeEnd = Math.min(props.data.length - 1, end)
|
|
||||||
|
|
||||||
if (safeStart > safeEnd) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentOffset = offset
|
|
||||||
const seenKeys = new Set()
|
|
||||||
|
|
||||||
for (let i = safeStart; i <= safeEnd; i++) {
|
|
||||||
const item = props.data[i]
|
|
||||||
if (!item) continue
|
if (!item) continue
|
||||||
|
|
||||||
const key = getItemKey(item, i)
|
const key = getItemKey(item, i)
|
||||||
if (seenKeys.has(key)) continue
|
|
||||||
seenKeys.add(key)
|
|
||||||
|
|
||||||
const cachedHeight = itemHeights.value.get(key)
|
if (currentRenderedKeys.has(key)) {
|
||||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
continue
|
||||||
|
}
|
||||||
|
currentRenderedKeys.add(key)
|
||||||
|
|
||||||
|
const offset = itemOffsets.value.get(key) ?? i * props.estimatedHeight
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
item,
|
key: String(key),
|
||||||
|
data: item,
|
||||||
index: i,
|
index: i,
|
||||||
key,
|
offset
|
||||||
offset: currentOffset,
|
|
||||||
height
|
|
||||||
})
|
})
|
||||||
|
|
||||||
currentOffset += height
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const wrapperStyle = computed(() => ({
|
function getItemStyle(item: { offset: number }): CSSProperties {
|
||||||
direction: 'rtl',
|
return {
|
||||||
height: '100%',
|
|
||||||
position: 'relative',
|
|
||||||
scrollbarWidth: 'auto',
|
|
||||||
overflow: 'hidden',
|
|
||||||
transform: 'rotate(180deg)',
|
|
||||||
width: '100%'
|
|
||||||
}))
|
|
||||||
|
|
||||||
const renderContainerStyle = computed(() => ({
|
|
||||||
direction: 'ltr',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
overflowX: 'hidden',
|
|
||||||
overflowY: 'auto',
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
width: '100%'
|
|
||||||
}))
|
|
||||||
|
|
||||||
const bottomPlaceholderStyle = computed(() => ({
|
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
top: `${item.offset}px`,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0
|
||||||
top: 0,
|
}
|
||||||
width: '100%',
|
}
|
||||||
height: `${props.bottomPlaceholderHeight}px`,
|
|
||||||
zIndex: 1
|
|
||||||
}))
|
|
||||||
|
|
||||||
const getItemStyle = (renderItem) => ({
|
function setItemRef(el: any, key: string) {
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
width: '100%',
|
|
||||||
transform: `translateY(${renderItem.offset}px)`,
|
|
||||||
willChange: 'transform'
|
|
||||||
})
|
|
||||||
|
|
||||||
const setItemRef = (el, key) => {
|
|
||||||
if (el) {
|
if (el) {
|
||||||
itemRefs.set(key, el)
|
itemRefs.set(key, el as HTMLElement)
|
||||||
} else {
|
} else {
|
||||||
itemRefs.delete(key)
|
itemRefs.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const measureItem = (key, element) => {
|
function measureItems() {
|
||||||
if (!element) return
|
if (!resizeObserver) return
|
||||||
|
|
||||||
const firstChild = element.firstElementChild
|
itemRefs.forEach((el, key) => {
|
||||||
const targetElement = firstChild || element
|
resizeObserver!.observe(el)
|
||||||
|
|
||||||
const height = targetElement.getBoundingClientRect().height
|
|
||||||
|
|
||||||
if (height > 0) {
|
|
||||||
const cachedHeight = itemHeights.value.get(key)
|
|
||||||
if (cachedHeight !== height) {
|
|
||||||
const newHeights = new Map(itemHeights.value)
|
|
||||||
newHeights.set(key, height)
|
|
||||||
itemHeights.value = newHeights
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setupResizeObserver = () => {
|
|
||||||
if (resizeObserver.value) {
|
|
||||||
resizeObserver.value.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
resizeObserver.value = new ResizeObserver((entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
const key = entry.target.dataset.key
|
|
||||||
if (key !== undefined) {
|
|
||||||
measureItem(key, entry.target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleWheel = (event) => {
|
function updateItemHeight(key: string | number, height: number) {
|
||||||
if (!renderContainerRef.value) return
|
const oldHeight = itemHeights.value.get(key)
|
||||||
|
if (oldHeight !== height) {
|
||||||
const { deltaY } = event
|
itemHeights.value.set(key, height)
|
||||||
const el = renderContainerRef.value
|
calculateOffsets()
|
||||||
|
}
|
||||||
el.scrollBy({
|
|
||||||
top: -deltaY,
|
|
||||||
behavior: 'instant'
|
|
||||||
})
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll = (event) => {
|
function handleScroll(event: Event) {
|
||||||
const target = event.target
|
const target = event.target as HTMLElement
|
||||||
const scrollHeight = target.scrollHeight
|
|
||||||
const currentScrollTop = target.scrollTop
|
const currentScrollTop = target.scrollTop
|
||||||
const viewportHeight = target.clientHeight
|
scrollTop.value = currentScrollTop
|
||||||
|
|
||||||
const visualScrollBottom = scrollHeight - currentScrollTop - viewportHeight
|
if (!scrollContainerRef.value) {
|
||||||
|
emit('scroll', currentScrollTop)
|
||||||
scrollTop.value = visualScrollBottom
|
return
|
||||||
|
|
||||||
isScrolling.value = true
|
|
||||||
|
|
||||||
if (scrollTimeout.value) {
|
|
||||||
clearTimeout(scrollTimeout.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollTimeout.value = setTimeout(() => {
|
const containerHeight = scrollContainerRef.value.clientHeight
|
||||||
isScrolling.value = false
|
const maxScroll = Math.max(0, totalHeight.value - containerHeight)
|
||||||
}, 150)
|
|
||||||
|
|
||||||
const st = target.scrollTop
|
const distanceToTop = currentScrollTop
|
||||||
const sh = target.scrollHeight
|
const distanceToBottom = maxScroll - currentScrollTop
|
||||||
const ch = target.clientHeight
|
|
||||||
|
|
||||||
const distanceToContainerTop = st
|
const isAtTop = currentScrollTop >= maxScroll - 10
|
||||||
const distanceToContainerBottom = sh - st - ch
|
const isAtBottom = currentScrollTop <= 10
|
||||||
|
|
||||||
const distanceToPageTop = distanceToContainerBottom
|
emit('scroll', currentScrollTop, {
|
||||||
const distanceToPageBottom = distanceToContainerTop
|
isAtTop,
|
||||||
const threshold = 5
|
isAtBottom,
|
||||||
const isAtPageTop = distanceToPageTop <= threshold
|
distanceToTop,
|
||||||
const isAtPageBottom = distanceToPageBottom <= threshold
|
distanceToBottom
|
||||||
|
|
||||||
emit('scroll', {
|
|
||||||
target,
|
|
||||||
scrollTop: visualScrollBottom,
|
|
||||||
scrollHeight,
|
|
||||||
clientHeight: ch,
|
|
||||||
distanceToPageTop,
|
|
||||||
distanceToPageBottom,
|
|
||||||
isAtPageTop,
|
|
||||||
isAtPageBottom
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isAtPageTop) {
|
|
||||||
emit('scroll-start')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAtPageBottom) {
|
|
||||||
emit('scroll-end')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollToIndex = (index, behavior = 'auto') => {
|
|
||||||
if (!renderContainerRef.value || index < 0 || index >= props.data.length) return
|
|
||||||
|
|
||||||
const position = getItemPosition(index)
|
|
||||||
const el = renderContainerRef.value
|
|
||||||
const scrollHeight = el.scrollHeight
|
|
||||||
const targetScrollTop = scrollHeight - position.offset - props.bottomPlaceholderHeight - el.clientHeight
|
|
||||||
|
|
||||||
el.scrollTo({
|
|
||||||
top: targetScrollTop,
|
|
||||||
behavior
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToBottom = (behavior = 'smooth') => {
|
function scrollToIndex(index: number) {
|
||||||
if (!renderContainerRef.value) return
|
if (!scrollContainerRef.value) return
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
let targetTop = 0
|
||||||
if (!renderContainerRef.value) return
|
for (let i = 0; i < index; i++) {
|
||||||
|
const key = getItemKey(props.items[i], i)
|
||||||
renderContainerRef.value.scrollTo({
|
targetTop += getItemHeight(key)
|
||||||
top: 0,
|
|
||||||
behavior
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToTop = (behavior = 'smooth') => {
|
scrollContainerRef.value.scrollTop = targetTop
|
||||||
if (!renderContainerRef.value) return
|
scrollTop.value = targetTop
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!renderContainerRef.value) return
|
|
||||||
|
|
||||||
const scrollHeight = renderContainerRef.value.scrollHeight
|
|
||||||
renderContainerRef.value.scrollTo({
|
|
||||||
top: scrollHeight,
|
|
||||||
behavior
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getScrollElement = () => renderContainerRef.value
|
function scrollToTop() {
|
||||||
|
if (!scrollContainerRef.value) return
|
||||||
const getVisibleIndices = () => {
|
const containerHeight = scrollContainerRef.value.clientHeight
|
||||||
const { start, end } = visibleRange.value
|
const maxScroll = Math.max(0, totalHeight.value - containerHeight)
|
||||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
scrollContainerRef.value.scrollTop = maxScroll
|
||||||
|
scrollTop.value = maxScroll
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetMeasurements = () => {
|
function scrollToBottom() {
|
||||||
itemHeights.value = new Map()
|
if (!scrollContainerRef.value) return
|
||||||
itemRefs.clear()
|
scrollContainerRef.value.scrollTop = 0
|
||||||
scrollTop.value = 0
|
scrollTop.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAtPageBottom = () => {
|
watch(
|
||||||
if (!renderContainerRef.value) return false
|
() => visibleRange.value,
|
||||||
const { scrollTop } = renderContainerRef.value
|
(newRange) => {
|
||||||
return scrollTop <= 5
|
emit('visible-change', newRange.start, newRange.end)
|
||||||
}
|
|
||||||
|
|
||||||
const isAtPageTop = () => {
|
|
||||||
if (!renderContainerRef.value) return false
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = renderContainerRef.value
|
|
||||||
return scrollHeight - scrollTop - clientHeight <= 5
|
|
||||||
}
|
|
||||||
|
|
||||||
const observeVisibleItems = () => {
|
|
||||||
if (!resizeObserver.value) return
|
|
||||||
|
|
||||||
resizeObserver.value.disconnect()
|
|
||||||
|
|
||||||
for (const [key, element] of itemRefs) {
|
|
||||||
if (element) {
|
|
||||||
resizeObserver.value.observe(element)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.data, (newData, oldData) => {
|
|
||||||
const newLength = newData?.length || 0
|
|
||||||
const oldLength = oldData?.length || 0
|
|
||||||
|
|
||||||
if (newLength > oldLength) {
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
observeVisibleItems()
|
measureItems()
|
||||||
})
|
})
|
||||||
} else if (newLength < oldLength) {
|
},
|
||||||
itemHeights.value = new Map()
|
{ deep: true }
|
||||||
itemRefs.clear()
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.items,
|
||||||
|
() => {
|
||||||
|
calculateOffsets()
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
observeVisibleItems()
|
measureItems()
|
||||||
})
|
})
|
||||||
} else {
|
},
|
||||||
const oldKeys = new Set()
|
{ immediate: true }
|
||||||
const newKeys = new Set()
|
)
|
||||||
|
|
||||||
for (let i = 0; i < oldLength; i++) {
|
watch(
|
||||||
oldKeys.add(getItemKey(oldData[i], i))
|
() => props.items.length,
|
||||||
}
|
async (newLength, oldLength) => {
|
||||||
for (let i = 0; i < newLength; i++) {
|
if (isReverseMode.value && newLength > (oldLength || 0)) {
|
||||||
newKeys.add(getItemKey(newData[i], i))
|
await nextTick()
|
||||||
}
|
scrollToBottom()
|
||||||
|
|
||||||
let hasChanges = false
|
|
||||||
for (const key of newKeys) {
|
|
||||||
if (!oldKeys.has(key)) {
|
|
||||||
hasChanges = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
if (hasChanges) {
|
|
||||||
itemHeights.value = new Map()
|
|
||||||
itemRefs.clear()
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
observeVisibleItems()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
watch(visibleItems, () => {
|
|
||||||
nextTick(() => {
|
|
||||||
observeVisibleItems()
|
|
||||||
})
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setupResizeObserver()
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const el = entry.target as HTMLElement
|
||||||
|
const key = el.dataset.key
|
||||||
|
if (key) {
|
||||||
|
updateItemHeight(key, entry.contentRect.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (scrollContainerRef.value && isReverseMode.value) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
observeVisibleItems()
|
scrollToBottom()
|
||||||
|
setTimeout(() => {
|
||||||
|
isInitializing.value = false
|
||||||
|
}, 100)
|
||||||
})
|
})
|
||||||
})
|
} else {
|
||||||
|
isInitializing.value = false
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (resizeObserver.value) {
|
|
||||||
resizeObserver.value.disconnect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scrollTimeout.value) {
|
measureItems()
|
||||||
clearTimeout(scrollTimeout.value)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
}
|
||||||
itemRefs.clear()
|
itemRefs.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
scrollToIndex,
|
scrollToIndex,
|
||||||
scrollToBottom,
|
|
||||||
scrollToTop,
|
scrollToTop,
|
||||||
getScrollElement,
|
scrollToBottom,
|
||||||
getVisibleIndices,
|
getScrollTop: () => scrollTop.value,
|
||||||
resetMeasurements,
|
getVisibleRange: () => visibleRange.value,
|
||||||
containerRef,
|
updateLayout: () => {
|
||||||
isAtPageBottom,
|
calculateOffsets()
|
||||||
isAtPageTop
|
measureItems()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style scoped>
|
||||||
.virtual-scroller {
|
.virtual-scroller {
|
||||||
-webkit-overflow-scrolling: touch;
|
position: relative;
|
||||||
scrollbar-width: none;
|
width: 100%;
|
||||||
-ms-overflow-style: none;
|
}
|
||||||
|
|
||||||
.virtual-scroller-wrapper {
|
.virtual-scroller-container {
|
||||||
contain: content;
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.virtual-scroller-spacer {
|
.virtual-scroller-spacer {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.virtual-scroller-render-container {
|
|
||||||
contain: layout style;
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
// transform: rotate(180deg);
|
|
||||||
display: none;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.virtual-scroller-item {
|
.virtual-scroller-item {
|
||||||
|
will-change: transform;
|
||||||
contain: layout style;
|
contain: layout style;
|
||||||
backface-visibility: hidden;
|
|
||||||
perspective: 1000px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.virtual-scroller-bottom-placeholder {
|
|
||||||
contain: layout style;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -29,49 +29,47 @@ const router = createRouter({
|
||||||
routes
|
routes
|
||||||
})
|
})
|
||||||
|
|
||||||
// router.beforeEach(async (to, from) => {
|
router.beforeEach(async (to, from) => {
|
||||||
// if(to.query.token){
|
if(to.query.token){
|
||||||
// setToken(to.query.token)
|
setToken(to.query.token)
|
||||||
// } else {
|
} else {
|
||||||
// // 检查是否有 token
|
// 检查是否有 token
|
||||||
// const token = getToken()
|
const token = getToken()
|
||||||
// if (!token) {
|
if (!token) {
|
||||||
// // 没有 token,重定向到登录页
|
// 没有 token,重定向到登录页
|
||||||
// return '/login'
|
return '/login'
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
// // 白名单路径(不需要验证 token 的路径)
|
// 白名单路径(不需要验证 token 的路径)
|
||||||
// const whiteList = ['/login']
|
const whiteList = ['/login']
|
||||||
// // 获取用户 store 实例
|
// 获取用户 store 实例
|
||||||
// const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
// // 如果访问的是白名单路径,直接放行
|
// 如果访问的是白名单路径,直接放行
|
||||||
// if (whiteList.includes(to.path)) {
|
if (whiteList.includes(to.path)) {
|
||||||
// return true
|
return true
|
||||||
// }
|
}
|
||||||
|
|
||||||
// // 检查 token 是否有效
|
// 检查 token 是否有效
|
||||||
// try {
|
try {
|
||||||
// const isTokenValid = await userStore.checkTokenValid()
|
const isTokenValid = await userStore.checkTokenValid()
|
||||||
// console.log(isTokenValid)
|
console.log(isTokenValid)
|
||||||
// if (isTokenValid) {
|
if (isTokenValid) {
|
||||||
// // token 有效,允许访问
|
// token 有效,允许访问
|
||||||
// if (!userStore.userInfo.id) {
|
if (!userStore.userInfo.id) {
|
||||||
// // 如果用户信息不存在,则从服务器获取
|
// 如果用户信息不存在,则从服务器获取
|
||||||
// await userStore.getInfo()
|
await userStore.getInfo()
|
||||||
// }
|
}
|
||||||
// return true
|
return true
|
||||||
// } else {
|
} else {
|
||||||
// // token 无效,重定向到登录页
|
// token 无效,重定向到登录页
|
||||||
// return '/login'
|
return '/login'
|
||||||
// }
|
}
|
||||||
// } catch (error) {
|
} catch (error) {
|
||||||
// // 验证过程中出错,重定向到登录页
|
// 验证过程中出错,重定向到登录页
|
||||||
// console.error('验证 token 时出错:', error)
|
console.error('验证 token 时出错:', error)
|
||||||
// return '/login'
|
return '/login'
|
||||||
// }
|
}
|
||||||
// })
|
})
|
||||||
|
|
||||||
router.beforeEach(async (to, from) => {return true})
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,13 @@ const DisplayStoreSetup = () => {
|
||||||
const currentPage = ref(0)
|
const currentPage = ref(0)
|
||||||
const hasMoreData = ref(true)
|
const hasMoreData = ref(true)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const currentResultData = ref(null)
|
||||||
|
const dialogBoxRef = ref(null)
|
||||||
|
|
||||||
|
const canvasVisible = ref(false)
|
||||||
|
const canvasImage = ref('')
|
||||||
|
const canvasReferenceImages = ref([])
|
||||||
|
const canvasSource = ref('')
|
||||||
|
|
||||||
const addGeneratingItem = (item) => {
|
const addGeneratingItem = (item) => {
|
||||||
const newItem = {
|
const newItem = {
|
||||||
|
|
@ -50,6 +57,13 @@ const DisplayStoreSetup = () => {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteHistoryItem = (id) => {
|
||||||
|
const index = tempList.value.findIndex(item => item.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
tempList.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const scrollToBottom = async () => {
|
const scrollToBottom = async () => {
|
||||||
const refValue = scrollerRef.value
|
const refValue = scrollerRef.value
|
||||||
|
|
||||||
|
|
@ -81,6 +95,40 @@ const DisplayStoreSetup = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setResultData = (data) => {
|
||||||
|
currentResultData.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDialogBoxRef = (ref) => {
|
||||||
|
dialogBoxRef.value = ref
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerGenerateWithResult = async () => {
|
||||||
|
if (dialogBoxRef.value && currentResultData.value) {
|
||||||
|
await dialogBoxRef.value.fillParamsFromResult(currentResultData.value)
|
||||||
|
await dialogBoxRef.value.handleStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillParamsForEdit = () => {
|
||||||
|
if (dialogBoxRef.value && currentResultData.value) {
|
||||||
|
dialogBoxRef.value.fillParamsFromResult(currentResultData.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCanvas = (data) => {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
canvasImage.value = data
|
||||||
|
canvasReferenceImages.value = []
|
||||||
|
canvasSource.value = ''
|
||||||
|
} else {
|
||||||
|
canvasImage.value = data.mainImage?.url || data.mainImage || ''
|
||||||
|
canvasReferenceImages.value = data.referenceImages || []
|
||||||
|
canvasSource.value = data.source || ''
|
||||||
|
}
|
||||||
|
canvasVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Sender_variant,
|
Sender_variant,
|
||||||
scrollerRef,
|
scrollerRef,
|
||||||
|
|
@ -89,13 +137,25 @@ const DisplayStoreSetup = () => {
|
||||||
currentPage,
|
currentPage,
|
||||||
hasMoreData,
|
hasMoreData,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
currentResultData,
|
||||||
|
dialogBoxRef,
|
||||||
|
canvasVisible,
|
||||||
|
canvasImage,
|
||||||
|
canvasReferenceImages,
|
||||||
|
canvasSource,
|
||||||
addGeneratingItem,
|
addGeneratingItem,
|
||||||
updateItemToSuccess,
|
updateItemToSuccess,
|
||||||
initHistoryList,
|
initHistoryList,
|
||||||
prependHistoryList,
|
prependHistoryList,
|
||||||
appendHistoryList,
|
appendHistoryList,
|
||||||
resetPagination,
|
resetPagination,
|
||||||
scrollToBottom
|
scrollToBottom,
|
||||||
|
deleteHistoryItem,
|
||||||
|
setResultData,
|
||||||
|
setDialogBoxRef,
|
||||||
|
triggerGenerateWithResult,
|
||||||
|
fillParamsForEdit,
|
||||||
|
openCanvas
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,8 @@ export async function generate(type, data) {
|
||||||
taskId: taskId,
|
taskId: taskId,
|
||||||
text: data.prompt || '生成中...',
|
text: data.prompt || '生成中...',
|
||||||
name: data.prompt || '生成中...',
|
name: data.prompt || '生成中...',
|
||||||
type: type
|
type: type,
|
||||||
|
result: data.result
|
||||||
})
|
})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
useDisplay.scrollToBottom()
|
useDisplay.scrollToBottom()
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="props.item.status === 'success'" class="bottom-btn-group">
|
<div v-if="props.item.status === 'success'" class="bottom-btn-group">
|
||||||
<div v-for="(item, index) in bottomBtnGroup" :key="index" class="bottom-btn" @click="item.click(file, index)">
|
<div v-for="(item, index) in bottomBtnGroup" :key="index" class="bottom-btn" @click="item.click()">
|
||||||
<img :src="item.icon" />
|
<img :src="item.icon" />
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,7 +94,7 @@ 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'
|
import Img from '@/components/Img/index.vue'
|
||||||
import { cancelOrCollect } from '@/apis/display'
|
import { cancelOrCollect, deleteGenerateHistory } from '@/apis/display'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
item: {
|
item: {
|
||||||
|
|
@ -103,7 +103,7 @@ const props = defineProps({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['open-canvas'])
|
const emit = defineEmits(['open-canvas', 'delete-success'])
|
||||||
|
|
||||||
const useDisplay = useDisplayStore()
|
const useDisplay = useDisplayStore()
|
||||||
const useParams = useParamStore()
|
const useParams = useParamStore()
|
||||||
|
|
@ -131,19 +131,45 @@ const AIbrush = (file, index) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const reEdit = (url, number) => {
|
const reEdit = () => {
|
||||||
console.log(number)
|
useDisplay.setResultData(props.item.result)
|
||||||
|
useDisplay.fillParamsForEdit()
|
||||||
}
|
}
|
||||||
|
|
||||||
const againGenerate = (url, number) => {
|
const againGenerate = () => {
|
||||||
console.log(number)
|
useDisplay.setResultData(props.item.result)
|
||||||
|
useDisplay.triggerGenerateWithResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteImage = (url, number) => {
|
const deleteImage = () => {
|
||||||
console.log(number)
|
ElMessageBox.confirm(
|
||||||
|
'确定要删除该批次图片吗?此操作不可恢复!',
|
||||||
|
'删除确认',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonClass: 'el-button--danger'
|
||||||
|
}
|
||||||
|
).then(async () => {
|
||||||
|
try {
|
||||||
|
const res = await deleteGenerateHistory({
|
||||||
|
id: props.item.id
|
||||||
|
})
|
||||||
|
if (res.success) {
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
emit('delete-success', props.item.id)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除操作失败:', error)
|
||||||
|
ElMessage.error('删除操作失败')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const bottomBtnGroup = [
|
const bottomBtnGroup = computed(() => [
|
||||||
{
|
{
|
||||||
name: '重新编辑',
|
name: '重新编辑',
|
||||||
icon: reEditIcon,
|
icon: reEditIcon,
|
||||||
|
|
@ -159,7 +185,7 @@ const bottomBtnGroup = [
|
||||||
icon: deleteImageIcon,
|
icon: deleteImageIcon,
|
||||||
click: deleteImage
|
click: deleteImage
|
||||||
}
|
}
|
||||||
]
|
])
|
||||||
|
|
||||||
const addCollection = async (url) => {
|
const addCollection = async (url) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -45,16 +45,20 @@
|
||||||
<VirtualScroller
|
<VirtualScroller
|
||||||
ref="scrollerRef"
|
ref="scrollerRef"
|
||||||
v-if="props.if"
|
v-if="props.if"
|
||||||
:data="list"
|
:items="list"
|
||||||
:item-key="'id'"
|
key-field="id"
|
||||||
:estimated-height="300"
|
:estimated-height="300"
|
||||||
:render-mode="'top'"
|
:buffer-size="3"
|
||||||
:buffer="2"
|
direction="reverse"
|
||||||
class="scroller"
|
class="scroller"
|
||||||
@scroll="handleScroll"
|
@scroll="handleScroll"
|
||||||
|
@visible-change="handleVisibleChange"
|
||||||
>
|
>
|
||||||
<template #default="{ item, index }">
|
<template #default="{ item, index }">
|
||||||
<Set :key="item.id" :item="item" @open-canvas="openCanvas" />
|
<Set :key="item.id" :item="item" @open-canvas="openCanvas" @delete-success="handleDeleteSuccess" />
|
||||||
|
</template>
|
||||||
|
<template #bottom-placeholder>
|
||||||
|
<div style="height: 350px;"></div>
|
||||||
</template>
|
</template>
|
||||||
</VirtualScroller>
|
</VirtualScroller>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -96,10 +100,7 @@ const scrollerRef = ref(null)
|
||||||
const isLoadingMoreLocked = ref(false)
|
const isLoadingMoreLocked = ref(false)
|
||||||
const activeTab = ref('all')
|
const activeTab = ref('all')
|
||||||
const isInitializing = ref(true)
|
const isInitializing = ref(true)
|
||||||
const canvasVisible = ref(false)
|
const { canvasVisible, canvasImage, canvasReferenceImages, canvasSource } = storeToRefs(useDisplay)
|
||||||
const canvasImage = ref('')
|
|
||||||
const canvasReferenceImages = ref([])
|
|
||||||
const canvasSource = ref('')
|
|
||||||
|
|
||||||
const chargeType = computed(() => getChargeType(props.type))
|
const chargeType = computed(() => getChargeType(props.type))
|
||||||
console.log(chargeType.value)
|
console.log(chargeType.value)
|
||||||
|
|
@ -137,14 +138,20 @@ const conversion = (newlist) => {
|
||||||
const temp = newlist.list.map((item) => {
|
const temp = newlist.list.map((item) => {
|
||||||
const files = item.fileUrl ? item.fileUrl.split(',').filter(url => url.trim()) : []
|
const files = item.fileUrl ? item.fileUrl.split(',').filter(url => url.trim()) : []
|
||||||
return {
|
return {
|
||||||
id: item.taskId,
|
id: item.id,
|
||||||
|
taskId: item.taskId,
|
||||||
collection: item.collection,
|
collection: item.collection,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
prompt: item.prompt,
|
prompt: item.prompt,
|
||||||
params: item.params,
|
params: item.params,
|
||||||
|
result: item.result,
|
||||||
time: item.createTime,
|
time: item.createTime,
|
||||||
files: files,
|
files: files,
|
||||||
collectStatus: item.collectStatus || {}
|
collectStatus: item.collectStatus || {},
|
||||||
|
model: item.model || '',
|
||||||
|
proportion: item.proportion || '',
|
||||||
|
resolution: item.resolution || '',
|
||||||
|
quantity: item.quantity || 1
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return temp
|
return temp
|
||||||
|
|
@ -237,12 +244,13 @@ const fetchHistory = async (isLoadMore = false) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll = (event) => {
|
const handleScroll = (scrollTop, scrollInfo) => {
|
||||||
if (isInitializing.value) return
|
if (isInitializing.value) return
|
||||||
|
|
||||||
const { distanceToPageTop, distanceToPageBottom, isAtPageTop, isAtPageBottom } = event
|
if (!scrollInfo) return
|
||||||
|
const { isAtTop, isAtBottom, distanceToTop, distanceToBottom } = scrollInfo
|
||||||
|
|
||||||
if (isAtPageTop && !isLoading.value && !isLoadingMoreLocked.value && hasMoreData.value) {
|
if (isAtTop && !isLoading.value && !isLoadingMoreLocked.value && hasMoreData.value) {
|
||||||
isLoadingMoreLocked.value = true
|
isLoadingMoreLocked.value = true
|
||||||
fetchHistory(true)
|
fetchHistory(true)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -250,24 +258,22 @@ const handleScroll = (event) => {
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAtPageBottom) {
|
if (isAtBottom) {
|
||||||
useDisplay.Sender_variant = 'updown'
|
useDisplay.Sender_variant = 'updown'
|
||||||
} else if (distanceToPageTop >= 350) {
|
} else if (distanceToTop >= 350) {
|
||||||
useDisplay.Sender_variant = 'default'
|
useDisplay.Sender_variant = 'default'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCanvas = (data) => {
|
const handleVisibleChange = (startIndex, endIndex) => {
|
||||||
if (typeof data === 'string') {
|
|
||||||
canvasImage.value = data
|
|
||||||
canvasReferenceImages.value = []
|
|
||||||
canvasSource.value = ''
|
|
||||||
} else {
|
|
||||||
canvasImage.value = data.mainImage?.url || data.mainImage || ''
|
|
||||||
canvasReferenceImages.value = data.referenceImages || []
|
|
||||||
canvasSource.value = data.source || ''
|
|
||||||
}
|
}
|
||||||
canvasVisible.value = true
|
|
||||||
|
const handleScrollStart = () => {}
|
||||||
|
|
||||||
|
const handleScrollEnd = () => {}
|
||||||
|
|
||||||
|
const openCanvas = (data) => {
|
||||||
|
useDisplay.openCanvas(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCanvasSend = (data) => {
|
const handleCanvasSend = (data) => {
|
||||||
|
|
@ -285,6 +291,10 @@ const handleExit = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeleteSuccess = (id) => {
|
||||||
|
useDisplay.deleteHistoryItem(id)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!props.loading) return
|
if (!props.loading) return
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,27 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import display from './display/index.vue'
|
import display from './display/index.vue'
|
||||||
import Canvas from '@/components/canvas/index.vue'
|
import { useDisplayStore } from '@/stores'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const useDisplay = useDisplayStore()
|
||||||
|
const dialogBoxRef = ref(null)
|
||||||
|
|
||||||
const shouldShowDisplay = computed(() => route.path === '/home')
|
const shouldShowDisplay = computed(() => route.path === '/home')
|
||||||
const loading = computed(() => route.query.loading ? false : (route.path === '/home'))
|
const loading = computed(() => route.query.loading ? false : (route.path === '/home'))
|
||||||
const Generate = computed(() => route.query.Generate || false)
|
const Generate = computed(() => route.query.Generate || false)
|
||||||
const type = computed(() => route.query.type || 'painting')
|
const type = computed(() => route.query.type || 'painting')
|
||||||
console.log(type.value)
|
console.log(type.value)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
useDisplay.setDialogBoxRef(dialogBoxRef.value)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<dialogBox :is-generate="shouldShowDisplay" :type="type" :generate="Generate" :loading="loading" @open-canvas="handleOpenCanvas" />
|
<dialogBox ref="dialogBoxRef" :is-generate="shouldShowDisplay" :type="type" :generate="Generate" :loading="loading" />
|
||||||
|
|
||||||
<display :if="shouldShowDisplay" :type="type" :loading="loading" />
|
<display :if="shouldShowDisplay" :type="type" :loading="loading" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue