Compare commits

...

4 Commits

24 changed files with 8822 additions and 964 deletions

1
auto-imports.d.ts vendored
View File

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

4
components.d.ts vendored
View File

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

344
out.txt

File diff suppressed because one or more lines are too long

7057
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,486 @@
<template>
<div class="virtual-scroller" :style="containerStyle">
<div
ref="scrollContainerRef"
class="virtual-scroller-container"
:style="scrollContainerStyle"
@scroll.passive="handleScroll"
@wheel="handleWheel"
>
<div class="virtual-scroller-spacer" :style="spacerStyle"></div>
<div class="virtual-scroller-placeholder" :style="placeholderStyle">
<slot name="bottom-placeholder" />
</div>
<div
v-for="item in visibleItems"
:key="item.key"
:ref="el => setItemRef(el, item.key)"
class="virtual-scroller-item"
:style="getItemStyle(item)"
:data-index="item.index"
:data-key="item.key"
>
<slot name="default" :item="item.data" :index="item.index" />
</div>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps({
data: {
type: Array,
required: true,
default: () => []
},
itemKey: {
type: [String, Function],
default: 'id'
},
estimatedHeight: {
type: Number,
default: 100
},
buffer: {
type: Number,
default: 5
},
placeholderHeight: {
type: Number,
default: 350
}
})
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end'])
const scrollContainerRef = ref(null)
const itemRefs = new Map()
const itemHeights = ref(new Map())
const resizeObserver = ref(null)
const isScrolling = ref(false)
const scrollTimeout = ref(null)
const getKey = (item, index) => {
if (typeof props.itemKey === 'function') {
return props.itemKey(item, index)
}
if (item && typeof item === 'object') {
return item[props.itemKey] ?? index
}
return index
}
const getItemHeight = (key) => {
return itemHeights.value.get(key) ?? props.estimatedHeight
}
const containerStyle = computed(() => ({
height: '100%',
width: '100%',
position: 'relative',
}))
const scrollContainerStyle = computed(() => ({
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
overflowX: 'hidden',
overflowY: 'auto',
direction: 'rtl',
transform: 'rotate(180deg)'
}))
const totalDataHeight = computed(() => {
let height = 0
for (let i = 0; i < props.data.length; i++) {
const key = getKey(props.data[i], i)
height += getItemHeight(key)
}
return height
})
const totalHeight = computed(() => {
return props.placeholderHeight + totalDataHeight.value
})
const spacerStyle = computed(() => ({
height: `${totalHeight.value}px`,
width: '100%',
flexShrink: 0
}))
const placeholderStyle = computed(() => ({
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: `${props.placeholderHeight}px`,
zIndex: 1,
direction: 'ltr'
}))
const visibleRange = computed(() => {
const count = props.data.length
if (count === 0) {
return { start: 0, end: 0, offset: 0 }
}
const el = scrollContainerRef.value
if (!el) {
return { start: 0, end: Math.min(count - 1, 9), offset: 0 }
}
const scrollTop = el.scrollTop
const viewportHeight = el.clientHeight || 600
const bufferCount = props.buffer
// In inverted scroll (180deg rotation):
// - scrollTop = 0: visual BOTTOM (shows newer data, lower index)
// - scrollTop = max: visual TOP (shows older data, higher index)
// - visibleStart/visibleEnd are offsets in the data area (after placeholder)
const visibleStart = Math.max(0, scrollTop - props.placeholderHeight)
const visibleEnd = visibleStart + viewportHeight
let startIndex = 0
let endIndex = count - 1
let startOffset = 0
let currentOffset = 0
// Find startIndex: first item that ends after visibleStart
for (let i = 0; i < count; i++) {
const key = getKey(props.data[i], i)
const height = getItemHeight(key)
const itemEnd = currentOffset + height
if (itemEnd > visibleStart) {
startIndex = Math.max(0, i - bufferCount)
break
}
currentOffset += height
}
// Calculate startOffset for startIndex
startOffset = 0
for (let i = 0; i < startIndex; i++) {
const key = getKey(props.data[i], i)
startOffset += getItemHeight(key)
}
// Find endIndex: last item that starts before visibleEnd
currentOffset = startOffset
for (let i = startIndex; i < count; i++) {
const key = getKey(props.data[i], i)
const height = getItemHeight(key)
// Check if this item is visible (item starts before visibleEnd)
if (currentOffset >= visibleEnd) {
// This item starts after visibleEnd, so previous item is the last visible
endIndex = Math.min(count - 1, Math.max(startIndex, i - 1 + bufferCount))
break
}
endIndex = i
currentOffset += height
}
return { start: startIndex, end: endIndex, offset: startOffset }
})
const visibleItems = computed(() => {
const { start, end, offset } = visibleRange.value
const items = []
const count = props.data.length
if (count === 0) return items
const safeStart = Math.max(0, start)
const safeEnd = Math.min(count - 1, end)
if (safeStart > safeEnd) return items
let currentOffset = offset + props.placeholderHeight
const seenKeys = new Set()
for (let i = safeStart; i <= safeEnd; i++) {
const data = props.data[i]
if (!data) continue
const key = getKey(data, i)
// Deduplicate by key
if (seenKeys.has(key)) continue
seenKeys.add(key)
const height = getItemHeight(key)
items.push({
data,
index: i,
key,
offset: currentOffset,
height
})
currentOffset += height
}
return items
})
const getItemStyle = (item) => ({
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${item.offset}px)`,
direction: 'ltr',
willChange: 'transform'
})
const setItemRef = (el, key) => {
if (el) {
itemRefs.set(key, el)
} else {
itemRefs.delete(key)
}
}
const measureItem = (key, element) => {
if (!element) return
const target = element.firstElementChild || element
const height = target.getBoundingClientRect().height
if (height > 0 && height !== itemHeights.value.get(key)) {
const newHeights = new Map(itemHeights.value)
newHeights.set(key, height)
itemHeights.value = newHeights
}
}
const setupResizeObserver = () => {
if (resizeObserver.value) {
resizeObserver.value.disconnect()
}
resizeObserver.value = new ResizeObserver((entries) => {
for (const entry of entries) {
const key = entry.target.dataset.key
if (key !== undefined) {
measureItem(key, entry.target)
}
}
})
}
const observeItems = () => {
if (!resizeObserver.value) return
resizeObserver.value.disconnect()
for (const [key, element] of itemRefs) {
if (element) {
resizeObserver.value.observe(element)
}
}
}
const handleWheel = (event) => {
if (!scrollContainerRef.value) return
scrollContainerRef.value.scrollBy({
top: -event.deltaY,
behavior: 'instant'
})
event.preventDefault()
}
const handleScroll = (event) => {
const target = event.target
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
isScrolling.value = true
if (scrollTimeout.value) {
clearTimeout(scrollTimeout.value)
}
scrollTimeout.value = setTimeout(() => {
isScrolling.value = false
}, 150)
// In inverted scroll:
// - distanceToTop (visual top) = scrollHeight - scrollTop - clientHeight
// - distanceToBottom (visual bottom) = scrollTop
// - isAtTop (visual top, older data) = distanceToTop <= threshold
// - isAtBottom (visual bottom, newer data) = distanceToBottom <= threshold
const distanceToTop = scrollHeight - scrollTop - clientHeight
const distanceToBottom = scrollTop
const threshold = 5
const isAtTop = distanceToTop <= threshold
const isAtBottom = distanceToBottom <= threshold
emit('scroll', {
scrollTop,
scrollHeight,
clientHeight,
distanceToTop,
distanceToBottom,
isAtTop,
isAtBottom
})
// scroll-start: reached visual top (older data, need to load more)
if (isAtTop) {
emit('scroll-start')
}
// scroll-end: reached visual bottom (newer data)
if (isAtBottom) {
emit('scroll-end')
}
}
const scrollToIndex = (index, behavior = 'auto') => {
if (!scrollContainerRef.value || index < 0 || index >= props.data.length) return
let offset = 0
for (let i = 0; i < index; i++) {
const key = getKey(props.data[i], i)
offset += getItemHeight(key)
}
const targetScrollTop = offset + props.placeholderHeight
scrollContainerRef.value.scrollTo({
top: targetScrollTop,
behavior
})
}
const scrollToBottom = (behavior = 'smooth') => {
if (!scrollContainerRef.value) return
requestAnimationFrame(() => {
if (!scrollContainerRef.value) return
// In inverted scroll, bottom is scrollTop = 0
scrollContainerRef.value.scrollTo({ top: 0, behavior })
})
}
const scrollToTop = (behavior = 'smooth') => {
if (!scrollContainerRef.value) return
requestAnimationFrame(() => {
if (!scrollContainerRef.value) return
// In inverted scroll, top is scrollTop = max
scrollContainerRef.value.scrollTo({
top: scrollContainerRef.value.scrollHeight,
behavior
})
})
}
const getScrollElement = () => scrollContainerRef.value
const isAtTop = () => {
if (!scrollContainerRef.value) return false
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.value
return scrollHeight - scrollTop - clientHeight <= 5
}
const isAtBottom = () => {
if (!scrollContainerRef.value) return false
return scrollContainerRef.value.scrollTop <= 5
}
const reset = () => {
itemHeights.value = new Map()
itemRefs.clear()
}
watch(() => props.data, (newData, oldData) => {
const newLength = newData?.length || 0
const oldLength = oldData?.length || 0
if (newLength < oldLength) {
reset()
}
nextTick(observeItems)
}, { deep: true })
watch(visibleItems, () => {
nextTick(observeItems)
}, { deep: true })
onMounted(() => {
setupResizeObserver()
nextTick(observeItems)
})
onBeforeUnmount(() => {
if (resizeObserver.value) {
resizeObserver.value.disconnect()
}
if (scrollTimeout.value) {
clearTimeout(scrollTimeout.value)
}
itemRefs.clear()
})
defineExpose({
scrollToIndex,
scrollToBottom,
scrollToTop,
getScrollElement,
isAtTop,
isAtBottom,
reset,
scrollContainerRef
})
</script>
<style lang="less" scoped>
.virtual-scroller {
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
&-container {
contain: layout style;
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
}
&-spacer {
flex-shrink: 0;
width: 100%;
}
&-item {
contain: layout style;
backface-visibility: hidden;
perspective: 1000px;
}
&-placeholder {
contain: layout style;
}
}
</style>

View File

@ -0,0 +1,518 @@
<template>
<div class="virtual-scroller" :style="containerStyle">
<div class="virtual-scroller-wrapper" :style="wrapperStyle">
<div
ref="scrollContainerRef"
class="virtual-scroller-container"
:style="containerInnerStyle"
@scroll.passive="handleScroll"
@wheel="handleWheel"
>
<div class="virtual-scroller-spacer" :style="spacerStyle"></div>
<div class="virtual-scroller-placeholder" :style="placeholderStyle">
<slot name="bottom-placeholder" />
</div>
<div
v-for="item in visibleItems"
:key="item.key"
:ref="el => setItemRef(el, item.key)"
class="virtual-scroller-item"
:style="getItemStyle(item)"
:data-index="item.index"
:data-key="item.key"
>
<slot name="default" :item="item.data" :index="item.index" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps({
data: {
type: Array,
required: true,
default: () => []
},
itemKey: {
type: [String, Function],
default: 'id'
},
estimatedHeight: {
type: Number,
default: 100
},
buffer: {
type: Number,
default: 5
},
placeholderHeight: {
type: Number,
default: 350
}
})
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end'])
const scrollContainerRef = ref(null)
const itemRefs = new Map()
const itemHeights = ref(new Map())
const resizeObserver = ref(null)
const isScrolling = ref(false)
const scrollTimeout = ref(null)
const getKey = (item, index) => {
if (typeof props.itemKey === 'function') {
return props.itemKey(item, index)
}
if (item && typeof item === 'object') {
return item[props.itemKey] ?? index
}
return index
}
const getItemHeight = (key) => {
return itemHeights.value.get(key) ?? props.estimatedHeight
}
const containerStyle = computed(() => ({
height: '100%',
width: '100%',
position: 'relative'
}))
const wrapperStyle = computed(() => ({
height: '100%',
width: '100%',
position: 'relative',
overflow: 'hidden',
transform: 'rotate(180deg)',
direction: 'rtl'
}))
const containerInnerStyle = computed(() => ({
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
overflowX: 'hidden',
overflowY: 'auto',
direction: 'ltr'
}))
const totalDataHeight = computed(() => {
let height = 0
for (let i = 0; i < props.data.length; i++) {
const key = getKey(props.data[i], i)
height += getItemHeight(key)
}
return height
})
const totalHeight = computed(() => {
return props.placeholderHeight + totalDataHeight.value
})
const spacerStyle = computed(() => ({
height: `${totalHeight.value}px`,
width: '100%',
flexShrink: 0
}))
const placeholderStyle = computed(() => ({
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: `${props.placeholderHeight}px`,
zIndex: 1
}))
const getItemOffsets = () => {
const offsets = []
let offset = 0
for (let i = 0; i < props.data.length; i++) {
offsets.push(offset)
const key = getKey(props.data[i], i)
offset += getItemHeight(key)
}
return offsets
}
const visibleRange = computed(() => {
const count = props.data.length
if (count === 0) {
return { start: 0, end: 0, offset: 0 }
}
const el = scrollContainerRef.value
if (!el) {
return { start: 0, end: Math.min(count - 1, 9), offset: 0 }
}
const scrollTop = el.scrollTop
const viewportHeight = el.clientHeight || 600
const bufferCount = props.buffer
// In inverted scroll (180deg rotation):
// - scrollTop = 0: visual BOTTOM (shows newer data, lower index)
// - scrollTop = max: visual TOP (shows older data, higher index)
// - Items are positioned from top: placeholderHeight, then data items
// - visibleStart/visibleEnd are offsets in the data area (after placeholder)
// When scrollTop = 0, we're at visual bottom, showing items near the START of data
// When scrollTop = max, we're at visual top, showing items near the END of data
// The visible area in data coordinates:
// - scrollTop 0 means we see items at offset 0 (start of data)
// - scrollTop increases means we see items at higher offsets (end of data)
const visibleStart = Math.max(0, scrollTop - props.placeholderHeight)
const visibleEnd = visibleStart + viewportHeight
let startIndex = 0
let endIndex = count - 1
let startOffset = 0
let currentOffset = 0
// Find startIndex: first item that ends after visibleStart
for (let i = 0; i < count; i++) {
const key = getKey(props.data[i], i)
const height = getItemHeight(key)
const itemEnd = currentOffset + height
if (itemEnd > visibleStart) {
startIndex = Math.max(0, i - bufferCount)
break
}
currentOffset += height
}
// Calculate startOffset for startIndex
startOffset = 0
for (let i = 0; i < startIndex; i++) {
const key = getKey(props.data[i], i)
startOffset += getItemHeight(key)
}
// Find endIndex: last item that starts before visibleEnd
currentOffset = startOffset
for (let i = startIndex; i < count; i++) {
const key = getKey(props.data[i], i)
const height = getItemHeight(key)
// Check if this item is visible (item starts before visibleEnd)
if (currentOffset >= visibleEnd) {
// This item starts after visibleEnd, so previous item is the last visible
endIndex = Math.min(count - 1, Math.max(startIndex, i - 1 + bufferCount))
break
}
endIndex = i
currentOffset += height
}
return { start: startIndex, end: endIndex, offset: startOffset }
})
const visibleItems = computed(() => {
const { start, end, offset } = visibleRange.value
const items = []
const count = props.data.length
if (count === 0) return items
const safeStart = Math.max(0, start)
const safeEnd = Math.min(count - 1, end)
if (safeStart > safeEnd) return items
let currentOffset = offset + props.placeholderHeight
const seenKeys = new Set()
for (let i = safeStart; i <= safeEnd; i++) {
const data = props.data[i]
if (!data) continue
const key = getKey(data, i)
// Deduplicate by key
if (seenKeys.has(key)) continue
seenKeys.add(key)
const height = getItemHeight(key)
items.push({
data,
index: i,
key,
offset: currentOffset,
height
})
currentOffset += height
}
return items
})
const getItemStyle = (item) => ({
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${item.offset}px)`,
willChange: 'transform'
})
const setItemRef = (el, key) => {
if (el) {
itemRefs.set(key, el)
} else {
itemRefs.delete(key)
}
}
const measureItem = (key, element) => {
if (!element) return
const target = element.firstElementChild || element
const height = target.getBoundingClientRect().height
if (height > 0 && height !== itemHeights.value.get(key)) {
const newHeights = new Map(itemHeights.value)
newHeights.set(key, height)
itemHeights.value = newHeights
}
}
const setupResizeObserver = () => {
if (resizeObserver.value) {
resizeObserver.value.disconnect()
}
resizeObserver.value = new ResizeObserver((entries) => {
for (const entry of entries) {
const key = entry.target.dataset.key
if (key !== undefined) {
measureItem(key, entry.target)
}
}
})
}
const observeItems = () => {
if (!resizeObserver.value) return
resizeObserver.value.disconnect()
for (const [key, element] of itemRefs) {
if (element) {
resizeObserver.value.observe(element)
}
}
}
const handleWheel = (event) => {
if (!scrollContainerRef.value) return
scrollContainerRef.value.scrollBy({
top: -event.deltaY,
behavior: 'instant'
})
event.preventDefault()
}
const handleScroll = (event) => {
const target = event.target
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
isScrolling.value = true
if (scrollTimeout.value) {
clearTimeout(scrollTimeout.value)
}
scrollTimeout.value = setTimeout(() => {
isScrolling.value = false
}, 150)
// In inverted scroll:
// - distanceToTop (visual top) = scrollHeight - scrollTop - clientHeight
// - distanceToBottom (visual bottom) = scrollTop
// - isAtTop (visual top, older data) = distanceToTop <= threshold
// - isAtBottom (visual bottom, newer data) = distanceToBottom <= threshold
const distanceToTop = scrollHeight - scrollTop - clientHeight
const distanceToBottom = scrollTop
const threshold = 5
const isAtTop = distanceToTop <= threshold
const isAtBottom = distanceToBottom <= threshold
emit('scroll', {
scrollTop,
scrollHeight,
clientHeight,
distanceToTop,
distanceToBottom,
isAtTop,
isAtBottom
})
// scroll-start: reached visual top (older data, need to load more)
if (isAtTop) {
emit('scroll-start')
}
// scroll-end: reached visual bottom (newer data)
if (isAtBottom) {
emit('scroll-end')
}
}
const scrollToIndex = (index, behavior = 'auto') => {
if (!scrollContainerRef.value || index < 0 || index >= props.data.length) return
let offset = 0
for (let i = 0; i < index; i++) {
const key = getKey(props.data[i], i)
offset += getItemHeight(key)
}
const targetScrollTop = offset + props.placeholderHeight
scrollContainerRef.value.scrollTo({
top: targetScrollTop,
behavior
})
}
const scrollToBottom = (behavior = 'smooth') => {
if (!scrollContainerRef.value) return
requestAnimationFrame(() => {
if (!scrollContainerRef.value) return
// In inverted scroll, bottom is scrollTop = 0
scrollContainerRef.value.scrollTo({ top: 0, behavior })
})
}
const scrollToTop = (behavior = 'smooth') => {
if (!scrollContainerRef.value) return
requestAnimationFrame(() => {
if (!scrollContainerRef.value) return
// In inverted scroll, top is scrollTop = max
scrollContainerRef.value.scrollTo({
top: scrollContainerRef.value.scrollHeight,
behavior
})
})
}
const getScrollElement = () => scrollContainerRef.value
const isAtTop = () => {
if (!scrollContainerRef.value) return false
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.value
return scrollHeight - scrollTop - clientHeight <= 5
}
const isAtBottom = () => {
if (!scrollContainerRef.value) return false
return scrollContainerRef.value.scrollTop <= 5
}
const reset = () => {
itemHeights.value = new Map()
itemRefs.clear()
}
watch(() => props.data, (newData, oldData) => {
const newLength = newData?.length || 0
const oldLength = oldData?.length || 0
if (newLength < oldLength) {
reset()
}
nextTick(observeItems)
}, { deep: true })
watch(visibleItems, () => {
nextTick(observeItems)
}, { deep: true })
onMounted(() => {
setupResizeObserver()
nextTick(observeItems)
})
onBeforeUnmount(() => {
if (resizeObserver.value) {
resizeObserver.value.disconnect()
}
if (scrollTimeout.value) {
clearTimeout(scrollTimeout.value)
}
itemRefs.clear()
})
defineExpose({
scrollToIndex,
scrollToBottom,
scrollToTop,
getScrollElement,
isAtTop,
isAtBottom,
reset,
scrollContainerRef
})
</script>
<style lang="less" scoped>
.virtual-scroller {
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
&-wrapper {
contain: content;
}
&-container {
contain: layout style;
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
}
&-spacer {
flex-shrink: 0;
width: 100%;
}
&-item {
contain: layout style;
backface-visibility: hidden;
perspective: 1000px;
}
&-placeholder {
contain: layout style;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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