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 宽度)
|
||||
│ ├── Select/ # 自定义下拉选择器(分组选项,与 Popover 协调互斥开关)
|
||||
│ ├── Img/ # 图片包装组件(点击全屏查看,Teleport 实现)
|
||||
│ ├── virtual-scroller/# 虚拟滚动列表组件(自定义实现,reverse 模式)
|
||||
│ ├── virtual-scroller/ # 虚拟滚动列表(自定义实现)。reverse 模式用 180deg 旋转实现底部锚定,slot 内容须反旋转
|
||||
│ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘,undo/redo)
|
||||
├── views/ # 页面(home、login)
|
||||
└── utils/
|
||||
@ -129,7 +129,7 @@ props: (config) => ({
|
||||
- **平台切换**:`const platform = computed(() => createPlatform(props.type))`,切换时重置默认模型并加载模型列表
|
||||
- **控件渲染**:`visibleControls = platform.controls.filter(c => c.show(getCurrentConfig()))`,用 `<component :is>` + `v-bind="ctrl.props(...)"` 渲染
|
||||
- **配置获取**:`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()`
|
||||
- **参数回填**:`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 预检失败。
|
||||
- **模型列表缓存**:`modelApi.js` 中 `fetchPlatformModels` 使用 localStorage 30 秒 TTL + `pendingRequests` Map 并发去重。
|
||||
- **平台包预加载**: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) => {
|
||||
if (!element) return
|
||||
|
||||
@ -307,8 +311,31 @@ const measureItem = (index, element) => {
|
||||
if (height > 0) {
|
||||
const cachedHeight = itemHeights.value.get(index)
|
||||
if (cachedHeight !== height) {
|
||||
const oldHeight = cachedHeight ?? props.estimatedHeight
|
||||
const delta = height - oldHeight
|
||||
|
||||
// 快照更新前的可视范围起始索引
|
||||
const prevStart = visibleRange.value.start
|
||||
|
||||
itemHeights.value.set(index, height)
|
||||
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() {
|
||||
return { limit: imageUploadLimit() }
|
||||
return { limit: this.imageUploadLimit() }
|
||||
},
|
||||
|
||||
showImageUploader() {
|
||||
|
||||
@ -135,7 +135,7 @@ export function defineVideoPlatform() {
|
||||
},
|
||||
|
||||
getUploaderBindings() {
|
||||
return { modelType: modelType.value, imagesCount: imageUploadLimit() }
|
||||
return { modelType: modelType.value, imagesCount: this.imageUploadLimit() }
|
||||
},
|
||||
|
||||
showImageUploader() {
|
||||
|
||||
@ -242,6 +242,7 @@ const fetchHistory = async (isLoadMore = false) => {
|
||||
message: '获取历史失败',
|
||||
type: 'warning'
|
||||
})
|
||||
isInitializing.value = false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@ -263,8 +264,10 @@ const handleScroll = (scrollInfo) => {
|
||||
|
||||
if (isAtPageBottom) {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
} else if (distanceToPageTop >= 350) {
|
||||
} else if (distanceToPageBottom >= 350) {
|
||||
useDisplay.Sender_variant = 'default'
|
||||
} else {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user