diff --git a/components.d.ts b/components.d.ts index 9020d24..6a438bf 100644 --- a/components.d.ts +++ b/components.d.ts @@ -11,71 +11,28 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { - AIgenerate: typeof import('./src/components/AIgenerate/AIgenerate.vue')['default'] Canvas: typeof import('./src/components/canvas/index.vue')['default'] - Collection: typeof import('./src/components/collection/index.vue')['default'] - copy: typeof import('./src/components/ModelDescription copy.vue')['default'] - DeepseekPopover: typeof import('./src/components/AIgenerate/DeepseekPopover.vue')['default'] DialogBox: typeof import('./src/components/dialogBox/index.vue')['default'] - ElAlert: typeof import('element-plus/es')['ElAlert'] - ElAside: typeof import('element-plus/es')['ElAside'] ElButton: typeof import('element-plus/es')['ElButton'] - ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] - ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] - ElContainer: typeof import('element-plus/es')['ElContainer'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] - ElDialog: typeof import('element-plus/es')['ElDialog'] - ElDrawer: typeof import('element-plus/es')['ElDrawer'] - ElForm: typeof import('element-plus/es')['ElForm'] - ElFormItem: typeof import('element-plus/es')['ElFormItem'] - ElHeader: typeof import('element-plus/es')['ElHeader'] - ElIcon: typeof import('element-plus/es')['ElIcon'] - ElInput: typeof import('element-plus/es')['ElInput'] - ElMain: typeof import('element-plus/es')['ElMain'] - ElPagination: typeof import('element-plus/es')['ElPagination'] ElPopover: typeof import('element-plus/es')['ElPopover'] - ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] - ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] - ElTable: typeof import('element-plus/es')['ElTable'] - ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] - ElTabPane: typeof import('element-plus/es')['ElTabPane'] - ElTabs: typeof import('element-plus/es')['ElTabs'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElUpload: typeof import('element-plus/es')['ElUpload'] - IEpArrow: typeof import('~icons/ep/arrow')['default'] - IEpArrowDown: typeof import('~icons/ep/arrow-down')['default'] - IEpBack: typeof import('~icons/ep/back')['default'] IEpCalendar: typeof import('~icons/ep/calendar')['default'] - IEpCirclePlusFilled: typeof import('~icons/ep/circle-plus-filled')['default'] IEpClose: typeof import('~icons/ep/close')['default'] - IEpDelete: typeof import('~icons/ep/delete')['default'] - IEpDownload: typeof import('~icons/ep/download')['default'] - IEpEdit: typeof import('~icons/ep/edit')['default'] - IEpHouse: typeof import('~icons/ep/house')['default'] IEpLoading: typeof import('~icons/ep/loading')['default'] IEpPlus: typeof import('~icons/ep/plus')['default'] - IEpRefresh: typeof import('~icons/ep/refresh')['default'] - IEpRefreshRight: typeof import('~icons/ep/refresh-right')['default'] - IEpRight: typeof import('~icons/ep/right')['default'] IEpStar: typeof import('~icons/ep/star')['default'] - IEpStarFilled: typeof import('~icons/ep/star-filled')['default'] - IEpTop: typeof import('~icons/ep/top')['default'] - IEpUploadFilled: typeof import('~icons/ep/upload-filled')['default'] ImageUploader: typeof import('./src/components/dialogBox/imageUploader/index.vue')['default'] Img: typeof import('./src/components/Img/index.vue')['default'] - 'Index(1)': typeof import('./src/components/canvas/index(1).vue')['default'] - Library: typeof import('./src/components/Library/index.vue')['default'] Model: typeof import('./src/components/dialogBox/model/index.vue')['default'] - ModelParam: typeof import('./src/components/AIgenerate/components/modelParam.vue')['default'] - Official: typeof import('./src/components/Library/official.vue')['default'] - Private: typeof import('./src/components/Library/private.vue')['default'] Proportion: typeof import('./src/components/dialogBox/proportion/index.vue')['default'] Quantity: typeof import('./src/components/dialogBox/quantity/index.vue')['default'] - Recommend: typeof import('./src/components/AIgenerate/components/recommend.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] Select: typeof import('./src/components/Select/index.vue')['default'] - Subpage: typeof import('./src/components/subpage.vue')['default'] UploadPicture: typeof import('./src/components/UploadPicture.vue')['default'] + VirtualScroller: typeof import('./src/components/virtual-scroller/VirtualScroller.vue')['default'] + VirtualScrollerItem: typeof import('./src/components/virtual-scroller/VirtualScrollerItem.vue')['default'] } } diff --git a/src/assets/dialog/LingmaUserSetup-x64.exe b/src/assets/dialog/LingmaUserSetup-x64.exe new file mode 100644 index 0000000..ba4abe4 Binary files /dev/null and b/src/assets/dialog/LingmaUserSetup-x64.exe differ diff --git a/src/components/Img/index.vue b/src/components/Img/index.vue index 4c0aa48..7d94082 100644 --- a/src/components/Img/index.vue +++ b/src/components/Img/index.vue @@ -6,14 +6,14 @@ class="img-element" @click="handleImageClick" /> -
+ diff --git a/src/components/virtual-scroller/README.md b/src/components/virtual-scroller/README.md new file mode 100644 index 0000000..7c3a63b --- /dev/null +++ b/src/components/virtual-scroller/README.md @@ -0,0 +1,175 @@ +# 高性能虚拟滚动组件 + +一个基于 Vue 3 Composition API 开发的高性能虚拟滚动组件,支持从底部开始渲染(容器向上增大),适用于聊天记录、历史列表等场景。 + +## 特性 + +### 核心功能 +- ✅ 虚拟滚动核心,只渲染可视区域内容 +- ✅ 支持未知高度内容,自动测量和缓存高度 +- ✅ 插槽式组件插入,灵活易用 +- ✅ 两种渲染模式:顶部模式(向下滚动)和底部模式(向上滚动) +- ✅ 滚动触发加载更多事件 +- ✅ 性能优化,防止滚动抖动 + +### 补充功能 +- ✅ 自定义渲染函数 +- ✅ 动态数据更新 +- ✅ 滚动位置保存和恢复 +- ✅ 自定义滚动阈值 + +### 外部接口 +- ✅ 丰富的 Props 配置 +- ✅ 完整的方法暴露 + +## 安装 + +组件位于 `@/components/virtual-scroller`,无需额外安装依赖。 + +## 快速开始 + +### 基本使用 + +```vue + + + +``` + +### Props 说明 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| data | Array | [] | 列表数据 | +| itemHeight | Number | 80 | 预估列表项高度 | +| estimatedHeight | Number | 80 | 未知高度内容的预估高度 | +| renderMode | String | 'bottom' | 渲染模式,'top' \| 'bottom' | +| scrollThreshold | Number | 100 | 滚动触发阈值 | +| onLoadMore | Function | null | 加载更多回调函数 | +| renderItem | Function | null | 自定义渲染函数 | +| keyExtractor | Function | (item, index) => index | 列表项 key 生成函数 | +| cacheHeight | Boolean | true | 是否缓存已测量的高度 | +| buffer | Number | 5 | 预渲染缓冲区数量 | + +### 事件说明 + +| 事件名 | 参数 | 说明 | +|--------|------|------| +| scroll | event | 滚动事件 | +| load-more | - | 加载更多事件 | +| item-height-change | index, height | 列表项高度变化时触发 | + +### 暴露方法 + +通过 `ref` 可以调用以下方法: + +```vue + + + +``` + +## 渲染模式说明 + +### 顶部模式 (renderMode: 'top') + +内容从顶部开始显示,向下滚动加载更多。适用于普通列表。 + +```vue + + + +``` + +### 底部模式 (renderMode: 'bottom') ⭐ 核心特性 + +内容从底部开始显示,向上滚动加载更多,**容器垂直向上增大**。适用于聊天记录、历史列表等场景。 + +```vue + + + +``` + +## 性能优化 + +- 使用 `requestAnimationFrame` 优化渲染 +- 节流处理滚动事件(16ms) +- 高度缓存机制,避免重复测量 +- 使用 Intersection Observer 优化可见性检测 +- 只渲染可视区域 + 缓冲区内容 + +## 注意事项 + +1. `renderMode: 'bottom'` 模式下,数据建议按时间倒序排列(最新的在最后) +2. 列表项需要有明确的高度或能被正确测量 +3. 使用图片等异步内容时,建议设置 `min-height` 防止布局抖动 + +## 文件结构 + +``` +virtual-scroller/ +├── index.js # 组件入口 +├── VirtualScroller.vue # 主组件 +├── VirtualScrollerItem.vue # 列表项组件 +├── useVirtualScroller.js # 组合式 API 钩子 +└── README.md # 本文档 +``` diff --git a/src/components/virtual-scroller/VirtualScroller.vue b/src/components/virtual-scroller/VirtualScroller.vue new file mode 100644 index 0000000..3537b65 --- /dev/null +++ b/src/components/virtual-scroller/VirtualScroller.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/src/components/virtual-scroller/VirtualScrollerItem.vue b/src/components/virtual-scroller/VirtualScrollerItem.vue new file mode 100644 index 0000000..d75a660 --- /dev/null +++ b/src/components/virtual-scroller/VirtualScrollerItem.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/src/components/virtual-scroller/index.js b/src/components/virtual-scroller/index.js new file mode 100644 index 0000000..a12037f --- /dev/null +++ b/src/components/virtual-scroller/index.js @@ -0,0 +1,11 @@ +import VirtualScroller from './VirtualScroller.vue' +import VirtualScrollerItem from './VirtualScrollerItem.vue' +import useVirtualScroller from './useVirtualScroller' + +export { + VirtualScroller, + VirtualScrollerItem, + useVirtualScroller +} + +export default VirtualScroller diff --git a/src/components/virtual-scroller/useVirtualScroller.js b/src/components/virtual-scroller/useVirtualScroller.js new file mode 100644 index 0000000..1c5fdee --- /dev/null +++ b/src/components/virtual-scroller/useVirtualScroller.js @@ -0,0 +1,246 @@ +import { ref, computed, watch, nextTick } from 'vue' + +export default function useVirtualScroller(props, emit, scrollerRef) { + const heights = ref(new Map()) + const positions = ref([]) + const scrollTop = ref(0) + const clientHeight = ref(0) + const isLoading = ref(false) + let lastScrollTime = 0 + let animationFrameId = null + const savedScrollPosition = ref(null) + + const getHeight = (index) => { + return heights.value.get(index) || props.estimatedHeight + } + + const initPositions = () => { + const data = props.data || [] + positions.value = [] + let top = 0 + for (let i = 0; i < data.length; i++) { + const height = getHeight(i) + positions.value.push({ + index: i, + top, + bottom: top + height, + height + }) + top += height + } + } + + const totalHeight = computed(() => { + if (positions.value.length === 0) return 0 + return positions.value[positions.value.length - 1].bottom + }) + + const getVisibleRange = () => { + const data = props.data || [] + if (data.length === 0) return { start: 0, end: 0 } + + let start = 0 + let end = data.length + + const currentScrollTop = scrollTop.value + const currentClientHeight = clientHeight.value + + for (let i = 0; i < positions.value.length; i++) { + if (positions.value[i].bottom > currentScrollTop) { + start = i + break + } + } + + for (let i = start; i < positions.value.length; i++) { + if (positions.value[i].top > currentScrollTop + currentClientHeight) { + end = i + break + } + } + + start = Math.max(0, start - props.buffer) + end = Math.min(data.length, end + props.buffer) + + return { start, end } + } + + const visibleData = computed(() => { + const { start, end } = getVisibleRange() + const data = props.data || [] + const result = [] + for (let i = start; i < end; i++) { + result.push({ + item: data[i], + index: i, + top: positions.value[i]?.top || 0 + }) + } + return result + }) + + const handleScroll = (event) => { + const now = Date.now() + const throttleTime = 16 + + if (now - lastScrollTime < throttleTime) return + lastScrollTime = now + + if (animationFrameId) { + cancelAnimationFrame(animationFrameId) + } + + animationFrameId = requestAnimationFrame(() => { + const target = event.target + scrollTop.value = target.scrollTop + clientHeight.value = target.clientHeight + + checkLoadMore(target) + }) + } + + const checkLoadMore = (target) => { + if (isLoading.value || !props.onLoadMore) return + + const { scrollTop: st, scrollHeight, clientHeight: ch } = target + const distanceToTop = st + const distanceToBottom = scrollHeight - st - ch + + if (props.renderMode === 'bottom') { + if (distanceToTop <= props.scrollThreshold) { + loadMore() + } + } else { + if (distanceToBottom <= props.scrollThreshold) { + loadMore() + } + } + } + + const loadMore = async () => { + if (isLoading.value || !props.onLoadMore) return + isLoading.value = true + emit('load-more') + try { + await props.onLoadMore() + } finally { + isLoading.value = false + } + } + + const scrollToBottom = () => { + nextTick(() => { + if (scrollerRef.value) { + scrollerRef.value.scrollTop = scrollerRef.value.scrollHeight + } + }) + } + + const scrollToTop = () => { + nextTick(() => { + if (scrollerRef.value) { + scrollerRef.value.scrollTop = 0 + } + }) + } + + const scrollToIndex = (index) => { + nextTick(() => { + if (scrollerRef.value && positions.value[index]) { + scrollerRef.value.scrollTop = positions.value[index].top + } + }) + } + + const updateData = (newData) => { + initPositions() + } + + const getScrollPosition = () => { + if (scrollerRef.value) { + return scrollerRef.value.scrollTop + } + return 0 + } + + const setScrollPosition = (position) => { + nextTick(() => { + if (scrollerRef.value) { + scrollerRef.value.scrollTop = position + } + }) + } + + const updateItemHeight = (index, height) => { + if (!props.cacheHeight) return + + const oldHeight = heights.value.get(index) || props.estimatedHeight + if (oldHeight === height) return + + heights.value.set(index, height) + + const oldScrollTop = scrollerRef.value?.scrollTop || 0 + const oldScrollHeight = scrollerRef.value?.scrollHeight || 0 + + initPositions() + + nextTick(() => { + if (scrollerRef.value && props.renderMode === 'bottom') { + const newScrollHeight = scrollerRef.value.scrollHeight + const heightDiff = newScrollHeight - oldScrollHeight + if (heightDiff > 0) { + scrollerRef.value.scrollTop = oldScrollTop + heightDiff + } + } + }) + } + + const clearHeightCache = () => { + heights.value.clear() + initPositions() + } + + const initScroll = () => { + initPositions() + nextTick(() => { + if (scrollerRef.value) { + clientHeight.value = scrollerRef.value.clientHeight + if (props.renderMode === 'bottom') { + scrollToBottom() + } + } + }) + } + + watch(() => props.data, () => { + const oldScrollTop = scrollerRef.value?.scrollTop || 0 + const oldScrollHeight = scrollerRef.value?.scrollHeight || 0 + + initPositions() + + nextTick(() => { + if (scrollerRef.value && props.renderMode === 'bottom') { + const newScrollHeight = scrollerRef.value.scrollHeight + const heightDiff = newScrollHeight - oldScrollHeight + if (heightDiff > 0) { + scrollerRef.value.scrollTop = oldScrollTop + heightDiff + } + } + }) + }, { deep: true, immediate: true }) + + return { + visibleData, + totalHeight, + scrollToBottom, + scrollToTop, + scrollToIndex, + updateData, + getScrollPosition, + setScrollPosition, + updateItemHeight, + clearHeightCache, + handleScroll, + initScroll + } +} diff --git a/src/router/index.js b/src/router/index.js index 3949417..7351d92 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -5,7 +5,7 @@ import { getToken, setToken } from '@/utils/auth' const routes = [ { path: '/', - redirect: '/home' + redirect: '/aipaint' }, { path: '/login', @@ -13,8 +13,8 @@ const routes = [ component: () => import('@/views/login/index.vue') }, { - path: '/home', - name: 'home', + path: '/aipaint', + name: 'aipaint', component: () => import('@/views/home/index.vue') }, { diff --git a/src/stores/display.js b/src/stores/display.js index 57e12b1..9352dfe 100644 --- a/src/stores/display.js +++ b/src/stores/display.js @@ -41,6 +41,13 @@ const DisplayStoreSetup = () => { } try { + if (typeof refValue.scrollToBottom === 'function') { + console.log('store - 使用新组件 scrollToBottom') + await nextTick() + refValue.scrollToBottom() + return + } + const scrollerEl = refValue.$el if (scrollerEl) { const viewport = scrollerEl.querySelector('.vue-recycle-scroller__viewport') diff --git a/src/views/home/display/components/set.vue b/src/views/home/display/components/set.vue index 70964c0..2dedf6a 100644 --- a/src/views/home/display/components/set.vue +++ b/src/views/home/display/components/set.vue @@ -44,7 +44,8 @@
- index + + index
@@ -75,13 +76,13 @@ + + diff --git a/src/views/home/display/index-vue-virtual-scroller.vue b/src/views/home/display/index-vue-virtual-scroller.vue new file mode 100644 index 0000000..1b16239 --- /dev/null +++ b/src/views/home/display/index-vue-virtual-scroller.vue @@ -0,0 +1,391 @@ + + + + + \ No newline at end of file diff --git a/src/views/home/display/index.vue b/src/views/home/display/index.vue new file mode 100644 index 0000000..233dcb3 --- /dev/null +++ b/src/views/home/display/index.vue @@ -0,0 +1,370 @@ + + + + + \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index 246436b..0b1a453 100644 --- a/vite.config.js +++ b/vite.config.js @@ -30,7 +30,7 @@ export default defineConfig(({ _command, mode }) => { build: { chunkSizeWarningLimit: 2000, outDir: 'dist', - minify: 'terser', + minify: 'esbuild', terserOptions: { compress: { keep_infinity: true,