优化滚动 组件

This commit is contained in:
王佑琳 2026-03-27 19:01:00 +08:00
parent 6ff21efb5f
commit ff4ae2bdc8
13 changed files with 1505 additions and 591 deletions

1
auto-imports.d.ts vendored
View File

@ -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

3
components.d.ts vendored
View File

@ -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']
} }
} }

View File

@ -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 })
}

View File

@ -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) => {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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
} }
} }

View File

@ -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()

View File

@ -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 {

View File

@ -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

View File

@ -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>