fix: VirtualScroller 滚动锚定防抖 + platform 方法引用修复 + CLAUDE.md 更新
- VirtualScroller: measureItem 高度变化时,对可视区上方项的累积 delta 通过微任务延迟补偿 scrollTop,避免同步调整导致的画面抖动 - VirtualScroller: 新增独立测试页 test.html + test-data.js,用于验证虚拟滚动行为 - platform: 修复 painting/video 中 imageUploadLimit() 调用方式为 this.imageUploadLimit() - display: 修复 Sender_variant 在非 pageTop/pageBottom 中间状态时未设置的问题,补充 isInitializing 异常状态重置 - CLAUDE.md: 补充 VirtualScroller 180deg 旋转机制说明、模型切换完整链路、反旋转注意事项
This commit is contained in:
parent
72e4acf956
commit
481afadd2b
@ -61,7 +61,7 @@ src/
|
|||||||
│ ├── Popover/ # 自定义弹出层(Teleport to body,position:fixed + fit-content 宽度)
|
│ ├── Popover/ # 自定义弹出层(Teleport to body,position:fixed + fit-content 宽度)
|
||||||
│ ├── Select/ # 自定义下拉选择器(分组选项,与 Popover 协调互斥开关)
|
│ ├── Select/ # 自定义下拉选择器(分组选项,与 Popover 协调互斥开关)
|
||||||
│ ├── Img/ # 图片包装组件(点击全屏查看,Teleport 实现)
|
│ ├── Img/ # 图片包装组件(点击全屏查看,Teleport 实现)
|
||||||
│ ├── virtual-scroller/# 虚拟滚动列表组件(自定义实现,reverse 模式)
|
│ ├── virtual-scroller/ # 虚拟滚动列表(自定义实现)。reverse 模式用 180deg 旋转实现底部锚定,slot 内容须反旋转
|
||||||
│ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘,undo/redo)
|
│ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘,undo/redo)
|
||||||
├── views/ # 页面(home、login)
|
├── views/ # 页面(home、login)
|
||||||
└── utils/
|
└── utils/
|
||||||
@ -129,7 +129,7 @@ props: (config) => ({
|
|||||||
- **平台切换**:`const platform = computed(() => createPlatform(props.type))`,切换时重置默认模型并加载模型列表
|
- **平台切换**:`const platform = computed(() => createPlatform(props.type))`,切换时重置默认模型并加载模型列表
|
||||||
- **控件渲染**:`visibleControls = platform.controls.filter(c => c.show(getCurrentConfig()))`,用 `<component :is>` + `v-bind="ctrl.props(...)"` 渲染
|
- **控件渲染**:`visibleControls = platform.controls.filter(c => c.show(getCurrentConfig()))`,用 `<component :is>` + `v-bind="ctrl.props(...)"` 渲染
|
||||||
- **配置获取**:`getCurrentConfig()` 返回 `platform.modelConfig?.value ?? platform.modelDisplayConfig?.value`,兼容两种配置来源
|
- **配置获取**:`getCurrentConfig()` 返回 `platform.modelConfig?.value ?? platform.modelDisplayConfig?.value`,兼容两种配置来源
|
||||||
- **模型切换**:监听 `model + modelType`,调用 `await platform.loadConfig(newModel, newModelType)`
|
- **模型切换**:`watch([model, modelType])` → `platform.loadConfig()` → `syncDefaults()` 将 schema 写入响应式 state → controls 的 `show()`/`props()` 读取 state → `visibleControls` 自动更新 → UI 重渲染
|
||||||
- **任务发起**:`handleStart()` 调用 `platform.validateBeforeSubmit()` → `platform.buildTaskBody()` → `generate()`
|
- **任务发起**:`handleStart()` 调用 `platform.validateBeforeSubmit()` → `platform.buildTaskBody()` → `generate()`
|
||||||
- **参数回填**:`fillParamsFromResult()` 委托给 `platform.fillFromResult()`
|
- **参数回填**:`fillParamsFromResult()` 委托给 `platform.fillFromResult()`
|
||||||
|
|
||||||
@ -185,6 +185,7 @@ Painting 模型参数 schema 在 `src/platforms/painting/models/*.js` 中,参
|
|||||||
- **`X-Session-Id`** 自定义 header 需要 nginx 在 `/suanli/` location 的 `Access-Control-Allow-Headers` 中加入,否则 POST 请求会触发 CORS 预检失败。
|
- **`X-Session-Id`** 自定义 header 需要 nginx 在 `/suanli/` location 的 `Access-Control-Allow-Headers` 中加入,否则 POST 请求会触发 CORS 预检失败。
|
||||||
- **模型列表缓存**:`modelApi.js` 中 `fetchPlatformModels` 使用 localStorage 30 秒 TTL + `pendingRequests` Map 并发去重。
|
- **模型列表缓存**:`modelApi.js` 中 `fetchPlatformModels` 使用 localStorage 30 秒 TTL + `pendingRequests` Map 并发去重。
|
||||||
- **平台包预加载**:dialogBox 顶层 `import` Painting 和 Video 平台包,触发自注册,确保首次使用时 registry 已就绪。
|
- **平台包预加载**:dialogBox 顶层 `import` Painting 和 Video 平台包,触发自注册,确保首次使用时 registry 已就绪。
|
||||||
|
- **VirtualScroller 反旋转**:组件用外层 `transform: rotate(180deg)` 实现 reverse 底部锚定。所有作为 slot 插入的内容必须在根元素加 `transform: rotate(180deg)` 反旋转,否则文字/图片会颠倒显示。参考 `src/views/home/display/components/set.vue:2`。
|
||||||
|
|
||||||
### 接口速查
|
### 接口速查
|
||||||
|
|
||||||
|
|||||||
@ -296,6 +296,10 @@ const getItemStyle = (renderItem) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 滚动锚定:累积可视区上方项的高度变化量,在微任务中统一补偿
|
||||||
|
let pendingScrollDelta = 0
|
||||||
|
let scrollAdjustPending = false
|
||||||
|
|
||||||
const measureItem = (index, element) => {
|
const measureItem = (index, element) => {
|
||||||
if (!element) return
|
if (!element) return
|
||||||
|
|
||||||
@ -307,8 +311,31 @@ const measureItem = (index, element) => {
|
|||||||
if (height > 0) {
|
if (height > 0) {
|
||||||
const cachedHeight = itemHeights.value.get(index)
|
const cachedHeight = itemHeights.value.get(index)
|
||||||
if (cachedHeight !== height) {
|
if (cachedHeight !== height) {
|
||||||
|
const oldHeight = cachedHeight ?? props.estimatedHeight
|
||||||
|
const delta = height - oldHeight
|
||||||
|
|
||||||
|
// 快照更新前的可视范围起始索引
|
||||||
|
const prevStart = visibleRange.value.start
|
||||||
|
|
||||||
itemHeights.value.set(index, height)
|
itemHeights.value.set(index, height)
|
||||||
heightVersion.value++
|
heightVersion.value++
|
||||||
|
|
||||||
|
// 可视区上方的项高度变化会影响当前视口,累积 delta 延迟补偿
|
||||||
|
if (delta !== 0 && index < prevStart) {
|
||||||
|
pendingScrollDelta += delta
|
||||||
|
if (!scrollAdjustPending) {
|
||||||
|
scrollAdjustPending = true
|
||||||
|
// 微任务在 Vue 重新渲染之后、浏览器绘制之前执行
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
scrollAdjustPending = false
|
||||||
|
const container = renderContainerRef.value
|
||||||
|
if (container && pendingScrollDelta !== 0) {
|
||||||
|
container.scrollTop += pendingScrollDelta
|
||||||
|
pendingScrollDelta = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
194
src/components/virtual-scroller/test-data.js
Normal file
194
src/components/virtual-scroller/test-data.js
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
window.TEST_DATA = [
|
||||||
|
{
|
||||||
|
"id": "839217090555557410",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/27/69ef43d23888d39e4ed1d062.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "839211834861958673",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/27/69ef3eed3888d39e4ed1d061.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "BananaPro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "839209605929121287",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/27/69ef3cd93888d39e4ed1d060.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "837370053866304564",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/22/69e88ba23888d39e4ed1d05c.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "837360015437214709",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/22/69e882493888d39e4ed1d05b.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "836534979084169461",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/20/69e581e93888d39e4ed1d025.png",
|
||||||
|
"prompt": "<div data-v-43afc57f=\"\" class=\"prompt-container\" style=\"width: 1118px; font-size: 14px; background-color: rgb(255, 255, 255); height: 39px;\"><div data-v-43afc57f=\"\" class=\"prompt-wrapper\" style=\"width: 1118px;\"><div data-v-43afc57f=\"\" class=\"prompt expanded\" style=\"max-height: none; overflow: visible;\"><span data-v-43afc57f=\"\" class=\"prompt-text\">将图1红色框内的【苹果】替换为【火龙果】</span></div><div><span data-v-43afc57f=\"\" class=\"prompt-text\"><br></span></div></div></div><div data-v-43afc57f=\"\" class=\"box success-box\" style=\"width: 1118px; font-size: 14px; background-color: rgb(255, 255, 255);\"><div data-v-43afc57f=\"\" class=\"one-box\"></div></div>",
|
||||||
|
"model": "banana"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "835464458670191734",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/17/69e19ce93888d39e4ed1cfaf.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "835463648116749398",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/17/69e19c273888d39e4ed1cfac.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "835463392293565520",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/17/69e19bea3888d39e4ed1cfab.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "832562717234575283",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/9/69d70e733888d39e4ed1cf66.png,http://test.xueai.art/file/2026/4/9/69d70e733888d39e4ed1cf67.png,http://test.xueai.art/file/2026/4/9/69d70e733888d39e4ed1cf68.png,http://test.xueai.art/file/2026/4/9/69d70e743888d39e4ed1cf69.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "839217090555557410",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/27/69ef43d23888d39e4ed1d062.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "839211834861958673",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/27/69ef3eed3888d39e4ed1d061.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "BananaPro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "839209605929121287",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/27/69ef3cd93888d39e4ed1d060.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "837370053866304564",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/22/69e88ba23888d39e4ed1d05c.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "837360015437214709",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/22/69e882493888d39e4ed1d05b.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "836534979084169461",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/20/69e581e93888d39e4ed1d025.png",
|
||||||
|
"prompt": "<div data-v-43afc57f=\"\" class=\"prompt-container\" style=\"width: 1118px; font-size: 14px; background-color: rgb(255, 255, 255); height: 39px;\"><div data-v-43afc57f=\"\" class=\"prompt-wrapper\" style=\"width: 1118px;\"><div data-v-43afc57f=\"\" class=\"prompt expanded\" style=\"max-height: none; overflow: visible;\"><span data-v-43afc57f=\"\" class=\"prompt-text\">将图1红色框内的【苹果】替换为【火龙果】</span></div><div><span data-v-43afc57f=\"\" class=\"prompt-text\"><br></span></div></div></div><div data-v-43afc57f=\"\" class=\"box success-box\" style=\"width: 1118px; font-size: 14px; background-color: rgb(255, 255, 255);\"><div data-v-43afc57f=\"\" class=\"one-box\"></div></div>",
|
||||||
|
"model": "banana"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "835464458670191734",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/17/69e19ce93888d39e4ed1cfaf.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "835463648116749398",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/17/69e19c273888d39e4ed1cfac.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "835463392293565520",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/17/69e19bea3888d39e4ed1cfab.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "832562717234575283",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/9/69d70e733888d39e4ed1cf66.png,http://test.xueai.art/file/2026/4/9/69d70e733888d39e4ed1cf67.png,http://test.xueai.art/file/2026/4/9/69d70e733888d39e4ed1cf68.png,http://test.xueai.art/file/2026/4/9/69d70e743888d39e4ed1cf69.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "830138209152283808",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/2/69ce3c743888d39e4ed1cf4f.png",
|
||||||
|
"prompt": "将图1红色框内的【苹果】替换为【火龙果】",
|
||||||
|
"model": "banana"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "830136945106498711",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/2/69ce3b463888d39e4ed1cf4e.png",
|
||||||
|
"prompt": "一个女孩在树下吃苹果",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "830083758811001839",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/4/2/69ce09be3888d39e4ed1cf48.png",
|
||||||
|
"prompt": "一个女孩在树下吃苹果",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "829393290267734386",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/3/31/69cb86b13888d39e4ed1cf32.png",
|
||||||
|
"prompt": "将图1红色框内的【女孩】替换为【图2中的女孩】",
|
||||||
|
"model": "banana"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "829389466203337022",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/3/31/69cb83223888d39e4ed1cf31.png",
|
||||||
|
"prompt": "将图1红色框内的【<span style=\"font-size: 14px;\">女孩</span>】替换为【图2中的女孩】",
|
||||||
|
"model": "banana"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "829388114303660338",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/3/31/69cb81df3888d39e4ed1cf30.png",
|
||||||
|
"prompt": "将图1红色框内的【女孩】替换为【男孩】",
|
||||||
|
"model": "banana"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "829381253919682782",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/3/31/69cb7b7c3888d39e4ed1cf2d.png",
|
||||||
|
"prompt": "将图1红色框内的【女孩】替换为【图2中的男孩】",
|
||||||
|
"model": "banana"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "829324561060212843",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/3/31/69cb46af3888d39e4ed1cf29.png",
|
||||||
|
"prompt": "一个女孩在树下吃苹果",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "829319226454978647",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/3/31/69cb41b73888d39e4ed1cf25.png,http://test.xueai.art/file/2026/3/31/69cb41b73888d39e4ed1cf26.png,http://test.xueai.art/file/2026/3/31/69cb41b73888d39e4ed1cf27.png,http://test.xueai.art/file/2026/3/31/69cb41b73888d39e4ed1cf28.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "829317957644464188",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/3/31/69cb40893888d39e4ed1cf20.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "829305227994738709",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/3/31/69cb34ae3888d39e4ed1cf1e.png",
|
||||||
|
"prompt": "这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格",
|
||||||
|
"model": "flux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "829068575099597628",
|
||||||
|
"fileUrl": "http://test.xueai.art/file/2026/3/30/69ca58473888d39e0bb8728b.png",
|
||||||
|
"prompt": "",
|
||||||
|
"model": ""
|
||||||
|
}
|
||||||
|
];
|
||||||
700
src/components/virtual-scroller/test.html
Normal file
700
src/components/virtual-scroller/test.html
Normal file
@ -0,0 +1,700 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VirtualScroller 测试页</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #eee;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 控制面板 */
|
||||||
|
.control-panel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #16213e;
|
||||||
|
border-bottom: 1px solid #0f3460;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.control-panel button {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: 1px solid #0f3460;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0f3460;
|
||||||
|
color: #e94560;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.control-panel button:hover {
|
||||||
|
background: #e94560;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #e94560;
|
||||||
|
}
|
||||||
|
.control-panel .stat {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a0a0b0;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.control-panel .stat span {
|
||||||
|
color: #e94560;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.control-panel .divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: #0f3460;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 虚拟滚动容器 */
|
||||||
|
.test-scroller-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消息卡片 */
|
||||||
|
.message-card {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.message-card .card-img-wrap {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 274px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0f3460;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.message-card .card-img-wrap img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.message-card .card-img-wrap .img-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 150px;
|
||||||
|
background: linear-gradient(135deg, #0f3460, #16213e);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a0a0b0;
|
||||||
|
}
|
||||||
|
.message-card .card-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.message-card .card-model {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #e94560;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.message-card .card-prompt {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #ccc;
|
||||||
|
word-break: break-word;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.message-card .card-index {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部加载提示 */
|
||||||
|
.bottom-loader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
gap: 8px;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.bottom-loader .dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e94560;
|
||||||
|
animation: pulse 1.4s infinite ease-in-out both;
|
||||||
|
}
|
||||||
|
.bottom-loader .dot:nth-child(1) { animation-delay: -0.32s; }
|
||||||
|
.bottom-loader .dot:nth-child(2) { animation-delay: -0.16s; }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 80%, 100% { transform: scale(0); opacity: 0.3; }
|
||||||
|
40% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 多图网格 */
|
||||||
|
.multi-img-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.multi-img-grid img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义滚动条 */
|
||||||
|
::-webkit-scrollbar { width: 4px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div class="control-panel">
|
||||||
|
<button @click="addSingleItem">+ 添加 1 条</button>
|
||||||
|
<button @click="addBatchItems">+ 批量添加 10 条</button>
|
||||||
|
<button @click="scrollToBottom">↓ 滚到底部</button>
|
||||||
|
<button @click="scrollToTop">↑ 滚到顶部</button>
|
||||||
|
<span class="divider"></span>
|
||||||
|
<span class="stat">总数: <span>{{ stats.total }}</span></span>
|
||||||
|
<span class="stat">可见范围: <span>{{ stats.firstVisible }} - {{ stats.lastVisible }}</span></span>
|
||||||
|
<span class="stat">可见数: <span>{{ stats.visibleCount }}</span></span>
|
||||||
|
<span class="stat">测量项目: <span>{{ stats.measuredCount }}</span></span>
|
||||||
|
<span class="stat">高度版本: <span>{{ stats.heightVersion }}</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-scroller-wrap">
|
||||||
|
<virtual-scroller
|
||||||
|
ref="scrollerRef"
|
||||||
|
:items="displayItems"
|
||||||
|
:estimated-height="180"
|
||||||
|
:buffer-size="2"
|
||||||
|
direction="reverse"
|
||||||
|
:bottom-placeholder-height="60"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
>
|
||||||
|
<!-- 消息卡片 -->
|
||||||
|
<template #default="{ item }">
|
||||||
|
<div class="message-card">
|
||||||
|
<div class="card-img-wrap">
|
||||||
|
<img
|
||||||
|
v-if="item.firstImageUrl"
|
||||||
|
:src="item.firstImageUrl"
|
||||||
|
@load="onImgLoad"
|
||||||
|
@error="onImgError"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
style="width:100%;height:auto;display:block;"
|
||||||
|
>
|
||||||
|
<div v-else class="img-placeholder">无图片</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-model">{{ item.model || 'Unknown' }}</div>
|
||||||
|
<div class="card-prompt">{{ item.prompt }}</div>
|
||||||
|
<div class="card-index">#{{ item._idx }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 底部占位 -->
|
||||||
|
<template #bottom-placeholder>
|
||||||
|
<div class="bottom-loader">
|
||||||
|
<div class="dot"></div>
|
||||||
|
<div class="dot"></div>
|
||||||
|
<div class="dot"></div>
|
||||||
|
<span>下拉加载更多</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</virtual-scroller>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./test-data.js"></script>
|
||||||
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||||
|
<script>
|
||||||
|
const { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount, defineComponent, createApp } = Vue
|
||||||
|
|
||||||
|
// ===================== VirtualScroller 组件 =====================
|
||||||
|
const VirtualScroller = defineComponent({
|
||||||
|
name: 'VirtualScroller',
|
||||||
|
props: {
|
||||||
|
items: { type: Array, default: () => [] },
|
||||||
|
itemKey: { type: [String, Function], default: 'id' },
|
||||||
|
keyField: { type: String, default: 'id' },
|
||||||
|
estimatedHeight: { type: Number, default: 100 },
|
||||||
|
buffer: { type: Number, default: 3 },
|
||||||
|
bufferSize: { type: Number, default: 3 },
|
||||||
|
direction: { type: String, default: 'reverse' },
|
||||||
|
bottomPlaceholderHeight: { type: Number, default: 350 },
|
||||||
|
},
|
||||||
|
emits: ['scroll', 'scroll-start', 'scroll-end', 'visible-change', 'height-version-change'],
|
||||||
|
setup(props, { emit, expose }) {
|
||||||
|
const containerRef = ref(null)
|
||||||
|
const renderContainerRef = ref(null)
|
||||||
|
const itemHeights = ref(new Map())
|
||||||
|
const heightVersion = ref(0)
|
||||||
|
const resizeObserver = ref(null)
|
||||||
|
const scrollTop = ref(0)
|
||||||
|
const pendingScrollToBottom = ref(false)
|
||||||
|
|
||||||
|
// 推算 buffer
|
||||||
|
const computedBuffer = computed(() =>
|
||||||
|
props.buffer !== 3 ? props.buffer : props.bufferSize
|
||||||
|
)
|
||||||
|
|
||||||
|
// 容器高度
|
||||||
|
const containerHeight = computed(() =>
|
||||||
|
renderContainerRef.value ? renderContainerRef.value.clientHeight : 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// 总高度
|
||||||
|
const totalHeight = computed(() => {
|
||||||
|
heightVersion.value // 依赖追踪
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < props.items.length; i++) {
|
||||||
|
h += itemHeights.value.get(i) ?? props.estimatedHeight
|
||||||
|
}
|
||||||
|
return h + props.bottomPlaceholderHeight
|
||||||
|
})
|
||||||
|
|
||||||
|
// key 提取
|
||||||
|
const getItemKey = (item, index) => {
|
||||||
|
const kf = typeof props.itemKey === 'function' ? props.itemKey : (props.itemKey !== 'id' ? props.itemKey : props.keyField)
|
||||||
|
if (typeof kf === 'function') return kf(item, index)
|
||||||
|
if (typeof kf === 'string' && item && typeof item === 'object') return item[kf] ?? index
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可见范围(两趟扫描)
|
||||||
|
const visibleRange = computed(() => {
|
||||||
|
if (!renderContainerRef.value || props.items.length === 0) {
|
||||||
|
return { start: 0, end: 0, offset: 0 }
|
||||||
|
}
|
||||||
|
heightVersion.value
|
||||||
|
const viewH = containerHeight.value
|
||||||
|
const st = scrollTop.value
|
||||||
|
const buf = computedBuffer.value
|
||||||
|
const len = props.items.length
|
||||||
|
|
||||||
|
// 第一趟:定位首个可见项
|
||||||
|
let offset = 0
|
||||||
|
let firstVisible = 0
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const h = itemHeights.value.get(i) ?? props.estimatedHeight
|
||||||
|
if (offset + h > st) { firstVisible = i; break }
|
||||||
|
offset += h
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIdx = Math.max(0, firstVisible - buf)
|
||||||
|
|
||||||
|
// 第二趟:计算 startOffset + 定位 endIndex
|
||||||
|
let startOffset = 0
|
||||||
|
let endIdx = len - 1
|
||||||
|
offset = 0
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const h = itemHeights.value.get(i) ?? props.estimatedHeight
|
||||||
|
if (i < startIdx) { startOffset += h }
|
||||||
|
if (i >= startIdx) {
|
||||||
|
if (offset + h > st + viewH) {
|
||||||
|
endIdx = Math.min(len - 1, i + buf)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
endIdx = i
|
||||||
|
}
|
||||||
|
offset += h
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start: startIdx, end: endIdx, offset: startOffset }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 可见项列表
|
||||||
|
const visibleItems = computed(() => {
|
||||||
|
const { start, end, offset } = visibleRange.value
|
||||||
|
const items = []
|
||||||
|
let curOffset = offset
|
||||||
|
for (let i = start; i <= end && i < props.items.length; i++) {
|
||||||
|
const h = itemHeights.value.get(i) ?? props.estimatedHeight
|
||||||
|
items.push({
|
||||||
|
item: props.items[i],
|
||||||
|
index: i,
|
||||||
|
offset: curOffset + props.bottomPlaceholderHeight,
|
||||||
|
height: h,
|
||||||
|
})
|
||||||
|
curOffset += h
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
// 测量单项高度(直接 mutate Map + 版本号)
|
||||||
|
// 滚动锚定:累积可视区上方项的高度变化量,在微任务中统一补偿
|
||||||
|
let pendingScrollDelta = 0
|
||||||
|
let scrollAdjustPending = false
|
||||||
|
|
||||||
|
const measureItem = (index, element) => {
|
||||||
|
if (!element) return
|
||||||
|
const firstChild = element.firstElementChild
|
||||||
|
const target = firstChild || element
|
||||||
|
const h = Math.ceil(target.offsetHeight)
|
||||||
|
if (h > 0) {
|
||||||
|
const cached = itemHeights.value.get(index)
|
||||||
|
if (cached !== h) {
|
||||||
|
const oldH = cached ?? props.estimatedHeight
|
||||||
|
const delta = h - oldH
|
||||||
|
const prevStart = visibleRange.value.start
|
||||||
|
|
||||||
|
itemHeights.value.set(index, h)
|
||||||
|
heightVersion.value++
|
||||||
|
|
||||||
|
if (delta !== 0 && index < prevStart) {
|
||||||
|
pendingScrollDelta += delta
|
||||||
|
if (!scrollAdjustPending) {
|
||||||
|
scrollAdjustPending = true
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
scrollAdjustPending = false
|
||||||
|
const container = renderContainerRef.value
|
||||||
|
if (container && pendingScrollDelta !== 0) {
|
||||||
|
container.scrollTop += pendingScrollDelta
|
||||||
|
pendingScrollDelta = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResizeObserver
|
||||||
|
const setupResizeObserver = () => {
|
||||||
|
if (resizeObserver.value) resizeObserver.value.disconnect()
|
||||||
|
resizeObserver.value = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const idx = parseInt(entry.target.dataset.index, 10)
|
||||||
|
if (!isNaN(idx)) measureItem(idx, entry.target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 观察可见项(基于 visibleItems + querySelector,不依赖外部 Map)
|
||||||
|
const observeVisibleItems = () => {
|
||||||
|
if (!resizeObserver.value || !renderContainerRef.value) return
|
||||||
|
resizeObserver.value.disconnect()
|
||||||
|
for (const vItem of visibleItems.value) {
|
||||||
|
const el = renderContainerRef.value.querySelector(`[data-index="${vItem.index}"]`)
|
||||||
|
if (el) resizeObserver.value.observe(el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚轮处理(反向容器中取反 deltaY)
|
||||||
|
const handleWheel = (event) => {
|
||||||
|
if (!renderContainerRef.value) return
|
||||||
|
renderContainerRef.value.scrollBy({ top: -event.deltaY, behavior: 'instant' })
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动事件
|
||||||
|
const handleScroll = (event) => {
|
||||||
|
const t = event.target
|
||||||
|
scrollTop.value = t.scrollTop
|
||||||
|
|
||||||
|
const st = t.scrollTop
|
||||||
|
const sh = t.scrollHeight
|
||||||
|
const ch = t.clientHeight
|
||||||
|
|
||||||
|
emit('scroll', {
|
||||||
|
target: t,
|
||||||
|
scrollTop: st,
|
||||||
|
scrollHeight: sh,
|
||||||
|
clientHeight: ch,
|
||||||
|
distanceToPageTop: sh - st - ch,
|
||||||
|
distanceToPageBottom: st,
|
||||||
|
isAtPageTop: sh - st - ch <= 0,
|
||||||
|
isAtPageBottom: st <= 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (sh - st - ch <= 0) emit('scroll-start')
|
||||||
|
if (st <= 0) emit('scroll-end')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 暴露方法 =====
|
||||||
|
const scrollToBottom = (behavior = 'smooth') => {
|
||||||
|
if (!renderContainerRef.value) { pendingScrollToBottom.value = true; return }
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
renderContainerRef.value?.scrollTo({ top: 0, behavior })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToTop = (behavior = 'smooth') => {
|
||||||
|
if (!renderContainerRef.value) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!renderContainerRef.value) return
|
||||||
|
renderContainerRef.value.scrollTo({ top: renderContainerRef.value.scrollHeight, behavior })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetMeasurements = () => {
|
||||||
|
itemHeights.value = new Map()
|
||||||
|
heightVersion.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听数据变化
|
||||||
|
watch(() => props.items, (newData, oldData) => {
|
||||||
|
const oldLen = oldData?.length || 0
|
||||||
|
const newLen = newData.length
|
||||||
|
if (newLen !== oldLen) {
|
||||||
|
const newHeights = new Map()
|
||||||
|
const minLen = Math.min(oldLen, newLen)
|
||||||
|
for (let i = 0; i < minLen; i++) {
|
||||||
|
if (itemHeights.value.has(i)) newHeights.set(i, itemHeights.value.get(i))
|
||||||
|
}
|
||||||
|
itemHeights.value = newHeights
|
||||||
|
heightVersion.value++
|
||||||
|
nextTick(() => observeVisibleItems())
|
||||||
|
}
|
||||||
|
}, { deep: false })
|
||||||
|
|
||||||
|
// 监听可见项变化
|
||||||
|
watch(visibleItems, (newItems) => {
|
||||||
|
nextTick(() => observeVisibleItems())
|
||||||
|
if (newItems.length > 0) {
|
||||||
|
emit('visible-change', newItems[0].index, newItems[newItems.length - 1].index)
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupResizeObserver()
|
||||||
|
nextTick(() => {
|
||||||
|
if (pendingScrollToBottom.value) {
|
||||||
|
pendingScrollToBottom.value = false
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
observeVisibleItems()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (resizeObserver.value) resizeObserver.value.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
expose({
|
||||||
|
scrollToBottom,
|
||||||
|
scrollToTop,
|
||||||
|
resetMeasurements,
|
||||||
|
getScrollElement: () => renderContainerRef.value,
|
||||||
|
heightVersion,
|
||||||
|
itemHeights,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 模板渲染函数
|
||||||
|
return {
|
||||||
|
containerRef, renderContainerRef,
|
||||||
|
totalHeight, visibleItems, getItemKey,
|
||||||
|
handleScroll, handleWheel,
|
||||||
|
bottomPlaceholderHeight: computed(() => props.bottomPlaceholderHeight),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div ref="containerRef" style="height:100%;width:100%;position:relative;">
|
||||||
|
<div style="direction:rtl;height:100%;position:relative;overflow:hidden;transform:rotate(180deg);width:100%;">
|
||||||
|
<div
|
||||||
|
ref="renderContainerRef"
|
||||||
|
@scroll.passive="handleScroll"
|
||||||
|
@wheel="handleWheel"
|
||||||
|
style="direction:ltr;display:flex;flex-direction:column;justify-content:flex-end;bottom:0;left:0;overflow-x:hidden;overflow-y:auto;position:absolute;right:0;top:0;width:100%;contain:layout style;"
|
||||||
|
>
|
||||||
|
<div style="flex-shrink:0;width:100%;" :style="{ height: totalHeight + 'px' }"></div>
|
||||||
|
<div :style="{ position:'absolute',left:0,right:0,top:0,width:'100%',height:bottomPlaceholderHeight+'px',zIndex:1 }">
|
||||||
|
<slot name="bottom-placeholder" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="vItem in visibleItems"
|
||||||
|
:key="getItemKey(vItem.item, vItem.index)"
|
||||||
|
:data-index="vItem.index"
|
||||||
|
:style="{ position:'absolute',left:0,right:0,top:0,width:'100%',transform:'translateY('+vItem.offset+'px)',willChange:'transform',contain:'layout style' }"
|
||||||
|
>
|
||||||
|
<slot name="default" :item="vItem.item" :index="vItem.index" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================== 测试应用 =====================
|
||||||
|
const app = createApp({
|
||||||
|
components: { VirtualScroller },
|
||||||
|
setup() {
|
||||||
|
// 初始化测试数据
|
||||||
|
const rawData = window.TEST_DATA || []
|
||||||
|
const baseItems = rawData.map((d, i) => ({
|
||||||
|
...d,
|
||||||
|
id: d.id || ('item-' + i),
|
||||||
|
_idx: i,
|
||||||
|
firstImageUrl: (d.fileUrl || '').split(',')[0].trim(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const displayItems = ref([...baseItems])
|
||||||
|
const scrollerRef = ref(null)
|
||||||
|
let counter = baseItems.length
|
||||||
|
|
||||||
|
const stats = reactive({
|
||||||
|
total: displayItems.value.length,
|
||||||
|
firstVisible: '-',
|
||||||
|
lastVisible: '-',
|
||||||
|
visibleCount: 0,
|
||||||
|
measuredCount: 0,
|
||||||
|
heightVersion: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 定时同步统计
|
||||||
|
setInterval(() => {
|
||||||
|
stats.total = displayItems.value.length
|
||||||
|
if (scrollerRef.value) {
|
||||||
|
stats.heightVersion = scrollerRef.value.heightVersion?.value ?? 0
|
||||||
|
stats.measuredCount = scrollerRef.value.itemHeights?.value?.size ?? 0
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
// 可见范围变化
|
||||||
|
const onVisibleChange = (first, last) => {
|
||||||
|
stats.firstVisible = first
|
||||||
|
stats.lastVisible = last
|
||||||
|
stats.visibleCount = last - first + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成一条新消息
|
||||||
|
const makeItem = () => {
|
||||||
|
const tpl = baseItems[counter % baseItems.length]
|
||||||
|
return {
|
||||||
|
...tpl,
|
||||||
|
id: 'new-' + (counter + 1) + '-' + Date.now(),
|
||||||
|
_idx: counter,
|
||||||
|
firstImageUrl: tpl.firstImageUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加单条
|
||||||
|
const addSingleItem = () => {
|
||||||
|
counter++
|
||||||
|
displayItems.value = [...displayItems.value, makeItem()]
|
||||||
|
nextTick(() => scrollerRef.value?.scrollToBottom('smooth'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量添加
|
||||||
|
const addBatchItems = () => {
|
||||||
|
const newItems = []
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
counter++
|
||||||
|
newItems.push(makeItem())
|
||||||
|
}
|
||||||
|
displayItems.value = [...displayItems.value, ...newItems]
|
||||||
|
nextTick(() => scrollerRef.value?.scrollToBottom('smooth'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = () => scrollerRef.value?.scrollToBottom('smooth')
|
||||||
|
const scrollToTop = () => scrollerRef.value?.scrollToTop('smooth')
|
||||||
|
|
||||||
|
const onImgLoad = () => {
|
||||||
|
// 图片加载后 ResizeObserver 会自动重新测量,无需额外处理
|
||||||
|
}
|
||||||
|
const onImgError = (e) => {
|
||||||
|
e.target.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => scrollerRef.value?.scrollToBottom('auto'))
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayItems, scrollerRef, stats,
|
||||||
|
onVisibleChange, addSingleItem, addBatchItems,
|
||||||
|
scrollToBottom, scrollToTop, onImgLoad, onImgError,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div style="height:100vh;display:flex;flex-direction:column;">
|
||||||
|
<div class="control-panel">
|
||||||
|
<button @click="addSingleItem">+ 添加 1 条</button>
|
||||||
|
<button @click="addBatchItems">+ 批量添加 10 条</button>
|
||||||
|
<button @click="scrollToBottom">↓ 滚到底部</button>
|
||||||
|
<button @click="scrollToTop">↑ 滚到顶部</button>
|
||||||
|
<span class="divider"></span>
|
||||||
|
<span class="stat">总数: <span>{{ stats.total }}</span></span>
|
||||||
|
<span class="stat">可见范围: <span>{{ stats.firstVisible }} - {{ stats.lastVisible }}</span></span>
|
||||||
|
<span class="stat">可见数: <span>{{ stats.visibleCount }}</span></span>
|
||||||
|
<span class="stat">测量项目: <span>{{ stats.measuredCount }}</span></span>
|
||||||
|
<span class="stat">高度版本: <span>{{ stats.heightVersion }}</span></span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-height:0;">
|
||||||
|
<virtual-scroller
|
||||||
|
ref="scrollerRef"
|
||||||
|
:items="displayItems"
|
||||||
|
:estimated-height="180"
|
||||||
|
:buffer-size="2"
|
||||||
|
direction="reverse"
|
||||||
|
:bottom-placeholder-height="60"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<div class="message-card">
|
||||||
|
<div class="card-img-wrap">
|
||||||
|
<img
|
||||||
|
v-if="item.firstImageUrl"
|
||||||
|
:src="item.firstImageUrl"
|
||||||
|
@load="onImgLoad"
|
||||||
|
@error="onImgError"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
style="width:100%;height:auto;display:block;"
|
||||||
|
>
|
||||||
|
<div v-else class="img-placeholder">无图片</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-model">{{ item.model || 'Unknown' }}</div>
|
||||||
|
<div class="card-prompt">{{ item.prompt }}</div>
|
||||||
|
<div class="card-index">#{{ item._idx }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #bottom-placeholder>
|
||||||
|
<div class="bottom-loader">
|
||||||
|
<div class="dot"></div>
|
||||||
|
<div class="dot"></div>
|
||||||
|
<div class="dot"></div>
|
||||||
|
<span>下拉加载更多</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</virtual-scroller>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -206,7 +206,7 @@ export function definePaintingPlatform() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getUploaderBindings() {
|
getUploaderBindings() {
|
||||||
return { limit: imageUploadLimit() }
|
return { limit: this.imageUploadLimit() }
|
||||||
},
|
},
|
||||||
|
|
||||||
showImageUploader() {
|
showImageUploader() {
|
||||||
|
|||||||
@ -135,7 +135,7 @@ export function defineVideoPlatform() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getUploaderBindings() {
|
getUploaderBindings() {
|
||||||
return { modelType: modelType.value, imagesCount: imageUploadLimit() }
|
return { modelType: modelType.value, imagesCount: this.imageUploadLimit() }
|
||||||
},
|
},
|
||||||
|
|
||||||
showImageUploader() {
|
showImageUploader() {
|
||||||
|
|||||||
@ -242,6 +242,7 @@ const fetchHistory = async (isLoadMore = false) => {
|
|||||||
message: '获取历史失败',
|
message: '获取历史失败',
|
||||||
type: 'warning'
|
type: 'warning'
|
||||||
})
|
})
|
||||||
|
isInitializing.value = false
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
@ -263,8 +264,10 @@ const handleScroll = (scrollInfo) => {
|
|||||||
|
|
||||||
if (isAtPageBottom) {
|
if (isAtPageBottom) {
|
||||||
useDisplay.Sender_variant = 'updown'
|
useDisplay.Sender_variant = 'updown'
|
||||||
} else if (distanceToPageTop >= 350) {
|
} else if (distanceToPageBottom >= 350) {
|
||||||
useDisplay.Sender_variant = 'default'
|
useDisplay.Sender_variant = 'default'
|
||||||
|
} else {
|
||||||
|
useDisplay.Sender_variant = 'updown'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user