Compare commits
4 Commits
70529ccd47
...
ff4ae2bdc8
| Author | SHA1 | Date |
|---|---|---|
|
|
ff4ae2bdc8 | |
|
|
6ff21efb5f | |
|
|
57c8863113 | |
|
|
3009e22d12 |
|
|
@ -8,6 +8,7 @@ export {}
|
|||
declare global {
|
||||
const EffectScope: typeof import('vue').EffectScope
|
||||
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 acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const computed: typeof import('vue').computed
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ export {}
|
|||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
2: typeof import('./src/components/virtual-scroller/VirtualScroller copy 2.vue')['default']
|
||||
Canvas: typeof import('./src/components/canvas/index.vue')['default']
|
||||
copy: typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default']
|
||||
DialogBox: typeof import('./src/components/dialogBox/index.vue')['default']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
|
|
@ -34,5 +36,7 @@ declare module 'vue' {
|
|||
Select: typeof import('./src/components/Select/index.vue')['default']
|
||||
Time: typeof import('./src/components/dialogBox/Time/index.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']
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,5 +7,10 @@ export function getGenerateHistoryList(query) {
|
|||
|
||||
// 取消或收藏
|
||||
export function cancelOrCollect(query) {
|
||||
return service.post('/collect/toggle', query)
|
||||
return service.post('/collect/toggle', null, { params: query })
|
||||
}
|
||||
|
||||
// 删除生成历史
|
||||
export function deleteGenerateHistory(query) {
|
||||
return service.delete('/taskRecordHistory/delete', { params: query })
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="36" rx="10" fill="#F8F9FA"/>
|
||||
<path d="M20.25 15H22.5H24C25.6569 15 27 16.3431 27 18C27 19.6569 25.6569 21 24 21H22.5H20.25M15.75 15H13.5H12C10.3431 15 9 16.3431 9 18C9 19.6569 10.3431 21 12 21H13.5H15.75" stroke="#666666" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M16 18H20" stroke="#666666" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M20.25 15H22.5H24C25.6569 15 27 16.3431 27 18C27 19.6569 25.6569 21 24 21H22.5H20.25M15.75 15H13.5H12C10.3431 15 9 16.3431 9 18C9 19.6569 10.3431 21 12 21H13.5H15.75" stroke="#BBBBBB" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 475 B After Width: | Height: | Size: 340 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.25 15H22.5H24C25.6569 15 27 16.3431 27 18C27 19.6569 25.6569 21 24 21H22.5H20.25M15.75 15H13.5H12C10.3431 15 9 16.3431 9 18C9 19.6569 10.3431 21 12 21H13.5H15.75" stroke="#666666" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M16 18H20" stroke="#666666" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.1494 2.5C13.039 2.5 14.4998 3.96763 14.5 5.88477C14.5 7.06494 13.9819 8.17537 12.9678 9.4209C11.9479 10.6736 10.4752 12.006 8.64648 13.6387C8.63893 13.6454 8.63116 13.652 8.62402 13.6592L8.35547 13.9307C8.15984 14.1281 7.84016 14.1281 7.64453 13.9307L7.37598 13.6592L7.35352 13.6387L6.05078 12.4668C4.81731 11.3444 3.79719 10.3604 3.03223 9.4209C2.01813 8.17537 1.5 7.06494 1.5 5.88477C1.50024 3.9673 2.9594 2.5 4.84863 2.5C5.88613 2.5 6.93824 2.99756 7.61523 3.80469C7.71024 3.91796 7.85021 3.9834 7.99805 3.9834C8.14588 3.9834 8.28586 3.91796 8.38086 3.80469C9.05777 2.99766 10.1098 2.5 11.1494 2.5Z" fill="#FF4D4F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 736 B |
|
|
@ -35,7 +35,7 @@
|
|||
<slot name="option" :option="option" :selected="option.value === selectedValue">
|
||||
<div class="option-content">
|
||||
<span class="option-label">{{ option.label }}</span>
|
||||
<span v-if="option.value === selectedValue" class="option-check">✓</span>
|
||||
<!-- <span v-if="option.value === selectedValue" class="option-check">✓</span> -->
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
<slot name="option" :option="option" :selected="option.value === selectedValue">
|
||||
<div class="option-content">
|
||||
<span class="option-label">{{ option.label }}</span>
|
||||
<span v-if="option.value === selectedValue" class="option-check">✓</span>
|
||||
<!-- <span v-if="option.value === selectedValue" class="option-check">✓</span> -->
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
|
@ -313,7 +313,6 @@ onBeforeUnmount(() => {
|
|||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
color: #666;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
|
|
@ -324,12 +323,14 @@ onBeforeUnmount(() => {
|
|||
|
||||
.dropdown-item:hover {
|
||||
color: #333333;
|
||||
background-color: #f5f6f7;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.dropdown-item.selected {
|
||||
color: #000F33;
|
||||
color: #333;
|
||||
font-weight: 400;
|
||||
background-color: #F8F9FA;
|
||||
background-color: rgba(0, 15, 51, 0.10);
|
||||
border-radius: 10px;
|
||||
|
||||
.option-label {
|
||||
|
|
@ -348,13 +349,13 @@ onBeforeUnmount(() => {
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
.option-check {
|
||||
/* .option-check {
|
||||
color: #333333;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
margin-left: 12px;
|
||||
animation: checkIn 0.2s ease;
|
||||
}
|
||||
} */
|
||||
|
||||
@keyframes checkIn {
|
||||
from {
|
||||
|
|
|
|||
|
|
@ -47,11 +47,11 @@ const quantityOptions = [
|
|||
height: 40px;
|
||||
padding: 0 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
background: #ffffff;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
|
||||
&:hover {
|
||||
background: #E5E7EB;
|
||||
background: #e9eaeb;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
</div>
|
||||
<div v-if="localPreviewList.length < limit" class="upload-trigger" @click="triggerUpload">
|
||||
<i-ep-plus color="#333333" />
|
||||
<span>参考内容</span>
|
||||
<div class="upload-text">参考内容</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-upload
|
||||
|
|
@ -264,4 +264,13 @@ defineExpose({
|
|||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
color: #666;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
<div v-else class="gerenate" :class="{ isprompt: prompt }" @click="handleStart">
|
||||
<img v-if="!prompt" src="@/assets/dialog/darkArrow.svg" alt="" />
|
||||
<img v-else src="@/assets/dialog/writerArrow.svg" alt="" />
|
||||
<div v-show="useDisplay.Sender_variant !== 'default'">生成</div>
|
||||
<div v-show="useDisplay.Sender_variant !== 'default'">发送</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -75,7 +75,6 @@ const props = defineProps({
|
|||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open-canvas'])
|
||||
const router = useRouter()
|
||||
const useDisplay = useDisplayStore()
|
||||
|
||||
|
|
@ -122,6 +121,20 @@ const handleStart = async () => {
|
|||
imgs.push({ name: `image_${index + 1}`, url: img.url })
|
||||
})
|
||||
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 = {
|
||||
AIGC: 'Painting',
|
||||
platform: 'runninghub',
|
||||
|
|
@ -133,12 +146,32 @@ const handleStart = async () => {
|
|||
{ name: 'aspect_ratio', data: proportion.value},
|
||||
{ name: 'resolution', data: resolution.value},
|
||||
],
|
||||
imgs
|
||||
imgs,
|
||||
result
|
||||
}
|
||||
await generate(modelType.value, data)
|
||||
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 = () => {
|
||||
if (useDisplay.Sender_variant === 'default') {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
|
|
@ -151,7 +184,7 @@ const handleScrollToBottom = () => {
|
|||
}
|
||||
|
||||
const handleOpenCanvas = (data) => {
|
||||
emit('open-canvas', data)
|
||||
useDisplay.openCanvas(data)
|
||||
}
|
||||
|
||||
watch(() => useDisplay.isSubGerenate, (newValue) => {
|
||||
|
|
@ -173,9 +206,9 @@ onMounted(() => {
|
|||
<style lang="less" scoped>
|
||||
/* 输入区域 */
|
||||
.input-container {
|
||||
width: 760px;
|
||||
width: 880px;
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
bottom: 30px;
|
||||
z-index: 100;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
|
@ -220,11 +253,13 @@ onMounted(() => {
|
|||
bottom: 0;
|
||||
left: 0;
|
||||
width: 80%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
gap: 16px;
|
||||
padding-left: 20px;
|
||||
|
||||
.reference-diagram {
|
||||
display: flex;
|
||||
|
|
@ -238,7 +273,7 @@ onMounted(() => {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 40px;
|
||||
// gap: 40px;
|
||||
position: relative;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
|
@ -270,9 +305,10 @@ onMounted(() => {
|
|||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: normal;
|
||||
margin-bottom: 106px;
|
||||
}
|
||||
:deep(.el-sender){
|
||||
background-color: #F8F9FA;
|
||||
background-color: #F5F6F7;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,11 +110,11 @@ const getModelType = (value) => {
|
|||
height: 40px;
|
||||
padding: 0 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
background: #ffffff;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
|
||||
&:hover {
|
||||
background: #E5E7EB;
|
||||
background: #e9eaeb;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,11 +57,11 @@ const selectedIcon = computed(() => {
|
|||
height: 40px;
|
||||
padding: 0 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
background: #ffffff;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
|
||||
&:hover {
|
||||
background: #E5E7EB;
|
||||
background: #e9eaeb;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,12 +37,15 @@
|
|||
<div class="size-inputs">
|
||||
<div class="input-group">
|
||||
<label>W</label>
|
||||
<input type="number" v-model.number="width" @input="updateWidth">
|
||||
<input type="number" v-model.number="width" @input="updateWidth" :disabled="isLocked">
|
||||
</div>
|
||||
<div class="lock-icon" :class="{ locked: isLocked }" @click="toggleLock">
|
||||
<img :src="isLocked ? lockIcon : lockNoIcon" alt="约束比例">
|
||||
<span class="tooltip">{{ isLocked ? '解绑比例' : '约束比例' }}</span>
|
||||
</div>
|
||||
<div class="lock-icon"><img src="@/assets/dialog/lock.svg" alt=""></div>
|
||||
<div class="input-group">
|
||||
<label>H</label>
|
||||
<input type="number" v-model.number="height" @input="updateHeight">
|
||||
<input type="number" v-model.number="height" @input="updateHeight" :disabled="isLocked">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -59,6 +62,8 @@
|
|||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Popover from '@/components/Popover/index.vue'
|
||||
import lockIcon from '@/assets/dialog/lock.svg'
|
||||
import lockNoIcon from '@/assets/dialog/lockNo.svg'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
|
|
@ -107,6 +112,11 @@ const resolutionOptions = [
|
|||
|
||||
const width = ref(2048)
|
||||
const height = ref(2048)
|
||||
const isLocked = ref(true)
|
||||
|
||||
const toggleLock = () => {
|
||||
isLocked.value = !isLocked.value
|
||||
}
|
||||
|
||||
const selectProportion = (value) => {
|
||||
proportion.value = value
|
||||
|
|
@ -166,7 +176,7 @@ const updateDimensionsByResolution = (resolutionValue) => {
|
|||
}
|
||||
|
||||
const updateWidth = () => {
|
||||
if (proportion.value !== '智能') {
|
||||
if (isLocked.value && proportion.value !== '智能') {
|
||||
const [w, h] = proportion.value.split(':').map(Number)
|
||||
const aspectRatio = w / h
|
||||
height.value = Math.round(width.value / aspectRatio)
|
||||
|
|
@ -175,7 +185,7 @@ const updateWidth = () => {
|
|||
}
|
||||
|
||||
const updateHeight = () => {
|
||||
if (proportion.value !== '智能') {
|
||||
if (isLocked.value && proportion.value !== '智能') {
|
||||
const [w, h] = proportion.value.split(':').map(Number)
|
||||
const aspectRatio = w / h
|
||||
width.value = Math.round(height.value * aspectRatio)
|
||||
|
|
@ -226,13 +236,13 @@ watch(() => [props.modelValue, props.resolution], () => {
|
|||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
background: #ffffff;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.choice-btn:hover{
|
||||
background: #E5E7EB;
|
||||
background: #e9eaeb;
|
||||
}
|
||||
|
||||
.proportion-container{
|
||||
|
|
@ -321,7 +331,7 @@ watch(() => [props.modelValue, props.resolution], () => {
|
|||
font-size: 14px;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
background: #f5f5f5;
|
||||
// background: #f5f5f5;
|
||||
color: #666;
|
||||
|
||||
&:hover{
|
||||
|
|
@ -357,27 +367,88 @@ watch(() => [props.modelValue, props.resolution], () => {
|
|||
|
||||
input{
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 12px 12px 12px 30px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: #f9f9f9;
|
||||
background: #f5f6f7;
|
||||
text-align: right;
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:focus{
|
||||
outline: none;
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
&:disabled{
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lock-icon{
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
img{
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.tooltip{
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tooltip::after{
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: #333;
|
||||
}
|
||||
|
||||
&:hover{
|
||||
color: #666;
|
||||
opacity: 0.8;
|
||||
|
||||
.tooltip{
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
&.locked{
|
||||
background: #f5f6f7;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -42,11 +42,11 @@ const quantityOptions = [
|
|||
height: 40px;
|
||||
padding: 0 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
background: #ffffff;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
|
||||
&:hover {
|
||||
background: #E5E7EB;
|
||||
background: #e9eaeb;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,690 +1,379 @@
|
|||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="virtual-scroller"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<div class="virtual-scroller" :style="containerStyle">
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
class="virtual-scroller-wrapper"
|
||||
:style="wrapperStyle"
|
||||
ref="scrollContainerRef"
|
||||
class="virtual-scroller-container"
|
||||
:style="scrollContainerStyle"
|
||||
@scroll.passive="handleScroll"
|
||||
>
|
||||
<div class="virtual-scroller-spacer" :style="spacerStyle"></div>
|
||||
<div class="virtual-scroller-placeholder" :style="placeholderStyle">
|
||||
<slot name="bottom-placeholder" />
|
||||
</div>
|
||||
<div
|
||||
ref="renderContainerRef"
|
||||
class="virtual-scroller-render-container"
|
||||
:style="renderContainerStyle"
|
||||
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"
|
||||
>
|
||||
<div class="virtual-scroller-spacer" :style="{ height: `${totalSize}px` }"></div>
|
||||
<div
|
||||
class="virtual-scroller-bottom-placeholder"
|
||||
:style="bottomPlaceholderStyle"
|
||||
>
|
||||
<slot name="bottom-placeholder" />
|
||||
</div>
|
||||
<div
|
||||
v-for="renderItem in pool"
|
||||
:key="renderItem.nr.key"
|
||||
:ref="el => setItemRef(el, renderItem.nr.key)"
|
||||
class="virtual-scroller-item"
|
||||
:style="getItemStyle(renderItem)"
|
||||
:data-index="renderItem.nr.index"
|
||||
:data-key="renderItem.nr.key"
|
||||
>
|
||||
<slot
|
||||
name="default"
|
||||
:item="renderItem.item"
|
||||
:index="renderItem.nr.index"
|
||||
/>
|
||||
</div>
|
||||
<slot name="default" :item="item.data" :index="item.index" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowReactive, watch, markRaw } from 'vue'
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, type CSSProperties } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
itemKey: {
|
||||
type: [String, Function],
|
||||
default: 'id'
|
||||
},
|
||||
estimatedHeight: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
minItemSize: {
|
||||
type: [Number, String],
|
||||
default: null
|
||||
},
|
||||
buffer: {
|
||||
type: Number,
|
||||
default: 200
|
||||
},
|
||||
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 emit = defineEmits(['scroll', 'scroll-start', 'scroll-end', 'resize', 'update'])
|
||||
|
||||
const containerRef = ref(null)
|
||||
const wrapperRef = ref(null)
|
||||
const renderContainerRef = ref(null)
|
||||
const itemRefs = new Map()
|
||||
const resizeObserver = ref(null)
|
||||
const scrollTop = ref(0)
|
||||
const isScrolling = ref(false)
|
||||
const scrollTimeout = ref(null)
|
||||
const isInitialized = ref(false)
|
||||
const pendingScrollToBottom = ref(false)
|
||||
|
||||
let uid = 0
|
||||
const pool = ref([])
|
||||
const viewMap = new Map()
|
||||
const unusedViews = new Map()
|
||||
const itemSizeMap = ref(new Map())
|
||||
const totalSize = ref(0)
|
||||
|
||||
let $_startIndex = 0
|
||||
let $_endIndex = 0
|
||||
let $_scrollDirty = false
|
||||
let $_lastUpdateScrollPosition = 0
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
return {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}
|
||||
})
|
||||
|
||||
const getItemKey = (item, index) => {
|
||||
if (typeof props.itemKey === 'function') {
|
||||
return props.itemKey(item, index)
|
||||
}
|
||||
if (typeof props.itemKey === 'string' && item && typeof item === 'object') {
|
||||
return item[props.itemKey] ?? index
|
||||
}
|
||||
return index
|
||||
interface VirtualScrollerProps {
|
||||
items: any[]
|
||||
estimatedHeight?: number
|
||||
bufferSize?: number
|
||||
keyField?: string
|
||||
height?: string | number
|
||||
direction?: 'normal' | 'reverse'
|
||||
}
|
||||
|
||||
const minSize = computed(() => {
|
||||
if (props.minItemSize) {
|
||||
return typeof props.minItemSize === 'string' ? parseInt(props.minItemSize, 10) : props.minItemSize
|
||||
}
|
||||
return props.estimatedHeight || 50
|
||||
})
|
||||
|
||||
const sizes = computed(() => {
|
||||
const sizesMap = {
|
||||
'-1': { accumulator: 0 }
|
||||
}
|
||||
const items = props.data
|
||||
let accumulator = 0
|
||||
let computedMinSize = 10000
|
||||
|
||||
for (let i = 0, l = items.length; i < l; i++) {
|
||||
const key = getItemKey(items[i], i)
|
||||
let size = itemSizeMap.value.get(key)
|
||||
|
||||
if (size === undefined) {
|
||||
size = minSize.value
|
||||
}
|
||||
|
||||
if (size < computedMinSize) {
|
||||
computedMinSize = size
|
||||
}
|
||||
|
||||
accumulator += size
|
||||
sizesMap[i] = { accumulator, size }
|
||||
}
|
||||
|
||||
return sizesMap
|
||||
})
|
||||
|
||||
const wrapperStyle = computed(() => ({
|
||||
direction: 'rtl',
|
||||
const props = withDefaults(defineProps<VirtualScrollerProps>(), {
|
||||
estimatedHeight: 100,
|
||||
bufferSize: 3,
|
||||
keyField: 'id',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
scrollbarWidth: 'auto',
|
||||
overflow: 'hidden',
|
||||
transform: 'rotate(180deg)',
|
||||
width: '100%'
|
||||
direction: 'reverse'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
scroll: [scrollTop: number, scrollInfo: {
|
||||
isAtTop: boolean
|
||||
isAtBottom: boolean
|
||||
distanceToTop: number
|
||||
distanceToBottom: number
|
||||
}]
|
||||
'visible-change': [startIndex: number, endIndex: number]
|
||||
}>()
|
||||
|
||||
const scrollContainerRef = ref<HTMLElement | null>(null)
|
||||
const scrollTop = ref(0)
|
||||
const itemRefs = new Map<string, HTMLElement>()
|
||||
const isReverseMode = computed(() => props.direction === 'reverse')
|
||||
const isInitializing = ref(true)
|
||||
|
||||
const itemHeights = ref(new Map<string | number, number>())
|
||||
const itemOffsets = ref(new Map<string | number, number>())
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
function getItemKey(item: any, index: number): string | number {
|
||||
return item[props.keyField] ?? index
|
||||
}
|
||||
|
||||
function getItemHeight(key: string | number): number {
|
||||
return itemHeights.value.get(key) ?? props.estimatedHeight
|
||||
}
|
||||
|
||||
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(() => {
|
||||
let height = 0
|
||||
for (let i = 0; i < props.items.length; i++) {
|
||||
const key = getItemKey(props.items[i], i)
|
||||
height += getItemHeight(key)
|
||||
}
|
||||
return height
|
||||
})
|
||||
|
||||
const containerStyle = computed<CSSProperties>(() => ({
|
||||
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
|
||||
overflow: 'hidden'
|
||||
}))
|
||||
|
||||
const renderContainerStyle = computed(() => ({
|
||||
direction: 'ltr',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
const scrollContainerStyle = computed<CSSProperties>(() => ({
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
transform: 'rotate(180deg)',
|
||||
position: 'relative'
|
||||
}))
|
||||
|
||||
const spacerStyle = computed<CSSProperties>(() => ({
|
||||
height: `${totalHeight.value}px`,
|
||||
width: '1px',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0
|
||||
}))
|
||||
|
||||
const placeholderStyle = computed<CSSProperties>(() => ({
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%'
|
||||
pointerEvents: 'none'
|
||||
}))
|
||||
|
||||
const bottomPlaceholderStyle = computed(() => ({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: `${props.bottomPlaceholderHeight}px`,
|
||||
transform: `translateY(0px)`,
|
||||
zIndex: 1
|
||||
}))
|
||||
const visibleRange = computed(() => {
|
||||
if (!scrollContainerRef.value || props.items.length === 0) {
|
||||
return { start: 0, end: Math.min(props.bufferSize * 2, props.items.length - 1) }
|
||||
}
|
||||
|
||||
const getItemStyle = (renderItem) => {
|
||||
const containerHeight = scrollContainerRef.value.clientHeight
|
||||
const scrollTopValue = scrollTop.value
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = props.items.length - 1
|
||||
let currentOffset = 0
|
||||
|
||||
for (let i = 0; i < props.items.length; i++) {
|
||||
const key = getItemKey(props.items[i], i)
|
||||
const itemHeight = getItemHeight(key)
|
||||
|
||||
if (currentOffset + itemHeight > scrollTopValue - props.bufferSize * props.estimatedHeight) {
|
||||
startIndex = Math.max(0, i - props.bufferSize)
|
||||
break
|
||||
}
|
||||
currentOffset += itemHeight
|
||||
}
|
||||
|
||||
currentOffset = 0
|
||||
for (let i = 0; i < props.items.length; i++) {
|
||||
const key = getItemKey(props.items[i], i)
|
||||
const itemHeight = getItemHeight(key)
|
||||
|
||||
if (currentOffset > scrollTopValue + containerHeight + props.bufferSize * props.estimatedHeight) {
|
||||
endIndex = Math.min(props.items.length - 1, i + props.bufferSize)
|
||||
break
|
||||
}
|
||||
currentOffset += itemHeight
|
||||
}
|
||||
|
||||
return { start: startIndex, end: endIndex }
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
const { start, end } = visibleRange.value
|
||||
const items: Array<{ key: string | number; data: any; index: number; offset: number }> = []
|
||||
const currentRenderedKeys = new Set<string | number>()
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
const item = props.items[i]
|
||||
if (!item) continue
|
||||
|
||||
const key = getItemKey(item, i)
|
||||
|
||||
if (currentRenderedKeys.has(key)) {
|
||||
continue
|
||||
}
|
||||
currentRenderedKeys.add(key)
|
||||
|
||||
const offset = itemOffsets.value.get(key) ?? i * props.estimatedHeight
|
||||
|
||||
items.push({
|
||||
key: String(key),
|
||||
data: item,
|
||||
index: i,
|
||||
offset
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
function getItemStyle(item: { offset: number }): CSSProperties {
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: `${item.offset}px`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${renderItem.position}px)`,
|
||||
willChange: 'transform'
|
||||
right: 0
|
||||
}
|
||||
}
|
||||
|
||||
const setItemRef = (el, key) => {
|
||||
function setItemRef(el: any, key: string) {
|
||||
if (el) {
|
||||
itemRefs.set(key, el)
|
||||
itemRefs.set(key, el as HTMLElement)
|
||||
} else {
|
||||
itemRefs.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const addView = (index, item, key) => {
|
||||
const nr = markRaw({
|
||||
id: uid++,
|
||||
index,
|
||||
used: true,
|
||||
key
|
||||
})
|
||||
const view = shallowReactive({
|
||||
item,
|
||||
position: 0,
|
||||
nr
|
||||
})
|
||||
pool.value.push(view)
|
||||
return view
|
||||
}
|
||||
|
||||
const unuseView = (view) => {
|
||||
view.nr.used = false
|
||||
view.position = -9999
|
||||
}
|
||||
|
||||
const getScroll = () => {
|
||||
const el = renderContainerRef.value
|
||||
if (!el) return { start: 0, end: 0 }
|
||||
function measureItems() {
|
||||
if (!resizeObserver) return
|
||||
|
||||
return {
|
||||
start: el.scrollTop,
|
||||
end: el.scrollTop + el.clientHeight
|
||||
}
|
||||
}
|
||||
|
||||
const updateVisibleItems = (checkItem = false, checkPositionDiff = false) => {
|
||||
const items = props.data
|
||||
const count = items.length
|
||||
const sizesMap = sizes.value
|
||||
const keyField = typeof props.itemKey === 'string' ? props.itemKey : 'id'
|
||||
|
||||
if (!count) {
|
||||
pool.value = []
|
||||
viewMap.clear()
|
||||
totalSize.value = props.bottomPlaceholderHeight
|
||||
return { continuous: true }
|
||||
}
|
||||
|
||||
const el = renderContainerRef.value
|
||||
if (!el) return { continuous: true }
|
||||
|
||||
const scrollHeight = el.scrollHeight
|
||||
const scrollTop = el.scrollTop
|
||||
const clientHeight = el.clientHeight
|
||||
|
||||
const visualScrollStart = scrollHeight - scrollTop - clientHeight
|
||||
const visualScrollEnd = scrollHeight - scrollTop
|
||||
|
||||
if (checkPositionDiff) {
|
||||
let positionDiff = visualScrollStart - $_lastUpdateScrollPosition
|
||||
if (positionDiff < 0) positionDiff = -positionDiff
|
||||
if (positionDiff < minSize.value) {
|
||||
return { continuous: true }
|
||||
}
|
||||
}
|
||||
|
||||
$_lastUpdateScrollPosition = visualScrollStart
|
||||
|
||||
const buffer = props.buffer
|
||||
let scrollStart = visualScrollStart - buffer
|
||||
let scrollEnd = visualScrollEnd + buffer
|
||||
|
||||
scrollStart -= props.bottomPlaceholderHeight
|
||||
scrollEnd += props.bottomPlaceholderHeight
|
||||
|
||||
scrollStart = Math.max(0, scrollStart)
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = count - 1
|
||||
let newTotalSize = 0
|
||||
|
||||
let a = 0
|
||||
let b = count - 1
|
||||
let i = ~~(count / 2)
|
||||
let oldI
|
||||
|
||||
do {
|
||||
oldI = i
|
||||
const h = sizesMap[i]?.accumulator || 0
|
||||
if (h < scrollStart) {
|
||||
a = i
|
||||
} else if (i < count - 1 && (sizesMap[i + 1]?.accumulator || 0) > scrollStart) {
|
||||
b = i
|
||||
}
|
||||
i = ~~((a + b) / 2)
|
||||
} while (i !== oldI)
|
||||
|
||||
i < 0 && (i = 0)
|
||||
startIndex = i
|
||||
|
||||
newTotalSize = sizesMap[count - 1]?.accumulator || count * minSize.value
|
||||
|
||||
for (endIndex = i; endIndex < count && (sizesMap[endIndex]?.accumulator || 0) < scrollEnd; endIndex++);
|
||||
|
||||
if (endIndex === -1) {
|
||||
endIndex = count - 1
|
||||
} else {
|
||||
endIndex++
|
||||
endIndex > count && (endIndex = count)
|
||||
}
|
||||
|
||||
totalSize.value = newTotalSize + props.bottomPlaceholderHeight
|
||||
|
||||
const continuous = startIndex <= $_endIndex && endIndex >= $_startIndex
|
||||
|
||||
if (continuous) {
|
||||
for (let j = 0, l = pool.value.length; j < l; j++) {
|
||||
const view = pool.value[j]
|
||||
if (view.nr.used) {
|
||||
if (checkItem) {
|
||||
const newKey = getItemKey(view.item, view.nr.index)
|
||||
let newIndex = -1
|
||||
for (let k = 0; k < count; k++) {
|
||||
if (getItemKey(items[k], k) === newKey) {
|
||||
newIndex = k
|
||||
break
|
||||
}
|
||||
}
|
||||
view.nr.index = newIndex
|
||||
}
|
||||
|
||||
if (
|
||||
view.nr.index == null ||
|
||||
view.nr.index < startIndex ||
|
||||
view.nr.index >= endIndex
|
||||
) {
|
||||
unuseView(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let j = startIndex; j < endIndex; j++) {
|
||||
const item = items[j]
|
||||
if (!item) continue
|
||||
|
||||
const key = getItemKey(item, j)
|
||||
let view = viewMap.get(key)
|
||||
|
||||
if (!view) {
|
||||
view = addView(j, item, key)
|
||||
viewMap.set(key, view)
|
||||
} else {
|
||||
if (!view.nr.used) {
|
||||
view.nr.used = true
|
||||
}
|
||||
}
|
||||
|
||||
view.item = item
|
||||
view.nr.index = j
|
||||
view.nr.key = key
|
||||
|
||||
const prevSize = j > 0 ? sizesMap[j - 1] : { accumulator: 0 }
|
||||
view.position = (prevSize?.accumulator || 0) + props.bottomPlaceholderHeight
|
||||
}
|
||||
|
||||
pool.value = pool.value.filter(v => v.nr.used)
|
||||
|
||||
$_startIndex = startIndex
|
||||
$_endIndex = endIndex
|
||||
|
||||
emit('update', startIndex, endIndex)
|
||||
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
})
|
||||
|
||||
return { continuous }
|
||||
}
|
||||
|
||||
const measureItem = (key, element) => {
|
||||
if (!element) return
|
||||
|
||||
const firstChild = element.firstElementChild
|
||||
const targetElement = firstChild || element
|
||||
|
||||
const height = targetElement.getBoundingClientRect().height
|
||||
|
||||
if (height > 0) {
|
||||
const currentSize = itemSizeMap.value.get(key)
|
||||
if (currentSize !== height) {
|
||||
const newSizes = new Map(itemSizeMap.value)
|
||||
newSizes.set(key, height)
|
||||
itemSizeMap.value = newSizes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setupResizeObserver = () => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
}
|
||||
|
||||
resizeObserver.value = new ResizeObserver((entries) => {
|
||||
let hasChanges = false
|
||||
for (const entry of entries) {
|
||||
const key = entry.target.dataset.key
|
||||
if (key !== undefined) {
|
||||
const oldSize = itemSizeMap.value.get(key)
|
||||
measureItem(key, entry.target)
|
||||
const newSize = itemSizeMap.value.get(key)
|
||||
if (oldSize !== newSize) {
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
updateVisibleItems(false)
|
||||
}
|
||||
itemRefs.forEach((el, key) => {
|
||||
resizeObserver!.observe(el)
|
||||
})
|
||||
}
|
||||
|
||||
const handleWheel = (event) => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
const { deltaY } = event
|
||||
const el = renderContainerRef.value
|
||||
|
||||
el.scrollBy({
|
||||
top: -deltaY,
|
||||
behavior: 'instant'
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleScroll = (event) => {
|
||||
if (!$_scrollDirty) {
|
||||
$_scrollDirty = true
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
$_scrollDirty = false
|
||||
updateVisibleItems(false, true)
|
||||
})
|
||||
}
|
||||
|
||||
const target = event.target
|
||||
scrollTop.value = target.scrollTop
|
||||
isScrolling.value = true
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
scrollTimeout.value = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, 150)
|
||||
|
||||
const st = target.scrollTop
|
||||
const scrollHeight = target.scrollHeight
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
const distanceToContainerTop = st
|
||||
const distanceToContainerBottom = scrollHeight - st - clientHeight
|
||||
|
||||
const distanceToPageTop = distanceToContainerBottom
|
||||
const distanceToPageBottom = distanceToContainerTop
|
||||
const isAtPageTop = distanceToPageTop <= 0
|
||||
const isAtPageBottom = distanceToPageBottom <= 0
|
||||
|
||||
emit('scroll', {
|
||||
target,
|
||||
scrollTop: st,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
distanceToPageTop,
|
||||
distanceToPageBottom,
|
||||
isAtPageTop,
|
||||
isAtPageBottom
|
||||
})
|
||||
|
||||
if (isAtPageTop) {
|
||||
emit('scroll-start')
|
||||
}
|
||||
|
||||
if (isAtPageBottom) {
|
||||
emit('scroll-end')
|
||||
function updateItemHeight(key: string | number, height: number) {
|
||||
const oldHeight = itemHeights.value.get(key)
|
||||
if (oldHeight !== height) {
|
||||
itemHeights.value.set(key, height)
|
||||
calculateOffsets()
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToIndex = (index, behavior = 'auto') => {
|
||||
if (!renderContainerRef.value || index < 0 || index >= props.data.length) return
|
||||
function handleScroll(event: Event) {
|
||||
const target = event.target as HTMLElement
|
||||
const currentScrollTop = target.scrollTop
|
||||
scrollTop.value = currentScrollTop
|
||||
|
||||
const sizesMap = sizes.value
|
||||
const offset = index > 0 ? (sizesMap[index - 1]?.accumulator || 0) : 0
|
||||
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: offset,
|
||||
behavior
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToBottom = (behavior = 'smooth') => {
|
||||
if (!renderContainerRef.value) {
|
||||
pendingScrollToBottom.value = true
|
||||
if (!scrollContainerRef.value) {
|
||||
emit('scroll', currentScrollTop)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: 0,
|
||||
behavior
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToTop = (behavior = 'smooth') => {
|
||||
if (!renderContainerRef.value) return
|
||||
const containerHeight = scrollContainerRef.value.clientHeight
|
||||
const maxScroll = Math.max(0, totalHeight.value - containerHeight)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
const scrollHeight = renderContainerRef.value.scrollHeight
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: scrollHeight,
|
||||
behavior
|
||||
})
|
||||
const distanceToTop = currentScrollTop
|
||||
const distanceToBottom = maxScroll - currentScrollTop
|
||||
|
||||
const isAtTop = currentScrollTop >= maxScroll - 10
|
||||
const isAtBottom = currentScrollTop <= 10
|
||||
|
||||
emit('scroll', currentScrollTop, {
|
||||
isAtTop,
|
||||
isAtBottom,
|
||||
distanceToTop,
|
||||
distanceToBottom
|
||||
})
|
||||
}
|
||||
|
||||
const getScrollElement = () => renderContainerRef.value
|
||||
|
||||
const getVisibleIndices = () => {
|
||||
const indices = []
|
||||
for (let i = $_startIndex; i < $_endIndex; i++) {
|
||||
indices.push(i)
|
||||
function scrollToIndex(index: number) {
|
||||
if (!scrollContainerRef.value) return
|
||||
|
||||
let targetTop = 0
|
||||
for (let i = 0; i < index; i++) {
|
||||
const key = getItemKey(props.items[i], i)
|
||||
targetTop += getItemHeight(key)
|
||||
}
|
||||
return indices
|
||||
|
||||
scrollContainerRef.value.scrollTop = targetTop
|
||||
scrollTop.value = targetTop
|
||||
}
|
||||
|
||||
const resetMeasurements = () => {
|
||||
itemSizeMap.value = new Map()
|
||||
itemRefs.clear()
|
||||
pool.value = []
|
||||
viewMap.clear()
|
||||
function scrollToTop() {
|
||||
if (!scrollContainerRef.value) return
|
||||
const containerHeight = scrollContainerRef.value.clientHeight
|
||||
const maxScroll = Math.max(0, totalHeight.value - containerHeight)
|
||||
scrollContainerRef.value.scrollTop = maxScroll
|
||||
scrollTop.value = maxScroll
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!scrollContainerRef.value) return
|
||||
scrollContainerRef.value.scrollTop = 0
|
||||
scrollTop.value = 0
|
||||
$_startIndex = 0
|
||||
$_endIndex = 0
|
||||
}
|
||||
|
||||
const isAtPageBottom = () => {
|
||||
if (!renderContainerRef.value) return false
|
||||
const { scrollTop } = renderContainerRef.value
|
||||
return scrollTop <= 0
|
||||
}
|
||||
watch(
|
||||
() => visibleRange.value,
|
||||
(newRange) => {
|
||||
emit('visible-change', newRange.start, newRange.end)
|
||||
nextTick(() => {
|
||||
measureItems()
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const isAtPageTop = () => {
|
||||
if (!renderContainerRef.value) return false
|
||||
const { scrollTop, scrollHeight, clientHeight } = renderContainerRef.value
|
||||
return scrollHeight - scrollTop - clientHeight <= 0
|
||||
}
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
calculateOffsets()
|
||||
nextTick(() => {
|
||||
measureItems()
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const observeVisibleItems = () => {
|
||||
if (!resizeObserver.value) return
|
||||
|
||||
resizeObserver.value.disconnect()
|
||||
|
||||
for (const [key, element] of itemRefs) {
|
||||
if (element) {
|
||||
resizeObserver.value.observe(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.data, () => {
|
||||
itemSizeMap.value = new Map()
|
||||
pool.value = []
|
||||
viewMap.clear()
|
||||
itemRefs.clear()
|
||||
$_startIndex = 0
|
||||
$_endIndex = 0
|
||||
|
||||
nextTick(() => {
|
||||
updateVisibleItems(true)
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
watch(sizes, () => {
|
||||
updateVisibleItems(false)
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
setupResizeObserver()
|
||||
isInitialized.value = true
|
||||
|
||||
nextTick(() => {
|
||||
if (pendingScrollToBottom.value) {
|
||||
pendingScrollToBottom.value = false
|
||||
watch(
|
||||
() => props.items.length,
|
||||
async (newLength, oldLength) => {
|
||||
if (isReverseMode.value && newLength > (oldLength || 0)) {
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
updateVisibleItems(true)
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
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(() => {
|
||||
scrollToBottom()
|
||||
setTimeout(() => {
|
||||
isInitializing.value = false
|
||||
}, 100)
|
||||
})
|
||||
} else {
|
||||
isInitializing.value = false
|
||||
}
|
||||
|
||||
measureItems()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
itemRefs.clear()
|
||||
viewMap.clear()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
scrollToIndex,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
getScrollElement,
|
||||
getVisibleIndices,
|
||||
resetMeasurements,
|
||||
containerRef,
|
||||
isAtPageBottom,
|
||||
isAtPageTop
|
||||
scrollToBottom,
|
||||
getScrollTop: () => scrollTop.value,
|
||||
getVisibleRange: () => visibleRange.value,
|
||||
updateLayout: () => {
|
||||
calculateOffsets()
|
||||
measureItems()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
<style scoped>
|
||||
.virtual-scroller {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.virtual-scroller-wrapper {
|
||||
contain: content;
|
||||
}
|
||||
|
||||
.virtual-scroller-spacer {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-scroller-placeholder {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-scroller-render-container {
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.virtual-scroller-item {
|
||||
contain: layout style;
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.virtual-scroller-bottom-placeholder {
|
||||
contain: layout style;
|
||||
}
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-scroller-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-scroller-spacer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.virtual-scroller-item {
|
||||
will-change: transform;
|
||||
contain: layout style;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ const DisplayStoreSetup = () => {
|
|||
const currentPage = ref(0)
|
||||
const hasMoreData = ref(true)
|
||||
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 newItem = {
|
||||
|
|
@ -49,6 +56,13 @@ const DisplayStoreSetup = () => {
|
|||
hasMoreData.value = true
|
||||
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 refValue = scrollerRef.value
|
||||
|
|
@ -80,6 +94,40 @@ const DisplayStoreSetup = () => {
|
|||
console.error('滚动出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
Sender_variant,
|
||||
|
|
@ -89,13 +137,25 @@ const DisplayStoreSetup = () => {
|
|||
currentPage,
|
||||
hasMoreData,
|
||||
isLoading,
|
||||
currentResultData,
|
||||
dialogBoxRef,
|
||||
canvasVisible,
|
||||
canvasImage,
|
||||
canvasReferenceImages,
|
||||
canvasSource,
|
||||
addGeneratingItem,
|
||||
updateItemToSuccess,
|
||||
initHistoryList,
|
||||
prependHistoryList,
|
||||
appendHistoryList,
|
||||
resetPagination,
|
||||
scrollToBottom
|
||||
scrollToBottom,
|
||||
deleteHistoryItem,
|
||||
setResultData,
|
||||
setDialogBoxRef,
|
||||
triggerGenerateWithResult,
|
||||
fillParamsForEdit,
|
||||
openCanvas
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,8 @@ export async function generate(type, data) {
|
|||
taskId: taskId,
|
||||
text: data.prompt || '生成中...',
|
||||
name: data.prompt || '生成中...',
|
||||
type: type
|
||||
type: type,
|
||||
result: data.result
|
||||
})
|
||||
setTimeout(() => {
|
||||
useDisplay.scrollToBottom()
|
||||
|
|
@ -136,7 +137,6 @@ export async function generate(type, data) {
|
|||
socket.onclose = async (event) => {
|
||||
console.log('WebSocket已关闭:', event)
|
||||
useDisplay.isSubGerenate = false
|
||||
// 清理心跳定时器
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
}
|
||||
|
|
@ -160,8 +160,6 @@ export async function generate(type, data) {
|
|||
} else {
|
||||
websocketError(event.code, event.reason)
|
||||
}
|
||||
// clearInterval(progressInterval.value)
|
||||
// 清理心跳定时器
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,14 +51,14 @@
|
|||
|
||||
<!-- 已完成 -->
|
||||
<div v-if="props.item.status === 'success'" class="box success-box">
|
||||
<div v-for="(file, index) in props.item.files" :key="index" class="one-box">
|
||||
<div v-for="(file, index) in props.item.files" :key="index" class="one-box" :class="{ 'collected': isCollected(file) }" @mouseenter="hoverIndex = index" @mouseleave="hoverIndex = -1">
|
||||
<!-- <img :src="file" alt="index" class="img" /> -->
|
||||
<Img :src="file" alt="index" class="img" />
|
||||
|
||||
<div class="left-top">
|
||||
<div class="left-top-btn" @click="downloadImage(file, 'image')"><img src="@/assets/display/download.svg" /></div>
|
||||
<span class="line" />
|
||||
<div class="left-top-btn" @click="addCollection(file)"><img src="@/assets/display/collection.svg" /></div>
|
||||
<div v-show="hoverIndex === index" class="left-top-btn download-btn" @click="downloadImage(file, 'image')"><img src="@/assets/display/download.svg" /></div>
|
||||
<span v-if="hoverIndex === index" class="line" />
|
||||
<div class="left-top-btn collect-btn" @click="addCollection(file)"><img :src="isCollected(file) ? collectionActiveIcon : collectionIcon" /></div>
|
||||
</div>
|
||||
|
||||
<el-tooltip
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
</div>
|
||||
|
||||
<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" />
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
|
|
@ -86,13 +86,15 @@
|
|||
|
||||
<script setup>
|
||||
import brush from '@/assets/display/brush.svg'
|
||||
import collectionIcon from '@/assets/display/collection.svg'
|
||||
import collectionActiveIcon from '@/assets/display/collection-active.svg'
|
||||
import { useDisplayStore, useParamStore, useUserStore } from '@/stores'
|
||||
import { downloadImage } from '@/utils/downloadImage.js'
|
||||
import reEditIcon from '@/assets/display/reEdit.svg'
|
||||
import againGenerateIcon from '@/assets/display/againGenerate.svg'
|
||||
import deleteImageIcon from '@/assets/display/deleteImage.svg'
|
||||
import Img from '@/components/Img/index.vue'
|
||||
import { cancelOrCollect } from '@/apis/display'
|
||||
import { cancelOrCollect, deleteGenerateHistory } from '@/apis/display'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
|
|
@ -101,12 +103,19 @@ const props = defineProps({
|
|||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open-canvas'])
|
||||
const emit = defineEmits(['open-canvas', 'delete-success'])
|
||||
|
||||
const useDisplay = useDisplayStore()
|
||||
const useParams = useParamStore()
|
||||
const useUser = useUserStore()
|
||||
|
||||
const localCollectStatus = ref({ ...props.item.collectStatus })
|
||||
const hoverIndex = ref(-1)
|
||||
|
||||
const isCollected = (url) => {
|
||||
return localCollectStatus.value[url] === true
|
||||
}
|
||||
|
||||
const generateStatusText = computed(() => {
|
||||
if (props.item.status === 'generate') {
|
||||
return '正在生成中...'
|
||||
|
|
@ -122,19 +131,45 @@ const AIbrush = (file, index) => {
|
|||
})
|
||||
}
|
||||
|
||||
const reEdit = (url, number) => {
|
||||
console.log(number)
|
||||
const reEdit = () => {
|
||||
useDisplay.setResultData(props.item.result)
|
||||
useDisplay.fillParamsForEdit()
|
||||
}
|
||||
|
||||
const againGenerate = (url, number) => {
|
||||
console.log(number)
|
||||
const againGenerate = () => {
|
||||
useDisplay.setResultData(props.item.result)
|
||||
useDisplay.triggerGenerateWithResult()
|
||||
}
|
||||
|
||||
const deleteImage = (url, number) => {
|
||||
console.log(number)
|
||||
const deleteImage = () => {
|
||||
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: '重新编辑',
|
||||
icon: reEditIcon,
|
||||
|
|
@ -150,7 +185,7 @@ const bottomBtnGroup = [
|
|||
icon: deleteImageIcon,
|
||||
click: deleteImage
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
const addCollection = async (url) => {
|
||||
try {
|
||||
|
|
@ -161,6 +196,7 @@ const addCollection = async (url) => {
|
|||
})
|
||||
if (res.success) {
|
||||
ElMessage.success(res.message || '操作成功')
|
||||
localCollectStatus.value[url] = !localCollectStatus.value[url]
|
||||
} else {
|
||||
ElMessage.error(res.message || '操作失败')
|
||||
}
|
||||
|
|
@ -346,6 +382,11 @@ const addCollection = async (url) => {
|
|||
display:flex
|
||||
}
|
||||
}
|
||||
.one-box.collected{
|
||||
.left-top{
|
||||
display:flex
|
||||
}
|
||||
}
|
||||
.success-box{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
|
|
|
|||
|
|
@ -45,16 +45,20 @@
|
|||
<VirtualScroller
|
||||
ref="scrollerRef"
|
||||
v-if="props.if"
|
||||
:data="list"
|
||||
:item-key="'id'"
|
||||
:items="list"
|
||||
key-field="id"
|
||||
:estimated-height="300"
|
||||
:render-mode="'top'"
|
||||
:buffer="2"
|
||||
:buffer-size="3"
|
||||
direction="reverse"
|
||||
class="scroller"
|
||||
@scroll="handleScroll"
|
||||
@visible-change="handleVisibleChange"
|
||||
>
|
||||
<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>
|
||||
</VirtualScroller>
|
||||
</div>
|
||||
|
|
@ -96,10 +100,7 @@ const scrollerRef = ref(null)
|
|||
const isLoadingMoreLocked = ref(false)
|
||||
const activeTab = ref('all')
|
||||
const isInitializing = ref(true)
|
||||
const canvasVisible = ref(false)
|
||||
const canvasImage = ref('')
|
||||
const canvasReferenceImages = ref([])
|
||||
const canvasSource = ref('')
|
||||
const { canvasVisible, canvasImage, canvasReferenceImages, canvasSource } = storeToRefs(useDisplay)
|
||||
|
||||
const chargeType = computed(() => getChargeType(props.type))
|
||||
console.log(chargeType.value)
|
||||
|
|
@ -137,13 +138,20 @@ const conversion = (newlist) => {
|
|||
const temp = newlist.list.map((item) => {
|
||||
const files = item.fileUrl ? item.fileUrl.split(',').filter(url => url.trim()) : []
|
||||
return {
|
||||
id: item.taskId,
|
||||
id: item.id,
|
||||
taskId: item.taskId,
|
||||
collection: item.collection,
|
||||
status: 'success',
|
||||
prompt: item.prompt,
|
||||
params: item.params,
|
||||
result: item.result,
|
||||
time: item.createTime,
|
||||
files: files
|
||||
files: files,
|
||||
collectStatus: item.collectStatus || {},
|
||||
model: item.model || '',
|
||||
proportion: item.proportion || '',
|
||||
resolution: item.resolution || '',
|
||||
quantity: item.quantity || 1
|
||||
}
|
||||
})
|
||||
return temp
|
||||
|
|
@ -236,12 +244,13 @@ const fetchHistory = async (isLoadMore = false) => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleScroll = (event) => {
|
||||
const handleScroll = (scrollTop, scrollInfo) => {
|
||||
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
|
||||
fetchHistory(true)
|
||||
setTimeout(() => {
|
||||
|
|
@ -249,24 +258,22 @@ const handleScroll = (event) => {
|
|||
}, 3000)
|
||||
}
|
||||
|
||||
if (isAtPageBottom) {
|
||||
if (isAtBottom) {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
} else if (distanceToPageTop >= 350) {
|
||||
} else if (distanceToTop >= 350) {
|
||||
useDisplay.Sender_variant = 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const handleVisibleChange = (startIndex, endIndex) => {
|
||||
}
|
||||
|
||||
const handleScrollStart = () => {}
|
||||
|
||||
const handleScrollEnd = () => {}
|
||||
|
||||
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
|
||||
useDisplay.openCanvas(data)
|
||||
}
|
||||
|
||||
const handleCanvasSend = (data) => {
|
||||
|
|
@ -284,6 +291,10 @@ const handleExit = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleDeleteSuccess = (id) => {
|
||||
useDisplay.deleteHistoryItem(id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.loading) return
|
||||
refreshing.value = true
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import display from './display/index.vue'
|
||||
import Canvas from '@/components/canvas/index.vue'
|
||||
import { useDisplayStore } from '@/stores'
|
||||
|
||||
const route = useRoute()
|
||||
const useDisplay = useDisplayStore()
|
||||
const dialogBoxRef = ref(null)
|
||||
|
||||
const shouldShowDisplay = computed(() => route.path === '/home')
|
||||
const loading = computed(() => route.query.loading ? false : (route.path === '/home'))
|
||||
|
|
@ -11,33 +14,14 @@ const Generate = computed(() => route.query.Generate || false)
|
|||
const type = computed(() => route.query.type || 'painting')
|
||||
console.log(type.value)
|
||||
|
||||
const canvasVisible = ref(false)
|
||||
const canvasImage = ref('')
|
||||
const canvasReferenceImages = ref([])
|
||||
const canvasSource = ref('')
|
||||
|
||||
const handleOpenCanvas = (data) => {
|
||||
canvasImage.value = data.mainImage?.url || data.mainImage || ''
|
||||
canvasReferenceImages.value = data.referenceImages || []
|
||||
canvasSource.value = data.source || 'uploader'
|
||||
canvasVisible.value = true
|
||||
}
|
||||
|
||||
const handleCanvasSend = (data) => {
|
||||
console.log('Canvas send:', data)
|
||||
}
|
||||
onMounted(() => {
|
||||
useDisplay.setDialogBoxRef(dialogBoxRef.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<Canvas
|
||||
v-model:visible="canvasVisible"
|
||||
:image="canvasImage"
|
||||
:reference-images="canvasReferenceImages"
|
||||
:source="canvasSource"
|
||||
@send="handleCanvasSend"
|
||||
/>
|
||||
<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" />
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue