fix: 移除 Painting descriptor 中未使用的 computed import
This commit is contained in:
parent
705a7a7ebf
commit
184fd6dd8c
2
components.d.ts
vendored
2
components.d.ts
vendored
@ -47,7 +47,5 @@ declare module 'vue' {
|
||||
VideoImageUploader: typeof import('./src/components/dialogBox/videoImageUploader/index.vue')['default']
|
||||
VirtualScroller: typeof import('./src/components/virtual-scroller/VirtualScroller.vue')['default']
|
||||
'VirtualScroller copy': typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default']
|
||||
'VirtualScroller copy 2': typeof import('./src/components/virtual-scroller/VirtualScroller copy 2.vue')['default']
|
||||
'VirtualScroller copy 3': typeof import('./src/components/virtual-scroller/VirtualScroller copy 3.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
1211
docs/superpowers/plans/2026-06-09-platform-architecture-plan.md
Normal file
1211
docs/superpowers/plans/2026-06-09-platform-architecture-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,187 @@
|
||||
# 平台化架构设计
|
||||
|
||||
## 目标
|
||||
|
||||
将 Painting / Video 两套硬编码分支重构为统一的平台描述符架构,使加入新平台只需新建一个文件夹并实现标准接口,零改动 dialogBox。
|
||||
|
||||
## 背景
|
||||
|
||||
当前 `dialogBox/index.vue`(792 行)通过 `v-if="type === 'Painting'"` / `v-if="type === 'Video'"` 承载两套完全不同的逻辑:
|
||||
|
||||
- **模型列表**:Painting 走后端 API,Video 走静态 JSON
|
||||
- **参数 schema**:Painting 走本地 JS 文件,Video 走远程 workflow JSON
|
||||
- **UI 控件**:Painting 有 proportion/dimension/quality/quantity,Video 有 pattern/proportion/time
|
||||
- **任务 body**:Painting 扁平 modelParams,Video `{ workflowId, nodeInfoList }`
|
||||
|
||||
后续模型参数后端化后,所有平台将统一为"API 获取模型列表 + API 获取参数 schema"模式。
|
||||
|
||||
## 核心设计:平台描述符模式
|
||||
|
||||
每个平台封装为一个文件夹,导出 `definePlatform()` 工厂函数,返回标准接口对象。dialogBox 退化为纯渲染引擎。
|
||||
|
||||
### 平台接口
|
||||
|
||||
```js
|
||||
// src/platforms/<name>/index.js
|
||||
export function definePlatform() {
|
||||
// 响应式状态(各平台自定义)
|
||||
const model = ref(defaultValue)
|
||||
const modelType = ref(defaultValue)
|
||||
const state = reactive({ ... })
|
||||
|
||||
// 模型选择器组件
|
||||
const ModelSelector = markRaw(Component)
|
||||
|
||||
// 参数控件列表(有序,决定渲染顺序)
|
||||
const controls = [
|
||||
{
|
||||
name: 'proportion',
|
||||
component: markRaw(ProportionComponent),
|
||||
show: (config) => config?.params?.some(p => p.ui === 'proportion'),
|
||||
props: (config) => ({ /* 额外 props */ }),
|
||||
},
|
||||
// ...
|
||||
]
|
||||
|
||||
// 图片上传器(可选)
|
||||
const ImageUploader = markRaw(Component) | null
|
||||
|
||||
return {
|
||||
id: 'painting', // 平台标识
|
||||
label: 'AI绘画2026', // 显示标题
|
||||
ModelSelector, // 模型选择器组件
|
||||
controls, // 有序控件列表
|
||||
ImageUploader, // 图片上传器组件(可选)
|
||||
state, // 平台自定义响应式状态
|
||||
model, // 当前模型 ref
|
||||
modelType, // 当前模型类型 ref
|
||||
|
||||
async loadModels() { }, // 获取模型列表
|
||||
async loadConfig(modelName) { }, // 获取模型参数配置
|
||||
buildTaskBody(state) { }, // 构造请求 body
|
||||
getDefaultModel() { }, // 默认模型名称
|
||||
isImageRequired(state) { }, // 是否必须上传图片
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 控件绑定约定
|
||||
|
||||
dialogBox 渲染控件时自动处理 `name` 与 `state` 的 v-model 绑定:
|
||||
|
||||
- `modelValue` → `state[name]`
|
||||
- `onUpdate:modelValue` → `state[name] = v`
|
||||
|
||||
控件 descriptor 仅需定义 `show` 条件(基于 modelConfig 上下文)和额外 `props`。
|
||||
|
||||
### 平台注册表
|
||||
|
||||
```js
|
||||
// src/platforms/registry.js
|
||||
import { definePaintingPlatform } from './painting/index.js'
|
||||
import { defineVideoPlatform } from './video/index.js'
|
||||
|
||||
const registry = { Painting, Video }
|
||||
|
||||
export function createPlatform(type) {
|
||||
const factory = registry[type]
|
||||
if (!factory) throw new Error(`未找到平台: ${type}`)
|
||||
return factory()
|
||||
}
|
||||
```
|
||||
|
||||
### dialogBox 角色变化
|
||||
|
||||
dialogBox 接收 `type` prop → 调用 `createPlatform(type)` → 获得 descriptor → 据 descriptor 渲染一切:
|
||||
|
||||
1. 渲染 `<component :is="platform.ModelSelector">`
|
||||
2. 遍历 `platform.controls`,`show` 返回 true 的渲染 `<component :is>`
|
||||
3. `handleStart()` 委托给 `platform.buildTaskBody(state)` → 调用 `taskPolling.generate(body)`
|
||||
4. `watch(model)` 委托给 `platform.loadConfig(name)`
|
||||
|
||||
## 数据流
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph dialogBox["dialogBox 编排层"]
|
||||
A["platform.loadModels()"] --> B[模型选择器渲染]
|
||||
B --> C["watch(model) → platform.loadConfig(name)"]
|
||||
C --> D[计算 visibleControls]
|
||||
D --> E["v-for 渲染 controls(自动 v-model)"]
|
||||
E --> F["handleStart → platform.buildTaskBody()"]
|
||||
F --> G["taskPolling.generate(body)"]
|
||||
end
|
||||
|
||||
subgraph platform["platform 包"]
|
||||
H["loadModels() → API"]
|
||||
I["loadConfig(name) → API"]
|
||||
J["buildTaskBody() → 扁平 body"]
|
||||
end
|
||||
|
||||
G --> K[POST /suanli/v1/tasks]
|
||||
K --> L[轮询 → displayStore 更新虚拟滚动列表]
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── platforms/ # 平台包(新增)
|
||||
│ ├── painting/
|
||||
│ │ ├── index.js # definePlatform()
|
||||
│ │ ├── modelSelector.vue
|
||||
│ │ ├── imageUploader.vue
|
||||
│ │ └── controls/
|
||||
│ │ ├── proportion.vue
|
||||
│ │ ├── dimension.vue
|
||||
│ │ ├── quality.vue
|
||||
│ │ └── quantity.vue
|
||||
│ ├── video/
|
||||
│ │ ├── index.js
|
||||
│ │ ├── modelSelector.vue
|
||||
│ │ ├── imageUploader.vue
|
||||
│ │ └── controls/
|
||||
│ │ ├── pattern.vue
|
||||
│ │ ├── proportion.vue
|
||||
│ │ └── time.vue
|
||||
│ └── registry.js
|
||||
│
|
||||
├── components/
|
||||
│ ├── dialogBox/index.vue # 精简后 ~200 行
|
||||
│ ├── Popover/ # 共享基础组件(不变)
|
||||
│ ├── Select/ # 共享基础组件(不变)
|
||||
│ ├── Img/ # 共享基础组件(不变)
|
||||
│ └── virtual-scroller/ # 共享基础组件(不变)
|
||||
│
|
||||
├── apis/ # API 层(不变)
|
||||
├── utils/
|
||||
│ ├── taskPolling.js # 任务轮询(不变)
|
||||
│ ├── request.js # Axios(不变)
|
||||
│ └── modelApi.js # 平台 API 封装
|
||||
│
|
||||
├── config/
|
||||
│ ├── models/ # 逐步废弃
|
||||
│ ├── runninghub/ # 逐步废弃
|
||||
│ └── plugins.js # 不变
|
||||
```
|
||||
|
||||
### 迁移路径
|
||||
|
||||
| 步骤 | 内容 | 影响范围 |
|
||||
|------|------|----------|
|
||||
| 1 | 新建 `src/platforms/` + `registry.js`,不删旧代码 | 纯新增 |
|
||||
| 2 | Painting 迁入 descriptor,dialogBox 切换读取路径 | dialogBox 精简 |
|
||||
| 3 | Video 迁入 descriptor | dialogBox 继续精简 |
|
||||
| 4 | 删除旧代码:`config/models/`、`config/runninghub/`、`modelConfig.js` | 清理 |
|
||||
| 5 | 后端化:各平台 `loadConfig()` 改为调 API | 仅改 descriptor 内部 |
|
||||
|
||||
每步独立提交,方便回滚。
|
||||
|
||||
## 不变更的部分
|
||||
|
||||
- `taskPolling.js`:任务创建和轮询逻辑通用,不变
|
||||
- `displayStore`:虚拟滚动列表状态通用,不变
|
||||
- `Popover`、`Select`、`Img`、`virtual-scroller`:共享 UI 组件
|
||||
- `home/index.vue`:仍然传 `type` prop,不变
|
||||
- `apis/`、`request.js`:HTTP 层不变
|
||||
- `config/plugins.js`、router、stores:不变
|
||||
@ -1,648 +1,190 @@
|
||||
# 模型参数配置后端化方案
|
||||
# 欢迎使用 RunningHub API,轻松调用 RunningHub 标准模型API
|
||||
|
||||
## 1. 背景与目标
|
||||
## 开始使用
|
||||
|
||||
当前模型参数配置硬编码在前端 `src/config/models/*.js` 中,新增模型或修改参数需要前端发版,业务层无法感知参数定义。
|
||||
### 注册用户
|
||||
|
||||
目标:将参数配置迁移到业务层,管理员后台配置模型参数,前端通过 API 按模型 ID 动态获取,实现**零发版上线新模型配置**。
|
||||
先注册成为RunningHub网站的用户,并充值钱包。标准模型API仅支持企业级-共享API Key
|
||||
|
||||
```
|
||||
页面加载:
|
||||
GET /suanli/v1/platforms/{code}/models → 模型列表(现有接口,不变)
|
||||
GET /suanli/v1/platforms/{code}/models/params → 批量拉全部模型参数(新接口)
|
||||
前端存入 Map<modelId, config>
|
||||
### 获取您的 API Key
|
||||
|
||||
切换模型:
|
||||
config = paramsMap.get(modelId) → 内存读取,0 网络请求
|
||||
RunningHub 为每位用户自动生成一个独特的 32 位 API KEY
|
||||
|
||||
兜底(缓存 miss 时):
|
||||
GET /suanli/v1/models/{model_id}/params → 单个模型参数
|
||||
请妥善保存您的 API KEY,不要外泄,后续步骤将依赖此密钥进行操作
|
||||
|
||||
### 提交请求
|
||||
|
||||
提交 API 请求。RunningHub API 已为您处理 API Key,您只需提交请求即可
|
||||
|
||||
```curl
|
||||
curl --location --request POST 'https://www.runninghub.cn/openapi/v2/rhart-video/ltx-2.3/text-to-video' \
|
||||
--header "Content-Type: application/json" \
|
||||
--header "Authorization: Bearer ${RUNNINGHUB_API_KEY}" \
|
||||
--data-raw '{
|
||||
"prompt": "第一视角无人机跟拍视角,主体为一只自由飞翔的鸟,镜头快速划过城市上空,与飞鸟同步高速飞行,沉浸式第一视角,流畅丝滑运镜,低空急速掠过楼宇街道,强烈速度感与飞行沉浸感,电影级动态光影,稳定不抖动,4K 超清画质,飞鸟为主视觉,城市背景虚化,氛围感拉满。",
|
||||
"resolution": "720p",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": 5
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
#### 请求参数说明
|
||||
|
||||
# 第一部分:后端方案
|
||||
| 参数说明 | 类型 | 必填/可选 | AI 应用程序生成的结果。 |
|
||||
| --- | --- | --- | --- |
|
||||
| `prompt` | String | 必填 | |
|
||||
| `resolution` | String | 必填 | 枚举值: [1080p, 720p, 480p] |
|
||||
| `aspectRatio` | String | 必填 | 枚举值: [16:9, 9:16] |
|
||||
| `duration` | Int | 必填 | 输入范围值: 5 - 15 |
|
||||
|
||||
## 2. API 设计
|
||||
#### 响应示例
|
||||
|
||||
### 2.1 批量获取平台下所有模型参数(主接口)
|
||||
|
||||
```
|
||||
GET /suanli/v1/platforms/{platform_code}/models/params
|
||||
```json
|
||||
{
|
||||
"taskId": "2013508786110730241",
|
||||
"status": "RUNNING",
|
||||
"errorCode": "",
|
||||
"errorMessage": "",
|
||||
"results": null,
|
||||
"clientId": "f828b9af25161bc066ef152db7b29ccc",
|
||||
"promptTips": "{\"result\": true, \"error\": null, \"outputs_to_execute\": [\"4\"], \"node_errors\": {}}"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
#### 响应字段说明
|
||||
|
||||
| 参数说明 | 类型 | AI 应用程序生成的结果。 |
|
||||
| --- | --- | --- |
|
||||
| `taskId` | String | 任务ID,用于后续查询任务状态 |
|
||||
| `status` | String | 当前任务状态,常见状态:QUEUED (排队中), RUNNING (运行中), SUCCESS (成功), FAILED (失败) |
|
||||
| `errorCode` | String | 错误码,仅在失败时返回 |
|
||||
| `errorMessage` | String | 错误具体信息 |
|
||||
| `results` | List | 生成结果(提交时为 null) |
|
||||
| ├ `url` | String | 重要提醒:该链接有效期仅为 24 小时。任务生成结束后,请务必在此时间窗口内将视频文件下载或转存至您的服务器。逾期后链接将永久失效且无法恢复。 |
|
||||
| ├ `nodeId` | String | 生成该结果的工作流节点 ID |
|
||||
| ├ `outputType` | String | 文件扩展名 (如 png, mp4, txt) |
|
||||
| └ `text` | String | 如果输出是纯文本,内容将显示在此字段 |
|
||||
| `clientId` | String | 客户端会话ID,用于标识本次连接 |
|
||||
| `promptTips` | String (JSON) | ComfyUI 后端的校验信息,包含需执行的节点ID等调试信息 |
|
||||
|
||||
### 查询结果与 Webhook
|
||||
|
||||
如果在提交时添加了 "webhookUrl": "https://example.com/webhook" 请求体参数,RunningHub 会在任务完成时向您的URL发送POST请求
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```curl
|
||||
curl --location --request POST 'https://www.runninghub.cn/openapi/v2/query' \
|
||||
--header "Content-Type: application/json" \
|
||||
--header "Authorization: Bearer ${RUNNINGHUB_API_KEY}" \
|
||||
--data-raw '{
|
||||
"taskId": "${RUNNINGHUB_TASKID}"
|
||||
}'
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"taskId": "2013508786110730241",
|
||||
"status": "SUCCESS",
|
||||
"errorCode": "",
|
||||
"errorMessage": "",
|
||||
"failedReason": {},
|
||||
"usage": {
|
||||
"consumeMoney": null,
|
||||
"consumeCoins": null,
|
||||
"taskCostTime": "0",
|
||||
"thirdPartyConsumeMoney": null
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"url": "https://rh-images-1252422369.cos.ap-beijing.myqcloud.com/b04e28cad0ee39193921a30a2eb4dc00/output/ComfyUI_00001_plhjr_1768892915.png",
|
||||
"nodeId": "2",
|
||||
"outputType": "png",
|
||||
"text": null
|
||||
}
|
||||
],
|
||||
"clientId": "",
|
||||
"promptTips": ""
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应字段说明
|
||||
|
||||
| 参数说明 | 类型 | AI 应用程序生成的结果。 |
|
||||
| --- | --- | --- |
|
||||
| `taskId` | String | 任务 ID |
|
||||
| `status` | String | 任务最终状态,SUCCESS 表示生成成功 |
|
||||
| `results` | List | 生成结果列表,包含图片、视频或文本等输出 |
|
||||
| ├ `url` | String | 重要提醒:该链接有效期仅为 24 小时。任务生成结束后,请务必在此时间窗口内将视频文件下载或转存至您的服务器。逾期后链接将永久失效且无法恢复。 |
|
||||
| ├ `nodeId` | String | 生成该结果的工作流节点 ID |
|
||||
| ├ `outputType` | String | 文件扩展名 (如 png, mp4, txt) |
|
||||
| └ `text` | String | 如果输出是纯文本,内容将显示在此字段 |
|
||||
| `errorCode` | String | 错误码 (如有) |
|
||||
| `errorMessage` | String | 错误信息 (如有) |
|
||||
| `failedReason` | Object | ComfyUI 相关的失败原因 |
|
||||
| `usage` | Object | 任务消耗信息 |
|
||||
| ├ `thirdPartyConsumeMoney` | String | 三方API消费金额 |
|
||||
| ├ `consumeMoney` | String | 运行时长消耗金额 |
|
||||
| ├ `consumeCoins` | String | 运行消耗的RH币 |
|
||||
| └ `taskCostTime` | String | 运行耗时(ComfyUI 工作流运行时长) |
|
||||
### 文件上传
|
||||
|
||||
资源文件(如 imageUrls)参数支持传入文件 URL 或 Base64 Data URI。
|
||||
|
||||
#### 公共 URL
|
||||
|
||||
直接传递可公开访问的 URL:
|
||||
|
||||
```json
|
||||
{
|
||||
"imageUrls": [
|
||||
"https://example.com/image.png"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Base64 data URI
|
||||
|
||||
以 Base64 格式嵌入图片:
|
||||
|
||||
```json
|
||||
{
|
||||
"images": [
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### RH 上传接口
|
||||
|
||||
上传本地文件以获取一个 URL。
|
||||
|
||||
**Endpoint:** `https://www.runninghub.cn/openapi/v2/media/upload/binary`
|
||||
|
||||
**请求**
|
||||
|
||||
```curl
|
||||
curl --location --request POST 'https://www.runninghub.cn/openapi/v2/media/upload/binary' \
|
||||
--header 'Authorization: Bearer [Your API KEY]' \
|
||||
--form 'file=@/path/to/image.png'
|
||||
```
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"models": [
|
||||
{
|
||||
"id": "uuid-of-flux-2",
|
||||
"input_type": "text",
|
||||
"max_images": 4,
|
||||
"params": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"label": "提示词",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"ui": "textarea"
|
||||
},
|
||||
{
|
||||
"name": "aspectRatio",
|
||||
"label": "比例",
|
||||
"type": "select",
|
||||
"default": "1:1",
|
||||
"ui": "proportion",
|
||||
"options": ["1:1", "4:3", "3:2", "16:9", "3:4", "2:3", "9:16", "custom"],
|
||||
"show_when": null
|
||||
},
|
||||
{
|
||||
"name": "customWidth",
|
||||
"label": "自定义宽度",
|
||||
"type": "number",
|
||||
"default": 1024,
|
||||
"min": 512,
|
||||
"max": 2048,
|
||||
"ui": "hidden",
|
||||
"show_when": { "aspectRatio": "custom" }
|
||||
},
|
||||
{
|
||||
"name": "resolution",
|
||||
"label": "分辨率",
|
||||
"type": "select",
|
||||
"default": "1k",
|
||||
"ui": "resolution",
|
||||
"options": ["1k", "2k", "4k"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "uuid-of-qwen-2.0",
|
||||
"input_type": "text",
|
||||
"params": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"label": "提示词",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"ui": "textarea"
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"label": "尺寸",
|
||||
"type": "string",
|
||||
"default": "1024*1024",
|
||||
"ui": "dimension",
|
||||
"dimension": {
|
||||
"delimiter": "*",
|
||||
"width": { "min": 512, "max": 2048 },
|
||||
"height": { "min": 512, "max": 2048 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "imageNum",
|
||||
"label": "生成张数",
|
||||
"type": "select",
|
||||
"default": 1,
|
||||
"ui": "quantity",
|
||||
"options": [1, 2, 3, 4, 5, 6]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"type": "image",
|
||||
"download_url": "xxxx.png",
|
||||
"fileName": "openapi/xxxx.png",
|
||||
"size": "3490"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 单个模型参数(兜底接口)
|
||||
**备注:** 上传后获得的链接有效期为 1 天,超期将无法通过 URL 直接访问。
|
||||
|
||||
```
|
||||
GET /suanli/v1/models/{model_id}/params
|
||||
```
|
||||
|
||||
**Response:** `data` 为单个模型对象(不含 `models` 数组包装),其余结构同 2.1。
|
||||
|
||||
### 2.3 参数字段规范
|
||||
|
||||
每个 param 对象的字段定义:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | string | 是 | 参数 key,提交任务时作为 body 字段名 |
|
||||
| `label` | string | 是 | 中文显示名 |
|
||||
| `type` | string | 是 | `string` / `number` / `boolean` / `select` / `image` |
|
||||
| `default` | any | 是 | 默认值,类型必须与 `type` 一致 |
|
||||
| `required` | bool | 否 | 是否必填,默认 false |
|
||||
| `ui` | string | 是 | UI 组件标识(见 2.4 映射表) |
|
||||
| `options` | array | 否 | `type=select` 时的可选项列表 |
|
||||
| `min` | number | 否 | `type=number` 时的最小值 |
|
||||
| `max` | number | 否 | `type=number` 时的最大值 |
|
||||
| `max_count` | number | 否 | `ui=imageUpload` 时的最大上传张数 |
|
||||
| `dimension` | object | 否 | `ui=dimension` 时的尺寸配置 |
|
||||
| `show_when` | object | 否 | 条件显示,如 `{"aspectRatio": "custom"}` |
|
||||
|
||||
**`dimension` 对象(仅 `ui=dimension` 时出现):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `delimiter` | string | W/H 分隔符,固定 `*` |
|
||||
| `width.min` | number | 宽度最小值 |
|
||||
| `width.max` | number | 宽度最大值 |
|
||||
| `height.min` | number | 高度最小值 |
|
||||
| `height.max` | number | 高度最大值 |
|
||||
|
||||
### 2.4 `ui` 字段枚举
|
||||
|
||||
| `ui` 值 | 前端渲染组件 | 说明 |
|
||||
|---------|-------------|------|
|
||||
| `textarea` | Sender 内置 textarea | 提示词输入框 |
|
||||
| `proportion` | `paintingProportion` | 比例选择 Popover(含 resolution + custom 尺寸) |
|
||||
| `resolution` | `paintingProportion` 内部 | 分辨率子选项 |
|
||||
| `dimension` | `DimensionInput` | 组合模式 W×H(如 `1024*1024`) |
|
||||
| `dimensionWidth` | `DimensionInput` | 拆分模式:独立宽度(须与 `dimensionHeight` 配对) |
|
||||
| `dimensionHeight` | `DimensionInput` | 拆分模式:独立高度(须与 `dimensionWidth` 配对) |
|
||||
| `select` | `Select` | 通用下拉选择(如 quality) |
|
||||
| `quantity` | `Quantity` | 生成张数选择器 |
|
||||
| `imageUpload` | `ImageUploader` | 参考图上传 |
|
||||
| `hidden` | 无 | 不渲染,静默写入默认值 |
|
||||
|
||||
> `dimensionWidth` + `dimensionHeight` 必须成对出现,前端自动关联到同一个 DimensionInput 组件。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据库设计
|
||||
|
||||
### 3.1 表结构
|
||||
|
||||
`model_params` 是独立的配置表,与 `models` 表分离,通过 `model_id` 关联:
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌──────────────────────────┐
|
||||
│ models(平台模型表) │ │ model_params(参数配置表) │
|
||||
│ │ 1:N │ │
|
||||
│ id (UUID) │◄────────│ model_id (FK) │
|
||||
│ display_name │ │ name, label, type │
|
||||
│ platform_code │ │ ui, default_val │
|
||||
│ tags │ │ options, min, max... │
|
||||
│ input_type │ │ sort_order │
|
||||
│ max_images │ │ created_at / updated_at │
|
||||
└─────────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TABLE model_params (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
model_id VARCHAR(64) NOT NULL, -- 关联 models.id(UUID)
|
||||
name VARCHAR(64) NOT NULL, -- 参数 key
|
||||
label VARCHAR(64) NOT NULL, -- 中文显示名
|
||||
type VARCHAR(16) NOT NULL, -- string | number | boolean | select | image
|
||||
default_val JSON NOT NULL, -- 默认值
|
||||
required TINYINT DEFAULT 0,
|
||||
ui VARCHAR(32) NOT NULL, -- UI 组件标识
|
||||
options JSON DEFAULT NULL,
|
||||
min_val INT DEFAULT NULL,
|
||||
max_val INT DEFAULT NULL,
|
||||
max_count INT DEFAULT NULL,
|
||||
show_when JSON DEFAULT NULL,
|
||||
sort_order INT DEFAULT 0,
|
||||
dimension JSON DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_model_id (model_id)
|
||||
);
|
||||
```
|
||||
|
||||
`dimension` JSON 格式:
|
||||
```json
|
||||
{
|
||||
"delimiter": "*",
|
||||
"width": { "min": 512, "max": 2048 },
|
||||
"height": { "min": 512, "max": 2048 }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 关键约束
|
||||
|
||||
- `model_params` 与 `models` 完全解耦,各自独立维护
|
||||
- 一个 `model_id` 可有多条参数行,`sort_order` 控制前端渲染顺序
|
||||
- 模型可以没有参数配置(`model_params` 中无记录),前端兼容处理(不渲染额外 UI)
|
||||
- `model_id` 由管理员手动关联,不是自动生成
|
||||
|
||||
### 3.3 初始数据迁移
|
||||
|
||||
将当前 `src/config/models/*.js` 中 8 个模型的参数转为 INSERT 语句。`model_id` 须对应 `models` 表中该模型的 UUID。完整示例见第 7 节。
|
||||
|
||||
---
|
||||
|
||||
## 4. 管理员配置后台
|
||||
|
||||
```
|
||||
操作流程:
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ 1. 进入「模型参数配置」页面 │
|
||||
│ 2. 选择平台 + 模型(从 models 表读取,按 platform_code 过滤) │
|
||||
│ 3. 为该模型添加/编辑参数行,每行配置: │
|
||||
│ - 参数名 (name) - 中文标签 (label) │
|
||||
│ - 数据类型 (type) - UI 组件 (ui) │
|
||||
│ - 默认值 (default) - 可选项 (options) │
|
||||
│ - 数值范围 (min/max) - 尺寸配置 (dimension) │
|
||||
│ - 条件显示 (show_when) │
|
||||
│ 4. 拖拽调整参数排序 (sort_order) │
|
||||
│ 5. 保存后前端缓存 TTL 过期自动生效,也可通知用户手动刷新 │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 接口实现伪代码
|
||||
|
||||
```python
|
||||
# GET /suanli/v1/platforms/{platform_code}/models/params
|
||||
def get_platform_model_params(platform_code):
|
||||
models = db.query(
|
||||
"SELECT id, input_type, max_images FROM models WHERE platform_code = ?",
|
||||
platform_code
|
||||
)
|
||||
result = []
|
||||
for m in models:
|
||||
params = db.query(
|
||||
"SELECT * FROM model_params WHERE model_id = ? ORDER BY sort_order",
|
||||
m.id
|
||||
)
|
||||
result.append({
|
||||
"id": m.id,
|
||||
"input_type": m.input_type,
|
||||
"max_images": m.max_images,
|
||||
"params": [format_param(p) for p in params],
|
||||
})
|
||||
return {"code": 0, "data": {"models": result}}
|
||||
|
||||
|
||||
# GET /suanli/v1/models/{model_id}/params
|
||||
def get_model_params(model_id):
|
||||
m = db.query("SELECT id, input_type, max_images FROM models WHERE id = ?", model_id)
|
||||
if not m:
|
||||
return {"code": 404, "msg": "模型不存在"}
|
||||
params = db.query(
|
||||
"SELECT * FROM model_params WHERE model_id = ? ORDER BY sort_order",
|
||||
model_id
|
||||
)
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"id": m.id,
|
||||
"input_type": m.input_type,
|
||||
"max_images": m.max_images,
|
||||
"params": [format_param(p) for p in params],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def format_param(p):
|
||||
return {
|
||||
"name": p.name,
|
||||
"label": p.label,
|
||||
"type": p.type,
|
||||
"default": json.loads(p.default_val),
|
||||
"required": bool(p.required),
|
||||
"ui": p.ui,
|
||||
"options": json.loads(p.options) if p.options else None,
|
||||
"min": p.min_val,
|
||||
"max": p.max_val,
|
||||
"max_count": p.max_count,
|
||||
"show_when": json.loads(p.show_when) if p.show_when else None,
|
||||
"dimension": json.loads(p.dimension) if p.dimension else None,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 响应示例汇总
|
||||
|
||||
### 6.1 即梦 4.6(拆分维度 `dimensionWidth` + `dimensionHeight`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "jimeng-uuid",
|
||||
"input_type": "text",
|
||||
"params": [
|
||||
{ "name": "prompt", "label": "提示词", "type": "string", "required": true, "ui": "textarea" },
|
||||
{ "name": "width", "label": "宽度", "type": "number", "default": 1024, "min": 900, "max": 6197, "ui": "dimensionWidth" },
|
||||
{ "name": "height", "label": "高度", "type": "number", "default": 1024, "min": 768, "max": 4096, "ui": "dimensionHeight" },
|
||||
{ "name": "forceSingle", "label": "强制单张", "type": "boolean", "default": false, "ui": "hidden" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 通义万相 2.0(组合维度 `dimension` + `quantity`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "qwen-uuid",
|
||||
"input_type": "text",
|
||||
"params": [
|
||||
{ "name": "prompt", "label": "提示词", "type": "string", "required": true, "ui": "textarea" },
|
||||
{
|
||||
"name": "size", "label": "尺寸", "type": "string", "default": "1024*1024", "ui": "dimension",
|
||||
"dimension": { "delimiter": "*", "width": { "min": 512, "max": 2048 }, "height": { "min": 512, "max": 2048 } }
|
||||
},
|
||||
{ "name": "imageNum", "label": "生成张数", "type": "select", "default": 1, "ui": "quantity", "options": [1, 2, 3, 4, 5, 6] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 GPT-Image-2(`proportion` + `resolution` + `select`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gpt-image-uuid",
|
||||
"input_type": "text",
|
||||
"params": [
|
||||
{ "name": "prompt", "label": "提示词", "type": "string", "required": true, "ui": "textarea" },
|
||||
{
|
||||
"name": "aspectRatio", "label": "比例", "type": "select", "default": "1:1", "ui": "proportion",
|
||||
"options": ["1:1", "4:3", "3:2", "16:9", "3:4", "2:3", "9:16", "custom"]
|
||||
},
|
||||
{ "name": "customWidth", "label": "自定义宽度", "type": "number", "default": 1024, "min": 512, "max": 2048, "ui": "hidden", "show_when": { "aspectRatio": "custom" } },
|
||||
{ "name": "customHight", "label": "自定义高度", "type": "number", "default": 1024, "min": 512, "max": 2048, "ui": "hidden", "show_when": { "aspectRatio": "custom" } },
|
||||
{ "name": "resolution", "label": "分辨率", "type": "select", "default": "1k", "ui": "resolution", "options": ["1k", "2k", "4k"] },
|
||||
{ "name": "quality", "label": "质量", "type": "select", "default": "medium", "ui": "select", "options": ["low", "medium", "high"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 第二部分:前端改动
|
||||
|
||||
## 7. 缓存策略
|
||||
|
||||
三级防护,全部位于客户端浏览器:
|
||||
|
||||
```
|
||||
请求流程:
|
||||
用户操作
|
||||
→ L1 内存缓存(Map,会话级,10min TTL)
|
||||
→ L2 localStorage 缓存(持久化,10min TTL)
|
||||
→ 请求冷却检查(localStorage,30s 冷却期)
|
||||
→ API 请求
|
||||
```
|
||||
|
||||
| 层级 | 存储位置 | 生命周期 | 说明 |
|
||||
|------|---------|---------|------|
|
||||
| L1 | JS 内存 Map | 标签页关闭即销毁 | 同会话内切换模型 0 延迟 |
|
||||
| L2 | localStorage | 持久化,跨会话/刷新 | 刷新页面后直接命中,无需请求 |
|
||||
| 冷却期 | localStorage | 独立于缓存 | 限制 API 调用频率最低 30s/次 |
|
||||
|
||||
**正常场景**:首次访问 → API 请求 → 写入 L1 + L2。刷新页面 → L2 命中,0 请求。
|
||||
|
||||
**极端场景**:清掉 localStorage 连刷 10 次 → 请求冷却生效,实际只有 1 次请求到达后端。
|
||||
|
||||
---
|
||||
|
||||
## 8. 新增文件
|
||||
|
||||
### 8.1 API 层
|
||||
|
||||
```js
|
||||
// src/apis/display/index.js(追加以下两个函数)
|
||||
|
||||
// 批量获取平台所有模型参数
|
||||
export const fetchPlatformModelParams = (platformCode) =>
|
||||
service.get(`/suanli/v1/platforms/${platformCode}/models/params`)
|
||||
|
||||
// 获取单个模型参数(兜底)
|
||||
export const fetchModelParams = (modelId) =>
|
||||
service.get(`/suanli/v1/models/${modelId}/params`)
|
||||
```
|
||||
|
||||
### 8.2 缓存层
|
||||
|
||||
```js
|
||||
// src/utils/modelParams.js(新文件)
|
||||
|
||||
import { fetchPlatformModelParams } from '@/apis/display'
|
||||
|
||||
const CACHE_TTL = 10 * 60 * 1000 // 缓存有效期:10 分钟
|
||||
const COOLDOWN = 30 * 1000 // 请求冷却期:30 秒
|
||||
const STORAGE_PREFIX = 'model_params_'
|
||||
|
||||
const memoryCache = new Map() // L1: { platformCode → { data, timestamp } }
|
||||
const pendingRequests = new Map() // 并发去重
|
||||
|
||||
function getStorageCache(platformCode) {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_PREFIX + platformCode)
|
||||
if (!raw) return null
|
||||
const { data, timestamp } = JSON.parse(raw)
|
||||
if (Date.now() - timestamp < CACHE_TTL) {
|
||||
return new Map(data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
|
||||
function setStorageCache(platformCode, map) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_PREFIX + platformCode, JSON.stringify({
|
||||
data: [...map],
|
||||
timestamp: Date.now(),
|
||||
}))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function getCooldownRemaining(platformCode) {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_PREFIX + platformCode + '_lastFetch')
|
||||
if (!raw) return 0
|
||||
const elapsed = Date.now() - parseInt(raw)
|
||||
return elapsed < COOLDOWN ? COOLDOWN - elapsed : 0
|
||||
} catch { return 0 }
|
||||
}
|
||||
|
||||
function setFetchTimestamp(platformCode) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_PREFIX + platformCode + '_lastFetch', Date.now().toString())
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function clearModelParamsCache(platformCode) {
|
||||
memoryCache.delete(platformCode)
|
||||
localStorage.removeItem(STORAGE_PREFIX + platformCode)
|
||||
}
|
||||
|
||||
export async function getModelParamsMap(platformCode) {
|
||||
// 1. L1 内存缓存
|
||||
const mem = memoryCache.get(platformCode)
|
||||
if (mem && Date.now() - mem.timestamp < CACHE_TTL) {
|
||||
return mem.data
|
||||
}
|
||||
|
||||
// 2. L2 localStorage 缓存
|
||||
const storage = getStorageCache(platformCode)
|
||||
if (storage) {
|
||||
memoryCache.set(platformCode, { data: storage, timestamp: Date.now() })
|
||||
return storage
|
||||
}
|
||||
|
||||
// 3. 并发去重
|
||||
if (pendingRequests.has(platformCode)) {
|
||||
return pendingRequests.get(platformCode)
|
||||
}
|
||||
|
||||
// 4. 请求冷却
|
||||
const cooldown = getCooldownRemaining(platformCode)
|
||||
if (cooldown > 0) {
|
||||
const promise = new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
pendingRequests.delete(platformCode)
|
||||
resolve(getModelParamsMap(platformCode))
|
||||
}, cooldown)
|
||||
})
|
||||
pendingRequests.set(platformCode, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
// 5. 发起请求
|
||||
setFetchTimestamp(platformCode)
|
||||
const promise = fetchPlatformModelParams(platformCode)
|
||||
.then(res => {
|
||||
const map = new Map()
|
||||
for (const m of res.data.models) {
|
||||
map.set(m.id, m)
|
||||
}
|
||||
memoryCache.set(platformCode, { data: map, timestamp: Date.now() })
|
||||
setStorageCache(platformCode, map)
|
||||
pendingRequests.delete(platformCode)
|
||||
return map
|
||||
})
|
||||
.catch(err => {
|
||||
pendingRequests.delete(platformCode)
|
||||
throw err
|
||||
})
|
||||
|
||||
pendingRequests.set(platformCode, promise)
|
||||
return promise
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 改造文件
|
||||
|
||||
### 9.1 dialogBox/index.vue
|
||||
|
||||
**数据流变化:**
|
||||
|
||||
```
|
||||
当前:
|
||||
model.value (display_name)
|
||||
→ getModelConfig(modelName) // 静态 JS 文件查找
|
||||
→ modelConfig (computed)
|
||||
→ UI 渲染
|
||||
|
||||
新方案:
|
||||
modelId (选中模型的 UUID)
|
||||
→ modelConfig = paramsMap.get(modelId) // 从预取 Map 查找
|
||||
→ UI 渲染
|
||||
```
|
||||
|
||||
**核心代码改造:**
|
||||
|
||||
```js
|
||||
// 当前
|
||||
const modelConfig = computed(() => {
|
||||
return props.type === 'Painting' ? getModelConfig(model.value) : null
|
||||
})
|
||||
|
||||
// 新方案
|
||||
const paramsMap = ref(new Map())
|
||||
const modelConfig = computed(() => {
|
||||
if (props.type !== 'Painting') return null
|
||||
return paramsMap.value.get(currentModelId.value) || null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const map = await getModelParamsMap('ai_painting_talk')
|
||||
paramsMap.value = map
|
||||
})
|
||||
```
|
||||
|
||||
**其他改动点:**
|
||||
|
||||
- `modelConfig` watcher 中维度初始化改用统一 `parseDimension()` 替代 `dimension.parse()`
|
||||
- `fillParamsFromResult` 中维度恢复同理
|
||||
- `displayNameMap` 逻辑移除
|
||||
|
||||
### 9.2 model/painting.vue(模型选择器)
|
||||
|
||||
- 选中值从 `display_name` 改为 `model_id`
|
||||
- emit 从 `update:modelValue(displayName)` 改为 `update:modelValue(modelId)`
|
||||
- 同时 emit `update:typeValue(inputType)` 保持不变
|
||||
|
||||
### 9.3 dimension 处理统一化
|
||||
|
||||
移除每个模型配置中自定义的 `parse`/`format` 函数,前端统一处理:
|
||||
|
||||
```js
|
||||
// src/components/dialogBox/index.vue 中集中定义
|
||||
function parseDimension(raw, delimiter = '*') {
|
||||
const parts = (raw || '1024*1024').split(delimiter)
|
||||
return {
|
||||
width: parseInt(parts[0]) || 1024,
|
||||
height: parseInt(parts[1]) || 1024,
|
||||
}
|
||||
}
|
||||
|
||||
function formatDimension(w, h, delimiter = '*') {
|
||||
return `${w}${delimiter}${h}`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 迁移步骤
|
||||
|
||||
| 阶段 | 负责方 | 内容 |
|
||||
|------|--------|------|
|
||||
| **1. 后端准备** | 后端 | 创建 `model_params` 表 → 录入 8 个模型配置 → 实现批量+单个接口 → 联调验证 |
|
||||
| **2. 前端适配** | 前端 | 新增 API 函数 + 缓存层 → 改造 dialogBox + model 选择器 → 保留静态配置为 fallback |
|
||||
| **3. 清理** | 前端 | 确认全量走新接口 → 删除 `src/config/models/*.js` → 删除 `displayNameMap`、`getModelConfig` |
|
||||
|
||||
---
|
||||
|
||||
## 11. 边界情况
|
||||
|
||||
### 11.1 接口失败降级
|
||||
|
||||
迁移期间接口失败时回退静态配置:
|
||||
|
||||
```js
|
||||
const modelConfig = computed(() => {
|
||||
if (paramsMap.value.size > 0) {
|
||||
return paramsMap.value.get(currentModelId.value) || null
|
||||
}
|
||||
return getModelConfig(model.value) // fallback
|
||||
})
|
||||
```
|
||||
|
||||
### 11.2 缓存主动刷新
|
||||
|
||||
管理员修改配置后,前端提供「刷新配置」按钮调用 `clearModelParamsCache()` 立即生效。
|
||||
|
||||
### 11.3 模型无参数配置
|
||||
|
||||
若 `model_id` 在 `model_params` 表中无记录,前端仅渲染 prompt 输入框(textarea),不显示其他 UI 组件。
|
||||
|
||||
### 11.4 `show_when` 条件显示
|
||||
|
||||
当前仅支持 `{ "aspectRatio": "custom" }` 条件。后续如需扩展,前后端同步约定新的条件字段和取值。
|
||||
|
||||
### 11.5 向后兼容
|
||||
|
||||
- 新增接口路径 `models/params`,不影响现有模型列表接口
|
||||
- Video 路径不受影响(继续使用 `runninghub.Playload()` 适配器)
|
||||
- Painting 的 `modelParams` 扁平提交格式不变
|
||||
|
||||
@ -1,222 +0,0 @@
|
||||
# VirtualScroller 虚拟滚动组件
|
||||
|
||||
一个高性能的虚拟滚动组件,支持未知高度子组件渲染和滚动方向反转功能。
|
||||
|
||||
## 特性
|
||||
|
||||
- 🚀 **高性能虚拟滚动** - 仅渲染可视区域内的元素,支持大数据量渲染
|
||||
- 🔄 **滚动方向反转** - 通过双重 CSS 旋转实现向上滚动效果
|
||||
- 📏 **未知高度支持** - 动态测量子组件高度,无需预设固定高度
|
||||
- 🎯 **精确滚动控制** - 提供滚动到指定索引、顶部、底部等 API
|
||||
- 📱 **响应式设计** - 适配不同屏幕尺寸
|
||||
- ⚡ **60fps 流畅滚动** - 优化的渲染策略确保流畅体验
|
||||
|
||||
## 安装
|
||||
|
||||
组件位于 `src/components/virtual-scroller/` 目录下,无需额外安装依赖。
|
||||
|
||||
## 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<VirtualScroller
|
||||
:data="list"
|
||||
:estimated-height="100"
|
||||
:buffer="5"
|
||||
class="scroller"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div class="item" style="transform: rotate(180deg)">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { VirtualScroller } from '@/components/virtual-scroller'
|
||||
|
||||
const list = ref([
|
||||
{ id: 1, name: 'Item 1' },
|
||||
{ id: 2, name: 'Item 2' },
|
||||
])
|
||||
|
||||
const handleScroll = (event) => {
|
||||
console.log('滚动事件', event)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| 属性名 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `data` | `Array` | `[]` | 数据源数组(必填) |
|
||||
| `itemKey` | `string \| Function` | `'id'` | 用于标识每个项目的键名或函数 |
|
||||
| `estimatedHeight` | `number` | `100` | 预估的项目高度(像素) |
|
||||
| `buffer` | `number` | `3` | 可视区域外预渲染的项目数量 |
|
||||
| `height` | `string \| number` | `'100%'` | 滚动容器高度 |
|
||||
| `width` | `string \| number` | `'100%'` | 滚动容器宽度 |
|
||||
| `renderMode` | `'default' \| 'top'` | `'default'` | 渲染模式,`top` 模式会自动滚动到页面底部 |
|
||||
|
||||
## Events
|
||||
|
||||
| 事件名 | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| `scroll` | `event: Event` | 滚动事件,包含 `distanceToPageTop`、`distanceToPageBottom`、`isAtPageTop`、`isAtPageBottom` 属性 |
|
||||
| `scroll-start` | - | 滚动到**页面顶部**时触发 |
|
||||
| `scroll-end` | - | 滚动到**页面底部**时触发 |
|
||||
|
||||
## Expose Methods
|
||||
|
||||
通过 `ref` 可以访问以下方法:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const scrollerRef = ref(null)
|
||||
|
||||
// 滚动到指定索引
|
||||
scrollerRef.value.scrollToIndex(10)
|
||||
|
||||
// 滚动到页面底部(最新数据位置)
|
||||
scrollerRef.value.scrollToBottom()
|
||||
|
||||
// 滚动到页面顶部(最旧数据位置)
|
||||
scrollerRef.value.scrollToTop()
|
||||
|
||||
// 判断是否在页面底部
|
||||
const atBottom = scrollerRef.value.isAtPageBottom()
|
||||
|
||||
// 判断是否在页面顶部
|
||||
const atTop = scrollerRef.value.isAtPageTop()
|
||||
</script>
|
||||
```
|
||||
|
||||
| 方法名 | 参数 | 返回值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `scrollToIndex` | `index: number, behavior?: ScrollBehavior` | `void` | 滚动到指定索引的项目 |
|
||||
| `scrollToBottom` | `behavior?: ScrollBehavior` | `void` | 滚动到**页面底部**(最新数据) |
|
||||
| `scrollToTop` | `behavior?: ScrollBehavior` | `void` | 滚动到**页面顶部**(最旧数据) |
|
||||
| `getScrollElement` | - | `HTMLElement \| null` | 获取滚动容器 DOM 元素 |
|
||||
| `getVisibleIndices` | - | `number[]` | 获取当前可视项目的索引数组 |
|
||||
| `resetMeasurements` | - | `void` | 重置所有高度测量缓存 |
|
||||
| `isAtPageBottom` | - | `boolean` | 判断是否在页面底部 |
|
||||
| `isAtPageTop` | - | `boolean` | 判断是否在页面顶部 |
|
||||
|
||||
## 滚动方向反转原理
|
||||
|
||||
组件通过 CSS `transform: rotate(180deg)` 实现滚动方向反转:
|
||||
|
||||
1. **容器旋转**:滚动容器应用 `transform: rotate(180deg)`
|
||||
2. **内容反向旋转**:子组件内部应用 `transform: rotate(180deg)` 抵消旋转
|
||||
|
||||
### 坐标映射关系
|
||||
|
||||
由于容器旋转 180 度,坐标系统发生反转:
|
||||
|
||||
| 页面概念 | 组件内部 scrollTop |
|
||||
|----------|-------------------|
|
||||
| 页面顶部(最旧数据) | `scrollTop = scrollHeight - clientHeight` |
|
||||
| 页面底部(最新数据) | `scrollTop = 0` |
|
||||
|
||||
### 滚轮方向处理
|
||||
|
||||
组件内部处理了滚轮方向映射:
|
||||
- 用户**向上**滚动滚轮 → 页面内容**向上**滚动
|
||||
- 用户**向下**滚动滚轮 → 页面内容**向下**滚动
|
||||
|
||||
## 使用示例
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="container">
|
||||
<VirtualScroller
|
||||
ref="scrollerRef"
|
||||
:data="messageList"
|
||||
:estimated-height="80"
|
||||
:buffer="5"
|
||||
height="600px"
|
||||
render-mode="top"
|
||||
@scroll="handleScroll"
|
||||
@scroll-start="loadMore"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<MessageItem
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
style="transform: rotate(180deg)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { VirtualScroller } from '@/components/virtual-scroller'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
|
||||
const scrollerRef = ref(null)
|
||||
const messageList = ref([])
|
||||
const page = ref(1)
|
||||
|
||||
const fetchMessages = async () => {
|
||||
const response = await fetch(`/api/messages?page=${page.value}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (page.value === 1) {
|
||||
messageList.value = data
|
||||
} else {
|
||||
messageList.value = [...data, ...messageList.value]
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
page.value++
|
||||
fetchMessages()
|
||||
}
|
||||
|
||||
const handleScroll = (event) => {
|
||||
const { distanceToPageTop, distanceToPageBottom, isAtPageTop, isAtPageBottom } = event
|
||||
console.log('距离页面顶部:', distanceToPageTop)
|
||||
console.log('距离页面底部:', distanceToPageBottom)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMessages()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## 性能优化建议
|
||||
|
||||
1. **合理设置 `estimatedHeight`**:预估高度越接近实际高度,重排越少
|
||||
2. **适当调整 `buffer`**:较大的 buffer 会预渲染更多元素,减少白屏但增加内存占用
|
||||
3. **使用唯一的 `itemKey`**:确保每个项目有唯一标识,避免不必要的重渲染
|
||||
4. **避免复杂计算**:在插槽中避免复杂计算,使用计算属性或缓存
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 子组件显示倒置怎么办?
|
||||
|
||||
A: 在子组件上添加 `style="transform: rotate(180deg)"` 来抵消容器的旋转。
|
||||
|
||||
### Q: 如何判断是否滚动到页面底部?
|
||||
|
||||
A: 使用 `isAtPageBottom()` 方法或监听 `scroll-end` 事件。
|
||||
|
||||
### Q: scrollToBottom 和 scrollToTop 的方向?
|
||||
|
||||
A:
|
||||
- `scrollToBottom()` - 滚动到**页面底部**(最新数据位置)
|
||||
- `scrollToTop()` - 滚动到**页面顶部**(最旧数据位置)
|
||||
|
||||
## 浏览器兼容性
|
||||
|
||||
- Chrome >= 64
|
||||
- Firefox >= 69
|
||||
- Safari >= 13.1
|
||||
- Edge >= 79
|
||||
|
||||
需要浏览器支持 `ResizeObserver` API。
|
||||
@ -1,615 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="virtual-scroller"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
class="virtual-scroller-wrapper"
|
||||
:style="wrapperStyle"
|
||||
>
|
||||
<div
|
||||
ref="renderContainerRef"
|
||||
class="virtual-scroller-render-container"
|
||||
:style="renderContainerStyle"
|
||||
@scroll.passive="handleScroll"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<div class="virtual-scroller-spacer" :style="{ height: `${totalHeight}px` }"></div>
|
||||
<div
|
||||
class="virtual-scroller-bottom-placeholder"
|
||||
:style="bottomPlaceholderStyle"
|
||||
>
|
||||
<slot name="bottom-placeholder" />
|
||||
</div>
|
||||
<div
|
||||
v-for="renderItem in visibleItems"
|
||||
:key="getItemKey(renderItem.item, renderItem.index)"
|
||||
:ref="el => setItemRef(el, renderItem.index)"
|
||||
class="virtual-scroller-item"
|
||||
:style="getItemStyle(renderItem)"
|
||||
:data-index="renderItem.index"
|
||||
>
|
||||
<slot
|
||||
name="default"
|
||||
:item="renderItem.item"
|
||||
:index="renderItem.index"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: false,
|
||||
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
|
||||
},
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: '100%'
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: '100%'
|
||||
},
|
||||
renderMode: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['default', 'top'].includes(value)
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'reverse',
|
||||
validator: (value) => ['normal', 'reverse'].includes(value)
|
||||
},
|
||||
bottomPlaceholderHeight: {
|
||||
type: Number,
|
||||
default: 350
|
||||
}
|
||||
})
|
||||
|
||||
const computedData = computed(() => {
|
||||
return props.data.length > 0 ? props.data : props.items
|
||||
})
|
||||
|
||||
const computedItemKey = computed(() => {
|
||||
if (typeof props.itemKey === 'function') return props.itemKey
|
||||
if (props.itemKey !== 'id') return props.itemKey
|
||||
return props.keyField
|
||||
})
|
||||
|
||||
const computedBuffer = computed(() => {
|
||||
return props.buffer !== 3 ? props.buffer : props.bufferSize
|
||||
})
|
||||
|
||||
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end', 'visible-change'])
|
||||
|
||||
const containerRef = ref(null)
|
||||
const wrapperRef = ref(null)
|
||||
const renderContainerRef = ref(null)
|
||||
const itemRefs = new Map()
|
||||
const itemHeights = ref(new Map())
|
||||
const resizeObserver = ref(null)
|
||||
const scrollTop = ref(0)
|
||||
const isScrolling = ref(false)
|
||||
const scrollTimeout = ref(null)
|
||||
const isInitialized = ref(false)
|
||||
const pendingScrollToBottom = ref(false)
|
||||
const previousDataLength = ref(0)
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
return {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
let height = 0
|
||||
const len = computedData.value.length
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
height += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
height += props.bottomPlaceholderHeight
|
||||
|
||||
return height
|
||||
})
|
||||
|
||||
const getItemPosition = (index) => {
|
||||
let offset = 0
|
||||
|
||||
for (let i = 0; i < index; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
offset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
const height = itemHeights.value.get(index) ?? props.estimatedHeight
|
||||
|
||||
return { offset, height }
|
||||
}
|
||||
|
||||
const containerHeight = computed(() => {
|
||||
if (!renderContainerRef.value) return 0
|
||||
return renderContainerRef.value.clientHeight
|
||||
})
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
if (!renderContainerRef.value || computedData.value.length === 0) {
|
||||
return { start: 0, end: 0, offset: 0 }
|
||||
}
|
||||
|
||||
const viewportHeight = containerHeight.value
|
||||
const currentScrollTop = scrollTop.value
|
||||
const bufferCount = computedBuffer.value
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = computedData.value.length - 1
|
||||
let startOffset = 0
|
||||
|
||||
let offset = 0
|
||||
for (let i = 0; i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
if (offset + height > currentScrollTop) {
|
||||
startIndex = Math.max(0, i - bufferCount)
|
||||
break
|
||||
}
|
||||
|
||||
offset += height
|
||||
}
|
||||
|
||||
startOffset = 0
|
||||
for (let i = 0; i < startIndex; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
startOffset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
offset = startOffset
|
||||
endIndex = startIndex
|
||||
for (let i = startIndex; i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
offset += height
|
||||
|
||||
if (offset > currentScrollTop + viewportHeight) {
|
||||
endIndex = Math.min(computedData.value.length - 1, i + bufferCount)
|
||||
break
|
||||
}
|
||||
|
||||
endIndex = i
|
||||
}
|
||||
|
||||
return { start: Math.min(startIndex, endIndex), end: Math.max(startIndex, endIndex), offset: startOffset }
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
const { start, end, offset } = visibleRange.value
|
||||
const items = []
|
||||
|
||||
let currentOffset = offset
|
||||
|
||||
for (let i = start; i <= end && i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
items.push({
|
||||
item: computedData.value[i],
|
||||
index: i,
|
||||
offset: currentOffset + props.bottomPlaceholderHeight,
|
||||
height
|
||||
})
|
||||
|
||||
currentOffset += height
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const wrapperStyle = computed(() => ({
|
||||
direction: 'rtl',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
scrollbarWidth: 'auto',
|
||||
overflow: 'hidden',
|
||||
transform: 'rotate(180deg)',
|
||||
width: '100%'
|
||||
}))
|
||||
|
||||
const renderContainerStyle = computed(() => ({
|
||||
direction: 'ltr',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%'
|
||||
}))
|
||||
|
||||
const bottomPlaceholderStyle = computed(() => ({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: `${props.bottomPlaceholderHeight}px`,
|
||||
transform: `translateY(0px)`,
|
||||
zIndex: 1
|
||||
}))
|
||||
|
||||
const getItemKey = (item, index) => {
|
||||
const keyField = computedItemKey.value
|
||||
if (typeof keyField === 'function') {
|
||||
return keyField(item, index)
|
||||
}
|
||||
if (typeof keyField === 'string' && item && typeof item === 'object') {
|
||||
return item[keyField] ?? index
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
const getItemStyle = (renderItem) => {
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${renderItem.offset}px)`,
|
||||
willChange: 'transform'
|
||||
}
|
||||
}
|
||||
|
||||
const setItemRef = (el, index) => {
|
||||
if (el) {
|
||||
itemRefs.set(index, el)
|
||||
} else {
|
||||
itemRefs.delete(index)
|
||||
}
|
||||
}
|
||||
|
||||
const measureItem = (index, element) => {
|
||||
if (!element) return
|
||||
|
||||
const firstChild = element.firstElementChild
|
||||
const targetElement = firstChild || element
|
||||
|
||||
const height = targetElement.getBoundingClientRect().height
|
||||
|
||||
if (height > 0) {
|
||||
const cachedHeight = itemHeights.value.get(index)
|
||||
if (cachedHeight !== height) {
|
||||
const newHeights = new Map(itemHeights.value)
|
||||
newHeights.set(index, height)
|
||||
itemHeights.value = newHeights
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setupResizeObserver = () => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
}
|
||||
|
||||
resizeObserver.value = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const index = parseInt(entry.target.dataset.index, 10)
|
||||
if (!isNaN(index)) {
|
||||
measureItem(index, entry.target)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleWheel = (event) => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
const { deltaY } = event
|
||||
const el = renderContainerRef.value
|
||||
|
||||
el.scrollBy({
|
||||
top: -deltaY,
|
||||
behavior: 'instant'
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleScroll = (event) => {
|
||||
const target = event.target
|
||||
scrollTop.value = target.scrollTop
|
||||
isScrolling.value = true
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
scrollTimeout.value = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, 150)
|
||||
|
||||
const st = target.scrollTop
|
||||
const scrollHeight = target.scrollHeight
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
const distanceToContainerTop = st
|
||||
const distanceToContainerBottom = scrollHeight - st - clientHeight
|
||||
|
||||
const distanceToPageTop = distanceToContainerBottom
|
||||
const distanceToPageBottom = distanceToContainerTop
|
||||
const isAtPageTop = distanceToPageTop <= 0
|
||||
const isAtPageBottom = distanceToPageBottom <= 0
|
||||
|
||||
emit('scroll', {
|
||||
target,
|
||||
scrollTop: st,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
distanceToPageTop,
|
||||
distanceToPageBottom,
|
||||
isAtPageTop,
|
||||
isAtPageBottom
|
||||
})
|
||||
|
||||
if (isAtPageTop) {
|
||||
emit('scroll-start')
|
||||
}
|
||||
|
||||
if (isAtPageBottom) {
|
||||
emit('scroll-end')
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToIndex = (index, behavior = 'auto') => {
|
||||
if (!renderContainerRef.value || index < 0 || index >= computedData.value.length) return
|
||||
|
||||
const position = getItemPosition(index)
|
||||
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: position.offset,
|
||||
behavior
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToBottom = (behavior = 'smooth') => {
|
||||
if (!renderContainerRef.value) {
|
||||
pendingScrollToBottom.value = true
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: 0,
|
||||
behavior
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToTop = (behavior = 'smooth') => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
const scrollHeight = renderContainerRef.value.scrollHeight
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: scrollHeight,
|
||||
behavior
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const getScrollElement = () => renderContainerRef.value
|
||||
|
||||
const getVisibleIndices = () => {
|
||||
const { start, end } = visibleRange.value
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
}
|
||||
|
||||
const resetMeasurements = () => {
|
||||
itemHeights.value = new Map()
|
||||
itemRefs.clear()
|
||||
}
|
||||
|
||||
const isAtPageBottom = () => {
|
||||
if (!renderContainerRef.value) return false
|
||||
const { scrollTop } = renderContainerRef.value
|
||||
return scrollTop <= 0
|
||||
}
|
||||
|
||||
const isAtPageTop = () => {
|
||||
if (!renderContainerRef.value) return false
|
||||
const { scrollTop, scrollHeight, clientHeight } = renderContainerRef.value
|
||||
return scrollHeight - scrollTop - clientHeight <= 0
|
||||
}
|
||||
|
||||
const observeVisibleItems = () => {
|
||||
if (!resizeObserver.value) return
|
||||
|
||||
resizeObserver.value.disconnect()
|
||||
|
||||
for (const [index, element] of itemRefs) {
|
||||
if (element) {
|
||||
resizeObserver.value.observe(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => computedData.value, (newData, oldData) => {
|
||||
const oldLength = oldData?.length || 0
|
||||
const newLength = newData.length
|
||||
|
||||
if (newLength !== oldLength) {
|
||||
const newHeights = new Map()
|
||||
|
||||
const minLen = Math.min(oldLength, newLength)
|
||||
for (let i = 0; i < minLen; i++) {
|
||||
if (itemHeights.value.has(i)) {
|
||||
newHeights.set(i, itemHeights.value.get(i))
|
||||
}
|
||||
}
|
||||
|
||||
itemHeights.value = newHeights
|
||||
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
})
|
||||
}
|
||||
|
||||
previousDataLength.value = newLength
|
||||
}, { deep: false })
|
||||
|
||||
watch(visibleItems, (newItems) => {
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
})
|
||||
if (newItems.length > 0) {
|
||||
const firstItem = newItems[0]
|
||||
const lastItem = newItems[newItems.length - 1]
|
||||
emit('visible-change', firstItem.index, lastItem.index)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
setupResizeObserver()
|
||||
isInitialized.value = true
|
||||
previousDataLength.value = computedData.value.length
|
||||
|
||||
nextTick(() => {
|
||||
if (pendingScrollToBottom.value) {
|
||||
pendingScrollToBottom.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
observeVisibleItems()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
}
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
itemRefs.clear()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
scrollToIndex,
|
||||
scrollToItem: scrollToIndex,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
getScrollElement,
|
||||
getVisibleIndices,
|
||||
resetMeasurements,
|
||||
containerRef,
|
||||
isAtPageBottom,
|
||||
isAtPageTop
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.virtual-scroller {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-scroller-wrapper {
|
||||
contain: content;
|
||||
|
||||
}
|
||||
|
||||
.virtual-scroller-spacer {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-scroller-placeholder {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-scroller-render-container {
|
||||
contain: layout style;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-scroller-item {
|
||||
contain: layout style;
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.virtual-scroller-bottom-placeholder {
|
||||
contain: layout style;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,615 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="virtual-scroller"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
class="virtual-scroller-wrapper"
|
||||
:style="wrapperStyle"
|
||||
>
|
||||
<div
|
||||
ref="renderContainerRef"
|
||||
class="virtual-scroller-render-container"
|
||||
:style="renderContainerStyle"
|
||||
@scroll.passive="handleScroll"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<div class="virtual-scroller-spacer" :style="{ height: `${totalHeight}px` }"></div>
|
||||
<div
|
||||
class="virtual-scroller-bottom-placeholder"
|
||||
:style="bottomPlaceholderStyle"
|
||||
>
|
||||
<slot name="bottom-placeholder" />
|
||||
</div>
|
||||
<div
|
||||
v-for="renderItem in visibleItems"
|
||||
:key="getItemKey(renderItem.item, renderItem.index)"
|
||||
:ref="el => setItemRef(el, renderItem.index)"
|
||||
class="virtual-scroller-item"
|
||||
:style="getItemStyle(renderItem)"
|
||||
:data-index="renderItem.index"
|
||||
>
|
||||
<slot
|
||||
name="default"
|
||||
:item="renderItem.item"
|
||||
:index="renderItem.index"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: false,
|
||||
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
|
||||
},
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: '100%'
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: '100%'
|
||||
},
|
||||
renderMode: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['default', 'top'].includes(value)
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'reverse',
|
||||
validator: (value) => ['normal', 'reverse'].includes(value)
|
||||
},
|
||||
bottomPlaceholderHeight: {
|
||||
type: Number,
|
||||
default: 350
|
||||
}
|
||||
})
|
||||
|
||||
const computedData = computed(() => {
|
||||
return props.data.length > 0 ? props.data : props.items
|
||||
})
|
||||
|
||||
const computedItemKey = computed(() => {
|
||||
if (typeof props.itemKey === 'function') return props.itemKey
|
||||
if (props.itemKey !== 'id') return props.itemKey
|
||||
return props.keyField
|
||||
})
|
||||
|
||||
const computedBuffer = computed(() => {
|
||||
return props.buffer !== 3 ? props.buffer : props.bufferSize
|
||||
})
|
||||
|
||||
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end', 'visible-change'])
|
||||
|
||||
const containerRef = ref(null)
|
||||
const wrapperRef = ref(null)
|
||||
const renderContainerRef = ref(null)
|
||||
const itemRefs = new Map()
|
||||
const itemHeights = ref(new Map())
|
||||
const resizeObserver = ref(null)
|
||||
const scrollTop = ref(0)
|
||||
const isScrolling = ref(false)
|
||||
const scrollTimeout = ref(null)
|
||||
const isInitialized = ref(false)
|
||||
const pendingScrollToBottom = ref(false)
|
||||
const previousDataLength = ref(0)
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
return {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
let height = 0
|
||||
const len = computedData.value.length
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
height += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
height += props.bottomPlaceholderHeight
|
||||
|
||||
return height
|
||||
})
|
||||
|
||||
const getItemPosition = (index) => {
|
||||
let offset = 0
|
||||
|
||||
for (let i = 0; i < index; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
offset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
const height = itemHeights.value.get(index) ?? props.estimatedHeight
|
||||
|
||||
return { offset, height }
|
||||
}
|
||||
|
||||
const containerHeight = computed(() => {
|
||||
if (!renderContainerRef.value) return 0
|
||||
return renderContainerRef.value.clientHeight
|
||||
})
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
if (!renderContainerRef.value || computedData.value.length === 0) {
|
||||
return { start: 0, end: 0, offset: 0 }
|
||||
}
|
||||
|
||||
const viewportHeight = containerHeight.value
|
||||
const currentScrollTop = scrollTop.value
|
||||
const bufferCount = computedBuffer.value
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = computedData.value.length - 1
|
||||
let startOffset = 0
|
||||
|
||||
let offset = 0
|
||||
for (let i = 0; i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
if (offset + height > currentScrollTop) {
|
||||
startIndex = Math.max(0, i - bufferCount)
|
||||
break
|
||||
}
|
||||
|
||||
offset += height
|
||||
}
|
||||
|
||||
startOffset = 0
|
||||
for (let i = 0; i < startIndex; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
startOffset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
offset = startOffset
|
||||
endIndex = startIndex
|
||||
for (let i = startIndex; i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
offset += height
|
||||
|
||||
if (offset > currentScrollTop + viewportHeight) {
|
||||
endIndex = Math.min(computedData.value.length - 1, i + bufferCount)
|
||||
break
|
||||
}
|
||||
|
||||
endIndex = i
|
||||
}
|
||||
|
||||
return { start: Math.min(startIndex, endIndex), end: Math.max(startIndex, endIndex), offset: startOffset }
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
const { start, end, offset } = visibleRange.value
|
||||
const items = []
|
||||
|
||||
let currentOffset = offset
|
||||
|
||||
for (let i = start; i <= end && i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
items.push({
|
||||
item: computedData.value[i],
|
||||
index: i,
|
||||
offset: currentOffset + props.bottomPlaceholderHeight,
|
||||
height
|
||||
})
|
||||
|
||||
currentOffset += height
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const wrapperStyle = computed(() => ({
|
||||
direction: 'rtl',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
scrollbarWidth: 'auto',
|
||||
overflow: 'hidden',
|
||||
transform: 'rotate(180deg)',
|
||||
width: '100%'
|
||||
}))
|
||||
|
||||
const renderContainerStyle = computed(() => ({
|
||||
direction: 'ltr',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%'
|
||||
}))
|
||||
|
||||
const bottomPlaceholderStyle = computed(() => ({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: `${props.bottomPlaceholderHeight}px`,
|
||||
transform: `translateY(0px)`,
|
||||
zIndex: 1
|
||||
}))
|
||||
|
||||
const getItemKey = (item, index) => {
|
||||
const keyField = computedItemKey.value
|
||||
if (typeof keyField === 'function') {
|
||||
return keyField(item, index)
|
||||
}
|
||||
if (typeof keyField === 'string' && item && typeof item === 'object') {
|
||||
return item[keyField] ?? index
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
const getItemStyle = (renderItem) => {
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${renderItem.offset}px)`,
|
||||
willChange: 'transform'
|
||||
}
|
||||
}
|
||||
|
||||
const setItemRef = (el, index) => {
|
||||
if (el) {
|
||||
itemRefs.set(index, el)
|
||||
} else {
|
||||
itemRefs.delete(index)
|
||||
}
|
||||
}
|
||||
|
||||
const measureItem = (index, element) => {
|
||||
if (!element) return
|
||||
|
||||
const firstChild = element.firstElementChild
|
||||
const targetElement = firstChild || element
|
||||
|
||||
const height = Math.ceil(targetElement.offsetHeight)
|
||||
|
||||
if (height > 0) {
|
||||
const cachedHeight = itemHeights.value.get(index)
|
||||
if (cachedHeight !== height) {
|
||||
const newHeights = new Map(itemHeights.value)
|
||||
newHeights.set(index, height)
|
||||
itemHeights.value = newHeights
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setupResizeObserver = () => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
}
|
||||
|
||||
resizeObserver.value = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const index = parseInt(entry.target.dataset.index, 10)
|
||||
if (!isNaN(index)) {
|
||||
measureItem(index, entry.target)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleWheel = (event) => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
const { deltaY } = event
|
||||
const el = renderContainerRef.value
|
||||
|
||||
el.scrollBy({
|
||||
top: -deltaY,
|
||||
behavior: 'instant'
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleScroll = (event) => {
|
||||
const target = event.target
|
||||
scrollTop.value = target.scrollTop
|
||||
isScrolling.value = true
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
scrollTimeout.value = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, 150)
|
||||
|
||||
const st = target.scrollTop
|
||||
const scrollHeight = target.scrollHeight
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
const distanceToContainerTop = st
|
||||
const distanceToContainerBottom = scrollHeight - st - clientHeight
|
||||
|
||||
const distanceToPageTop = distanceToContainerBottom
|
||||
const distanceToPageBottom = distanceToContainerTop
|
||||
const isAtPageTop = distanceToPageTop <= 0
|
||||
const isAtPageBottom = distanceToPageBottom <= 0
|
||||
|
||||
emit('scroll', {
|
||||
target,
|
||||
scrollTop: st,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
distanceToPageTop,
|
||||
distanceToPageBottom,
|
||||
isAtPageTop,
|
||||
isAtPageBottom
|
||||
})
|
||||
|
||||
if (isAtPageTop) {
|
||||
emit('scroll-start')
|
||||
}
|
||||
|
||||
if (isAtPageBottom) {
|
||||
emit('scroll-end')
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToIndex = (index, behavior = 'auto') => {
|
||||
if (!renderContainerRef.value || index < 0 || index >= computedData.value.length) return
|
||||
|
||||
const position = getItemPosition(index)
|
||||
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: position.offset,
|
||||
behavior
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToBottom = (behavior = 'smooth') => {
|
||||
if (!renderContainerRef.value) {
|
||||
pendingScrollToBottom.value = true
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: 0,
|
||||
behavior
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToTop = (behavior = 'smooth') => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
const scrollHeight = renderContainerRef.value.scrollHeight
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: scrollHeight,
|
||||
behavior
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const getScrollElement = () => renderContainerRef.value
|
||||
|
||||
const getVisibleIndices = () => {
|
||||
const { start, end } = visibleRange.value
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
}
|
||||
|
||||
const resetMeasurements = () => {
|
||||
itemHeights.value = new Map()
|
||||
itemRefs.clear()
|
||||
}
|
||||
|
||||
const isAtPageBottom = () => {
|
||||
if (!renderContainerRef.value) return false
|
||||
const { scrollTop } = renderContainerRef.value
|
||||
return scrollTop <= 0
|
||||
}
|
||||
|
||||
const isAtPageTop = () => {
|
||||
if (!renderContainerRef.value) return false
|
||||
const { scrollTop, scrollHeight, clientHeight } = renderContainerRef.value
|
||||
return scrollHeight - scrollTop - clientHeight <= 0
|
||||
}
|
||||
|
||||
const observeVisibleItems = () => {
|
||||
if (!resizeObserver.value) return
|
||||
|
||||
resizeObserver.value.disconnect()
|
||||
|
||||
for (const [index, element] of itemRefs) {
|
||||
if (element) {
|
||||
resizeObserver.value.observe(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => computedData.value, (newData, oldData) => {
|
||||
const oldLength = oldData?.length || 0
|
||||
const newLength = newData.length
|
||||
|
||||
if (newLength !== oldLength) {
|
||||
const newHeights = new Map()
|
||||
|
||||
const minLen = Math.min(oldLength, newLength)
|
||||
for (let i = 0; i < minLen; i++) {
|
||||
if (itemHeights.value.has(i)) {
|
||||
newHeights.set(i, itemHeights.value.get(i))
|
||||
}
|
||||
}
|
||||
|
||||
itemHeights.value = newHeights
|
||||
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
})
|
||||
}
|
||||
|
||||
previousDataLength.value = newLength
|
||||
}, { deep: false })
|
||||
|
||||
watch(visibleItems, (newItems) => {
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
})
|
||||
if (newItems.length > 0) {
|
||||
const firstItem = newItems[0]
|
||||
const lastItem = newItems[newItems.length - 1]
|
||||
emit('visible-change', firstItem.index, lastItem.index)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
setupResizeObserver()
|
||||
isInitialized.value = true
|
||||
previousDataLength.value = computedData.value.length
|
||||
|
||||
nextTick(() => {
|
||||
if (pendingScrollToBottom.value) {
|
||||
pendingScrollToBottom.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
observeVisibleItems()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
}
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
itemRefs.clear()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
scrollToIndex,
|
||||
scrollToItem: scrollToIndex,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
getScrollElement,
|
||||
getVisibleIndices,
|
||||
resetMeasurements,
|
||||
containerRef,
|
||||
isAtPageBottom,
|
||||
isAtPageTop
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.virtual-scroller {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-scroller-wrapper {
|
||||
contain: content;
|
||||
|
||||
}
|
||||
|
||||
.virtual-scroller-spacer {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-scroller-placeholder {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-scroller-render-container {
|
||||
contain: layout style;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-scroller-item {
|
||||
contain: layout style;
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.virtual-scroller-bottom-placeholder {
|
||||
contain: layout style;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
import { ref, reactive, computed, markRaw } from 'vue'
|
||||
import { ref, reactive, markRaw } from 'vue'
|
||||
import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
|
||||
import { getModelConfig } from '@/config/models/index.js'
|
||||
import PaintingModelSelector from './modelSelector.vue'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user