Compare commits

..

No commits in common. "ff4ae2bdc8986e3fa090d4430433fc295124ebeb" and "70529ccd47a9581a583c2a5aa564281127a1712e" have entirely different histories.

24 changed files with 952 additions and 8810 deletions

1
auto-imports.d.ts vendored
View File

@ -8,7 +8,6 @@ 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

4
components.d.ts vendored
View File

@ -11,9 +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']
DialogBox: typeof import('./src/components/dialogBox/index.vue')['default'] DialogBox: typeof import('./src/components/dialogBox/index.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
@ -36,7 +34,5 @@ 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']
} }
} }

344
out.txt

File diff suppressed because one or more lines are too long

7057
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,10 +7,5 @@ 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', query)
}
// 删除生成历史
export function deleteGenerateHistory(query) {
return service.delete('/taskRecordHistory/delete', { params: query })
} }

View File

@ -1,3 +1,5 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg"> <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="#BBBBBB" stroke-width="1.5" stroke-linecap="round"/> <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"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 340 B

After

Width:  |  Height:  |  Size: 475 B

View File

@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 421 B

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 736 B

View File

@ -35,7 +35,7 @@
<slot name="option" :option="option" :selected="option.value === selectedValue"> <slot name="option" :option="option" :selected="option.value === selectedValue">
<div class="option-content"> <div class="option-content">
<span class="option-label">{{ option.label }}</span> <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> </div>
</slot> </slot>
</div> </div>
@ -52,7 +52,7 @@
<slot name="option" :option="option" :selected="option.value === selectedValue"> <slot name="option" :option="option" :selected="option.value === selectedValue">
<div class="option-content"> <div class="option-content">
<span class="option-label">{{ option.label }}</span> <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> </div>
</slot> </slot>
</div> </div>
@ -313,6 +313,7 @@ onBeforeUnmount(() => {
height: 36px; height: 36px;
display: flex; display: flex;
align-items: center; align-items: center;
color: #666; color: #666;
font-family: "Microsoft YaHei"; font-family: "Microsoft YaHei";
font-size: 14px; font-size: 14px;
@ -323,14 +324,12 @@ onBeforeUnmount(() => {
.dropdown-item:hover { .dropdown-item:hover {
color: #333333; color: #333333;
background-color: #f5f6f7;
border-radius: 10px;
} }
.dropdown-item.selected { .dropdown-item.selected {
color: #333; color: #000F33;
font-weight: 400; font-weight: 400;
background-color: rgba(0, 15, 51, 0.10); background-color: #F8F9FA;
border-radius: 10px; border-radius: 10px;
.option-label { .option-label {
@ -349,13 +348,13 @@ onBeforeUnmount(() => {
flex: 1; flex: 1;
} }
/* .option-check { .option-check {
color: #333333; color: #333333;
font-weight: bold; font-weight: bold;
font-size: 16px; font-size: 16px;
margin-left: 12px; margin-left: 12px;
animation: checkIn 0.2s ease; animation: checkIn 0.2s ease;
} */ }
@keyframes checkIn { @keyframes checkIn {
from { from {

View File

@ -47,11 +47,11 @@ const quantityOptions = [
height: 40px; height: 40px;
padding: 0 15px; padding: 0 15px;
border-radius: 10px; border-radius: 10px;
border: 1px solid #E8E9EB; border: 1px solid rgba(0, 0, 0, 0.10);
background: #f5f6f7; background: #ffffff;
&:hover { &:hover {
background: #e9eaeb; background: #E5E7EB;
} }
} }

View File

@ -15,7 +15,7 @@
</div> </div>
<div v-if="localPreviewList.length < limit" class="upload-trigger" @click="triggerUpload"> <div v-if="localPreviewList.length < limit" class="upload-trigger" @click="triggerUpload">
<i-ep-plus color="#333333" /> <i-ep-plus color="#333333" />
<div class="upload-text">参考内容</div> <span>参考内容</span>
</div> </div>
</div> </div>
<el-upload <el-upload
@ -264,13 +264,4 @@ defineExpose({
transform: scale(1.05); 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> </style>

View File

@ -38,7 +38,7 @@
<div v-else class="gerenate" :class="{ isprompt: prompt }" @click="handleStart"> <div v-else class="gerenate" :class="{ isprompt: prompt }" @click="handleStart">
<img v-if="!prompt" src="@/assets/dialog/darkArrow.svg" alt="" /> <img v-if="!prompt" src="@/assets/dialog/darkArrow.svg" alt="" />
<img v-else src="@/assets/dialog/writerArrow.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>
</div> </div>
</template> </template>
@ -75,6 +75,7 @@ const props = defineProps({
} }
}) })
const emit = defineEmits(['open-canvas'])
const router = useRouter() const router = useRouter()
const useDisplay = useDisplayStore() const useDisplay = useDisplayStore()
@ -121,20 +122,6 @@ 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',
@ -146,32 +133,12 @@ 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'
@ -184,7 +151,7 @@ const handleScrollToBottom = () => {
} }
const handleOpenCanvas = (data) => { const handleOpenCanvas = (data) => {
useDisplay.openCanvas(data) emit('open-canvas', data)
} }
watch(() => useDisplay.isSubGerenate, (newValue) => { watch(() => useDisplay.isSubGerenate, (newValue) => {
@ -206,9 +173,9 @@ onMounted(() => {
<style lang="less" scoped> <style lang="less" scoped>
/* 输入区域 */ /* 输入区域 */
.input-container { .input-container {
width: 880px; width: 760px;
position: absolute; position: absolute;
bottom: 30px; bottom: 10px;
z-index: 100; z-index: 100;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
@ -253,13 +220,11 @@ onMounted(() => {
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 80%; width: 80%;
width: 100%;
display: flex; display: flex;
justify-content: start; justify-content: start;
align-items: center; align-items: center;
z-index: 1; z-index: 1;
gap: 16px; gap: 16px;
padding-left: 20px;
.reference-diagram { .reference-diagram {
display: flex; display: flex;
@ -273,7 +238,7 @@ onMounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
// gap: 40px; gap: 40px;
position: relative; position: relative;
border: none; border: none;
box-shadow: none; box-shadow: none;
@ -305,10 +270,9 @@ onMounted(() => {
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
line-height: normal; line-height: normal;
margin-bottom: 106px;
} }
:deep(.el-sender){ :deep(.el-sender){
background-color: #F5F6F7; background-color: #F8F9FA;
border: none; border: none;
border-radius: 20px; border-radius: 20px;
} }

View File

@ -110,11 +110,11 @@ const getModelType = (value) => {
height: 40px; height: 40px;
padding: 0 15px; padding: 0 15px;
border-radius: 10px; border-radius: 10px;
border: 1px solid #E8E9EB; border: 1px solid rgba(0, 0, 0, 0.10);
background: #f5f6f7; background: #ffffff;
&:hover { &:hover {
background: #e9eaeb; background: #E5E7EB;
} }
} }

View File

@ -57,11 +57,11 @@ const selectedIcon = computed(() => {
height: 40px; height: 40px;
padding: 0 15px; padding: 0 15px;
border-radius: 10px; border-radius: 10px;
border: 1px solid #E8E9EB; border: 1px solid rgba(0, 0, 0, 0.10);
background: #f5f6f7; background: #ffffff;
&:hover { &:hover {
background: #e9eaeb; background: #E5E7EB;
} }
} }

View File

@ -37,15 +37,12 @@
<div class="size-inputs"> <div class="size-inputs">
<div class="input-group"> <div class="input-group">
<label>W</label> <label>W</label>
<input type="number" v-model.number="width" @input="updateWidth" :disabled="isLocked"> <input type="number" v-model.number="width" @input="updateWidth">
</div>
<div class="lock-icon" :class="{ locked: isLocked }" @click="toggleLock">
<img :src="isLocked ? lockIcon : lockNoIcon" alt="约束比例">
<span class="tooltip">{{ isLocked ? '解绑比例' : '约束比例' }}</span>
</div> </div>
<div class="lock-icon"><img src="@/assets/dialog/lock.svg" alt=""></div>
<div class="input-group"> <div class="input-group">
<label>H</label> <label>H</label>
<input type="number" v-model.number="height" @input="updateHeight" :disabled="isLocked"> <input type="number" v-model.number="height" @input="updateHeight">
</div> </div>
</div> </div>
</div> </div>
@ -62,8 +59,6 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import Popover from '@/components/Popover/index.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({ const props = defineProps({
modelValue: { modelValue: {
@ -112,11 +107,6 @@ const resolutionOptions = [
const width = ref(2048) const width = ref(2048)
const height = ref(2048) const height = ref(2048)
const isLocked = ref(true)
const toggleLock = () => {
isLocked.value = !isLocked.value
}
const selectProportion = (value) => { const selectProportion = (value) => {
proportion.value = value proportion.value = value
@ -176,7 +166,7 @@ const updateDimensionsByResolution = (resolutionValue) => {
} }
const updateWidth = () => { const updateWidth = () => {
if (isLocked.value && proportion.value !== '智能') { if (proportion.value !== '智能') {
const [w, h] = proportion.value.split(':').map(Number) const [w, h] = proportion.value.split(':').map(Number)
const aspectRatio = w / h const aspectRatio = w / h
height.value = Math.round(width.value / aspectRatio) height.value = Math.round(width.value / aspectRatio)
@ -185,7 +175,7 @@ const updateWidth = () => {
} }
const updateHeight = () => { const updateHeight = () => {
if (isLocked.value && proportion.value !== '智能') { if (proportion.value !== '智能') {
const [w, h] = proportion.value.split(':').map(Number) const [w, h] = proportion.value.split(':').map(Number)
const aspectRatio = w / h const aspectRatio = w / h
width.value = Math.round(height.value * aspectRatio) width.value = Math.round(height.value * aspectRatio)
@ -236,13 +226,13 @@ watch(() => [props.modelValue, props.resolution], () => {
align-items: center; align-items: center;
gap: 5px; gap: 5px;
border-radius: 10px; border-radius: 10px;
border: 1px solid #E8E9EB; border: 1px solid rgba(0, 0, 0, 0.10);
background: #f5f6f7; background: #ffffff;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
} }
.choice-btn:hover{ .choice-btn:hover{
background: #e9eaeb; background: #E5E7EB;
} }
.proportion-container{ .proportion-container{
@ -331,7 +321,7 @@ watch(() => [props.modelValue, props.resolution], () => {
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
transition: all 0.2s ease; transition: all 0.2s ease;
// background: #f5f5f5; background: #f5f5f5;
color: #666; color: #666;
&:hover{ &:hover{
@ -367,88 +357,27 @@ watch(() => [props.modelValue, props.resolution], () => {
input{ input{
width: 100%; width: 100%;
height: 36px;
padding: 12px 12px 12px 30px; padding: 12px 12px 12px 30px;
border: none; border: 1px solid #e0e0e0;
border-radius: 8px; border-radius: 8px;
font-size: 14px; font-size: 14px;
background: #f5f6f7; background: #f9f9f9;
text-align: right;
-moz-appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
&:focus{ &:focus{
outline: none; 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{ .lock-icon{
display: flex; font-size: 16px;
align-items: center; color: #999;
justify-content: center;
width: 36px;
height: 36px;
cursor: pointer; 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{ &:hover{
opacity: 0.8; color: #666;
.tooltip{
opacity: 1;
visibility: visible;
}
}
&.locked{
background: #f5f6f7;
} }
} }
</style> </style>

View File

@ -42,11 +42,11 @@ const quantityOptions = [
height: 40px; height: 40px;
padding: 0 15px; padding: 0 15px;
border-radius: 10px; border-radius: 10px;
border: 1px solid #E8E9EB; border: 1px solid rgba(0, 0, 0, 0.10);
background: #f5f6f7; background: #ffffff;
&:hover { &:hover {
background: #e9eaeb; background: #E5E7EB;
} }
} }

View File

@ -1,486 +0,0 @@
<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

@ -1,518 +0,0 @@
<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,379 +1,690 @@
<template> <template>
<div class="virtual-scroller" :style="containerStyle">
<div <div
ref="scrollContainerRef" ref="containerRef"
class="virtual-scroller-container" class="virtual-scroller"
:style="scrollContainerStyle" :style="containerStyle"
@scroll.passive="handleScroll" >
<div
ref="wrapperRef"
class="virtual-scroller-wrapper"
:style="wrapperStyle"
>
<div
ref="renderContainerRef"
class="virtual-scroller-render-container"
:style="renderContainerStyle"
>
<div class="virtual-scroller-spacer" :style="{ height: `${totalSize}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="item in visibleItems" v-for="renderItem in pool"
:key="item.key" :key="renderItem.nr.key"
:ref="el => setItemRef(el, item.key)" :ref="el => setItemRef(el, renderItem.nr.key)"
class="virtual-scroller-item" class="virtual-scroller-item"
:style="getItemStyle(item)" :style="getItemStyle(renderItem)"
:data-index="item.index" :data-index="renderItem.nr.index"
:data-key="item.key" :data-key="renderItem.nr.key"
> >
<slot name="default" :item="item.data" :index="item.index" /> <slot
name="default"
:item="renderItem.item"
:index="renderItem.nr.index"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick, type CSSProperties } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowReactive, watch, markRaw } from 'vue'
interface VirtualScrollerProps { const props = defineProps({
items: any[] data: {
estimatedHeight?: number type: Array,
bufferSize?: number required: true,
keyField?: string default: () => []
height?: string | number },
direction?: 'normal' | 'reverse' itemKey: {
} type: [String, Function],
default: 'id'
const props = withDefaults(defineProps<VirtualScrollerProps>(), { },
estimatedHeight: 100, estimatedHeight: {
bufferSize: 3, type: Number,
keyField: 'id', default: null
height: '100%', },
direction: 'reverse' 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<{ const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end', 'resize', 'update'])
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 containerRef = ref(null)
const wrapperRef = ref(null)
const renderContainerRef = ref(null)
const itemRefs = new Map()
const resizeObserver = ref(null)
const scrollTop = ref(0) const scrollTop = ref(0)
const itemRefs = new Map<string, HTMLElement>() const isScrolling = ref(false)
const isReverseMode = computed(() => props.direction === 'reverse') const scrollTimeout = ref(null)
const isInitializing = ref(true) const isInitialized = ref(false)
const pendingScrollToBottom = ref(false)
const itemHeights = ref(new Map<string | number, number>()) let uid = 0
const itemOffsets = ref(new Map<string | number, number>()) const pool = ref([])
let resizeObserver: ResizeObserver | null = null const viewMap = new Map()
const unusedViews = new Map()
const itemSizeMap = ref(new Map())
const totalSize = ref(0)
function getItemKey(item: any, index: number): string | number { let $_startIndex = 0
return item[props.keyField] ?? index let $_endIndex = 0
} let $_scrollDirty = false
let $_lastUpdateScrollPosition = 0
function getItemHeight(key: string | number): number { const containerStyle = computed(() => {
return itemHeights.value.get(key) ?? props.estimatedHeight return {
} height: '100%',
width: '100%',
function calculateOffsets() { position: 'relative'
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>(() => ({ const getItemKey = (item, index) => {
height: typeof props.height === 'number' ? `${props.height}px` : props.height, if (typeof props.itemKey === 'function') {
overflow: 'hidden' return props.itemKey(item, index)
})) }
if (typeof props.itemKey === 'string' && item && typeof item === 'object') {
return item[props.itemKey] ?? index
}
return index
}
const scrollContainerStyle = computed<CSSProperties>(() => ({ 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',
height: '100%', height: '100%',
overflowY: 'auto', position: 'relative',
overflowX: 'hidden', scrollbarWidth: 'auto',
overflow: 'hidden',
transform: 'rotate(180deg)', transform: 'rotate(180deg)',
position: 'relative' width: '100%'
})) }))
const spacerStyle = computed<CSSProperties>(() => ({ const renderContainerStyle = computed(() => ({
height: `${totalHeight.value}px`, direction: 'ltr',
width: '1px', display: 'flex',
pointerEvents: 'none', flexDirection: 'column',
position: 'absolute', justifyContent: 'flex-end',
top: 0,
left: 0
}))
const placeholderStyle = computed<CSSProperties>(() => ({
position: 'absolute',
bottom: 0, bottom: 0,
left: 0, left: 0,
overflowX: 'hidden',
overflowY: 'auto',
position: 'absolute',
right: 0, right: 0,
pointerEvents: 'none' top: 0,
width: '100%'
})) }))
const visibleRange = computed(() => { const bottomPlaceholderStyle = computed(() => ({
if (!scrollContainerRef.value || props.items.length === 0) { position: 'absolute',
return { start: 0, end: Math.min(props.bufferSize * 2, props.items.length - 1) } left: 0,
} right: 0,
top: 0,
width: '100%',
height: `${props.bottomPlaceholderHeight}px`,
transform: `translateY(0px)`,
zIndex: 1
}))
const containerHeight = scrollContainerRef.value.clientHeight const getItemStyle = (renderItem) => {
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 { return {
position: 'absolute', position: 'absolute',
top: `${item.offset}px`,
left: 0, left: 0,
right: 0 right: 0,
top: 0,
width: '100%',
transform: `translateY(${renderItem.position}px)`,
willChange: 'transform'
} }
} }
function setItemRef(el: any, key: string) { const setItemRef = (el, key) => {
if (el) { if (el) {
itemRefs.set(key, el as HTMLElement) itemRefs.set(key, el)
} else { } else {
itemRefs.delete(key) itemRefs.delete(key)
} }
} }
function measureItems() { const addView = (index, item, key) => {
if (!resizeObserver) return const nr = markRaw({
id: uid++,
itemRefs.forEach((el, key) => { index,
resizeObserver!.observe(el) used: true,
key
}) })
const view = shallowReactive({
item,
position: 0,
nr
})
pool.value.push(view)
return view
} }
function updateItemHeight(key: string | number, height: number) { const unuseView = (view) => {
const oldHeight = itemHeights.value.get(key) view.nr.used = false
if (oldHeight !== height) { view.position = -9999
itemHeights.value.set(key, height) }
calculateOffsets()
const getScroll = () => {
const el = renderContainerRef.value
if (!el) return { start: 0, end: 0 }
return {
start: el.scrollTop,
end: el.scrollTop + el.clientHeight
} }
} }
function handleScroll(event: Event) { const updateVisibleItems = (checkItem = false, checkPositionDiff = false) => {
const target = event.target as HTMLElement const items = props.data
const currentScrollTop = target.scrollTop const count = items.length
scrollTop.value = currentScrollTop const sizesMap = sizes.value
const keyField = typeof props.itemKey === 'string' ? props.itemKey : 'id'
if (!scrollContainerRef.value) { if (!count) {
emit('scroll', currentScrollTop) 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)
}
})
}
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')
}
}
const scrollToIndex = (index, behavior = 'auto') => {
if (!renderContainerRef.value || index < 0 || index >= props.data.length) return
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
return return
} }
const containerHeight = scrollContainerRef.value.clientHeight requestAnimationFrame(() => {
const maxScroll = Math.max(0, totalHeight.value - containerHeight) if (!renderContainerRef.value) return
const distanceToTop = currentScrollTop renderContainerRef.value.scrollTo({
const distanceToBottom = maxScroll - currentScrollTop top: 0,
behavior
const isAtTop = currentScrollTop >= maxScroll - 10 })
const isAtBottom = currentScrollTop <= 10
emit('scroll', currentScrollTop, {
isAtTop,
isAtBottom,
distanceToTop,
distanceToBottom
}) })
} }
function scrollToIndex(index: number) { const scrollToTop = (behavior = 'smooth') => {
if (!scrollContainerRef.value) return if (!renderContainerRef.value) return
let targetTop = 0 requestAnimationFrame(() => {
for (let i = 0; i < index; i++) { if (!renderContainerRef.value) return
const key = getItemKey(props.items[i], i)
targetTop += getItemHeight(key) const scrollHeight = renderContainerRef.value.scrollHeight
renderContainerRef.value.scrollTo({
top: scrollHeight,
behavior
})
})
}
const getScrollElement = () => renderContainerRef.value
const getVisibleIndices = () => {
const indices = []
for (let i = $_startIndex; i < $_endIndex; i++) {
indices.push(i)
} }
return indices
scrollContainerRef.value.scrollTop = targetTop
scrollTop.value = targetTop
} }
function scrollToTop() { const resetMeasurements = () => {
if (!scrollContainerRef.value) return itemSizeMap.value = new Map()
const containerHeight = scrollContainerRef.value.clientHeight itemRefs.clear()
const maxScroll = Math.max(0, totalHeight.value - containerHeight) pool.value = []
scrollContainerRef.value.scrollTop = maxScroll viewMap.clear()
scrollTop.value = maxScroll
}
function scrollToBottom() {
if (!scrollContainerRef.value) return
scrollContainerRef.value.scrollTop = 0
scrollTop.value = 0 scrollTop.value = 0
$_startIndex = 0
$_endIndex = 0
} }
watch( const isAtPageBottom = () => {
() => visibleRange.value, if (!renderContainerRef.value) return false
(newRange) => { const { scrollTop } = renderContainerRef.value
emit('visible-change', newRange.start, newRange.end) return scrollTop <= 0
nextTick(() => { }
measureItems()
})
},
{ deep: true }
)
watch( const isAtPageTop = () => {
() => props.items, if (!renderContainerRef.value) return false
() => { const { scrollTop, scrollHeight, clientHeight } = renderContainerRef.value
calculateOffsets() return scrollHeight - scrollTop - clientHeight <= 0
nextTick(() => { }
measureItems()
})
},
{ immediate: true }
)
watch( const observeVisibleItems = () => {
() => props.items.length, if (!resizeObserver.value) return
async (newLength, oldLength) => {
if (isReverseMode.value && newLength > (oldLength || 0)) { resizeObserver.value.disconnect()
await nextTick()
scrollToBottom() 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(() => { onMounted(() => {
resizeObserver = new ResizeObserver((entries) => { setupResizeObserver()
for (const entry of entries) { isInitialized.value = true
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(() => {
if (pendingScrollToBottom.value) {
pendingScrollToBottom.value = false
scrollToBottom() scrollToBottom()
setTimeout(() => {
isInitializing.value = false
}, 100)
})
} else {
isInitializing.value = false
} }
measureItems() updateVisibleItems(true)
})
}) })
onUnmounted(() => { onBeforeUnmount(() => {
if (resizeObserver) { if (resizeObserver.value) {
resizeObserver.disconnect() resizeObserver.value.disconnect()
resizeObserver = null
} }
if (scrollTimeout.value) {
clearTimeout(scrollTimeout.value)
}
itemRefs.clear() itemRefs.clear()
viewMap.clear()
}) })
defineExpose({ defineExpose({
scrollToIndex, scrollToIndex,
scrollToTop,
scrollToBottom, scrollToBottom,
getScrollTop: () => scrollTop.value, scrollToTop,
getVisibleRange: () => visibleRange.value, getScrollElement,
updateLayout: () => { getVisibleIndices,
calculateOffsets() resetMeasurements,
measureItems() containerRef,
} isAtPageBottom,
isAtPageTop
}) })
</script> </script>
<style scoped> <style lang="less" scoped>
.virtual-scroller { .virtual-scroller {
position: relative; -webkit-overflow-scrolling: touch;
width: 100%; scrollbar-width: none;
} -ms-overflow-style: none;
.virtual-scroller-container { &::-webkit-scrollbar {
position: relative; display: none;
width: 100%; width: 0;
} height: 0;
}
.virtual-scroller-spacer { .virtual-scroller-wrapper {
contain: content;
}
.virtual-scroller-spacer {
flex-shrink: 0; flex-shrink: 0;
} width: 100%;
}
.virtual-scroller-item { .virtual-scroller-placeholder {
will-change: transform; width: 100%;
}
.virtual-scroller-render-container {
contain: layout style; contain: layout style;
}
.virtual-scroller-item {
contain: layout style;
backface-visibility: hidden;
perspective: 1000px;
}
.virtual-scroller-bottom-placeholder {
contain: layout style;
}
} }
</style> </style>

View File

@ -6,13 +6,6 @@ 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 = {
@ -57,13 +50,6 @@ 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
@ -95,40 +81,6 @@ 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,
@ -137,25 +89,13 @@ 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,8 +104,7 @@ 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()
@ -137,6 +136,7 @@ export async function generate(type, data) {
socket.onclose = async (event) => { socket.onclose = async (event) => {
console.log('WebSocket已关闭:', event) console.log('WebSocket已关闭:', event)
useDisplay.isSubGerenate = false useDisplay.isSubGerenate = false
// 清理心跳定时器
if (heartbeatInterval) { if (heartbeatInterval) {
clearInterval(heartbeatInterval) clearInterval(heartbeatInterval)
} }
@ -160,6 +160,8 @@ export async function generate(type, data) {
} else { } else {
websocketError(event.code, event.reason) websocketError(event.code, event.reason)
} }
// clearInterval(progressInterval.value)
// 清理心跳定时器
if (heartbeatInterval) { if (heartbeatInterval) {
clearInterval(heartbeatInterval) clearInterval(heartbeatInterval)
} }

View File

@ -51,14 +51,14 @@
<!-- 已完成 --> <!-- 已完成 -->
<div v-if="props.item.status === 'success'" class="box success-box"> <div v-if="props.item.status === 'success'" class="box success-box">
<div v-for="(file, index) in props.item.files" :key="index" class="one-box" :class="{ 'collected': isCollected(file) }" @mouseenter="hoverIndex = index" @mouseleave="hoverIndex = -1"> <div v-for="(file, index) in props.item.files" :key="index" class="one-box">
<!-- <img :src="file" alt="index" class="img" /> --> <!-- <img :src="file" alt="index" class="img" /> -->
<Img :src="file" alt="index" class="img" /> <Img :src="file" alt="index" class="img" />
<div class="left-top"> <div class="left-top">
<div v-show="hoverIndex === index" class="left-top-btn download-btn" @click="downloadImage(file, 'image')"><img src="@/assets/display/download.svg" /></div> <div class="left-top-btn" @click="downloadImage(file, 'image')"><img src="@/assets/display/download.svg" /></div>
<span v-if="hoverIndex === index" class="line" /> <span class="line" />
<div class="left-top-btn collect-btn" @click="addCollection(file)"><img :src="isCollected(file) ? collectionActiveIcon : collectionIcon" /></div> <div class="left-top-btn" @click="addCollection(file)"><img src="@/assets/display/collection.svg" /></div>
</div> </div>
<el-tooltip <el-tooltip
@ -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()"> <div v-for="(item, index) in bottomBtnGroup" :key="index" class="bottom-btn" @click="item.click(file, index)">
<img :src="item.icon" /> <img :src="item.icon" />
<span>{{ item.name }}</span> <span>{{ item.name }}</span>
</div> </div>
@ -86,15 +86,13 @@
<script setup> <script setup>
import brush from '@/assets/display/brush.svg' 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 { useDisplayStore, useParamStore, useUserStore } from '@/stores'
import { downloadImage } from '@/utils/downloadImage.js' import { downloadImage } from '@/utils/downloadImage.js'
import reEditIcon from '@/assets/display/reEdit.svg' import reEditIcon from '@/assets/display/reEdit.svg'
import againGenerateIcon from '@/assets/display/againGenerate.svg' import againGenerateIcon from '@/assets/display/againGenerate.svg'
import deleteImageIcon from '@/assets/display/deleteImage.svg' import deleteImageIcon from '@/assets/display/deleteImage.svg'
import Img from '@/components/Img/index.vue' import Img from '@/components/Img/index.vue'
import { cancelOrCollect, deleteGenerateHistory } from '@/apis/display' import { cancelOrCollect } from '@/apis/display'
const props = defineProps({ const props = defineProps({
item: { item: {
@ -103,19 +101,12 @@ const props = defineProps({
} }
}) })
const emit = defineEmits(['open-canvas', 'delete-success']) const emit = defineEmits(['open-canvas'])
const useDisplay = useDisplayStore() const useDisplay = useDisplayStore()
const useParams = useParamStore() const useParams = useParamStore()
const useUser = useUserStore() const useUser = useUserStore()
const localCollectStatus = ref({ ...props.item.collectStatus })
const hoverIndex = ref(-1)
const isCollected = (url) => {
return localCollectStatus.value[url] === true
}
const generateStatusText = computed(() => { const generateStatusText = computed(() => {
if (props.item.status === 'generate') { if (props.item.status === 'generate') {
return '正在生成中...' return '正在生成中...'
@ -131,45 +122,19 @@ const AIbrush = (file, index) => {
}) })
} }
const reEdit = () => { const reEdit = (url, number) => {
useDisplay.setResultData(props.item.result) console.log(number)
useDisplay.fillParamsForEdit()
} }
const againGenerate = () => { const againGenerate = (url, number) => {
useDisplay.setResultData(props.item.result) console.log(number)
useDisplay.triggerGenerateWithResult()
} }
const deleteImage = () => { const deleteImage = (url, number) => {
ElMessageBox.confirm( console.log(number)
'确定要删除该批次图片吗?此操作不可恢复!',
'删除确认',
{
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 = computed(() => [ const bottomBtnGroup = [
{ {
name: '重新编辑', name: '重新编辑',
icon: reEditIcon, icon: reEditIcon,
@ -185,7 +150,7 @@ const bottomBtnGroup = computed(() => [
icon: deleteImageIcon, icon: deleteImageIcon,
click: deleteImage click: deleteImage
} }
]) ]
const addCollection = async (url) => { const addCollection = async (url) => {
try { try {
@ -196,7 +161,6 @@ const addCollection = async (url) => {
}) })
if (res.success) { if (res.success) {
ElMessage.success(res.message || '操作成功') ElMessage.success(res.message || '操作成功')
localCollectStatus.value[url] = !localCollectStatus.value[url]
} else { } else {
ElMessage.error(res.message || '操作失败') ElMessage.error(res.message || '操作失败')
} }
@ -382,11 +346,6 @@ const addCollection = async (url) => {
display:flex display:flex
} }
} }
.one-box.collected{
.left-top{
display:flex
}
}
.success-box{ .success-box{
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);

View File

@ -45,20 +45,16 @@
<VirtualScroller <VirtualScroller
ref="scrollerRef" ref="scrollerRef"
v-if="props.if" v-if="props.if"
:items="list" :data="list"
key-field="id" :item-key="'id'"
:estimated-height="300" :estimated-height="300"
:buffer-size="3" :render-mode="'top'"
direction="reverse" :buffer="2"
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" @delete-success="handleDeleteSuccess" /> <Set :key="item.id" :item="item" @open-canvas="openCanvas" />
</template>
<template #bottom-placeholder>
<div style="height: 350px;"></div>
</template> </template>
</VirtualScroller> </VirtualScroller>
</div> </div>
@ -100,7 +96,10 @@ 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, canvasImage, canvasReferenceImages, canvasSource } = storeToRefs(useDisplay) const canvasVisible = ref(false)
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)
@ -138,20 +137,13 @@ 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.id, id: item.taskId,
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 || {},
model: item.model || '',
proportion: item.proportion || '',
resolution: item.resolution || '',
quantity: item.quantity || 1
} }
}) })
return temp return temp
@ -244,13 +236,12 @@ const fetchHistory = async (isLoadMore = false) => {
} }
} }
const handleScroll = (scrollTop, scrollInfo) => { const handleScroll = (event) => {
if (isInitializing.value) return if (isInitializing.value) return
if (!scrollInfo) return const { distanceToPageTop, distanceToPageBottom, isAtPageTop, isAtPageBottom } = event
const { isAtTop, isAtBottom, distanceToTop, distanceToBottom } = scrollInfo
if (isAtTop && !isLoading.value && !isLoadingMoreLocked.value && hasMoreData.value) { if (isAtPageTop && !isLoading.value && !isLoadingMoreLocked.value && hasMoreData.value) {
isLoadingMoreLocked.value = true isLoadingMoreLocked.value = true
fetchHistory(true) fetchHistory(true)
setTimeout(() => { setTimeout(() => {
@ -258,22 +249,24 @@ const handleScroll = (scrollTop, scrollInfo) => {
}, 3000) }, 3000)
} }
if (isAtBottom) { if (isAtPageBottom) {
useDisplay.Sender_variant = 'updown' useDisplay.Sender_variant = 'updown'
} else if (distanceToTop >= 350) { } else if (distanceToPageTop >= 350) {
useDisplay.Sender_variant = 'default' useDisplay.Sender_variant = 'default'
} }
} }
const handleVisibleChange = (startIndex, endIndex) => {
}
const handleScrollStart = () => {}
const handleScrollEnd = () => {}
const openCanvas = (data) => { const openCanvas = (data) => {
useDisplay.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
} }
const handleCanvasSend = (data) => { const handleCanvasSend = (data) => {
@ -291,10 +284,6 @@ 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,12 +1,9 @@
<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 { useDisplayStore } from '@/stores' import Canvas from '@/components/canvas/index.vue'
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'))
@ -14,14 +11,33 @@ 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(() => { const canvasVisible = ref(false)
useDisplay.setDialogBoxRef(dialogBoxRef.value) 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)
}
</script> </script>
<template> <template>
<div class="app-container"> <div class="app-container">
<dialogBox ref="dialogBoxRef" :is-generate="shouldShowDisplay" :type="type" :generate="Generate" :loading="loading" /> <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" />
<display :if="shouldShowDisplay" :type="type" :loading="loading" /> <display :if="shouldShowDisplay" :type="type" :loading="loading" />
</div> </div>