Compare commits
34 Commits
b81c1f858e
...
79afa037e2
| Author | SHA1 | Date | |
|---|---|---|---|
| 79afa037e2 | |||
| 2d12c5a20b | |||
| 61867e4f59 | |||
| 45a80b83ff | |||
| ab4e0591a9 | |||
| 6879b08fe3 | |||
| b8ff25a8d7 | |||
| 0eee8b1f7f | |||
| d4ef09247c | |||
| e98ff3a2c4 | |||
| b964c826ce | |||
| 18e7dbc6ed | |||
| 5c24de354b | |||
| 2cd3f8fad6 | |||
| fe1ce00f66 | |||
| 308581e2e4 | |||
| 33094e675c | |||
| 2207720438 | |||
| af7debd54c | |||
| 025ce0de9f | |||
| 481afadd2b | |||
| 72e4acf956 | |||
| ac7a592618 | |||
| 3d5d356700 | |||
| 73f7bd888e | |||
| bcd83fc0a8 | |||
| 3507eddfb3 | |||
| ec81dce28a | |||
| 615afbc211 | |||
| 184fd6dd8c | |||
| 705a7a7ebf | |||
| d2a04613d5 | |||
| 1fa28d10db | |||
| a1134d85ad |
@ -2,7 +2,13 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)"
|
||||
"Bash(git commit *)",
|
||||
"Bash(git -C \"D:/WebUI/Kexue/操作平台/AI_Painting_V2.0\" log --oneline --all -- src/views/home/display/index.vue)",
|
||||
"Bash(git -C \"D:/WebUI/Kexue/操作平台/AI_Painting_V2.0\" log --oneline --all -- src/stores/display.js)",
|
||||
"Bash(git -C \"D:/WebUI/Kexue/操作平台/AI_Painting_V2.0\" log --oneline --all -- src/components/dialogBox/index.vue)",
|
||||
"Bash(git -C \"D:/WebUI/Kexue/操作平台/AI_Painting_V2.0\" log --all --oneline --follow -p -- src/stores/display.js)",
|
||||
"Bash(git -C \"D:/WebUI/Kexue/操作平台/AI_Painting_V2.0\" log --all --oneline -p -- src/components/dialogBox/index.vue)",
|
||||
"Bash(npx eslint *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ VITE_API_PAY_PREFIX = '/pay'
|
||||
VITE_API_PAY_TARGET = 'http://test.xueai.art' # http://43.248.133.202 test.xueai.art
|
||||
|
||||
# 任务处理模块
|
||||
VITE_API_WORKFLOW_UPLOAD = 'http://43.248.97.19:4000/aigc/workflow/file/upload' # https://sxwz.xueai.art/workflow https://designtools.xueai.art/workflow
|
||||
VITE_API_WORKFLOW_UPLOAD = 'http://test.xueai.art/AIGC/Temp/uploadImage' # https://sxwz.xueai.art/workflow https://designtools.xueai.art/workflow
|
||||
VITE_API_TASK_PREFIX = '/suanli'
|
||||
VITE_API_TASK_TARGET = 'http://test.xueai.art'
|
||||
|
||||
|
||||
5
.gitignore
vendored
@ -23,3 +23,8 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
TEST/
|
||||
docs/
|
||||
.superpowers/
|
||||
运维/
|
||||
bug.txt
|
||||
out.txt
|
||||
|
||||
343
CLAUDE.md
@ -5,7 +5,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
pnpm dev # 启动 Vite 开发服务器
|
||||
pnpm install # 安装依赖
|
||||
pnpm dev # 启动 Vite 开发服务器(默认 http://localhost:5173)
|
||||
pnpm build # 生产构建
|
||||
pnpm preview # 预览生产构建
|
||||
npx eslint . # 代码检查(@antfu/eslint-config,Vue 支持,无 TypeScript)
|
||||
@ -13,122 +14,282 @@ npx eslint . # 代码检查(@antfu/eslint-config,Vue 支持,无 TypeS
|
||||
|
||||
## 技术栈
|
||||
|
||||
Vue 3 (Composition API) + Vite 7 + Pinia + Vue Router + Element Plus + `vue-element-plus-x`(多媒体编辑) + Less + pnpm
|
||||
Vue 3 (Composition API) + Vite 7 + Pinia + Vue Router + Element Plus + `vue-element-plus-x`(提供 Sender 输入框组件) + Less + pnpm
|
||||
|
||||
## 架构概览
|
||||
|
||||
AI 绘画/视频生成前端操作平台,通过 HTTP 接口对接算力调度后端(suanli)和第三方 AI 平台(RunningHub),提交生成任务并轮询结果。
|
||||
AI 绘画/视频生成前端操作平台,通过 HTTP 接口对接算力调度后端(suanli),提交生成任务并轮询结果。
|
||||
|
||||
**Painting 和 Video 走两套不同的任务构造路径:**
|
||||
|
||||
- **Painting(新架构)**:本地模型参数 schema → 专用控件 + 动态表单 → `X-Session-Id` header + 扁平 API body
|
||||
- **Video(旧架构)**:远程 workflow JSON → RunningHub Playload 适配器 → `{ workflowId, nodeInfoList }` body
|
||||
**核心架构:Platform Descriptor 模式。** Painting 和 Video 是两个独立的平台包,通过统一的注册表动态加载。dialogBox 是通用编排壳,不包含任何平台特定逻辑。
|
||||
|
||||
### 关键目录
|
||||
|
||||
```
|
||||
├── model-configs/ # 运维参考:模型参数配置 JSON 文件(hailuo, ltx, vidu 等,位于项目根目录)
|
||||
├── config/
|
||||
│ └── plugins.js # Vite 插件配置(unplugin-auto-import + unplugin-vue-components resolver)
|
||||
├── vite.config.js # 构建配置:alias(@→src, ~→根目录)、envPrefix: ['VITE','FILE']、optimizeDeps
|
||||
src/
|
||||
├── main.js # 入口:创建 Vue 应用,安装 Pinia/Router/VueVirtualScroller
|
||||
├── main.js # 入口:创建 Vue 应用,安装 Pinia/Router/vue-virtual-scroller
|
||||
├── router/index.js # 路由定义 + token 验证守卫
|
||||
├── assets/ # 静态资源
|
||||
│ ├── dialog/ # 控件区域 SVG 图标(model, proportion, quantity, time, videoPattern 等)
|
||||
│ └── display/ # 结果展示区 SVG 图标(download, collection, delete, back, brush 等)
|
||||
├── platforms/ # 平台包(独立、可插拔)
|
||||
│ ├── registry.js # 注册表:registerPlatform(id, factory) + createPlatform(type)
|
||||
│ ├── painting/ # Painting 平台
|
||||
│ │ ├── index.js # definePaintingPlatform():controls、state、loadConfig、buildTaskBody 等
|
||||
│ │ ├── modelSelector.vue # 模型选择器(按 API tags 分组)
|
||||
│ │ ├── imageUploader.vue # 图片上传组件
|
||||
│ │ └── controls/ # 平台专用控件
|
||||
│ │ ├── proportion.vue
|
||||
│ │ ├── dimension.vue
|
||||
│ │ ├── quality.vue
|
||||
│ │ └── quantity.vue
|
||||
│ └── video/ # Video 平台
|
||||
│ ├── index.js # defineVideoPlatform()
|
||||
│ ├── modelSelector.vue
|
||||
│ ├── imageUploader.vue
|
||||
│ └── controls/
|
||||
│ ├── pattern.vue
|
||||
│ ├── proportion.vue
|
||||
│ └── time.vue
|
||||
├── stores/ # Pinia 状态管理
|
||||
│ ├── user.js # 用户认证、信息(含 sessionId),使用 pinia persist 持久化 token
|
||||
│ ├── user.js # 用户认证、信息(含 sessionId),pinia persist 持久化 token
|
||||
│ ├── display.js # 生成历史列表、UI 状态(滚动、画布等)
|
||||
│ └── param.js # 参数 store(当前为空)
|
||||
├── apis/ # HTTP API 层:纯请求封装,不含业务逻辑
|
||||
│ ├── auth/ # 认证相关(登录、token 校验、用户信息、验证码)
|
||||
│ └── display/ # 任务创建/轮询/历史、平台模型、收藏/删除
|
||||
├── components/
|
||||
│ ├── dialogBox/ # 生成参数输入面板(核心交互入口)
|
||||
│ │ ├── index.vue # 编排中心:组装所有控件,处理 handleStart()、模型配置加载、参数回填
|
||||
│ │ ├── model/ # 模型选择器(按 API 返回的 tags 分组,value 编码为 tag::display_name)
|
||||
│ │ ├── proportion/ # 比例/分辨率选择器(painting.vue 用于 Painting,video.vue 用于 Video)
|
||||
│ │ ├── imageUploader/ # 图片上传(Painting)
|
||||
│ │ ├── videoImageUploader/ # 视频图片上传(Video)
|
||||
│ │ ├── quantity/ # 生成数量选择器(支持 1-6,上限由模型配置派生)
|
||||
│ │ ├── Time/ # 视频时长选择器
|
||||
│ │ └── pattern/ # 视频模式选择器
|
||||
│ ├── dialogBox/ # 通用编排壳(核心交互入口)
|
||||
│ │ └── index.vue # <component :is> 动态渲染平台控件,不含平台分支
|
||||
│ ├── Popover/ # 自定义弹出层(Teleport to body,position:fixed + fit-content 宽度)
|
||||
│ ├── Select/ # 自定义下拉选择器(分组选项,与 Popover 协调互斥开关)
|
||||
│ ├── ParamGroup/ # 动态参数容器:遍历 config.params 中未被专用控件处理的 select/switch 参数
|
||||
│ ├── SwitchControl/ # 纯 CSS 布尔开关(不用外部组件库)
|
||||
│ ├── Img/ # 图片包装组件(点击全屏查看,Teleport 实现)
|
||||
│ ├── virtual-scroller/# 虚拟滚动列表组件(自定义实现,reverse 模式)
|
||||
│ ├── virtual-scroller/ # 虚拟滚动列表(自定义实现)。reverse 模式用 180deg 旋转实现底部锚定,slot 内容须反旋转
|
||||
│ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘,undo/redo)
|
||||
├── views/ # 页面(home、login)
|
||||
├── utils/
|
||||
│ ├── request.js # Axios 实例 + 拦截器:统一 Auth(不带 Bearer)+ 按前缀路由 baseURL
|
||||
│ ├── taskPolling.js # 任务生成入口:组装参数 → POST 创建任务 → 20s HTTP 轮询直至完成/失败
|
||||
│ ├── modelApi.js # 模型业务层:localStorage 30s 缓存 + pendingRequests 并发去重 + 平台编码映射
|
||||
│ ├── createTask.js # 任务 body 构造:Painting 返回 modelParams,Video 走 Playload 适配器
|
||||
│ ├── modelConfig.js # Video 旧架构:从远程 JSON 加载 workflow 配置(含 localStorage 每日缓存)
|
||||
│ ├── downloadImage.js # 图片/视频下载:fetch → Blob → 自动文件名下载
|
||||
│ ├── tokenError.js # 认证失败处理:提示后 5 秒刷新页面
|
||||
│ ├── encrypt.ts # 加密工具(Base64/MD5/RSA/AES,依赖 crypto-js、jsencrypt)
|
||||
│ └── auth.ts # token 存取工具(localStorage)
|
||||
├── config/
|
||||
│ ├── plugins.js # Vite 插件配置(unplugin-auto-import + unplugin-vue-components)
|
||||
│ ├── index.js # 平台配置入口(目前仅导出 runninghub 供 Video 使用)
|
||||
│ ├── runninghub/ # RunningHub 平台适配器:Playload() 构造和 result() 解析(Video 专用)
|
||||
│ ├── models/ # Painting 模型参数 schema:每模型一个 JS 文件,定义 params 和各字段的 ui 类型
|
||||
│ └── modelConfig/ # 静态模型列表:painting.json(按 tag 分组)、video.json(按 pattern 分组)
|
||||
└── utils/
|
||||
├── request.js # Axios 实例 + 拦截器:统一 Auth(不带 Bearer)+ 按前缀路由 baseURL
|
||||
├── taskPolling.js # 任务生成入口:组装参数 → POST 创建任务 → 首次 5s + 后续 20s HTTP 轮询直至完成/失败
|
||||
├── modelApi.js # 模型业务层:localStorage 缓存 + 并发去重。平台模型列表(30s TTL) + 模型参数配置(60s TTL)
|
||||
├── modelConfigHelper.js # 模型配置共享工具:syncDefaults / syncParamValues / getDimConfig / checkShowWhen
|
||||
├── downloadImage.js # 图片/视频下载:fetch → Blob → 自动文件名下载
|
||||
├── uploadImage.js # 图片上传工具
|
||||
├── tokenError.js # 认证失败处理:提示后 5 秒刷新页面
|
||||
├── encrypt.ts # 加密工具(⚠️ 死代码:依赖 crypto-js/jsencrypt 未安装,无任何 import 引用)
|
||||
└── auth.ts # token 存取工具(localStorage)
|
||||
```
|
||||
|
||||
### 模型参数配置(Painting 新架构)
|
||||
### Platform Descriptor 模式
|
||||
|
||||
`src/config/models/` 下每个模型一个 JS 文件,参数通过不同 UI 组件承载:
|
||||
每个平台通过 `defineXxxPlatform()` 工厂函数返回标准接口:
|
||||
|
||||
- **`ui: 'textarea'`** → Sender 组件主输入框(prompt)
|
||||
- **`ui: 'proportion'`** + **`ui: 'resolution'`** → `paintingProportion` 组件(共用 Popover,options 可含 `custom` 开启自定义 W/H)
|
||||
- **`ui: 'quantity'`** → `Quantity` 组件(动态上限由 model config 的 `options` 数组最大值派生)
|
||||
- **`ui: 'imageUpload'`** → `ImageUploader` 组件
|
||||
- **`ui: 'hidden'`** → 无 UI,仅写入默认值(如 outputFormat: 'png')
|
||||
```js
|
||||
const platform = {
|
||||
id: 'painting', // 平台唯一标识
|
||||
label: 'AI绘画2026', // 显示名称
|
||||
ModelSelector: markRaw(Component), // 模型选择器组件
|
||||
modelSelectorProps: null, // 模型选择器的额外 props(可为函数)
|
||||
controls: [{ // 控件描述符数组
|
||||
name: 'proportion',
|
||||
component: markRaw(Component),
|
||||
beforeModel: false, // 设为 true 则渲染在 ModelSelector 之前
|
||||
show: (config) => boolean, // 根据 model config 决定是否显示
|
||||
props: (config) => ({ ... }) // 根据 model config 生成 v-model props
|
||||
}],
|
||||
ImageUploader: markRaw(Component), // 图片上传组件(可为 null)
|
||||
state: { ... }, // 平台所有响应式状态
|
||||
model, modelType, // 当前模型/类型(ref)
|
||||
modelConfig, // 当前模型参数配置(ref)
|
||||
promptPlaceholder, // 提示词占位文本(ref)
|
||||
|
||||
模型选择器从 API(`fetchPlatformModels`)获取模型列表,按 API 返回的 `tags` 数组字段分组(`text`→生成模型,`edit`→编辑模型,`vision`→视觉理解模型)。
|
||||
async loadModels() { ... }, // 获取模型列表
|
||||
async loadConfig(modelName, modelType) { ... }, // 加载模型参数配置(modelName 可以是 UUID/name/display_name)
|
||||
getDefaultModel() { ... }, // 返回默认模型标识(可返回 '' 交由 modelSelector 自动纠错)
|
||||
imageUploadLimit() { ... }, // 返回图片上传槽位数(应累加所有 imageUpload 参数的 maxCount)
|
||||
validateBeforeSubmit() { ... }, // 提交前校验,返回 null 表示通过
|
||||
getUploaderBindings() { ... }, // 图片上传组件的绑定参数(modelType + imagesCount)
|
||||
showImageUploader() { ... }, // 是否显示图片上传区域
|
||||
isImageRequired() { ... }, // 图片是否必填
|
||||
buildTaskBody({ prompt, referenceImages }) { ... }, // 构造扁平 modelParams,需将 referenceImages 映射到 imageUpload 参数
|
||||
fillFromResult(resultData) { ... }, // 从历史结果回填参数
|
||||
}
|
||||
```
|
||||
|
||||
### `displayNameMap` 机制
|
||||
控件通过 `ctrl.props(config)` 接收 v-model 绑定对:
|
||||
```js
|
||||
props: (config) => ({
|
||||
modelValue: proportion.value,
|
||||
'onUpdate:modelValue': (v) => { proportion.value = v },
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
`src/config/models/index.js` 中 `displayNameMap` 负责将 API 返回的 `display_name` 映射到 config key。因为同一模型在不同 tag 下可能共用一个 `display_name`(如 `GPT-Image-2` 和 `GPT-image-2` 分别对应编辑/生成),config key 采用内部中文名区分。
|
||||
**自注册:** 每个平台文件底部调用 `registerPlatform('Painting', definePaintingPlatform)`,在 import 时自动注册。dialogBox 通过 `createPlatform(props.type)` 获取实例。注册表内部将 key 统一转为小写,因此 `'Painting'` 和 `'painting'` 等效。
|
||||
|
||||
### dialogBox 编排中心
|
||||
### dialogBox 通用编排壳
|
||||
|
||||
`src/components/dialogBox/index.vue` 是核心编排组件,负责:
|
||||
`src/components/dialogBox/index.vue` 是纯编排组件,不含任何平台分支:
|
||||
|
||||
- **模型选择切换**:监听 `model` + `modelType` 变化,调用 `loadModelConfig()` 加载模型参数 schema
|
||||
- **派生 UI 配置**:从 model config 计算 `paintingProportionOpts`(滤除 `custom` 选项)、`paintingResolutionOpts`、`hasCustomSize`(是否显示自定义尺寸)、`quantityMax`
|
||||
- **状态管理**:`customWidth`/`customHight` 通过 `v-model:width`/`v-model:height` 与 `paintingProportion` 双向绑定
|
||||
- **参数回填**:`fillParamsFromResult()` 供历史记录重编辑使用
|
||||
- **任务发起**:`handleStart()` 收集所有参数 → 构造 `data` → 调用 `taskPolling.js:generate()`
|
||||
- **平台切换**:`const platform = computed(() => createPlatform(props.type))`,切换时重置默认模型并加载模型列表
|
||||
- **控件渲染**:`visibleControls = platform.controls.filter(c => c.show(getCurrentConfig()))`,按 `beforeModel` 拆分为两组,`beforeModel` 控件 → ModelSelector → 其余控件,用 `<component :is>` + `v-bind="ctrl.props(...)"` 渲染
|
||||
- **配置获取**:`getCurrentConfig()` 返回 `platform.modelConfig?.value`
|
||||
- **模型切换**:`watch([model, modelType])` → `platform.loadConfig()` → `syncDefaults()` 将 schema 写入响应式 state → controls 的 `show()`/`props()` 读取 state → `visibleControls` 自动更新 → UI 重渲染
|
||||
- **任务发起**:`handleStart()` 调用 `platform.validateBeforeSubmit()` → `platform.buildTaskBody()` → `generate()`
|
||||
- **参数回填**:`fillParamsFromResult()` 委托给 `platform.fillFromResult()`
|
||||
|
||||
### 统一数据流(Painting + Video)
|
||||
|
||||
两个平台现已统一,通过后端 API 获取模型参数配置:
|
||||
|
||||
1. 用户选择模型 → `platform.loadConfig(modelName, modelType)` → 调用 `GET /suanli/v1/models/:id/config`(优先 60s 缓存)加载参数 schema
|
||||
2. 参数 schema 驱动 `controls` 渲染 UI,用户填写参数
|
||||
3. 用户点击发送 → `handleStart()` → `platform.buildTaskBody({ prompt, referenceImages })` 返回扁平 `modelParams`
|
||||
4. `taskPolling.js` 直接读取 `data.body` → `getModelId(type, modelName)` 查找 UUID → POST `/suanli/v1/tasks`(`X-Session-Id` header)
|
||||
5. 20s 间隔轮询直至完成/失败
|
||||
|
||||
### 模型标识与查找
|
||||
|
||||
API 返回的模型对象包含三个标识字段:
|
||||
|
||||
| 字段 | 示例 | 用途 |
|
||||
|------|------|------|
|
||||
| `id` | `e7e1e743-7621-403d-b8fb-2b7f4fa1b4fc` | UUID 主键,**唯一且稳定**,用于 API 调用和内部追踪 |
|
||||
| `name` | `vidu-text-to-video-q3-turbo` | 内部标识名,通常也唯一 |
|
||||
| `display_name` | `Vidu q3-turbo` | 用户可见的显示名,**可能重复**(不同 pattern 下的同名模型) |
|
||||
|
||||
**Video 平台使用 `id`(UUID)作为 `model.value`**,避免 `display_name` 冲突导致的模型查找错误。`modelSelector.vue` 中 `modelGroups` 的 `value` 设为 `m.id`,`label` 仍用 `display_name` 显示。
|
||||
|
||||
`getModelId(type, modelName)` 查找优先级:`m.id === modelName` → `m.name === modelName` → `m.display_name === modelName`,向下兼容旧的 name/display_name 调用。
|
||||
|
||||
Video 的 `getDefaultModel()` 返回 `''`,不硬编码 UUID;模型列表加载后由 `modelSelector` 的 watcher 自动选择第一个可用模型。
|
||||
|
||||
### 模型参数配置
|
||||
|
||||
模型参数配置通过后端 API `GET /suanli/v1/models/:id/config` 获取(60s localStorage 缓存),替代了旧的本地硬编码。参数通过 `ui` 字段映射到前端控件:
|
||||
|
||||
| `ui` 值 | 控件 | 说明 |
|
||||
|---------|------|------|
|
||||
| `textarea` | Sender 内置 textarea | prompt 输入框 |
|
||||
| `proportion` | `PaintingProportion` / `VideoProportion` | 比例选择 Popover(`options` 含 `custom` 时可自定义宽高) |
|
||||
| `resolution` | proportion 控件内部 | 分辨率子选项,与 proportion 共用 Popover |
|
||||
| `dimension` | `DimensionInput` | **组合模式**:单字段 `"W*H"` 格式,后端传 `dimension.separator`,前端生成 parse/format |
|
||||
| `dimensionWidth` + `dimensionHeight` | `DimensionInput` | **拆分模式**:两个独立字段(含 `min`/`max`),共享比例锁 |
|
||||
| `number` | proportion 控件内部 | 自定义宽高(如 Flux 的 customWidth/customHight),配合 `showWhen` 条件显示 |
|
||||
| `select` | `Select`(通过 ParamGroup 或专用控件) | 通用下拉。专用控件直接使用 Select,其余由 ParamGroup 动态渲染 |
|
||||
| `switch` | `SwitchControl`(通过 ParamGroup) | 布尔开关,纯 CSS 实现,不用外部组件库 |
|
||||
| `quantity` | `Quantity` | 生成数量,`options` 必须为数字数组,上限由 `Math.max()` 派生 |
|
||||
| `imageUpload` | `ImageUploader` | 参考图上传,`maxCount` 控制上限 |
|
||||
| `hidden` | 无 | 静默写入默认值 |
|
||||
|
||||
**dimension 模式区分**:`getDimConfig()`(来自 `modelConfigHelper.js`)自动检测 `ui: 'dimension'`(组合)或 `ui: 'dimensionWidth'`(拆分),两种模式共用 `DimensionInput` 组件。
|
||||
|
||||
**条件显示**:`showWhen: { aspectRatio: 'custom' }` 使参数仅在 proportion 选 `custom` 时显示。`checkShowWhen()` 在 controls 的 `show()` 回调中调用,因直接读取 reactive 的 `paramValues[key]`,computed 自动追踪依赖。
|
||||
|
||||
### modelConfigHelper 共享工具
|
||||
|
||||
`src/utils/modelConfigHelper.js` 提供 Painting/Video 双平台共用的 4 个纯函数:
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `syncDefaults(config, state)` | 将 API 返回的 config 同步到响应式 state。处理 `paramValues` 初始化、专用 ref 同步(proportion/resolution/quantity/quality/duration/dimension)、`promptPlaceholder` 同步。`resolution` 同时匹配 `ui:"resolution"` 和 `ui:"select"` |
|
||||
| `syncParamValues(config, state)` | 在 `buildTaskBody` 前将专用 ref(proportion/resolution/quantity/quality/duration/dimension)回写到 `paramValues` |
|
||||
| `getDimConfig(config)` | 检测 combined/split 模式 |
|
||||
| `checkShowWhen(param, paramValues)` | 检查 `showWhen` 条件 |
|
||||
|
||||
`state` 参数对象需包含 `modelConfig, paramValues, proportion, resolution, quantity, quality, customWidth, customHight, dimWidth, dimHeight, promptPlaceholder`。Video 平台额外包含 `duration`(用于 syncDefaults 同步时长默认值)。各平台通过包装函数适配。
|
||||
|
||||
### ParamGroup 动态参数渲染
|
||||
|
||||
当 API 返回的 params 中包含无专用控件的类型(如 `select`、`switch`),由 `ParamGroup` 统一处理:
|
||||
|
||||
- **容器定位**:作为平台 controls 数组的最后一项,`show()` 检查是否存在未被 `handledUis` 覆盖的参数
|
||||
- **`handledUis`**:`['textarea', 'proportion', 'resolution', 'dimension', 'dimensionWidth', 'dimensionHeight', 'quantity', 'imageUpload', 'hidden', 'number']` — 这些类型的参数由专用控件处理,ParamGroup 跳过
|
||||
- **`excludeNames` prop**:额外按参数名排除(如 `resolution`、`duration`),因为 VideoProportion/Time 已处理这些参数(即使其 `ui` 类型不在 handledUis 中)
|
||||
- **双层过滤**:平台 `show()` 做第一层(判断是否渲染 ParamGroup),ParamGroup 内部 `dynamicParams` 做第二层(判断具体渲染哪些参数)。**两层必须保持一致**,否则会出现重复控件
|
||||
|
||||
```js
|
||||
// Video 平台的 ParamGroup 配置
|
||||
{
|
||||
name: 'paramGroup',
|
||||
component: markRaw(ParamGroup),
|
||||
show: (config) => {
|
||||
// 第一层:有未被处理的参数才显示
|
||||
return config.params.some((p) => {
|
||||
if (handledUis.includes(p.ui)) return false
|
||||
if (p.name === 'resolution') return false
|
||||
if (p.name === 'duration') return false
|
||||
return true
|
||||
})
|
||||
},
|
||||
props: (config) => ({
|
||||
config,
|
||||
paramValues,
|
||||
excludeNames: ['resolution', 'duration'] // 第二层过滤
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 组件规范
|
||||
|
||||
**禁止在项目组件中使用外部 UI 库**(Element Plus 等),图标除外。自定义组件使用项目自研的 `Select`、`Popover` 或纯 CSS 实现。`SwitchControl` 即为一例——纯 CSS 滑动开关,不依赖 `el-switch`。
|
||||
|
||||
### `$attrs` 穿透注意
|
||||
|
||||
向子组件传递的 prop 如果子组件未声明,会通过 `$attrs` 穿透到根元素。例如 `dialogBox` 向 `paintingProportion` 传递 `v-model:width="customWidth"`,若 `paintingProportion` 未声明 `width` prop,值会穿透到 `<Popover>` 的 `width` prop,导致异常宽度。**所有通过 `v-model` 传递的值,子组件必须声明对应的 prop。**
|
||||
向子组件传递的 prop 如果子组件未声明,会通过 `$attrs` 穿透到根元素。**所有通过 `v-model` 传递的值,子组件必须声明对应的 prop。**
|
||||
|
||||
### API 层设计原则
|
||||
|
||||
- `src/apis/` 只做纯 HTTP 调用(`service.get/post/delete`),不含缓存、localStorage、业务逻辑
|
||||
- 缓存、数据转换等业务逻辑放在 `src/utils/` 中
|
||||
|
||||
### 核心数据流
|
||||
|
||||
**Painting(新架构):**
|
||||
|
||||
1. 用户设置参数 → `dialogBox` 从 model config 派生 proportion/resolution 选项、hasCustomSize、quantityMax
|
||||
2. `handleStart()` 收集参数 → 组装 `{ modelParams, request }`
|
||||
3. `taskPolling.js:generate()` → `createTask(data)` → Painting 直接返回 `data.modelParams`
|
||||
4. `getModelId(type, modelName)` 查找 UUID(内部调用 `fetchPlatformModels` 走缓存)
|
||||
5. `requestCreateTask(body, sessionId)` → POST `/suanli/v1/tasks`,携带 `X-Session-Id` header
|
||||
6. 返回 task_id → 20s 间隔轮询直至完成/失败
|
||||
|
||||
**Video(旧架构,保留):**
|
||||
|
||||
1. 用户设置 Pattern、videoModel、比例、时长
|
||||
2. `createTask(data)` → `runninghub.Playload(data)` → `fetchModelConfig()` 获取 workflow JSON → 返回 `{ workflowId, nodeInfoList }`
|
||||
3. 后续同 Painting 步骤 4-6
|
||||
|
||||
### 关键注意事项
|
||||
|
||||
- **`sessionId`** 来自登录接口返回的 `userInfo.sessionId`,存储在 `useUserStore().userInfo` 中。`taskPolling.js` 必须使用该值,禁止随机生成。
|
||||
- **`X-Session-Id`** 自定义 header 需要 nginx 在 `/suanli/` location 的 `Access-Control-Allow-Headers` 中加入,否则 POST 请求会触发 CORS 预检失败。
|
||||
- **模型列表缓存**:`modelApi.js` 中 `fetchPlatformModels` 使用 localStorage 30 秒 TTL + `pendingRequests` Map 并发去重,避免重复请求。
|
||||
- **页面加载预请求**:平台模型列表在 `dialogBox onMounted` 时预请求,避免首次点击"发送"时才触发。
|
||||
- **模型列表缓存**:`modelApi.js` 中 `fetchPlatformModels` 使用 localStorage 30 秒 TTL + `pendingRequests` Map 并发去重。
|
||||
- **模型配置缓存**:`modelApi.js` 中 `getModelConfig` 使用 localStorage 60 秒 TTL + `pendingConfigRequests` Map 并发去重。`loadModels()` 会在获取模型列表后调用 `preloadModelConfigs` 批量预加载。
|
||||
- **平台包预加载**:dialogBox 顶层 `import` Painting 和 Video 平台包,触发自注册,确保首次使用时 registry 已就绪。
|
||||
- **VirtualScroller 实现说明**:虽然 `package.json` 安装了 `vue-virtual-scroller`(npm 包)并在 `main.js` 中全局注册,但 `display/index.vue` 中 `<VirtualScroller>` 实际使用的是 `src/components/virtual-scroller/VirtualScroller.vue` 自定义实现(通过 `unplugin-vue-components` 自动注册的同名组件遮蔽 npm 包)。`vue-virtual-scroller` npm 包当前为冗余依赖。
|
||||
- **VirtualScroller 反旋转**:组件用外层 `transform: rotate(180deg)` 实现 reverse 底部锚定。所有作为 slot 插入的内容必须在根元素加 `transform: rotate(180deg)` 反旋转,否则文字/图片会颠倒显示。参考 `src/views/home/display/components/set.vue:2`。
|
||||
- **VirtualScroller 存在独立测试页**:`src/components/virtual-scroller/test.html` + `test-data.js`,可用于验证虚拟滚动行为。
|
||||
- **VirtualScroller 残留备份文件**:`src/components/virtual-scroller/VirtualScroller copy.vue` 是备份副本,不应被引用或修改。
|
||||
- **Element Plus `<el-input type="textarea">` 不响应 `autosize` 动态变化**:`ElInput` 只在 mount 时读取 `autosize`,prop 更新时不会重新计算高度。因此 `dialogBox` 中 `Sender` 必须绑定 `:key="useDisplay.Sender_variant"`,通过强制重挂载来使新的 `autosize`(`minRows`/`maxRows`)生效。
|
||||
- **Video modelSelector pattern→modelType 映射**:`getModelType()` 在 `modelSelector.vue` 中将 pattern tag 映射为 modelType——"文生视频"→`text`,"图生视频"→`imageToVideo`,"首尾帧"→`image`,"数字人"→`digitalHuman`,"全能参考"→`allReference`,"主体参考"→`subjectReference`。该值用于 imageUploader 的标签文本和上传槽位数量。
|
||||
- **Video `imageUploadLimit()` 累加逻辑**:对于有多个 `imageUpload` 参数的模型(如首尾帧模型的 `firstImageUrl` + `lastImageUrl`),应累加所有 `imageUpload` 参数的 `maxCount`,而非只取第一个。否则首尾帧模型只显示一个上传槽位,尾帧上传无法触发。
|
||||
- **`buildTaskBody` 参考图映射**:Video 平台在 `buildTaskBody` 中需将 `referenceImages` 按索引顺序写入 `imageUpload` 参数(`referenceImages[0]` → `firstImageUrl`,`referenceImages[1]` → `lastImageUrl`),否则图片数据不会包含在任务请求中。
|
||||
|
||||
### VirtualScroller 坐标系统
|
||||
|
||||
`VirtualScroller` 使用 180deg 旋转实现 reverse 模式。`handleScroll` 内部将容器物理坐标映射为"页面逻辑坐标"后通过 `emit('scroll', {...})` 发出:
|
||||
|
||||
| 字段 | 计算 | 含义 |
|
||||
|------|------|------|
|
||||
| `distanceToPageTop` | `scrollHeight - scrollTop - clientHeight` | 距离页面**顶部**(旧内容端)的距离 |
|
||||
| `distanceToPageBottom` | `scrollTop` | 距离页面**底部**(新内容端)的距离 |
|
||||
| `isAtPageTop` | `distanceToPageTop <= 0` | 滚动到页面最顶部 |
|
||||
| `isAtPageBottom` | `distanceToPageBottom <= 0` | 滚动到页面最底部 |
|
||||
|
||||
**注意**:`distanceToPageBottom = scrollTop`(不是 `distanceToPageTop`),表示从底部向上滚动的距离。判断"用户向上滚动了多少"应使用此字段。
|
||||
|
||||
### 输入框滚动收缩机制
|
||||
|
||||
滚动列表时提示词输入框自动收缩为一行,这是跨 4 个文件的响应式链路:
|
||||
|
||||
1. `VirtualScroller` 发出 `scroll` 事件(含 `isAtPageBottom`、`distanceToPageBottom` 等)
|
||||
2. `display/index.vue` 的 `handleScroll` 根据滚动位置切换 `useDisplay.Sender_variant`:
|
||||
- `isAtPageBottom`(底部)→ `'updown'`(展开,5-9 行)
|
||||
- `distanceToPageBottom >= 350`(已滚离底部 350px)→ `'default'`(收缩,1 行)
|
||||
- 中间状态 → `'updown'`(展开)
|
||||
3. `display.js` store 中 `Sender_variant` 是响应式 ref
|
||||
4. `dialogBox/index.vue` 的 `autoSizeConfig` 计算属性读取 `Sender_variant`,返回 `{ minRows, maxRows }`
|
||||
5. `Sender` 组件通过 `:key="useDisplay.Sender_variant"` **强制重挂载**(因为 Element Plus `ElInput` 不支持 `autosize` 动态更新),新实例以正确的行数初始化
|
||||
|
||||
滚动阈值 350px 对应 `VirtualScroller` 的 `bottomPlaceholderHeight`。
|
||||
|
||||
### 接口速查
|
||||
|
||||
@ -137,7 +298,9 @@ src/
|
||||
| `requestCreateTask` | POST `/suanli/v1/tasks` | 创建任务(带 `X-Session-Id` header) |
|
||||
| `requestTaskStatus` | GET `/suanli/v1/tasks/:id` | 查询单个任务状态 |
|
||||
| `requestTaskHistory` | GET `/suanli/v1/tasks/history` | 历史任务列表(支持 `user_id`/`platform_code`/`page`/`pageSize`) |
|
||||
| `fetchPlatformModels` | GET `/suanli/v1/platforms/:code/models` | 获取平台模型列表(返回 `{id, display_name, tags, disabled?}`) |
|
||||
| `fetchPlatformModels` | GET `/suanli/v1/platforms/:code/models` | 获取平台模型列表(返回 `{id(UUID), name, display_name, tags, disabled?}`)。`id`=UUID 主键,`name`=内部标识,`display_name`=用户可见名 |
|
||||
| `requestModelConfigsBatch` | POST `/suanli/v1/models/configs` | 批量获取模型配置(body: `{ modelIds: [...] }`) |
|
||||
| `requestModelConfig` | GET `/suanli/v1/models/:id/config` | 单条模型配置(60s 缓存优先) |
|
||||
| `cancelOrCollect` | POST `/collect/toggle` | 收藏/取消收藏 |
|
||||
| `deleteGenerateHistory` | DELETE `/taskRecordHistory/delete` | 删除历史记录 |
|
||||
|
||||
@ -160,15 +323,35 @@ src/
|
||||
|
||||
### 请求拦截器路由
|
||||
|
||||
拦截器统一设置 `Authorization: <token>`(不带 Bearer 前缀),根据 URL 前缀切换后端:
|
||||
拦截器统一设置 `Authorization: <token>`(不带 Bearer 前缀),根据请求 URL 前缀(由环境变量 `VITE_API_TASK_PREFIX` / `VITE_API_PAY_PREFIX` / `VITE_API_AIGC_PREFIX` 定义)切换后端:
|
||||
|
||||
| URL 前缀 | 环境变量 |
|
||||
|----------|----------|
|
||||
| `/suanli` | `VITE_API_TASK_TARGET` |
|
||||
| `/pay` | `VITE_API_PAY_TARGET` |
|
||||
| `/aigc` | `VITE_API_AIGC_TARGET` |
|
||||
| URL 前缀(由环境变量定义) | 目标环境变量 |
|
||||
|---------------------------|-------------|
|
||||
| `VITE_API_TASK_PREFIX` 对应前缀 | `VITE_API_TASK_TARGET` |
|
||||
| `VITE_API_PAY_PREFIX` 对应前缀 | `VITE_API_PAY_TARGET` |
|
||||
| `VITE_API_AIGC_PREFIX` 对应前缀 | `VITE_API_AIGC_TARGET` |
|
||||
| 其他 | `VITE_API_BASE_URL`(默认) |
|
||||
|
||||
**注意**:前缀字符串本身来自环境变量(如 `VITE_API_TASK_PREFIX=/suanli`),不是硬编码。`request.js` 在初始化时读取这些变量,构建 prefix→target 映射表。
|
||||
|
||||
### 环境变量速查
|
||||
|
||||
```bash
|
||||
# .env.development
|
||||
VITE_BASE = '/' # 应用基础路径
|
||||
VITE_API_PREFIX = '/api' # 主服务前缀
|
||||
VITE_API_BASE_URL = 'http://...' # 主服务(默认目标)
|
||||
VITE_API_PAY_PREFIX = '/pay' # 支付服务前缀
|
||||
VITE_API_PAY_TARGET = 'http://...' # 支付服务目标
|
||||
VITE_API_TASK_PREFIX = '/suanli' # 任务服务前缀
|
||||
VITE_API_TASK_TARGET = 'http://...' # 任务服务目标
|
||||
VITE_API_WORKFLOW_UPLOAD = 'http://...' # 图片上传地址(imageUploader 组件 action)
|
||||
VITE_OPEN_DEVTOOLS = false # 是否开启开发者工具
|
||||
FILE_OPEN_PREVIEW = true # 是否开启 KKFileView 预览
|
||||
```
|
||||
|
||||
`vite.config.js` 中 `envPrefix: ['VITE', 'FILE']`,因此只有以 `VITE_` 和 `FILE_` 开头的变量会被暴露给客户端代码。
|
||||
|
||||
### 平台编码映射
|
||||
|
||||
| 类型 | 平台编码 |
|
||||
@ -181,5 +364,5 @@ src/
|
||||
### 自动导入
|
||||
|
||||
- `unplugin-auto-import`:自动导入 Vue/Router/Pinia API
|
||||
- `unplugin-vue-components`:自动注册 `src/components/` 下的组件和 Element Plus 组件
|
||||
- `unplugin-vue-components`:自动注册 `src/components/` 下的组件和 Element Plus 组件,**生成 `components.d.ts`(勿手动编辑)**
|
||||
- Element Plus 图标通过 `unplugin-icons` 按需加载
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
{
|
||||
"nodeInfoList": {
|
||||
"prompt":{ "nodeId":"2", "fieldName":"prompt", "fieldValue":"" },
|
||||
"resolution":{ "nodeId":"2", "fieldName":"resolution", "fieldValue":"" },
|
||||
"proportion":{ "nodeId":"2", "fieldName":"aspect_ratio", "fieldValue":"" },
|
||||
"duration":{ "nodeId":"2", "fieldName":"duration", "fieldValue": 5},
|
||||
"audio":{ "nodeId":"2", "fieldName":"audio", "fieldValue": false}
|
||||
},
|
||||
"workflowId": "2036349280088231938",
|
||||
"display": {
|
||||
"promptPlaceholder": {"default": "描述你想生成的画面和动作。"},
|
||||
"prompt": {"default": ""},
|
||||
"resolution": {"default": "1k","options":[
|
||||
{ "value": "360", "label": "流畅 360P" },
|
||||
{ "value": "540", "label": "标清 540P" },
|
||||
{ "value": "720", "label": "高清 720P" },
|
||||
{ "value": "1k", "label": "超清 1K" }
|
||||
]},
|
||||
"proportion": {"default": "16:9","options":[
|
||||
{ "value": "21:9", "label": "21:9" },
|
||||
{ "value": "16:9", "label": "16:9" },
|
||||
{ "value": "4:3", "label": "4:3" },
|
||||
{ "value": "1:1", "label": "1:1" },
|
||||
{ "value": "3:4", "label": "3:4" },
|
||||
{ "value": "9:16", "label": "9:16" }
|
||||
]},
|
||||
"duration": {"default": 5,"options":[
|
||||
{ "value": 5, "label": "5秒" },
|
||||
{ "value": 10, "label": "10秒" },
|
||||
{ "value": 15, "label": "15秒" }
|
||||
]},
|
||||
"audio": {"default": false}
|
||||
}
|
||||
}
|
||||
22
components.d.ts
vendored
@ -11,18 +11,12 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
2: typeof import('./src/components/virtual-scroller/VirtualScroller copy 2.vue')['default']
|
||||
3: typeof import('./src/components/virtual-scroller/VirtualScroller copy 3.vue')['default']
|
||||
AudioPlayer: typeof import('./src/components/AudioPlayer/index.vue')['default']
|
||||
Canvas: typeof import('./src/components/canvas/index.vue')['default']
|
||||
copy: typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default']
|
||||
CustomSlider: typeof import('./src/components/CustomSlider/index.vue')['default']
|
||||
DialogBox: typeof import('./src/components/dialogBox/index.vue')['default']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
IEpCalendar: typeof import('~icons/ep/calendar')['default']
|
||||
@ -31,22 +25,14 @@ declare module 'vue' {
|
||||
IEpLoading: typeof import('~icons/ep/loading')['default']
|
||||
IEpPlus: typeof import('~icons/ep/plus')['default']
|
||||
IEpStar: typeof import('~icons/ep/star')['default']
|
||||
ImageUploader: typeof import('./src/components/dialogBox/imageUploader/index.vue')['default']
|
||||
Img: typeof import('./src/components/Img/index.vue')['default']
|
||||
Painting: typeof import('./src/components/dialogBox/model/painting.vue')['default']
|
||||
ParamControl: typeof import('./src/components/dialogBox/ParamControl.vue')['default']
|
||||
Pattern: typeof import('./src/components/dialogBox/pattern/index.vue')['default']
|
||||
ParamGroup: typeof import('./src/components/ParamGroup/index.vue')['default']
|
||||
Popover: typeof import('./src/components/Popover/index.vue')['default']
|
||||
Quantity: typeof import('./src/components/dialogBox/quantity/index.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Select: typeof import('./src/components/Select/index.vue')['default']
|
||||
Time: typeof import('./src/components/dialogBox/Time/index.vue')['default']
|
||||
Video: typeof import('./src/components/dialogBox/model/video.vue')['default']
|
||||
VideoImageUploader: typeof import('./src/components/dialogBox/videoImageUploader/index.vue')['default']
|
||||
SwitchControl: typeof import('./src/components/SwitchControl/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']
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import Icons from 'unplugin-icons/vite'
|
||||
import IconsResolver from 'unplugin-icons/resolver'
|
||||
import Icons from 'unplugin-icons/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
|
||||
export const autoImportConfig = AutoImport({
|
||||
imports: [
|
||||
|
||||
39
model-configs/hailuo-02-fast.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"modelName": "海螺 02-fast 图生视频",
|
||||
"modelDescription": "RunningHub MiniMax 海螺 02-fast 图生视频模型,基于参考图生成快节奏电影感动画",
|
||||
"endpoint": "/minimax/hailuo-02/fast",
|
||||
"params": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"ui": "textarea",
|
||||
"label": "提示词",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "enablePromptExpansion",
|
||||
"ui": "switch",
|
||||
"label": "提示词扩展",
|
||||
"default": true,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "imageUrl",
|
||||
"ui": "imageUpload",
|
||||
"label": "参考图片",
|
||||
"maxCount": 1,
|
||||
"required": true,
|
||||
"maxSizeMB": 10
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"ui": "select",
|
||||
"label": "时长(秒)",
|
||||
"default": "6",
|
||||
"options": ["6", "10"],
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"promptPlaceholder": "描述基于原图的动画效果(可选)。",
|
||||
"inputType": "image",
|
||||
"maxImages": 1
|
||||
}
|
||||
47
model-configs/ltx-2.3image.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"modelName": "LTX-2.3 图生视频",
|
||||
"modelDescription": "RunningHub LTX-2.3 图生视频模型,基于图片生成视频",
|
||||
"endpoint": "/rhart-video/ltx-2.3/image-to-video",
|
||||
"params": [
|
||||
{
|
||||
"name": "imageUrl",
|
||||
"ui": "imageUpload",
|
||||
"label": "参考图片",
|
||||
"maxCount": 1,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "prompt",
|
||||
"ui": "textarea",
|
||||
"label": "提示词",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "aspectRatio",
|
||||
"ui": "proportion",
|
||||
"label": "画面比例",
|
||||
"default": "16:9",
|
||||
"options": ["9:16", "16:9"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "resolution",
|
||||
"ui": "resolution",
|
||||
"label": "分辨率",
|
||||
"default": "720p",
|
||||
"options": ["480p", "720p", "1080p"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"ui": "select",
|
||||
"label": "时长(秒)",
|
||||
"default": 5,
|
||||
"options": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"promptPlaceholder": "描述图片和期望的视频运动效果。",
|
||||
"inputType": "image",
|
||||
"maxImages": 1
|
||||
}
|
||||
40
model-configs/ltx-2.3text.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"modelName": "LTX-2.3 文生视频",
|
||||
"modelDescription": "RunningHub LTX-2.3 文生视频模型(Text-to-Video),基于文本生成视频",
|
||||
"endpoint": "/rhart-video/ltx-2.3/text-to-video",
|
||||
"params": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"ui": "textarea",
|
||||
"label": "提示词",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "resolution",
|
||||
"ui": "select",
|
||||
"label": "分辨率",
|
||||
"default": "720p",
|
||||
"options": ["1080p", "720p", "480p"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "aspectRatio",
|
||||
"ui": "proportion",
|
||||
"label": "画面比例",
|
||||
"default": "16:9",
|
||||
"options": ["16:9", "9:16"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"ui": "number",
|
||||
"label": "时长(秒)",
|
||||
"default": 5,
|
||||
"min": 5,
|
||||
"max": 15,
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"promptPlaceholder": "描述你想生成的画面和动作。",
|
||||
"inputType": "text"
|
||||
}
|
||||
64
model-configs/vidu-start-end-to-video-q3-turbo.json
Normal file
@ -0,0 +1,64 @@
|
||||
{
|
||||
"modelName": "Vidu 首尾帧生视频 q3-turbo",
|
||||
"modelDescription": "RunningHub Vidu 首尾帧生视频 q3-turbo 模型,通过首尾帧图片驱动视频生成,支持音视频直出",
|
||||
"endpoint": "/vidu/start-end-to-video-q3-turbo",
|
||||
"params": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"ui": "textarea",
|
||||
"label": "提示词",
|
||||
"required": true,
|
||||
"maxLength": 4000
|
||||
},
|
||||
{
|
||||
"name": "firstImageUrl",
|
||||
"ui": "imageUpload",
|
||||
"label": "首帧图片",
|
||||
"maxCount": 1,
|
||||
"required": true,
|
||||
"maxSizeMB": 50
|
||||
},
|
||||
{
|
||||
"name": "lastImageUrl",
|
||||
"ui": "imageUpload",
|
||||
"label": "尾帧图片",
|
||||
"maxCount": 1,
|
||||
"required": true,
|
||||
"maxSizeMB": 50
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"ui": "select",
|
||||
"label": "时长(秒)",
|
||||
"default": "5",
|
||||
"options": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "resolution",
|
||||
"ui": "resolution",
|
||||
"label": "分辨率",
|
||||
"default": "720p",
|
||||
"options": ["540p", "720p", "1080p"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "movementAmplitude",
|
||||
"ui": "select",
|
||||
"label": "运动幅度",
|
||||
"default": "auto",
|
||||
"options": ["auto", "small", "medium", "large"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "audio",
|
||||
"ui": "switch",
|
||||
"label": "音频直出",
|
||||
"default": true,
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"promptPlaceholder": "描述首尾帧之间的动作补全。文本长度限制 1-4000。",
|
||||
"inputType": "image",
|
||||
"maxImages": 2
|
||||
}
|
||||
55
model-configs/vidu-text-to-video-q3-turbo.json
Normal file
@ -0,0 +1,55 @@
|
||||
{
|
||||
"modelName": "Vidu 文生视频 q3-turbo",
|
||||
"modelDescription": "RunningHub Vidu 文生视频 q3-turbo 模型,支持音视频直出",
|
||||
"endpoint": "/vidu/text-to-video-q3-turbo",
|
||||
"params": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"ui": "textarea",
|
||||
"label": "提示词",
|
||||
"required": true,
|
||||
"maxLength": 4000
|
||||
},
|
||||
{
|
||||
"name": "style",
|
||||
"ui": "select",
|
||||
"label": "风格",
|
||||
"default": "general",
|
||||
"options": ["general", "anime"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "aspectRatio",
|
||||
"ui": "proportion",
|
||||
"label": "画面比例",
|
||||
"default": "16:9",
|
||||
"options": ["4:3", "3:4", "16:9", "9:16", "1:1"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "resolution",
|
||||
"ui": "resolution",
|
||||
"label": "分辨率",
|
||||
"default": "720p",
|
||||
"options": ["540p", "720p", "1080p"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"ui": "select",
|
||||
"label": "时长(秒)",
|
||||
"default": "5",
|
||||
"options": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "audio",
|
||||
"ui": "switch",
|
||||
"label": "音频直出",
|
||||
"default": true,
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"promptPlaceholder": "描述你想生成的视频画面和音频。文本长度限制 1-4000。",
|
||||
"inputType": "text"
|
||||
}
|
||||
65
out.txt
@ -1,65 +0,0 @@
|
||||
接口文档
|
||||
|
||||
根据平台编码获取可学官方模型
|
||||
|
||||
请求
|
||||
|
||||
GET /suanli/v1/platforms/:code/models
|
||||
|
||||
┌──────┬────────┬──────┬─────────────────────────────────────────────────────┐
|
||||
│ 参数 │ 类型 │ 必填 │ 说明 │
|
||||
├──────┼────────┼──────┼─────────────────────────────────────────────────────┤
|
||||
│ code │ string │ 是 │ 平台编码(platform_identifiers.code),URL 路径参数 │
|
||||
└──────┴────────┴──────┴─────────────────────────────────────────────────────┘
|
||||
|
||||
请求头
|
||||
|
||||
Authorization: <token>
|
||||
|
||||
▎ 无需 Bearer 前缀。
|
||||
|
||||
响应
|
||||
|
||||
成功
|
||||
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"platform": {
|
||||
"id": "uuid",
|
||||
"code": "openai",
|
||||
"name": "OpenAI"
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "gpt-4",
|
||||
"display_name": "GPT-4",
|
||||
"category": "llm",
|
||||
"billing_unit": "token",
|
||||
"unit_price": 0.03,
|
||||
"billing_mode": "post",
|
||||
"plugin_code": null,
|
||||
"endpoint": null,
|
||||
"sort_order": 1,
|
||||
"is_public": 1,
|
||||
"owner_org_id": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
┌───────────────┬──────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 字段 │ 说明 │
|
||||
├───────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ data.platform │ 平台信息 │
|
||||
├───────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ data.models │ 该平台下 owner_type=platform 且 status=active 的模型列表,按 sort_order、created_at 排序 │
|
||||
└───────────────┴──────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
平台不存在或已禁用
|
||||
|
||||
{
|
||||
"code": 1,
|
||||
"message": "平台不存在或已禁用"
|
||||
}
|
||||
@ -43,5 +43,5 @@ export const getUserInfo = () => {
|
||||
}
|
||||
|
||||
export const checkUsertoken = () => {
|
||||
return service.post(`/login/validateToken`)
|
||||
return service.post('/login/validateToken')
|
||||
}
|
||||
|
||||
@ -37,3 +37,13 @@ export function requestTaskHistory(params) {
|
||||
export function fetchPlatformModels(code) {
|
||||
return service.get(`/suanli/v1/platforms/${code}/models`)
|
||||
}
|
||||
|
||||
// 批量获取模型配置(POST /suanli/v1/models/configs)
|
||||
export function requestModelConfigsBatch(modelIds) {
|
||||
return service.post('/suanli/v1/models/configs', { modelIds })
|
||||
}
|
||||
|
||||
// 单条查询模型配置(GET /suanli/v1/models/:modelId/config)
|
||||
export function requestModelConfig(modelId) {
|
||||
return service.get(`/suanli/v1/models/${modelId}/config`)
|
||||
}
|
||||
|
||||
3
src/assets/dialog/beautify.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.1403 9.92944C16.1733 10.4175 16.3717 11.6017 17.48 12.4494C18.3647 13.1261 19.3661 13.0687 20 12.9268C17.5782 13.8572 17.4411 15.861 17.5801 16.9878C16.6857 14.0517 13.4579 14.6885 12.8697 14.8314C15.9283 14.0108 16.1412 10.8064 16.1403 9.92944ZM4.7575 2.8575C5.125 2.945 6.04278 2.93042 6.91486 2.30236C7.61 1.80069 7.77333 0.753611 7.80931 0.25C7.95806 2.26153 9.42319 3.11514 10.2856 3.26972C7.85889 3.26 7.66056 5.93458 7.64597 6.30305C7.74028 3.82097 5.40792 3.05972 4.7575 2.8575ZM2.72653 7.9218C2.83754 8.09622 2.9846 8.24485 3.15783 8.3577C3.33106 8.47056 3.52644 8.54503 3.73083 8.57611C4.19458 8.64319 4.55236 8.41472 4.7575 8.23194C4.04972 9.07583 4.4075 9.84389 4.68653 10.2308C3.73861 9.30722 2.64583 10.2775 2.5 10.4156C3.47903 9.48222 2.90542 8.24653 2.72653 7.9218ZM13.4113 9.95861L11.9724 8.51194L13.7194 6.86792C13.818 6.77493 13.9494 6.72485 14.0848 6.72868C14.2203 6.73251 14.3487 6.78993 14.4418 6.88833L15.1418 7.62917C15.1878 7.67765 15.2238 7.73474 15.2478 7.79716C15.2717 7.85957 15.2831 7.9261 15.2813 7.99292C15.2795 8.05974 15.2645 8.12555 15.2372 8.18659C15.21 8.24762 15.1709 8.30268 15.1224 8.34861L13.4113 9.95861ZM11.4396 9.01458L12.8785 10.4603L5.28444 17.611C5.08028 17.8044 4.75653 17.7957 4.56208 17.5915L3.86208 16.8507C3.81595 16.8023 3.77984 16.7452 3.75584 16.6828C3.73184 16.6203 3.72041 16.5538 3.72222 16.4869C3.72402 16.4201 3.73903 16.3542 3.76637 16.2932C3.79371 16.2321 3.83285 16.1771 3.88153 16.1312L11.4396 9.01458Z" fill="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
6
src/assets/dialog/commonMode.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="2.5" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
|
||||
<rect x="13" y="1.3614" width="5" height="5" rx="0.5" transform="rotate(45 13 1.3614)" stroke="#666666"/>
|
||||
<rect x="2.5" y="10" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
|
||||
<rect x="10" y="10" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 431 B |
4
src/assets/dialog/editMode.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 9V15C16 15.5523 15.5523 16 15 16H3C2.44772 16 2 15.5523 2 15V3C2 2.44772 2.44772 2 3 2H9" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M16 2L14 4L10 8L9 9" stroke="#666666" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 320 B |
@ -1,3 +1,5 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.25 15H22.5H24C25.6569 15 27 16.3431 27 18C27 19.6569 25.6569 21 24 21H22.5H20.25M15.75 15H13.5H12C10.3431 15 9 16.3431 9 18C9 19.6569 10.3431 21 12 21H13.5H15.75" stroke="#BBBBBB" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<rect width="36" height="36" rx="10" fill="#F8F9FA"/>
|
||||
<path d="M20.25 15H22.5H24C25.6569 15 27 16.3431 27 18C27 19.6569 25.6569 21 24 21H22.5H20.25M15.75 15H13.5H12C10.3431 15 9 16.3431 9 18C9 19.6569 10.3431 21 12 21H13.5H15.75" stroke="#666666" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M16 18H20" stroke="#666666" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 340 B After Width: | Height: | Size: 475 B |
6
src/assets/dialog/lyrics.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="2.5" width="13" height="13" rx="0.5" stroke="#333333"/>
|
||||
<path d="M7 6H11" stroke="#333333" stroke-linecap="round"/>
|
||||
<path d="M7 12H11" stroke="#333333" stroke-linecap="round"/>
|
||||
<path d="M6 9H12" stroke="#333333" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 357 B |
8
src/assets/dialog/professionalMode.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="2.5" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
|
||||
<rect x="2.5" y="10" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
|
||||
<path d="M10 3.25H16" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M10 7.25H16" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M10 10.75H16" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M10 14.75H16" stroke="#666666" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 510 B |
11
src/assets/dialog/randomSeed.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.5 3.5L7.5 0.5L14.5 3.5L7.5 6.5L0.5 3.5Z" stroke="#666666" stroke-linejoin="round"/>
|
||||
<path d="M0.5 12.5V3.5L7.5 6.5V15.5L0.5 12.5Z" stroke="#666666" stroke-linejoin="round"/>
|
||||
<path d="M7.5 6.5L14.5 3.5V12.5L7.5 15.5V6.5Z" stroke="#666666" stroke-linejoin="round"/>
|
||||
<ellipse cx="7.4498" cy="3.55078" rx="1.15" ry="0.75" fill="#666666"/>
|
||||
<ellipse cx="4.4498" cy="3.55078" rx="1.15" ry="0.75" fill="#666666"/>
|
||||
<ellipse cx="10.4498" cy="3.55078" rx="1.15" ry="0.75" fill="#666666"/>
|
||||
<ellipse cx="12.25" cy="7.85" rx="1.15" ry="0.75" transform="rotate(-90 12.25 7.85)" fill="#666666"/>
|
||||
<ellipse cx="10.25" cy="10.85" rx="1.15" ry="0.75" transform="rotate(-90 10.25 10.85)" fill="#666666"/>
|
||||
<ellipse cx="3.84961" cy="9.65078" rx="1.15" ry="0.75" transform="rotate(-90 3.84961 9.65078)" fill="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 911 B |
9
src/assets/dialog/remixMode.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="2.5" width="13" height="13" rx="0.5" stroke="#666666"/>
|
||||
<path d="M7 6V12" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M8 9C8 9.55228 7.55228 10 7 10C6.44772 10 6 9.55228 6 9C6 8.44772 6.44772 8 7 8C7.55228 8 8 8.44772 8 9Z" fill="white"/>
|
||||
<path d="M7.5 9C7.5 8.72386 7.27614 8.5 7 8.5C6.72386 8.5 6.5 8.72386 6.5 9C6.5 9.27614 6.72386 9.5 7 9.5C7.27614 9.5 7.5 9.27614 7.5 9ZM8.5 9C8.5 9.82843 7.82843 10.5 7 10.5C6.17157 10.5 5.5 9.82843 5.5 9C5.5 8.17157 6.17157 7.5 7 7.5C7.82843 7.5 8.5 8.17157 8.5 9Z" fill="#666666"/>
|
||||
<path d="M11 6V12" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M12 11C12 11.5523 11.5523 12 11 12C10.4477 12 10 11.5523 10 11C10 10.4477 10.4477 10 11 10C11.5523 10 12 10.4477 12 11Z" fill="white"/>
|
||||
<path d="M11.5 11C11.5 10.7239 11.2761 10.5 11 10.5C10.7239 10.5 10.5 10.7239 10.5 11C10.5 11.2761 10.7239 11.5 11 11.5C11.2761 11.5 11.5 11.2761 11.5 11ZM12.5 11C12.5 11.8284 11.8284 12.5 11 12.5C10.1716 12.5 9.5 11.8284 9.5 11C9.5 10.1716 10.1716 9.5 11 9.5C11.8284 9.5 12.5 10.1716 12.5 11Z" fill="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
src/assets/dialog/restore.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 4.47053H4.47052M15.1761 4.47053H12.7056M12.7056 4.47053V4C12.7056 2.89543 11.8102 2 10.7056 2H6.47053C5.36596 2 4.47052 2.89543 4.47052 4V4.47053M12.7056 4.47053H4.47052" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M3.64746 6.94141V14C3.64746 15.1046 4.54289 16 5.64746 16H11.5296C12.6341 16 13.5296 15.1046 13.5296 14V6.94141M6.94149 7.76491V14.353M10.2355 7.76491V14.353" stroke="#666666" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 538 B |
BIN
src/assets/display/background1.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/display/background2.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/display/background3.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/display/background4.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src/assets/display/image1.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src/assets/display/image2.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src/assets/display/image3.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
src/assets/display/image4.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
4
src/assets/display/time.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="9" cy="9" r="6.5" stroke="black"/>
|
||||
<path d="M9 6V10" stroke="black" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 208 B |
267
src/components/AudioPlayer/index.vue
Normal file
@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div class="audio-placeholder" :style="{ backgroundImage: `url(${backgroundImage})` }">
|
||||
<div class="audio-left">
|
||||
<img :src="coverImage" alt="音频封面" class="audio-cover" />
|
||||
</div>
|
||||
|
||||
<div class="audio-center">
|
||||
<div class="audio-title">{{ audioTitle }}</div>
|
||||
<div class="audio-progress">
|
||||
<div class="progress-bar" @click="handleProgressClick">
|
||||
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
|
||||
</div>
|
||||
<div class="progress-time">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="audio-right">
|
||||
<div class="play-button" @click.stop="handlePlayPause">
|
||||
<svg v-if="!isPlaying" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 5V19L19 12L8 5Z" fill="white"/>
|
||||
</svg>
|
||||
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import background1 from '@/assets/display/background1.png'
|
||||
import background2 from '@/assets/display/background2.png'
|
||||
import background3 from '@/assets/display/background3.png'
|
||||
import background4 from '@/assets/display/background4.png'
|
||||
import image1 from '@/assets/display/image1.png'
|
||||
import image2 from '@/assets/display/image2.png'
|
||||
import image3 from '@/assets/display/image3.png'
|
||||
import image4 from '@/assets/display/image4.png'
|
||||
|
||||
const props = defineProps({
|
||||
audioUrl: { type: String, default: '' },
|
||||
audioTitle: { type: String, default: '我的音乐' },
|
||||
cardIndex: { type: Number, default: 0 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['play', 'pause', 'ended'])
|
||||
|
||||
const backgroundImages = [background1, background2, background3, background4]
|
||||
const coverImages = [image1, image2, image3, image4]
|
||||
|
||||
const backgroundImage = computed(() => backgroundImages[props.cardIndex % 4])
|
||||
const coverImage = computed(() => coverImages[props.cardIndex % 4])
|
||||
|
||||
const audioRef = ref(null)
|
||||
const isPlaying = ref(false)
|
||||
const isPlayPending = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
|
||||
let timeupdateHandler = null
|
||||
let loadedmetadataHandler = null
|
||||
let endedHandler = null
|
||||
|
||||
const progressPercentage = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
})
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
if (!seconds || isNaN(seconds)) return '00:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const handleProgressClick = (event) => {
|
||||
if (!audioRef.value || duration.value === 0) return
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const clickX = event.clientX - rect.left
|
||||
const percentage = Math.max(0, Math.min(1, clickX / rect.width))
|
||||
const newTime = percentage * duration.value
|
||||
audioRef.value.currentTime = newTime
|
||||
currentTime.value = newTime
|
||||
}
|
||||
|
||||
const setupAudio = (url) => {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.pause()
|
||||
audioRef.value.removeEventListener('timeupdate', timeupdateHandler)
|
||||
audioRef.value.removeEventListener('loadedmetadata', loadedmetadataHandler)
|
||||
audioRef.value.removeEventListener('ended', endedHandler)
|
||||
audioRef.value.src = ''
|
||||
audioRef.value.load()
|
||||
audioRef.value = null
|
||||
}
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
duration.value = 0
|
||||
if (!url) return
|
||||
|
||||
audioRef.value = new Audio(url)
|
||||
audioRef.value.crossOrigin = 'anonymous'
|
||||
timeupdateHandler = () => {
|
||||
if (audioRef.value) currentTime.value = audioRef.value.currentTime
|
||||
}
|
||||
loadedmetadataHandler = () => {
|
||||
if (audioRef.value) duration.value = audioRef.value.duration || 0
|
||||
}
|
||||
endedHandler = () => {
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
emit('ended')
|
||||
}
|
||||
audioRef.value.addEventListener('timeupdate', timeupdateHandler)
|
||||
audioRef.value.addEventListener('loadedmetadata', loadedmetadataHandler)
|
||||
audioRef.value.addEventListener('ended', endedHandler)
|
||||
}
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (!audioRef.value) {
|
||||
setupAudio(props.audioUrl)
|
||||
}
|
||||
if (!audioRef.value) return
|
||||
|
||||
if (isPlaying.value) {
|
||||
audioRef.value.pause()
|
||||
isPlaying.value = false
|
||||
isPlayPending.value = false
|
||||
emit('pause')
|
||||
} else if (!isPlayPending.value) {
|
||||
isPlayPending.value = true
|
||||
audioRef.value.play().then(() => {
|
||||
isPlaying.value = true
|
||||
isPlayPending.value = false
|
||||
emit('play')
|
||||
}).catch((error) => {
|
||||
console.error('播放失败:', error)
|
||||
isPlaying.value = false
|
||||
isPlayPending.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { if (props.audioUrl) setupAudio(props.audioUrl) })
|
||||
onUnmounted(() => {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.pause()
|
||||
audioRef.value.removeEventListener('timeupdate', timeupdateHandler)
|
||||
audioRef.value.removeEventListener('loadedmetadata', loadedmetadataHandler)
|
||||
audioRef.value.removeEventListener('ended', endedHandler)
|
||||
audioRef.value.src = ''
|
||||
audioRef.value.load()
|
||||
audioRef.value = null
|
||||
}
|
||||
})
|
||||
watch(() => props.audioUrl, (newUrl, oldUrl) => {
|
||||
if (newUrl === oldUrl) return
|
||||
setupAudio(newUrl)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.audio-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 80px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
|
||||
.audio-left {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.audio-cover {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 5px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
|
||||
.audio-title {
|
||||
color: white;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.audio-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
&:hover {
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: white;
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-time {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audio-right {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.play-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
svg { width: 20px; height: 20px; fill: white; }
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
168
src/components/CustomSlider/index.vue
Normal file
@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="custom-slider" ref="sliderRef" @mousedown="handleMouseDown" @mouseleave="handleMouseLeave">
|
||||
<div class="slider-tooltip" v-show="isDragging && showTooltip" :style="{ left: fillPercentage + '%' }">{{ displayValue }}</div>
|
||||
<div class="slider-track"></div>
|
||||
<div class="slider-fill" :style="{ width: fillPercentage + '%' }"></div>
|
||||
<div class="slider-thumb" :style="{ left: fillPercentage + '%' }" @mousedown="handleThumbMouseDown"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Number, String],
|
||||
default: 0
|
||||
},
|
||||
min: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
step: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
showTooltip: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'mousedown', 'mouseup', 'mouseleave'])
|
||||
|
||||
const sliderRef = ref(null)
|
||||
const isDragging = ref(false)
|
||||
|
||||
const fillPercentage = computed(() => {
|
||||
if (props.modelValue === 'Auto' || typeof props.modelValue !== 'number') return 0
|
||||
const value = props.modelValue
|
||||
const percentage = ((value - props.min) / (props.max - props.min)) * 100
|
||||
return Math.max(0, Math.min(100, percentage))
|
||||
})
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (props.modelValue === 'Auto' || typeof props.modelValue !== 'number') return 'Auto'
|
||||
const decimalPlaces = props.step < 0.1 ? 2 : 1
|
||||
return props.modelValue.toFixed(decimalPlaces)
|
||||
})
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
isDragging.value = true
|
||||
emit('mousedown', e)
|
||||
updateValue(e.clientX)
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
const handleThumbMouseDown = (e) => {
|
||||
isDragging.value = true
|
||||
emit('mousedown', e)
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isDragging.value) return
|
||||
updateValue(e.clientX)
|
||||
}
|
||||
|
||||
const handleMouseUp = (e) => {
|
||||
isDragging.value = false
|
||||
emit('mouseup', e)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
const handleMouseLeave = (e) => {
|
||||
emit('mouseleave', e)
|
||||
}
|
||||
|
||||
const updateValue = (clientX) => {
|
||||
if (!sliderRef.value) return
|
||||
|
||||
const rect = sliderRef.value.getBoundingClientRect()
|
||||
const x = clientX - rect.left
|
||||
const percentage = Math.max(0, Math.min(1, x / rect.width))
|
||||
const rawValue = props.min + percentage * (props.max - props.min)
|
||||
const steppedValue = Math.round(rawValue / props.step) * props.step
|
||||
const value = Math.max(props.min, Math.min(props.max, steppedValue))
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.custom-slider {
|
||||
position: relative;
|
||||
width: 244px;
|
||||
height: 2px;
|
||||
cursor: pointer;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #F8F9FA;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.slider-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: #000F33;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #ffffff;
|
||||
border: 2px solid #000F33;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-tooltip {
|
||||
position: absolute;
|
||||
top: -35px;
|
||||
transform: translateX(-50%);
|
||||
background: #000F33;
|
||||
color: #fff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: "Microsoft YaHei";
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 5px solid #000F33;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -93,10 +93,6 @@ onUnmounted(() => {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.img-element:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.fullscreen-icon {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
|
||||
69
src/components/ParamGroup/index.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<template v-for="param in dynamicParams" :key="param.name">
|
||||
<Select
|
||||
v-if="param.ui === 'select' || param.type === 'select'"
|
||||
:model-value="paramValues[param.name]"
|
||||
:options="(param.options || []).map(o => (typeof o === 'object' ? o : { value: o, label: String(o) }))"
|
||||
class="param-select"
|
||||
position="top"
|
||||
@update:model-value="(v) => paramValues[param.name] = v"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="param-label">{{ param.label || param.name }}</span>
|
||||
</template>
|
||||
</Select>
|
||||
<SwitchControl
|
||||
v-else-if="param.ui === 'switch' || param.type === 'boolean' || param.type === 'Boolean'"
|
||||
:model-value="paramValues[param.name] === true || paramValues[param.name] === 'true'"
|
||||
:label="param.label || param.name"
|
||||
@update:model-value="(v) => paramValues[param.name] = v"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import SwitchControl from '@/components/SwitchControl/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
config: { type: Object, default: null },
|
||||
paramValues: { type: Object, default: () => ({}) },
|
||||
excludeNames: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const handledUis = ['textarea', 'proportion', 'resolution', 'dimension', 'dimensionWidth', 'dimensionHeight', 'quantity', 'imageUpload', 'hidden', 'number']
|
||||
|
||||
const dynamicParams = computed(() => {
|
||||
if (!props.config?.params) return []
|
||||
return props.config.params.filter((p) => {
|
||||
if (handledUis.includes(p.ui)) return false
|
||||
if (props.excludeNames.includes(p.name)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.param-select {
|
||||
:deep(.select-header) {
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
&:hover { background: #e9eaeb; }
|
||||
}
|
||||
:deep(.select-text) { font-size: 14px; }
|
||||
:deep(.dropdown-menu) { min-width: 136px; }
|
||||
:deep(.dropdown-item) {
|
||||
min-width: 80px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
.param-label {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="custom-popover" ref="popoverRef">
|
||||
<div class="popover-trigger" ref="triggerRef" @click.stop="togglePopover">
|
||||
<div ref="popoverRef" class="custom-popover">
|
||||
<div ref="triggerRef" class="popover-trigger" @click.stop="togglePopover">
|
||||
<slot name="reference" />
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
@ -143,10 +143,10 @@ const handleClickOutside = (e) => {
|
||||
const contentEl = contentRef.value
|
||||
|
||||
if (
|
||||
triggerEl &&
|
||||
!triggerEl.contains(e.target) &&
|
||||
contentEl &&
|
||||
!contentEl.contains(e.target)
|
||||
triggerEl
|
||||
&& !triggerEl.contains(e.target)
|
||||
&& contentEl
|
||||
&& !contentEl.contains(e.target)
|
||||
) {
|
||||
visible.value = false
|
||||
window.__currentOpenPopoverId__ = null
|
||||
|
||||
@ -313,6 +313,7 @@ onBeforeUnmount(() => {
|
||||
box-sizing: border-box;
|
||||
border-radius: 5px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
|
||||
81
src/components/SwitchControl/index.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div
|
||||
class="switch-control"
|
||||
:class="{ active: modelValue }"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="switch-label">{{ label }}</span>
|
||||
<span class="switch-track">
|
||||
<span class="switch-thumb" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
label: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
function toggle() {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.switch-control {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #e9eaeb;
|
||||
}
|
||||
}
|
||||
.switch-label {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.switch-track {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: #c0c4cc;
|
||||
transition: background 0.25s;
|
||||
flex-shrink: 0;
|
||||
|
||||
.active & {
|
||||
background: #000F33;
|
||||
}
|
||||
}
|
||||
.switch-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.25s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.active & {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -9,8 +9,8 @@
|
||||
</div>
|
||||
<div class="close-btn" @click="handleClose">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="13" viewBox="0 0 12 13" fill="none">
|
||||
<path d="M1.28809 1.01469L10.9873 11.0052" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M10.6846 1.02413L0.985354 11.0147" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M1.28809 1.01469L10.9873 11.0052" stroke="#666666" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M10.6846 1.02413L0.985354 11.0147" stroke="#666666" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@ -32,8 +32,8 @@
|
||||
ref="editableDivRef"
|
||||
contenteditable="true"
|
||||
class="custom-textarea"
|
||||
@input="handleInput"
|
||||
:data-placeholder="!inputText ? '请输入提示词或使用圆形/矩形工具' : ''"
|
||||
@input="handleInput"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@ -48,7 +48,7 @@
|
||||
@click="currentShape = 'rectangle'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<rect x="1.84961" y="1.84998" width="14.3" height="14.3" rx="1.5" :stroke="currentShape === 'rectangle' ? '#000F33' : '#888888'"/>
|
||||
<rect x="1.84961" y="1.84998" width="14.3" height="14.3" rx="1.5" :stroke="currentShape === 'rectangle' ? '#000F33' : '#888888'" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@ -59,7 +59,7 @@
|
||||
@click="currentShape = 'circle'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<circle cx="9.00039" cy="9.00002" r="7.6" :stroke="currentShape === 'circle' ? '#000F33' : '#888888'"/>
|
||||
<circle cx="9.00039" cy="9.00002" r="7.6" :stroke="currentShape === 'circle' ? '#000F33' : '#888888'" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@ -68,22 +68,22 @@
|
||||
<!-- 上一步 -->
|
||||
<div class="shape-btn" @click="undo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path d="M1.64645 3.64645C1.45118 3.84171 1.45118 4.15829 1.64645 4.35355L4.82843 7.53553C5.02369 7.7308 5.34027 7.7308 5.53553 7.53553C5.7308 7.34027 5.7308 7.02369 5.53553 6.82843L2.70711 4L5.53553 1.17157C5.7308 0.976311 5.7308 0.659728 5.53553 0.464466C5.34027 0.269204 5.02369 0.269204 4.82843 0.464466L1.64645 3.64645ZM2 14.5C1.72386 14.5 1.5 14.7239 1.5 15C1.5 15.2761 1.72386 15.5 2 15.5V15V14.5ZM2 4V4.5H10.5V4V3.5H2V4ZM10.5 15V14.5H2V15V15.5H10.5V15ZM16 9.5H15.5C15.5 12.2614 13.2614 14.5 10.5 14.5V15V15.5C13.8137 15.5 16.5 12.8137 16.5 9.5H16ZM16 9.5H16.5C16.5 6.18629 13.8137 3.5 10.5 3.5V4V4.5C13.2614 4.5 15.5 6.73858 15.5 9.5H16Z" fill="#000F33"/>
|
||||
<path d="M1.64645 3.64645C1.45118 3.84171 1.45118 4.15829 1.64645 4.35355L4.82843 7.53553C5.02369 7.7308 5.34027 7.7308 5.53553 7.53553C5.7308 7.34027 5.7308 7.02369 5.53553 6.82843L2.70711 4L5.53553 1.17157C5.7308 0.976311 5.7308 0.659728 5.53553 0.464466C5.34027 0.269204 5.02369 0.269204 4.82843 0.464466L1.64645 3.64645ZM2 14.5C1.72386 14.5 1.5 14.7239 1.5 15C1.5 15.2761 1.72386 15.5 2 15.5V15V14.5ZM2 4V4.5H10.5V4V3.5H2V4ZM10.5 15V14.5H2V15V15.5H10.5V15ZM16 9.5H15.5C15.5 12.2614 13.2614 14.5 10.5 14.5V15V15.5C13.8137 15.5 16.5 12.8137 16.5 9.5H16ZM16 9.5H16.5C16.5 6.18629 13.8137 3.5 10.5 3.5V4V4.5C13.2614 4.5 15.5 6.73858 15.5 9.5H16Z" fill="#000F33" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 下一步 -->
|
||||
<div class="shape-btn" @click="redo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path d="M16.3536 3.64645C16.5488 3.84171 16.5488 4.15829 16.3536 4.35355L13.1716 7.53553C12.9763 7.7308 12.6597 7.7308 12.4645 7.53553C12.2692 7.34027 12.2692 7.02369 12.4645 6.82843L15.2929 4L12.4645 1.17157C12.2692 0.976311 12.2692 0.659728 12.4645 0.464466C12.6597 0.269204 12.9763 0.269204 13.1716 0.464466L16.3536 3.64645ZM16 13.5C16.2761 13.5 16.5 13.7239 16.5 14C16.5 14.2761 16.2761 14.5 16 14.5V14V13.5ZM16 4V4.5H8V4V3.5H16V4ZM8 14V13.5H16V14V14.5H8V14ZM3 9H3.5C3.5 11.4853 5.51472 13.5 8 13.5V14V14.5C4.96243 14.5 2.5 12.0376 2.5 9H3ZM3 9H2.5C2.5 5.96243 4.96243 3.5 8 3.5V4V4.5C5.51472 4.5 3.5 6.51472 3.5 9H3Z" fill="#000F33"/>
|
||||
<path d="M16.3536 3.64645C16.5488 3.84171 16.5488 4.15829 16.3536 4.35355L13.1716 7.53553C12.9763 7.7308 12.6597 7.7308 12.4645 7.53553C12.2692 7.34027 12.2692 7.02369 12.4645 6.82843L15.2929 4L12.4645 1.17157C12.2692 0.976311 12.2692 0.659728 12.4645 0.464466C12.6597 0.269204 12.9763 0.269204 13.1716 0.464466L16.3536 3.64645ZM16 13.5C16.2761 13.5 16.5 13.7239 16.5 14C16.5 14.2761 16.2761 14.5 16 14.5V14V13.5ZM16 4V4.5H8V4V3.5H16V4ZM8 14V13.5H16V14V14.5H8V14ZM3 9H3.5C3.5 11.4853 5.51472 13.5 8 13.5V14V14.5C4.96243 14.5 2.5 12.0376 2.5 9H3ZM3 9H2.5C2.5 5.96243 4.96243 3.5 8 3.5V4V4.5C5.51472 4.5 3.5 6.51472 3.5 9H3Z" fill="#000F33" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 删除 -->
|
||||
<div class="shape-btn" @click="deleteShape">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path d="M1.7998 3.60002H4.4998M16.1998 3.60002H13.4998M13.4998 3.60002V2.90003C13.4998 1.79546 12.6044 0.900024 11.4998 0.900024H6.4998C5.39523 0.900024 4.4998 1.79545 4.4998 2.90002V3.60002M13.4998 3.60002H4.4998" stroke="#000F33" stroke-linecap="round"/>
|
||||
<path d="M3.59961 6.29999V14.2C3.59961 15.3046 4.49504 16.2 5.59961 16.2H12.3996C13.5042 16.2 14.3996 15.3046 14.3996 14.2V6.29999M7.19961 7.19999V14.4M10.7996 7.19999V14.4" stroke="#000F33" stroke-linecap="round"/>
|
||||
<path d="M1.7998 3.60002H4.4998M16.1998 3.60002H13.4998M13.4998 3.60002V2.90003C13.4998 1.79546 12.6044 0.900024 11.4998 0.900024H6.4998C5.39523 0.900024 4.4998 1.79545 4.4998 2.90002V3.60002M13.4998 3.60002H4.4998" stroke="#000F33" stroke-linecap="round" />
|
||||
<path d="M3.59961 6.29999V14.2C3.59961 15.3046 4.49504 16.2 5.59961 16.2H12.3996C13.5042 16.2 14.3996 15.3046 14.3996 14.2V6.29999M7.19961 7.19999V14.4M10.7996 7.19999V14.4" stroke="#000F33" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@ -103,8 +103,8 @@
|
||||
<span class="brush-panel-title">请输入替换内容描述</span>
|
||||
<div class="brush-panel-close" @click="closeBrushPanel">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="13" viewBox="0 0 12 13" fill="none">
|
||||
<path d="M1.28809 1.01468L10.9873 11.0052" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M10.6846 1.02411L0.985354 11.0147" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M1.28809 1.01468L10.9873 11.0052" stroke="#666666" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M10.6846 1.02411L0.985354 11.0147" stroke="#666666" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@ -114,8 +114,8 @@
|
||||
ref="brushTextareaRef"
|
||||
contenteditable="true"
|
||||
class="brush-textarea"
|
||||
@input="handleBrushInput"
|
||||
:data-placeholder="!currentShapeDescription ? '请输入描述...' : ''"
|
||||
@input="handleBrushInput"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@ -135,7 +135,7 @@
|
||||
<img :src="img" alt="参考图" />
|
||||
<div class="reference-image-delete" @click.stop="removeReferenceImage(index)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="7" height="7" viewBox="0 0 7 7" fill="none">
|
||||
<path d="M0.5 0.5L6.5 6.5M6.5 0.5L0.5 6.5" stroke="white" stroke-linecap="round"/>
|
||||
<path d="M0.5 0.5L6.5 6.5M6.5 0.5L0.5 6.5" stroke="white" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@ -145,7 +145,7 @@
|
||||
@click="handleUploadReference"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||
<path d="M7.49316 0C7.76931 3.42279e-07 7.99316 0.223857 7.99316 0.5V6.99316H14.5C14.7761 6.99316 15 7.21702 15 7.49316C14.9999 7.76925 14.7761 7.99316 14.5 7.99316H7.99316V14.5C7.99316 14.7761 7.76931 15 7.49316 15C7.21702 15 6.99316 14.7761 6.99316 14.5V7.99316H0.5C0.223898 7.99316 6.59601e-05 7.76925 0 7.49316C0 7.21702 0.223858 6.99316 0.5 6.99316H6.99316V0.5C6.99316 0.223857 7.21702 1.20706e-08 7.49316 0Z" fill="#000F33"/>
|
||||
<path d="M7.49316 0C7.76931 3.42279e-07 7.99316 0.223857 7.99316 0.5V6.99316H14.5C14.7761 6.99316 15 7.21702 15 7.49316C14.9999 7.76925 14.7761 7.99316 14.5 7.99316H7.99316V14.5C7.99316 14.7761 7.76931 15 7.49316 15C7.21702 15 6.99316 14.7761 6.99316 14.5V7.99316H0.5C0.223898 7.99316 6.59601e-05 7.76925 0 7.49316C0 7.21702 0.223858 6.99316 0.5 6.99316H6.99316V0.5C6.99316 0.223857 7.21702 1.20706e-08 7.49316 0Z" fill="#000F33" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@ -160,10 +160,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { generate } from '@/utils/taskPolling'
|
||||
import { useDisplayStore } from '@/stores'
|
||||
import request from '@/utils/request'
|
||||
import { getModelId } from '@/utils/modelApi'
|
||||
import request from '@/utils/request'
|
||||
import { generate } from '@/utils/taskPolling'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
@ -268,7 +268,7 @@ watch(() => props.visible, (newVal) => {
|
||||
historyIndex.value = -1
|
||||
promptHistory.value = []
|
||||
promptHistoryIndex.value = -1
|
||||
allReferenceImages.value = props.referenceImages.map(img => img.url || img)
|
||||
allReferenceImages.value = props.referenceImages.map((img) => img.url || img)
|
||||
brushPanelVisible.value = false
|
||||
isPanelOpen.value = false
|
||||
currentShapeDescription.value = ''
|
||||
@ -310,8 +310,8 @@ const initCanvas = () => {
|
||||
img.src = imageUrl
|
||||
} else {
|
||||
fetch(imageUrl)
|
||||
.then(res => res.blob())
|
||||
.then(blob => {
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const imgScale = Math.min(containerWidth / img.width, containerHeight / img.height)
|
||||
@ -378,7 +378,7 @@ const handleMouseMove = (e) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.drawImage(bgImage.value, 0, 0)
|
||||
|
||||
shapes.value.forEach(shape => {
|
||||
shapes.value.forEach((shape) => {
|
||||
drawShape(ctx, shape)
|
||||
})
|
||||
|
||||
@ -396,7 +396,7 @@ const handleMouseMove = (e) => {
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
shapes.value.forEach(shape => {
|
||||
shapes.value.forEach((shape) => {
|
||||
drawShape(ctx, shape)
|
||||
})
|
||||
|
||||
@ -431,8 +431,8 @@ const handleMouseUp = (e) => {
|
||||
type: currentShape.value,
|
||||
startX: startX.value,
|
||||
startY: startY.value,
|
||||
endX: endX,
|
||||
endY: endY,
|
||||
endX,
|
||||
endY,
|
||||
color: shapeColors[colorIndex],
|
||||
description: '',
|
||||
referenceImages: []
|
||||
@ -515,7 +515,7 @@ const redrawCanvas = () => {
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
shapes.value.forEach(shape => {
|
||||
shapes.value.forEach((shape) => {
|
||||
drawShape(ctx, shape)
|
||||
})
|
||||
}
|
||||
@ -530,7 +530,7 @@ const drawShape = (ctx, shape) => {
|
||||
ctx.strokeRect(shape.startX, shape.startY, width, height)
|
||||
} else if (shape.type === 'circle') {
|
||||
const radius = Math.sqrt(
|
||||
Math.pow(shape.endX - shape.startX, 2) + Math.pow(shape.endY - shape.startY, 2)
|
||||
(shape.endX - shape.startX) ** 2 + (shape.endY - shape.startY) ** 2
|
||||
)
|
||||
ctx.beginPath()
|
||||
ctx.arc(shape.startX, shape.startY, radius, 0, Math.PI * 2)
|
||||
@ -743,9 +743,9 @@ const handleSend = async () => {
|
||||
modelId,
|
||||
quantity: 1,
|
||||
params: [
|
||||
{ name: 'prompt', data: inputText.value + '并且去除掉图1中的框' },
|
||||
{ name: 'prompt', data: `${inputText.value}并且去除掉图1中的框` },
|
||||
{ name: 'index', data: 1 },
|
||||
{ name: 'proportion', data: proportion?.aspectRatio || '4:3' },
|
||||
{ name: 'proportion', data: proportion?.aspectRatio || '4:3' }
|
||||
],
|
||||
imgs: uploadedImgs,
|
||||
request: JSON.stringify(generateData)
|
||||
|
||||
@ -1,60 +1,57 @@
|
||||
<template>
|
||||
<Transition name="slide-up">
|
||||
<div class="input-container" :class="{ generate : !props.isGenerate }" @click="handleContainerClick">
|
||||
<div v-if="!props.isGenerate && props.type === 'Painting'" class="title">AI绘画2026</div>
|
||||
<div v-if="!props.isGenerate && props.type === 'Video'" class="title">AI视频2026</div>
|
||||
<div class="input-container" :class="{ generate: !props.isGenerate }" @click="handleContainerClick">
|
||||
<div v-if="!props.isGenerate" class="title">{{ platform.label }}</div>
|
||||
|
||||
<div class="sender-top">
|
||||
<div v-if="useDisplay.Sender_variant === 'default'" class="scroll-to-bottom-text" @click.stop="handleScrollToBottom">回到底部<img src="@/assets/dialog/ArrowDown.svg"></div>
|
||||
<div v-if="useDisplay.Sender_variant === 'default'" class="scroll-to-bottom-text" @click.stop="handleScrollToBottom">
|
||||
回到底部<img src="@/assets/dialog/ArrowDown.svg">
|
||||
</div>
|
||||
|
||||
<div v-show="showImageUploader" class="upload-img-container">
|
||||
<div v-show="showUploader" class="upload-img-container">
|
||||
<div class="reference-diagram">
|
||||
<ImageUploader
|
||||
v-if="props.type === 'Painting'"
|
||||
<component
|
||||
:is="platform.ImageUploader"
|
||||
v-if="platform.ImageUploader"
|
||||
ref="referenceDiagramRef"
|
||||
v-model="referenceImages"
|
||||
:limit="imageUploadLimit"
|
||||
@open-canvas="handleOpenCanvas"
|
||||
/>
|
||||
<VideoImageUploader
|
||||
v-else-if="props.type === 'Video'"
|
||||
ref="referenceDiagramRef"
|
||||
v-model="referenceImages"
|
||||
:model-type="modelType"
|
||||
:images-count="modelDisplayConfig?.display?.images || 1"
|
||||
v-bind="uploaderBindings"
|
||||
@open-canvas="(data) => useDisplay.openCanvas(data)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sender :key="useDisplay.Sender_variant" v-model="prompt" :variant="useDisplay.Sender_variant" :placeholder="promptPlaceholder" :submit-btn-disabled="isgerenate.value" :auto-size="autoSizeConfig">
|
||||
<template #prefix>
|
||||
<div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Painting'" class="prefix-self-wrap">
|
||||
<paintingModel v-model="model" v-model:typeValue="modelType" />
|
||||
<paintingProportion
|
||||
v-if="showProportion"
|
||||
v-model="proportion"
|
||||
v-model:resolution="resolution"
|
||||
v-model:width="customWidth"
|
||||
v-model:height="customHight"
|
||||
:proportion-options="paintingProportionOpts"
|
||||
:resolution-options="paintingResolutionOpts"
|
||||
:allow-custom="hasCustomSize"
|
||||
<Sender
|
||||
:key="useDisplay.Sender_variant"
|
||||
v-model="prompt"
|
||||
:variant="useDisplay.Sender_variant"
|
||||
:placeholder="platform.promptPlaceholder.value"
|
||||
:submit-btn-disabled="isgerenate"
|
||||
:auto-size="autoSizeConfig"
|
||||
>
|
||||
<template v-if="useDisplay.Sender_variant !== 'default'" #prefix>
|
||||
<div class="prefix-self-wrap">
|
||||
<template v-for="ctrl in beforeModelControls" :key="ctrl.name">
|
||||
<component
|
||||
:is="ctrl.component"
|
||||
v-bind="ctrl.props(getCurrentConfig())"
|
||||
/>
|
||||
<Quantity v-if="showQuantity" v-model="quantity" :max="quantityMax" />
|
||||
</div>
|
||||
|
||||
<div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Video'" class="prefix-self-wrap">
|
||||
<Pattern v-model="videoPattern" />
|
||||
<videoModel v-model="model" v-model:typeValue="modelType" :video-pattern="videoPattern" />
|
||||
|
||||
<videoProportion
|
||||
v-model="proportion"
|
||||
v-model:resolution="resolution"
|
||||
:proportion-options="proportionOptions"
|
||||
:resolution-options="resolutionOptions"
|
||||
</template>
|
||||
<component
|
||||
:is="platform.ModelSelector"
|
||||
:model-value="platform.model.value"
|
||||
:type-value="platform.modelType.value"
|
||||
v-bind="(platform.modelSelectorProps && platform.modelSelectorProps()) || {}"
|
||||
@update:model-value="platform.model.value = $event"
|
||||
@update:type-value="platform.modelType.value = $event"
|
||||
/>
|
||||
<Time v-model="duration" :options="durationOptions" />
|
||||
<template v-for="ctrl in afterModelControls" :key="ctrl.name">
|
||||
<component
|
||||
:is="ctrl.component"
|
||||
v-bind="ctrl.props(getCurrentConfig())"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -64,351 +61,125 @@
|
||||
<i-ep-loading style="animation: spin 1s linear infinite;" />
|
||||
</el-button>
|
||||
<div v-else class="gerenate" :class="{ isprompt: prompt }" @click="handleStart">
|
||||
<img v-if="!prompt" src="@/assets/dialog/darkArrow.svg" alt="" />
|
||||
<img v-else src="@/assets/dialog/writerArrow.svg" alt="" />
|
||||
<img v-if="!prompt" src="@/assets/dialog/darkArrow.svg" alt="">
|
||||
<img v-else src="@/assets/dialog/writerArrow.svg" alt="">
|
||||
<div v-show="useDisplay.Sender_variant !== 'default'">发送</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Sender>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import videoProportion from './proportion/video.vue'
|
||||
import paintingModel from './model/painting.vue'
|
||||
import videoModel from './model/video.vue'
|
||||
import Pattern from './pattern/index.vue'
|
||||
import ImageUploader from './imageUploader/index.vue'
|
||||
import VideoImageUploader from './videoImageUploader/index.vue'
|
||||
import Time from './Time/index.vue'
|
||||
import paintingProportion from './proportion/painting.vue'
|
||||
import Quantity from './quantity/index.vue'
|
||||
import { Sender } from 'vue-element-plus-x'
|
||||
import { useDisplayStore } from '@/stores'
|
||||
import { generate } from '@/utils/taskPolling'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getModelId, fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
|
||||
import { fetchModelConfig } from '@/utils/modelConfig'
|
||||
import { getModelConfig } from '@/config/models/index.js'
|
||||
import { createPlatform } from '@/platforms/registry.js'
|
||||
import { useDisplayStore } from '@/stores'
|
||||
import { getModelId } from '@/utils/modelApi'
|
||||
import { generate } from '@/utils/taskPolling'
|
||||
|
||||
// 确保平台包被加载(触发自注册)
|
||||
import '@/platforms/painting/index.js'
|
||||
import '@/platforms/video/index.js'
|
||||
import '@/platforms/music/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
isGenerate: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
generate: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'Painting'
|
||||
}
|
||||
isGenerate: { type: Boolean, default: false },
|
||||
generate: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'Painting' }
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const useDisplay = useDisplayStore()
|
||||
const isgerenate = ref(false)
|
||||
|
||||
const model = ref() // 模型
|
||||
const modelType = ref('text')
|
||||
|
||||
// 当前模型配置
|
||||
const modelConfig = computed(() => {
|
||||
return props.type === 'Painting' ? getModelConfig(model.value) : null
|
||||
})
|
||||
|
||||
// 模型参数值
|
||||
const paramValues = reactive({})
|
||||
|
||||
const showImageUploader = computed(() => {
|
||||
if (props.type === 'Video') return modelType.value !== 'text'
|
||||
return modelType.value !== 'text' || modelConfig.value?.inputType === 'image' || modelConfig.value?.inputType === 'both'
|
||||
})
|
||||
|
||||
// 模型是否有数量参数(imageNum),或生成类模型显示张数选择
|
||||
const showQuantity = computed(() => {
|
||||
if (props.type !== 'Painting') return false
|
||||
const qtyParam = modelConfig.value?.params?.find(p => p.ui === 'quantity')
|
||||
if (qtyParam) return true
|
||||
return modelType.value === 'text' && !modelConfig.value?.params?.find(p => p.name === 'forceSingle')
|
||||
})
|
||||
|
||||
// 模型是否使用 proportion 组件(aspectRatio 参数)
|
||||
const showProportion = computed(() => {
|
||||
return !!modelConfig.value?.params?.find(p => p.ui === 'proportion')
|
||||
})
|
||||
|
||||
// 模型是否支持自定义尺寸(aspectRatio 选项含 'custom')
|
||||
const hasCustomSize = computed(() => {
|
||||
const ratioParam = modelConfig.value?.params?.find(p => p.ui === 'proportion')
|
||||
return ratioParam?.options?.includes('custom') || false
|
||||
})
|
||||
|
||||
// 从模型配置派生比例选项,回退到默认值
|
||||
const paintingProportionOpts = computed(() => {
|
||||
const ratioParam = modelConfig.value?.params?.find(p => p.ui === 'proportion')
|
||||
if (ratioParam?.options) {
|
||||
return ratioParam.options
|
||||
.filter(o => o !== 'custom')
|
||||
.map(o => ({ value: o, label: o }))
|
||||
}
|
||||
return proportionOptions.value
|
||||
})
|
||||
|
||||
// 从模型配置派生分辨率选项(仅模型有 resolution 参数时显示)
|
||||
const paintingResolutionOpts = computed(() => {
|
||||
const resParam = modelConfig.value?.params?.find(p => p.ui === 'resolution')
|
||||
if (resParam?.options) {
|
||||
return resParam.options.map(o => ({ value: o, label: o.toUpperCase() }))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const imageUploadLimit = computed(() => {
|
||||
if (!modelConfig.value) return 4
|
||||
const imageParam = modelConfig.value.params.find(p => p.ui === 'imageUpload')
|
||||
return imageParam?.maxCount || modelConfig.value.maxImages || 4
|
||||
})
|
||||
|
||||
const promptPlaceholder = ref('描述你想生成的画面和动作。') // 提示词占位符
|
||||
|
||||
const prompt = ref('') // 提示词
|
||||
const proportion = ref('16:9') // 比例(Video 用)
|
||||
const resolution = ref('1k') // 分辨率(Video 用)
|
||||
const prompt = ref('')
|
||||
const referenceImages = ref([])
|
||||
|
||||
// 绘画
|
||||
const quantity = ref(1) // 生成数量
|
||||
const customWidth = ref(1024) // 自定义宽度
|
||||
const customHight = ref(1024) // 自定义高度
|
||||
const platform = computed(() => createPlatform(props.type))
|
||||
|
||||
const quantityMax = computed(() => {
|
||||
const qtyParam = modelConfig.value?.params?.find(p => p.ui === 'quantity')
|
||||
if (qtyParam?.options?.length) return Math.max(...qtyParam.options)
|
||||
return 4
|
||||
const getCurrentConfig = () => {
|
||||
return platform.value.modelConfig?.value ?? platform.value.modelDisplayConfig?.value ?? null
|
||||
}
|
||||
|
||||
const visibleControls = computed(() => {
|
||||
const config = getCurrentConfig()
|
||||
return platform.value.controls.filter((c) => c.show(config))
|
||||
})
|
||||
|
||||
// 同步模型默认值到 paramValues 和 UI refs
|
||||
watch(modelConfig, (config) => {
|
||||
if (!config) return
|
||||
config.params.forEach(p => {
|
||||
if (!(p.name in paramValues)) {
|
||||
if (p.name === 'outputFormat') {
|
||||
paramValues[p.name] = 'png'
|
||||
} else {
|
||||
paramValues[p.name] = p.default ?? ''
|
||||
}
|
||||
}
|
||||
})
|
||||
// 同步默认值到 UI 控件
|
||||
const ratioParam = config.params.find(p => p.ui === 'proportion')
|
||||
if (ratioParam) proportion.value = ratioParam.default || '1:1'
|
||||
const resParam = config.params.find(p => p.ui === 'resolution')
|
||||
if (resParam) resolution.value = resParam.default || '2k'
|
||||
const qtyParam = config.params.find(p => p.ui === 'quantity')
|
||||
if (qtyParam) quantity.value = qtyParam.default || 1
|
||||
const cwParam = config.params.find(p => p.name === 'customWidth')
|
||||
if (cwParam) customWidth.value = cwParam.default || 1024
|
||||
const chParam = config.params.find(p => p.name === 'customHight')
|
||||
if (chParam) customHight.value = chParam.default || 1024
|
||||
}, { immediate: true })
|
||||
const beforeModelControls = computed(() => visibleControls.value.filter((c) => c.beforeModel))
|
||||
const afterModelControls = computed(() => visibleControls.value.filter((c) => !c.beforeModel))
|
||||
|
||||
// 反向同步:UI refs → paramValues
|
||||
watch(proportion, (val) => {
|
||||
const p = modelConfig.value?.params?.find(param => param.ui === 'proportion')
|
||||
if (p) paramValues[p.name] = val
|
||||
})
|
||||
watch(resolution, (val) => {
|
||||
const p = modelConfig.value?.params?.find(param => param.ui === 'resolution')
|
||||
if (p) paramValues[p.name] = val
|
||||
})
|
||||
watch(quantity, (val) => {
|
||||
const p = modelConfig.value?.params?.find(param => param.ui === 'quantity')
|
||||
if (p) paramValues[p.name] = val
|
||||
})
|
||||
watch(customWidth, (val) => {
|
||||
if (modelConfig.value?.params?.find(p => p.name === 'customWidth')) {
|
||||
paramValues.customWidth = val
|
||||
}
|
||||
})
|
||||
watch(customHight, (val) => {
|
||||
if (modelConfig.value?.params?.find(p => p.name === 'customHight')) {
|
||||
paramValues.customHight = val
|
||||
}
|
||||
const showUploader = computed(() => {
|
||||
return platform.value.showImageUploader()
|
||||
})
|
||||
|
||||
// 同步参考图片到 paramValues
|
||||
watch(referenceImages, (imgs) => {
|
||||
const imageParam = modelConfig.value?.params?.find(p => p.ui === 'imageUpload')
|
||||
if (imageParam) {
|
||||
paramValues[imageParam.name] = imgs.map(img => img.url)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 视频
|
||||
const duration = ref(5) // 时间
|
||||
const videoPattern = ref('文生视频') // 视频模式下,默认值为'文生视频'
|
||||
|
||||
const resolutionOptions = ref([
|
||||
{ value: '1k', label: '标清 1K' },
|
||||
{ value: '2k', label: '高清 2K' },
|
||||
{ value: '4k', label: '超清 4K' },
|
||||
])
|
||||
const proportionOptions = ref([
|
||||
{ value: '智能', label: '智能' },
|
||||
{ value: '21:9', label: '21:9' },
|
||||
{ value: '16:9', label: '16:9' },
|
||||
{ value: '4:3', label: '4:3' },
|
||||
{ value: '1:1', label: '1:1' },
|
||||
{ value: '3:4', label: '3:4' },
|
||||
{ value: '9:16', label: '9:16' },
|
||||
])
|
||||
const durationOptions = ref([])
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const uploaderBindings = computed(() => platform.value.getUploaderBindings())
|
||||
|
||||
const autoSizeConfig = computed(() => {
|
||||
if (useDisplay.Sender_variant !== 'default') {
|
||||
return { minRows: 5, maxRows: 9 }
|
||||
} else {
|
||||
return { minRows: 1, maxRows: 1 }
|
||||
}
|
||||
return { minRows: 1, maxRows: 1 }
|
||||
})
|
||||
|
||||
const modelDisplayConfig = ref(null)
|
||||
|
||||
// Video: 从远程加载 workflow 配置(保留旧逻辑)
|
||||
const loadVideoModelConfig = async (modelName, currentModelType) => {
|
||||
try {
|
||||
const config = await fetchModelConfig(props.type, modelName, currentModelType)
|
||||
modelDisplayConfig.value = config
|
||||
|
||||
if (config.display) {
|
||||
const display = config.display
|
||||
if (display.promptPlaceholder) {
|
||||
promptPlaceholder.value = display.promptPlaceholder.default || '描述你想生成的画面和动作。'
|
||||
}
|
||||
if (display.prompt && !isInitialized.value) {
|
||||
prompt.value = display.prompt.default || ''
|
||||
}
|
||||
if (display.resolution) {
|
||||
resolution.value = display.resolution.default || '1k'
|
||||
resolutionOptions.value = display.resolution.options || []
|
||||
}
|
||||
if (display.proportion) {
|
||||
proportion.value = display.proportion.default || '16:9'
|
||||
proportionOptions.value = display.proportion.options || []
|
||||
}
|
||||
if (display.duration) {
|
||||
duration.value = display.duration.default || 5
|
||||
durationOptions.value = display.duration.options || []
|
||||
}
|
||||
}
|
||||
isInitialized.value = true
|
||||
} catch (error) {
|
||||
console.error('加载视频模型配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStart = async () => {
|
||||
const currentType = props.type
|
||||
let currentModelType = modelType.value
|
||||
const p = platform.value
|
||||
|
||||
if(model.value === 'Seedance 2.0') {
|
||||
ElMessage.primary('敬请期待 Seedance 2.0')
|
||||
const validationError = p.validateBeforeSubmit()
|
||||
if (validationError) {
|
||||
ElMessage.primary(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.isGenerate) {
|
||||
router.push({ name: 'home', query: { loading: false, Generate: true, type: currentType } })
|
||||
router.push({ name: 'home', query: { loading: false, Generate: true, type: props.type } })
|
||||
}
|
||||
if (!prompt.value) {
|
||||
// eslint-disable-next-line no-undef
|
||||
ElMessage.error('请输入提示词')
|
||||
return
|
||||
}
|
||||
if (showImageUploader.value && !referenceImages.value.length){
|
||||
if (showUploader.value && p.isImageRequired() && !referenceImages.value.length) {
|
||||
ElMessage.warning('请上传图片')
|
||||
return
|
||||
}
|
||||
|
||||
isgerenate.value = true
|
||||
console.log('生成开始', isgerenate.value)
|
||||
const imgs = []
|
||||
referenceImages.value.forEach((img, index) => {
|
||||
imgs.push({ name: `image_${index + 1}`, url: img.url })
|
||||
})
|
||||
|
||||
// 构建模型参数(Painting 用)
|
||||
const modelParams = { ...paramValues }
|
||||
if (prompt.value) modelParams.prompt = prompt.value
|
||||
const modelId = await getModelId(props.type, p.model.value)
|
||||
|
||||
// 所有平台统一返回扁平 modelParams
|
||||
const body = await p.buildTaskBody({ prompt, referenceImages })
|
||||
|
||||
const generateData = {
|
||||
model: model.value,
|
||||
modelType: currentModelType,
|
||||
model: p.model.value,
|
||||
modelType: p.modelType.value,
|
||||
prompt: prompt.value,
|
||||
proportion: proportion.value,
|
||||
referenceImages: referenceImages.value,
|
||||
quantity: quantity.value,
|
||||
resolution: resolution.value,
|
||||
customWidth: customWidth.value,
|
||||
customHight: customHight.value,
|
||||
duration: duration.value,
|
||||
videoPattern: videoPattern.value,
|
||||
modelParams,
|
||||
modelParams: body
|
||||
}
|
||||
|
||||
const modelId = await getModelId(currentType, model.value)
|
||||
|
||||
// Painting 用新架构扁平参数,Video 保留旧 params 数组
|
||||
const isPainting = currentType === 'Painting'
|
||||
const data = {
|
||||
type: currentType,
|
||||
modelType: currentModelType,
|
||||
AIGC: currentType,
|
||||
platform: 'runninghub',
|
||||
modelName: model.value,
|
||||
type: props.type,
|
||||
modelType: p.modelType.value,
|
||||
modelName: p.model.value,
|
||||
modelId: modelId || '',
|
||||
modelParams: isPainting ? modelParams : {},
|
||||
params: isPainting ? [] : [
|
||||
{ name: 'prompt', data: prompt.value },
|
||||
{ name: 'quantity', data: quantity.value },
|
||||
{ name: 'proportion', data: proportion.value },
|
||||
{ name: 'resolution', data: resolution.value },
|
||||
{ name: 'duration', data: duration.value },
|
||||
],
|
||||
imgs,
|
||||
body,
|
||||
request: JSON.stringify(generateData)
|
||||
}
|
||||
|
||||
await generate(data, generateData)
|
||||
console.log('生成中', isgerenate.value)
|
||||
}
|
||||
|
||||
const fillParamsFromResult = (resultData) => {
|
||||
if (!resultData) return
|
||||
|
||||
if (resultData.model !== undefined) model.value = resultData.model
|
||||
if (resultData.modelType !== undefined) modelType.value = resultData.modelType
|
||||
platform.value.fillFromResult(resultData)
|
||||
if (resultData.prompt !== undefined) prompt.value = resultData.prompt
|
||||
if (resultData.proportion !== undefined) proportion.value = resultData.proportion
|
||||
if (resultData.referenceImages !== undefined) referenceImages.value = resultData.referenceImages
|
||||
if (resultData.quantity !== undefined) quantity.value = resultData.quantity
|
||||
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
|
||||
if (resultData.customWidth !== undefined) customWidth.value = resultData.customWidth
|
||||
if (resultData.customHight !== undefined) customHight.value = resultData.customHight
|
||||
if (resultData.duration !== undefined) duration.value = resultData.duration
|
||||
if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern
|
||||
if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
fillParamsFromResult,
|
||||
handleStart
|
||||
})
|
||||
defineExpose({ fillParamsFromResult, handleStart })
|
||||
|
||||
const handleContainerClick = () => {
|
||||
if (useDisplay.Sender_variant === 'default') {
|
||||
@ -417,49 +188,32 @@ const handleContainerClick = () => {
|
||||
}
|
||||
|
||||
const handleScrollToBottom = () => {
|
||||
console.log('点击回到底部按钮')
|
||||
useDisplay.scrollToBottom()
|
||||
}
|
||||
|
||||
const handleOpenCanvas = (data) => {
|
||||
useDisplay.openCanvas(data)
|
||||
}
|
||||
watch(() => useDisplay.isSubGerenate, (v) => { isgerenate.value = v }, { immediate: true })
|
||||
|
||||
watch(() => useDisplay.isSubGerenate, (newValue) => {
|
||||
console.log('生成状态', newValue)
|
||||
isgerenate.value = newValue
|
||||
}, { immediate: true })
|
||||
|
||||
watch([() => model.value, () => modelType.value], async ([newModel, newModelType]) => {
|
||||
console.log('模型或类型改变:', newModel, newModelType)
|
||||
// 模型变更 → 加载配置
|
||||
watch(
|
||||
[() => platform.value.model.value, () => platform.value.modelType.value],
|
||||
async ([newModel, newModelType]) => {
|
||||
if (!newModel) return
|
||||
if (props.type !== 'Painting') {
|
||||
await loadVideoModelConfig(newModel, newModelType)
|
||||
await platform.value.loadConfig(newModel, newModelType)
|
||||
}
|
||||
})
|
||||
|
||||
// 预加载平台模型列表,避免首次点击"发送"时才请求接口
|
||||
const prefetchModels = () => {
|
||||
const code = getPlatformCode(props.type)
|
||||
fetchPlatformModels(code)
|
||||
}
|
||||
)
|
||||
|
||||
// 平台切换 → 设置默认模型 + 预加载模型列表
|
||||
watch(() => props.type, (newType) => {
|
||||
if (newType === 'Video') {
|
||||
model.value = 'LTX2.0'
|
||||
} else {
|
||||
model.value = 'flux'
|
||||
}
|
||||
prefetchModels()
|
||||
const p = createPlatform(newType)
|
||||
p.model.value = p.getDefaultModel()
|
||||
p.loadModels()
|
||||
}, { immediate: true })
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 输入区域 */
|
||||
.input-container {
|
||||
width: 50%;
|
||||
max-width: 880px;
|
||||
max-width: 1024px;
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
z-index: 100;
|
||||
@ -494,20 +248,14 @@ watch(() => props.type, (newType) => {
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
&:hover {
|
||||
background-color: #F0F1F2;
|
||||
}
|
||||
&:active { transform: scale(0.95); }
|
||||
&:hover { background-color: #F0F1F2; }
|
||||
}
|
||||
|
||||
// 上传图片
|
||||
.upload-img-container{
|
||||
.upload-img-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 80%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
@ -523,35 +271,31 @@ watch(() => props.type, (newType) => {
|
||||
}
|
||||
}
|
||||
|
||||
.generate{
|
||||
.generate {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
// gap: 40px;
|
||||
position: relative;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
:deep(.el-sender){
|
||||
:deep(.el-sender) {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.prefix-self-wrap{
|
||||
|
||||
.prefix-self-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
|
||||
img{
|
||||
height: 50px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
img { height: 50px; border-radius: 4px; }
|
||||
}
|
||||
.title{
|
||||
|
||||
.title {
|
||||
background-color: #FFF;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
@ -562,27 +306,28 @@ watch(() => props.type, (newType) => {
|
||||
line-height: normal;
|
||||
margin-bottom: 106px;
|
||||
}
|
||||
:deep(.el-sender){
|
||||
|
||||
:deep(.el-sender) {
|
||||
background-color: #F5F6F7;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-sender:focus-within){
|
||||
:deep(.el-sender:focus-within) {
|
||||
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:deep(.el-popover.el-popper){
|
||||
:deep(.el-popover.el-popper) {
|
||||
border-radius: 20px;
|
||||
}
|
||||
// 时间选择器
|
||||
.select{
|
||||
|
||||
.select {
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
// 视频效果选择器
|
||||
.upload-btn{
|
||||
|
||||
.upload-btn {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
@ -595,10 +340,9 @@ watch(() => props.type, (newType) => {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.upload-btn:hover{
|
||||
background: #E5E7EB;
|
||||
}
|
||||
/* 圆形按钮 */
|
||||
|
||||
.upload-btn:hover { background: #E5E7EB; }
|
||||
|
||||
.circle-btn {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
@ -614,18 +358,10 @@ watch(() => props.type, (newType) => {
|
||||
transition: all 0.3s ease;
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 20px;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
// box-shadow: 0 6px 16px rgba(98, 106, 239, 0.6);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
&:hover { transform: scale(1.1); }
|
||||
&:active { transform: scale(0.95); }
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@ -641,7 +377,7 @@ watch(() => props.type, (newType) => {
|
||||
transform: translate(-50%, 100%);
|
||||
}
|
||||
|
||||
.gerenate{
|
||||
.gerenate {
|
||||
display: inline-flex;
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
@ -651,7 +387,6 @@ watch(() => props.type, (newType) => {
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 15, 51, 0.10);
|
||||
cursor: pointer;
|
||||
|
||||
color: #000F33;
|
||||
text-align: center;
|
||||
font-family: "Microsoft YaHei";
|
||||
@ -660,11 +395,9 @@ watch(() => props.type, (newType) => {
|
||||
font-weight: 700;
|
||||
line-height: normal;
|
||||
}
|
||||
.isprompt{
|
||||
|
||||
.isprompt {
|
||||
color: #ffffff;
|
||||
background-color: #000F33;
|
||||
}
|
||||
// .gerenate:hover{
|
||||
// background: rgba(0, 15, 51, 0.20);
|
||||
// }
|
||||
</style>
|
||||
|
||||
@ -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>
|
||||
@ -1,27 +1,41 @@
|
||||
<template>
|
||||
<div class="virtual-scroller" :style="containerStyle">
|
||||
<div class="virtual-scroller-wrapper" :style="wrapperStyle">
|
||||
<div
|
||||
ref="scrollContainerRef"
|
||||
class="virtual-scroller-container"
|
||||
:style="containerInnerStyle"
|
||||
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="spacerStyle"></div>
|
||||
<div class="virtual-scroller-placeholder" :style="placeholderStyle">
|
||||
<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="item in visibleItems"
|
||||
:key="item.key"
|
||||
:ref="el => setItemRef(el, item.key)"
|
||||
v-for="renderItem in visibleItems"
|
||||
:key="getItemKey(renderItem.item, renderItem.index)"
|
||||
:ref="el => setItemRef(el, renderItem.index)"
|
||||
class="virtual-scroller-item"
|
||||
:style="getItemStyle(item)"
|
||||
:data-index="item.index"
|
||||
:data-key="item.key"
|
||||
:style="getItemStyle(renderItem)"
|
||||
:data-index="renderItem.index"
|
||||
>
|
||||
<slot name="default" :item="item.data" :index="item.index" />
|
||||
<slot
|
||||
name="default"
|
||||
:item="renderItem.item"
|
||||
:index="renderItem.index"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -34,224 +48,192 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
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: 5
|
||||
default: 3
|
||||
},
|
||||
placeholderHeight: {
|
||||
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 emit = defineEmits(['scroll', 'scroll-start', 'scroll-end'])
|
||||
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end', 'visible-change'])
|
||||
|
||||
const scrollContainerRef = ref(null)
|
||||
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 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 getKey = (item, index) => {
|
||||
if (typeof props.itemKey === 'function') {
|
||||
return props.itemKey(item, index)
|
||||
}
|
||||
if (item && typeof item === 'object') {
|
||||
return item[props.itemKey] ?? index
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
const getItemHeight = (key) => {
|
||||
return itemHeights.value.get(key) ?? props.estimatedHeight
|
||||
}
|
||||
|
||||
const containerStyle = computed(() => ({
|
||||
const containerStyle = computed(() => {
|
||||
return {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}))
|
||||
|
||||
const wrapperStyle = computed(() => ({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
transform: 'rotate(180deg)',
|
||||
direction: 'rtl'
|
||||
}))
|
||||
|
||||
const containerInnerStyle = computed(() => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
direction: 'ltr'
|
||||
}))
|
||||
|
||||
const totalDataHeight = computed(() => {
|
||||
let height = 0
|
||||
for (let i = 0; i < props.data.length; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
height += getItemHeight(key)
|
||||
}
|
||||
return height
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
return props.placeholderHeight + totalDataHeight.value
|
||||
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 spacerStyle = computed(() => ({
|
||||
height: `${totalHeight.value}px`,
|
||||
width: '100%',
|
||||
flexShrink: 0
|
||||
}))
|
||||
|
||||
const placeholderStyle = computed(() => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: `${props.placeholderHeight}px`,
|
||||
zIndex: 1
|
||||
}))
|
||||
|
||||
const getItemOffsets = () => {
|
||||
const offsets = []
|
||||
const getItemPosition = (index) => {
|
||||
let offset = 0
|
||||
for (let i = 0; i < props.data.length; i++) {
|
||||
offsets.push(offset)
|
||||
const key = getKey(props.data[i], i)
|
||||
offset += getItemHeight(key)
|
||||
|
||||
for (let i = 0; i < index; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
offset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
return offsets
|
||||
|
||||
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(() => {
|
||||
const count = props.data.length
|
||||
if (count === 0) {
|
||||
if (!renderContainerRef.value || computedData.value.length === 0) {
|
||||
return { start: 0, end: 0, offset: 0 }
|
||||
}
|
||||
|
||||
const el = scrollContainerRef.value
|
||||
if (!el) {
|
||||
return { start: 0, end: Math.min(count - 1, 9), offset: 0 }
|
||||
}
|
||||
|
||||
const scrollTop = el.scrollTop
|
||||
const viewportHeight = el.clientHeight || 600
|
||||
const bufferCount = props.buffer
|
||||
|
||||
// In inverted scroll (180deg rotation):
|
||||
// - scrollTop = 0: visual BOTTOM (shows newer data, lower index)
|
||||
// - scrollTop = max: visual TOP (shows older data, higher index)
|
||||
// - Items are positioned from top: placeholderHeight, then data items
|
||||
// - visibleStart/visibleEnd are offsets in the data area (after placeholder)
|
||||
|
||||
// When scrollTop = 0, we're at visual bottom, showing items near the START of data
|
||||
// When scrollTop = max, we're at visual top, showing items near the END of data
|
||||
|
||||
// The visible area in data coordinates:
|
||||
// - scrollTop 0 means we see items at offset 0 (start of data)
|
||||
// - scrollTop increases means we see items at higher offsets (end of data)
|
||||
|
||||
const visibleStart = Math.max(0, scrollTop - props.placeholderHeight)
|
||||
const visibleEnd = visibleStart + viewportHeight
|
||||
const viewportHeight = containerHeight.value
|
||||
const currentScrollTop = scrollTop.value
|
||||
const bufferCount = computedBuffer.value
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = count - 1
|
||||
let endIndex = computedData.value.length - 1
|
||||
let startOffset = 0
|
||||
let currentOffset = 0
|
||||
|
||||
// Find startIndex: first item that ends after visibleStart
|
||||
for (let i = 0; i < count; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
const height = getItemHeight(key)
|
||||
const itemEnd = currentOffset + height
|
||||
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 (itemEnd > visibleStart) {
|
||||
if (offset + height > currentScrollTop) {
|
||||
startIndex = Math.max(0, i - bufferCount)
|
||||
break
|
||||
}
|
||||
|
||||
currentOffset += height
|
||||
offset += height
|
||||
}
|
||||
|
||||
// Calculate startOffset for startIndex
|
||||
startOffset = 0
|
||||
for (let i = 0; i < startIndex; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
startOffset += getItemHeight(key)
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
startOffset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
// Find endIndex: last item that starts before visibleEnd
|
||||
currentOffset = startOffset
|
||||
for (let i = startIndex; i < count; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
const height = getItemHeight(key)
|
||||
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
|
||||
|
||||
// Check if this item is visible (item starts before visibleEnd)
|
||||
if (currentOffset >= visibleEnd) {
|
||||
// This item starts after visibleEnd, so previous item is the last visible
|
||||
endIndex = Math.min(count - 1, Math.max(startIndex, i - 1 + bufferCount))
|
||||
offset += height
|
||||
|
||||
if (offset > currentScrollTop + viewportHeight) {
|
||||
endIndex = Math.min(computedData.value.length - 1, i + bufferCount)
|
||||
break
|
||||
}
|
||||
|
||||
endIndex = i
|
||||
currentOffset += height
|
||||
}
|
||||
|
||||
return { start: startIndex, end: endIndex, offset: startOffset }
|
||||
return { start: Math.min(startIndex, endIndex), end: Math.max(startIndex, endIndex), offset: startOffset }
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
const { start, end, offset } = visibleRange.value
|
||||
const items = []
|
||||
const count = props.data.length
|
||||
|
||||
if (count === 0) return items
|
||||
let currentOffset = offset
|
||||
|
||||
const safeStart = Math.max(0, start)
|
||||
const safeEnd = Math.min(count - 1, end)
|
||||
|
||||
if (safeStart > safeEnd) return items
|
||||
|
||||
let currentOffset = offset + props.placeholderHeight
|
||||
const seenKeys = new Set()
|
||||
|
||||
for (let i = safeStart; i <= safeEnd; i++) {
|
||||
const data = props.data[i]
|
||||
if (!data) continue
|
||||
|
||||
const key = getKey(data, i)
|
||||
|
||||
// Deduplicate by key
|
||||
if (seenKeys.has(key)) continue
|
||||
seenKeys.add(key)
|
||||
|
||||
const height = getItemHeight(key)
|
||||
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({
|
||||
data,
|
||||
item: computedData.value[i],
|
||||
index: i,
|
||||
key,
|
||||
offset: currentOffset,
|
||||
offset: currentOffset + props.bottomPlaceholderHeight,
|
||||
height
|
||||
})
|
||||
|
||||
@ -261,34 +243,89 @@ const visibleItems = computed(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
const getItemStyle = (item) => ({
|
||||
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,
|
||||
transform: `translateY(${item.offset}px)`,
|
||||
willChange: 'transform'
|
||||
})
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: `${props.bottomPlaceholderHeight}px`,
|
||||
transform: 'translateY(0px)',
|
||||
zIndex: 1
|
||||
}))
|
||||
|
||||
const setItemRef = (el, key) => {
|
||||
if (el) {
|
||||
itemRefs.set(key, el)
|
||||
} else {
|
||||
itemRefs.delete(key)
|
||||
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 measureItem = (key, element) => {
|
||||
const setItemRef = (el, index) => {
|
||||
if (el) {
|
||||
itemRefs.set(index, el)
|
||||
} else {
|
||||
itemRefs.delete(index)
|
||||
}
|
||||
}
|
||||
|
||||
const measureItem = (index, element) => {
|
||||
if (!element) return
|
||||
|
||||
const target = element.firstElementChild || element
|
||||
const height = target.getBoundingClientRect().height
|
||||
const firstChild = element.firstElementChild
|
||||
const targetElement = firstChild || element
|
||||
|
||||
if (height > 0 && height !== itemHeights.value.get(key)) {
|
||||
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(key, height)
|
||||
newHeights.set(index, height)
|
||||
itemHeights.value = newHeights
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setupResizeObserver = () => {
|
||||
@ -298,43 +335,33 @@ const setupResizeObserver = () => {
|
||||
|
||||
resizeObserver.value = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const key = entry.target.dataset.key
|
||||
if (key !== undefined) {
|
||||
measureItem(key, entry.target)
|
||||
const index = Number.parseInt(entry.target.dataset.index, 10)
|
||||
if (!isNaN(index)) {
|
||||
measureItem(index, entry.target)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const observeItems = () => {
|
||||
if (!resizeObserver.value) return
|
||||
|
||||
resizeObserver.value.disconnect()
|
||||
|
||||
for (const [key, element] of itemRefs) {
|
||||
if (element) {
|
||||
resizeObserver.value.observe(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleWheel = (event) => {
|
||||
if (!scrollContainerRef.value) return
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
scrollContainerRef.value.scrollBy({
|
||||
top: -event.deltaY,
|
||||
const { deltaY } = event
|
||||
const el = renderContainerRef.value
|
||||
|
||||
el.scrollBy({
|
||||
top: -deltaY,
|
||||
behavior: 'instant'
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const scrollCleanupTimeout = ref(null)
|
||||
|
||||
const handleScroll = (event) => {
|
||||
const target = event.target
|
||||
const scrollHeight = target.scrollHeight
|
||||
const scrollTop = target.scrollTop
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
scrollTop.value = target.scrollTop
|
||||
isScrolling.value = true
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
@ -345,115 +372,210 @@ const handleScroll = (event) => {
|
||||
isScrolling.value = false
|
||||
}, 150)
|
||||
|
||||
// In inverted scroll:
|
||||
// - distanceToTop (visual top) = scrollHeight - scrollTop - clientHeight
|
||||
// - distanceToBottom (visual bottom) = scrollTop
|
||||
// - isAtTop (visual top, older data) = distanceToTop <= threshold
|
||||
// - isAtBottom (visual bottom, newer data) = distanceToBottom <= threshold
|
||||
const distanceToTop = scrollHeight - scrollTop - clientHeight
|
||||
const distanceToBottom = scrollTop
|
||||
const threshold = 5
|
||||
// 滚动时添加防抖清理,每100ms最多执行一次
|
||||
if (scrollCleanupTimeout.value) {
|
||||
clearTimeout(scrollCleanupTimeout.value)
|
||||
}
|
||||
scrollCleanupTimeout.value = setTimeout(() => {
|
||||
cleanupExtraItems(visibleItems.value)
|
||||
}, 300)
|
||||
|
||||
const isAtTop = distanceToTop <= threshold
|
||||
const isAtBottom = distanceToBottom <= threshold
|
||||
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', {
|
||||
scrollTop,
|
||||
target,
|
||||
scrollTop: st,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
distanceToTop,
|
||||
distanceToBottom,
|
||||
isAtTop,
|
||||
isAtBottom
|
||||
distanceToPageTop,
|
||||
distanceToPageBottom,
|
||||
isAtPageTop,
|
||||
isAtPageBottom
|
||||
})
|
||||
|
||||
// scroll-start: reached visual top (older data, need to load more)
|
||||
if (isAtTop) {
|
||||
if (isAtPageTop) {
|
||||
emit('scroll-start')
|
||||
}
|
||||
|
||||
// scroll-end: reached visual bottom (newer data)
|
||||
if (isAtBottom) {
|
||||
if (isAtPageBottom) {
|
||||
emit('scroll-end')
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToIndex = (index, behavior = 'auto') => {
|
||||
if (!scrollContainerRef.value || index < 0 || index >= props.data.length) return
|
||||
if (!renderContainerRef.value || index < 0 || index >= computedData.value.length) return
|
||||
|
||||
let offset = 0
|
||||
for (let i = 0; i < index; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
offset += getItemHeight(key)
|
||||
}
|
||||
const position = getItemPosition(index)
|
||||
|
||||
const targetScrollTop = offset + props.placeholderHeight
|
||||
|
||||
scrollContainerRef.value.scrollTo({
|
||||
top: targetScrollTop,
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: position.offset,
|
||||
behavior
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToBottom = (behavior = 'smooth') => {
|
||||
if (!scrollContainerRef.value) return
|
||||
if (!renderContainerRef.value) {
|
||||
pendingScrollToBottom.value = true
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!scrollContainerRef.value) return
|
||||
// In inverted scroll, bottom is scrollTop = 0
|
||||
scrollContainerRef.value.scrollTo({ top: 0, behavior })
|
||||
})
|
||||
}
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
const scrollToTop = (behavior = 'smooth') => {
|
||||
if (!scrollContainerRef.value) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!scrollContainerRef.value) return
|
||||
// In inverted scroll, top is scrollTop = max
|
||||
scrollContainerRef.value.scrollTo({
|
||||
top: scrollContainerRef.value.scrollHeight,
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: 0,
|
||||
behavior
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const getScrollElement = () => scrollContainerRef.value
|
||||
const scrollToTop = (behavior = 'smooth') => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
const isAtTop = () => {
|
||||
if (!scrollContainerRef.value) return false
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.value
|
||||
return scrollHeight - scrollTop - clientHeight <= 5
|
||||
requestAnimationFrame(() => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
const scrollHeight = renderContainerRef.value.scrollHeight
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: scrollHeight,
|
||||
behavior
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const isAtBottom = () => {
|
||||
if (!scrollContainerRef.value) return false
|
||||
return scrollContainerRef.value.scrollTop <= 5
|
||||
const getScrollElement = () => renderContainerRef.value
|
||||
|
||||
const getVisibleIndices = () => {
|
||||
const { start, end } = visibleRange.value
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
const resetMeasurements = () => {
|
||||
itemHeights.value = new Map()
|
||||
itemRefs.clear()
|
||||
}
|
||||
|
||||
watch(() => props.data, (newData, oldData) => {
|
||||
const newLength = newData?.length || 0
|
||||
const oldLength = oldData?.length || 0
|
||||
const isAtPageBottom = () => {
|
||||
if (!renderContainerRef.value) return false
|
||||
const { scrollTop } = renderContainerRef.value
|
||||
return scrollTop <= 0
|
||||
}
|
||||
|
||||
if (newLength < oldLength) {
|
||||
reset()
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
nextTick(observeItems)
|
||||
itemHeights.value = newHeights
|
||||
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
})
|
||||
}
|
||||
|
||||
previousDataLength.value = newLength
|
||||
}, { deep: false })
|
||||
|
||||
watch(visibleItems, (newItems) => {
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
cleanupExtraItems(newItems)
|
||||
})
|
||||
if (newItems.length > 0) {
|
||||
const firstItem = newItems[0]
|
||||
const lastItem = newItems[newItems.length - 1]
|
||||
emit('visible-change', firstItem.index, lastItem.index)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
watch(visibleItems, () => {
|
||||
nextTick(observeItems)
|
||||
}, { deep: true })
|
||||
const cleanupExtraItems = (currentVisibleItems) => {
|
||||
if (!renderContainerRef.value || !currentVisibleItems.length) return
|
||||
|
||||
// 构建当前应该可见的索引集合
|
||||
const visibleIndices = new Set(currentVisibleItems.map((item) => item.index))
|
||||
|
||||
// 直接获取 render-container 内所有实际渲染的 .virtual-scroller-item 元素
|
||||
const renderedItems = renderContainerRef.value.querySelectorAll('.virtual-scroller-item')
|
||||
|
||||
const toRemove = []
|
||||
|
||||
for (const el of renderedItems) {
|
||||
const dataIndex = Number.parseInt(el.getAttribute('data-index'), 10)
|
||||
|
||||
// 如果元素的 data-index 不在可见范围内,标记为删除
|
||||
if (!isNaN(dataIndex) && !visibleIndices.has(dataIndex)) {
|
||||
toRemove.push(el)
|
||||
}
|
||||
}
|
||||
|
||||
// 从 DOM 中删除多余元素
|
||||
for (const el of toRemove) {
|
||||
if (el.parentNode) {
|
||||
el.parentNode.removeChild(el)
|
||||
}
|
||||
// 同步清理 itemRefs Map
|
||||
const index = Number.parseInt(el.getAttribute('data-index'), 10)
|
||||
if (!isNaN(index)) {
|
||||
itemRefs.delete(index)
|
||||
}
|
||||
}
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
console.log(`[VirtualScroller] 清理了 ${toRemove.length} 个多余DOM元素`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupResizeObserver()
|
||||
nextTick(observeItems)
|
||||
isInitialized.value = true
|
||||
previousDataLength.value = computedData.value.length
|
||||
|
||||
nextTick(() => {
|
||||
if (pendingScrollToBottom.value) {
|
||||
pendingScrollToBottom.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
observeVisibleItems()
|
||||
cleanupExtraItems(visibleItems.value)
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@ -465,53 +587,81 @@ onBeforeUnmount(() => {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
if (scrollCleanupTimeout.value) {
|
||||
clearTimeout(scrollCleanupTimeout.value)
|
||||
}
|
||||
|
||||
itemRefs.clear()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
scrollToIndex,
|
||||
scrollToItem: scrollToIndex,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
getScrollElement,
|
||||
isAtTop,
|
||||
isAtBottom,
|
||||
reset,
|
||||
scrollContainerRef
|
||||
getVisibleIndices,
|
||||
resetMeasurements,
|
||||
containerRef,
|
||||
isAtPageBottom,
|
||||
isAtPageTop
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.virtual-scroller {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&-wrapper {
|
||||
contain: content;
|
||||
}
|
||||
|
||||
&-container {
|
||||
contain: layout style;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
&-spacer {
|
||||
.virtual-scroller-wrapper {
|
||||
contain: content;
|
||||
|
||||
}
|
||||
|
||||
.virtual-scroller-spacer {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-item {
|
||||
.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;
|
||||
}
|
||||
|
||||
&-placeholder {
|
||||
.virtual-scroller-bottom-placeholder {
|
||||
contain: layout style;
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,7 +26,6 @@
|
||||
<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"
|
||||
@ -100,6 +99,8 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end', 'visible-change'])
|
||||
|
||||
const computedData = computed(() => {
|
||||
return props.data.length > 0 ? props.data : props.items
|
||||
})
|
||||
@ -114,20 +115,14 @@ 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 heightVersion = ref(0)
|
||||
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 {
|
||||
@ -138,6 +133,7 @@ const containerStyle = computed(() => {
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
heightVersion.value // 依赖追踪:measureItem 直接 mutate Map 时通过此版本号触发重算
|
||||
let height = 0
|
||||
const len = computedData.value.length
|
||||
|
||||
@ -174,50 +170,49 @@ const visibleRange = computed(() => {
|
||||
return { start: 0, end: 0, offset: 0 }
|
||||
}
|
||||
|
||||
heightVersion.value // 依赖追踪:measureItem 直接 mutate Map 时通过此版本号触发重算
|
||||
const viewportHeight = containerHeight.value
|
||||
const currentScrollTop = scrollTop.value
|
||||
const bufferCount = computedBuffer.value
|
||||
const len = computedData.value.length
|
||||
|
||||
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
|
||||
|
||||
let firstVisibleIdx = 0
|
||||
for (let i = 0; i < len; i++) {
|
||||
const height = itemHeights.value.get(i) ?? props.estimatedHeight
|
||||
if (offset + height > currentScrollTop) {
|
||||
startIndex = Math.max(0, i - bufferCount)
|
||||
firstVisibleIdx = i
|
||||
break
|
||||
}
|
||||
|
||||
offset += height
|
||||
}
|
||||
|
||||
startOffset = 0
|
||||
for (let i = 0; i < startIndex; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
startOffset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
const startIndex = Math.max(0, firstVisibleIdx - bufferCount)
|
||||
|
||||
// 第二趟:计算 startOffset 并同时定位 endIndex
|
||||
let startOffset = 0
|
||||
let endIndex = len - 1
|
||||
offset = 0
|
||||
for (let i = 0; i < len; i++) {
|
||||
const height = itemHeights.value.get(i) ?? props.estimatedHeight
|
||||
|
||||
if (i < startIndex) {
|
||||
startOffset += height
|
||||
}
|
||||
|
||||
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)
|
||||
if (i >= startIndex) {
|
||||
if (offset + height > currentScrollTop + viewportHeight) {
|
||||
endIndex = Math.min(len - 1, i + bufferCount)
|
||||
break
|
||||
}
|
||||
|
||||
endIndex = i
|
||||
}
|
||||
|
||||
return { start: Math.min(startIndex, endIndex), end: Math.max(startIndex, endIndex), offset: startOffset }
|
||||
offset += height
|
||||
}
|
||||
|
||||
return { start: startIndex, end: endIndex, offset: startOffset }
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
@ -247,7 +242,6 @@ const wrapperStyle = computed(() => ({
|
||||
direction: 'rtl',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
scrollbarWidth: 'auto',
|
||||
overflow: 'hidden',
|
||||
transform: 'rotate(180deg)',
|
||||
width: '100%'
|
||||
@ -275,7 +269,7 @@ const bottomPlaceholderStyle = computed(() => ({
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: `${props.bottomPlaceholderHeight}px`,
|
||||
transform: `translateY(0px)`,
|
||||
transform: 'translateY(0px)',
|
||||
zIndex: 1
|
||||
}))
|
||||
|
||||
@ -302,13 +296,9 @@ const getItemStyle = (renderItem) => {
|
||||
}
|
||||
}
|
||||
|
||||
const setItemRef = (el, index) => {
|
||||
if (el) {
|
||||
itemRefs.set(index, el)
|
||||
} else {
|
||||
itemRefs.delete(index)
|
||||
}
|
||||
}
|
||||
// 滚动锚定:累积可视区上方项的高度变化量,在微任务中统一补偿
|
||||
let pendingScrollDelta = 0
|
||||
let scrollAdjustPending = false
|
||||
|
||||
const measureItem = (index, element) => {
|
||||
if (!element) return
|
||||
@ -321,9 +311,31 @@ const measureItem = (index, element) => {
|
||||
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 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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -335,7 +347,7 @@ const setupResizeObserver = () => {
|
||||
|
||||
resizeObserver.value = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const index = parseInt(entry.target.dataset.index, 10)
|
||||
const index = Number.parseInt(entry.target.dataset.index, 10)
|
||||
if (!isNaN(index)) {
|
||||
measureItem(index, entry.target)
|
||||
}
|
||||
@ -357,28 +369,9 @@ const handleWheel = (event) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const scrollCleanupTimeout = ref(null)
|
||||
|
||||
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)
|
||||
|
||||
// 滚动时添加防抖清理,每100ms最多执行一次
|
||||
if (scrollCleanupTimeout.value) {
|
||||
clearTimeout(scrollCleanupTimeout.value)
|
||||
}
|
||||
scrollCleanupTimeout.value = setTimeout(() => {
|
||||
cleanupExtraItems(visibleItems.value)
|
||||
}, 300)
|
||||
|
||||
const st = target.scrollTop
|
||||
const scrollHeight = target.scrollHeight
|
||||
@ -462,7 +455,6 @@ const getVisibleIndices = () => {
|
||||
|
||||
const resetMeasurements = () => {
|
||||
itemHeights.value = new Map()
|
||||
itemRefs.clear()
|
||||
}
|
||||
|
||||
const isAtPageBottom = () => {
|
||||
@ -478,13 +470,15 @@ const isAtPageTop = () => {
|
||||
}
|
||||
|
||||
const observeVisibleItems = () => {
|
||||
if (!resizeObserver.value) return
|
||||
if (!resizeObserver.value || !renderContainerRef.value) return
|
||||
|
||||
resizeObserver.value.disconnect()
|
||||
|
||||
for (const [index, element] of itemRefs) {
|
||||
if (element) {
|
||||
resizeObserver.value.observe(element)
|
||||
// 基于 visibleItems 的 index 通过 DOM 查询定位元素,避免 itemRefs 残留旧 DOM 引用
|
||||
for (const item of visibleItems.value) {
|
||||
const el = renderContainerRef.value.querySelector(`[data-index="${item.index}"]`)
|
||||
if (el) {
|
||||
resizeObserver.value.observe(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -509,14 +503,11 @@ watch(() => computedData.value, (newData, oldData) => {
|
||||
observeVisibleItems()
|
||||
})
|
||||
}
|
||||
|
||||
previousDataLength.value = newLength
|
||||
}, { deep: false })
|
||||
|
||||
watch(visibleItems, (newItems) => {
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
cleanupExtraItems(newItems)
|
||||
})
|
||||
if (newItems.length > 0) {
|
||||
const firstItem = newItems[0]
|
||||
@ -525,47 +516,8 @@ watch(visibleItems, (newItems) => {
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
const cleanupExtraItems = (currentVisibleItems) => {
|
||||
if (!renderContainerRef.value || !currentVisibleItems.length) return
|
||||
|
||||
// 构建当前应该可见的索引集合
|
||||
const visibleIndices = new Set(currentVisibleItems.map(item => item.index))
|
||||
|
||||
// 直接获取 render-container 内所有实际渲染的 .virtual-scroller-item 元素
|
||||
const renderedItems = renderContainerRef.value.querySelectorAll('.virtual-scroller-item')
|
||||
|
||||
const toRemove = []
|
||||
|
||||
for (const el of renderedItems) {
|
||||
const dataIndex = parseInt(el.getAttribute('data-index'), 10)
|
||||
|
||||
// 如果元素的 data-index 不在可见范围内,标记为删除
|
||||
if (!isNaN(dataIndex) && !visibleIndices.has(dataIndex)) {
|
||||
toRemove.push(el)
|
||||
}
|
||||
}
|
||||
|
||||
// 从 DOM 中删除多余元素
|
||||
for (const el of toRemove) {
|
||||
if (el.parentNode) {
|
||||
el.parentNode.removeChild(el)
|
||||
}
|
||||
// 同步清理 itemRefs Map
|
||||
const index = parseInt(el.getAttribute('data-index'), 10)
|
||||
if (!isNaN(index)) {
|
||||
itemRefs.delete(index)
|
||||
}
|
||||
}
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
console.log(`[VirtualScroller] 清理了 ${toRemove.length} 个多余DOM元素`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupResizeObserver()
|
||||
isInitialized.value = true
|
||||
previousDataLength.value = computedData.value.length
|
||||
|
||||
nextTick(() => {
|
||||
if (pendingScrollToBottom.value) {
|
||||
@ -574,7 +526,6 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
observeVisibleItems()
|
||||
cleanupExtraItems(visibleItems.value)
|
||||
})
|
||||
})
|
||||
|
||||
@ -582,16 +533,6 @@ onBeforeUnmount(() => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
}
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
if (scrollCleanupTimeout.value) {
|
||||
clearTimeout(scrollCleanupTimeout.value)
|
||||
}
|
||||
|
||||
itemRefs.clear()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
@ -657,8 +598,6 @@ defineExpose({
|
||||
|
||||
.virtual-scroller-item {
|
||||
contain: layout style;
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.virtual-scroller-bottom-placeholder {
|
||||
|
||||
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
@ -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>
|
||||
@ -1,4 +0,0 @@
|
||||
import * as runninghub from './runninghub/index.js'
|
||||
// import * as suno from './suno.js'
|
||||
|
||||
export default { runninghub }
|
||||
@ -1,17 +0,0 @@
|
||||
{
|
||||
"generate": [
|
||||
{ "value": "flux", "label": "flux" },
|
||||
{ "value": "zImage", "label": "Z-image" },
|
||||
{ "value": "jimeng", "label": "jimeng" },
|
||||
{ "value": "QwenImage", "label": "QwenImage" }
|
||||
],
|
||||
"edit": [
|
||||
{ "value": "BananaPro", "label": "Banana-Pro" },
|
||||
{ "value": "Qwen-image", "label": "Qwen-image" },
|
||||
{ "value": "Kontext", "label": "Kontext" },
|
||||
{ "value": "Jimeng_4.0", "label": "Jimeng.4.0" }
|
||||
],
|
||||
"vision": [
|
||||
{ "value": "Qwen3.5plus", "label": "Qwen3.5plus", "disabled": true }
|
||||
]
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
"文生视频": [
|
||||
{ "value": "LTX2.0", "label": "LTX2.0 T2V" },
|
||||
{ "value": "viduQ3-T2V", "label": "viduQ3 T2V" }
|
||||
],
|
||||
"首尾帧": [
|
||||
{ "value": "Hailuo-02-fast", "label": "海螺 fast" },
|
||||
{ "value": "LTX2.0-I2V", "label": "LTX2.0 I2V" },
|
||||
{ "value": "LTX2.3-T2V", "label": "LTX2.3 T2V", "disabled": true },
|
||||
{ "value": "ViduQ3-turbo", "label": "ViduQ3-turbo" }
|
||||
],
|
||||
"数字人": [
|
||||
{ "value": "FlashHead", "label": "FlashHead" }
|
||||
],
|
||||
"全能参考": [
|
||||
{ "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true }
|
||||
],
|
||||
"智能多帧": [
|
||||
{ "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true }
|
||||
],
|
||||
"主体参考": [
|
||||
{ "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true }
|
||||
]
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
// Flux 2 Dev — 文生图
|
||||
export default {
|
||||
name: 'Flux 2',
|
||||
tag: '文生图',
|
||||
inputType: 'text',
|
||||
params: [
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '提示词',
|
||||
type: 'string',
|
||||
required: true,
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'aspectRatio',
|
||||
label: '比例',
|
||||
type: 'select',
|
||||
default: '1:1',
|
||||
options: ['1:1', '3:4', '4:3', '9:16', '16:9', '2:3', '3:2', 'custom'],
|
||||
ui: 'proportion',
|
||||
},
|
||||
{
|
||||
name: 'customWidth',
|
||||
label: '宽度',
|
||||
type: 'number',
|
||||
default: 1024,
|
||||
min: 256,
|
||||
max: 1536,
|
||||
ui: 'number',
|
||||
showWhen: { aspectRatio: 'custom' },
|
||||
},
|
||||
{
|
||||
name: 'customHight',
|
||||
label: '高度',
|
||||
type: 'number',
|
||||
default: 1024,
|
||||
min: 256,
|
||||
max: 1536,
|
||||
ui: 'number',
|
||||
showWhen: { aspectRatio: 'custom' },
|
||||
},
|
||||
{
|
||||
name: 'outputFormat',
|
||||
label: '输出格式',
|
||||
type: 'string',
|
||||
default: 'png',
|
||||
options: ['png', 'jpeg', 'webp(lossless)', 'webp(lossy)'],
|
||||
ui: 'hidden',
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
// GPT-Image-2 I2I — 图片编辑
|
||||
export default {
|
||||
name: 'GPT-Image-2 I2I',
|
||||
tag: '图片编辑',
|
||||
inputType: 'image',
|
||||
maxImages: 10,
|
||||
params: [
|
||||
{
|
||||
name: 'imageUrls',
|
||||
label: '参考图片',
|
||||
type: 'image',
|
||||
required: true,
|
||||
ui: 'imageUpload',
|
||||
maxCount: 10,
|
||||
},
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '编辑指令',
|
||||
type: 'string',
|
||||
required: true,
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'aspectRatio',
|
||||
label: '比例',
|
||||
type: 'select',
|
||||
default: '1:1',
|
||||
options: ['1:1', '1:2', '2:1', '1:3', '3:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '21:9', '9:21', '16:9'],
|
||||
ui: 'proportion',
|
||||
},
|
||||
{
|
||||
name: 'resolution',
|
||||
label: '分辨率',
|
||||
type: 'select',
|
||||
default: '2k',
|
||||
options: ['1k', '2k', '4k'],
|
||||
ui: 'resolution',
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: '画质',
|
||||
type: 'select',
|
||||
default: 'medium',
|
||||
options: ['low', 'medium', 'high'],
|
||||
ui: 'select',
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
// GPT-Image-2 — 文生图
|
||||
export default {
|
||||
name: 'GPT-Image-2',
|
||||
tag: '文生图',
|
||||
inputType: 'text',
|
||||
params: [
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '提示词',
|
||||
type: 'string',
|
||||
required: true,
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'aspectRatio',
|
||||
label: '比例',
|
||||
type: 'select',
|
||||
default: '1:1',
|
||||
options: ['1:1', '1:2', '2:1', '1:3', '3:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '21:9', '9:21', '16:9'],
|
||||
ui: 'proportion',
|
||||
},
|
||||
{
|
||||
name: 'resolution',
|
||||
label: '分辨率',
|
||||
type: 'select',
|
||||
default: '2k',
|
||||
options: ['1k', '2k', '4k'],
|
||||
ui: 'resolution',
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: '画质',
|
||||
type: 'select',
|
||||
default: 'medium',
|
||||
options: ['low', 'medium', 'high'],
|
||||
ui: 'select',
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
// 模型配置注册表 — 按模型名称查找参数 schema
|
||||
import flux from './flux.js'
|
||||
import zImage from './z-image.js'
|
||||
import jimeng from './jimeng.js'
|
||||
import qwen from './qwen.js'
|
||||
import gptImage from './gpt-image.js'
|
||||
import nanoPro from './nano-pro.js'
|
||||
import qwenEdit from './qwen-edit.js'
|
||||
import gptImageI2i from './gpt-image-i2i.js'
|
||||
|
||||
const configs = {
|
||||
'Flux 2': flux,
|
||||
'Z-Image Turbo': zImage,
|
||||
'即梦4.6': jimeng,
|
||||
'通义万相2.0': qwen,
|
||||
'GPT-Image-2': gptImage,
|
||||
'Nano Pro': nanoPro,
|
||||
'通义万相2.0 Pro': qwenEdit,
|
||||
'GPT-Image-2 I2i': gptImageI2i,
|
||||
}
|
||||
|
||||
// API display_name → config key 映射(API 返回的 display_name 可能与 config 的 name 不同)
|
||||
const displayNameMap = {
|
||||
'flux': 'Flux 2',
|
||||
'Z-image': 'Z-Image Turbo',
|
||||
'Jimeng4.6': '即梦4.6',
|
||||
'QwenImage2.0': '通义万相2.0',
|
||||
'GPT-image-2': 'GPT-Image-2',
|
||||
'Banana-Pro': 'Nano Pro',
|
||||
'QwenImage2.0-Pro': '通义万相2.0 Pro',
|
||||
'GPT-Image-2': 'GPT-Image-2 I2I',
|
||||
}
|
||||
|
||||
/** 根据模型名称获取参数配置,支持 API display_name 和 config key 两种方式查找 */
|
||||
export function getModelConfig(modelName) {
|
||||
if (configs[modelName]) return configs[modelName]
|
||||
const mappedKey = displayNameMap[modelName]
|
||||
if (mappedKey && configs[mappedKey]) return configs[mappedKey]
|
||||
return null
|
||||
}
|
||||
|
||||
/** 获取所有模型配置 */
|
||||
export function getAllModelConfigs() {
|
||||
return configs
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
// 即梦 4.6 — 文生图(直接指定宽高像素)
|
||||
export default {
|
||||
name: '即梦4.6',
|
||||
tag: '文生图',
|
||||
inputType: 'text',
|
||||
params: [
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '提示词',
|
||||
type: 'string',
|
||||
required: true,
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'width',
|
||||
label: '宽度',
|
||||
type: 'number',
|
||||
default: 1024,
|
||||
min: 900,
|
||||
max: 6197,
|
||||
ui: 'number',
|
||||
},
|
||||
{
|
||||
name: 'height',
|
||||
label: '高度',
|
||||
type: 'number',
|
||||
default: 1024,
|
||||
min: 768,
|
||||
max: 4096,
|
||||
ui: 'number',
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
// Nano Pro — 图片编辑
|
||||
export default {
|
||||
name: 'Nano Pro',
|
||||
tag: '图片编辑',
|
||||
inputType: 'image',
|
||||
maxImages: 10,
|
||||
params: [
|
||||
{
|
||||
name: 'imageUrls',
|
||||
label: '参考图片',
|
||||
type: 'image',
|
||||
required: true,
|
||||
ui: 'imageUpload',
|
||||
maxCount: 10,
|
||||
},
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '编辑指令',
|
||||
type: 'string',
|
||||
required: true,
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'aspectRatio',
|
||||
label: '比例',
|
||||
type: 'select',
|
||||
default: '1:1',
|
||||
options: ['1:1', '3:2', '2:3', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9'],
|
||||
ui: 'proportion',
|
||||
},
|
||||
{
|
||||
name: 'resolution',
|
||||
label: '分辨率',
|
||||
type: 'select',
|
||||
default: '2k',
|
||||
options: ['1k', '2k', '4k'],
|
||||
ui: 'resolution',
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
// 通义万相 2.0 Pro — 图片编辑
|
||||
export default {
|
||||
name: '通义万相2.0 Pro',
|
||||
tag: '图片编辑',
|
||||
inputType: 'image',
|
||||
maxImages: 3,
|
||||
params: [
|
||||
{
|
||||
name: 'imageUrls',
|
||||
label: '参考图片',
|
||||
type: 'image',
|
||||
required: true,
|
||||
ui: 'imageUpload',
|
||||
maxCount: 3,
|
||||
},
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '提示词',
|
||||
type: 'string',
|
||||
default: '',
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
label: '分辨率',
|
||||
type: 'select',
|
||||
default: '1024*1024',
|
||||
options: [
|
||||
'1024*1024', '1536*1536',
|
||||
'768*1152', '1024*1536', '1152*768', '1536*1024',
|
||||
'960*1280', '1080*1440', '1280*960', '1440*1080',
|
||||
'720*1280', '1080*1920', '1280*720', '1920*1080',
|
||||
'1344*576', '2048*872',
|
||||
],
|
||||
ui: 'select',
|
||||
},
|
||||
{
|
||||
name: 'imageNum',
|
||||
label: '生成张数',
|
||||
type: 'select',
|
||||
default: 1,
|
||||
options: [1, 2, 3, 4, 5, 6],
|
||||
ui: 'quantity',
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
// 通义万相 2.0 — 文生图
|
||||
export default {
|
||||
name: '通义万相2.0',
|
||||
tag: '文生图',
|
||||
inputType: 'text',
|
||||
maxImages: 6,
|
||||
params: [
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '提示词',
|
||||
type: 'string',
|
||||
required: true,
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
label: '分辨率',
|
||||
type: 'select',
|
||||
default: '1024*1024',
|
||||
options: [
|
||||
'1024*1024', '1536*1536',
|
||||
'768*1152', '1024*1536', '1152*768', '1536*1024',
|
||||
'960*1280', '1080*1440', '1280*960', '1440*1080',
|
||||
'720*1280', '1080*1920', '1280*720', '1920*1080',
|
||||
'1344*576', '2048*872',
|
||||
],
|
||||
ui: 'select',
|
||||
},
|
||||
{
|
||||
name: 'imageNum',
|
||||
label: '生成张数',
|
||||
type: 'select',
|
||||
default: 1,
|
||||
options: [1, 2, 3, 4, 5, 6],
|
||||
ui: 'quantity',
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
// Z-Image Turbo — 文生图
|
||||
export default {
|
||||
name: 'Z-Image Turbo',
|
||||
tag: '文生图',
|
||||
inputType: 'text',
|
||||
params: [
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '提示词',
|
||||
type: 'string',
|
||||
required: true,
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'aspectRatio',
|
||||
label: '比例',
|
||||
type: 'select',
|
||||
default: '1:1',
|
||||
options: ['1:1', '3:4', '4:3', '9:16', '16:9', '2:3', '3:2'],
|
||||
ui: 'proportion',
|
||||
},
|
||||
{
|
||||
name: 'outputFormat',
|
||||
label: '输出格式',
|
||||
type: 'string',
|
||||
default: 'png',
|
||||
options: ['png', 'jpeg', 'webp(lossless)', 'webp(lossy)'],
|
||||
ui: 'hidden',
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -1,113 +0,0 @@
|
||||
import { fetchModelConfig } from '@/utils/modelConfig'
|
||||
|
||||
function getWidthHeight({ proportion, resolution }) {
|
||||
let baseSize
|
||||
switch (resolution) {
|
||||
case '1k':
|
||||
baseSize = 1024
|
||||
break
|
||||
case '2k':
|
||||
baseSize = 2048
|
||||
break
|
||||
case '4k':
|
||||
baseSize = 4096
|
||||
break
|
||||
default:
|
||||
baseSize = 2048
|
||||
}
|
||||
|
||||
if (proportion === '智能') {
|
||||
return { width: baseSize, height: baseSize }
|
||||
}
|
||||
|
||||
const [w, h] = String(proportion).split(':').map(Number)
|
||||
const aspectRatio = w / h
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
return {
|
||||
width: baseSize,
|
||||
height: Math.round(baseSize / aspectRatio)
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
height: baseSize,
|
||||
width: Math.round(baseSize * aspectRatio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function Playload(data) {
|
||||
try {
|
||||
const json = await fetchModelConfig(data.type, data.modelName, data.modelType)
|
||||
|
||||
const nodeInfoList = []
|
||||
const proportionParam = data.params.find(param => param.name === 'proportion') || {data: 0}
|
||||
const resolutionParam = data.params.find(param => param.name === 'resolution') || {data: 0}
|
||||
|
||||
if (Array.isArray(data.imgs) && data.imgs.length > 0 && (data.modelType === 'image' || data.modelType === 'edit')) {
|
||||
for (const key of data.imgs) {
|
||||
if (json.nodeInfoList[key.name]) {
|
||||
console.log(key)
|
||||
json.nodeInfoList[key.name].fieldValue = key.url
|
||||
nodeInfoList.push(json.nodeInfoList[key.name])
|
||||
}
|
||||
if (json.imageIndex && json.imageIndex[key.name]) {
|
||||
json.imageIndex[key.name].fieldValue = key.index
|
||||
nodeInfoList.push(json.imageIndex[key.name])
|
||||
}
|
||||
}
|
||||
if (json.nodeInfoList.index) {
|
||||
json.nodeInfoList.index.fieldValue = data.imgs.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(data.params)) {
|
||||
for (const key of data.params) {
|
||||
if (json.nodeInfoList[key.name]) {
|
||||
console.log(key)
|
||||
json.nodeInfoList[key.name].fieldValue = key.data
|
||||
nodeInfoList.push(json.nodeInfoList[key.name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((json.nodeInfoList.width || json.nodeInfoList.height) && (proportionParam.data && resolutionParam.data)) {
|
||||
const { width, height } = getWidthHeight({
|
||||
proportion: proportionParam.data,
|
||||
resolution: resolutionParam.data
|
||||
})
|
||||
json.nodeInfoList.width.fieldValue = width
|
||||
json.nodeInfoList.height.fieldValue = height
|
||||
nodeInfoList.push(json.nodeInfoList.width, json.nodeInfoList.height)
|
||||
}
|
||||
|
||||
if (Array.isArray(json.seed)) {
|
||||
const min = Math.pow(10, 0)
|
||||
const max = Math.pow(10, 9) - 1
|
||||
const randomNum = Math.floor(Math.random() * (max - min + 1)) + min
|
||||
json.seed.map((seedItem) => {
|
||||
seedItem.fieldValue = randomNum
|
||||
nodeInfoList.push(seedItem)
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(json.must)) {
|
||||
nodeInfoList.push(...json.must)
|
||||
}
|
||||
|
||||
return {
|
||||
workflowId: json.workflowId,
|
||||
nodeInfoList
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取 JSON 文件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function result(result) {
|
||||
if (result.code === 0 && result.msg === 'success') {
|
||||
return { type: true, url: result.data[0].fileUrl }
|
||||
}
|
||||
return { type: false, message: result.data.exception_message }
|
||||
}
|
||||
120
src/platforms/music/controls/lyricsInput.vue
Normal file
@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="custom-popover-wrapper">
|
||||
<div class="choice-btn" :class="{ active: showPopover }" @click.stop="togglePopover">
|
||||
<img src="@/assets/dialog/lyrics.svg" alt="" style="width: 16px;">
|
||||
<span>歌词</span>
|
||||
</div>
|
||||
|
||||
<Transition name="popover">
|
||||
<div v-show="showPopover" class="custom-popover" @click.stop>
|
||||
<div class="input-wrapper">
|
||||
<textarea v-model="localLyrics" class="lyrics-input" placeholder="输入歌词" @mousedown.stop></textarea>
|
||||
</div>
|
||||
<div class="button-wrapper">
|
||||
<button class="confirm-btn" @click="handleConfirm">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const showPopover = ref(false)
|
||||
const localLyrics = ref(props.modelValue)
|
||||
|
||||
const togglePopover = () => {
|
||||
showPopover.value = !showPopover.value
|
||||
if (showPopover.value) localLyrics.value = props.modelValue
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('update:modelValue', localLyrics.value)
|
||||
showPopover.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.choice-btn {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
&:hover, &.active { background: #E9EAEB; }
|
||||
}
|
||||
|
||||
.custom-popover-wrapper { position: relative; display: inline-block; }
|
||||
|
||||
.custom-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
z-index: 9999;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.input-wrapper { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.lyrics-input {
|
||||
flex: 1;
|
||||
height: 200px;
|
||||
width: 370px;
|
||||
padding: 15px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #F8F9FA;
|
||||
font-size: 14px;
|
||||
font-family: "Microsoft YaHei";
|
||||
color: #333;
|
||||
outline: none;
|
||||
resize: none;
|
||||
&::placeholder { color: #999; }
|
||||
}
|
||||
|
||||
.button-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
background: #F8F9FA;
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
height: 32px;
|
||||
width: 120px;
|
||||
padding: 0 20px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-family: "Microsoft YaHei";
|
||||
cursor: pointer;
|
||||
background: rgba(0, 15, 51, 0.10);
|
||||
color: #000F33;
|
||||
&:hover { background: #5a62d9; }
|
||||
}
|
||||
|
||||
.popover-enter-active, .popover-leave-active { transition: all 0.3s ease; }
|
||||
.popover-enter-from, .popover-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
</style>
|
||||
145
src/platforms/music/controls/modeSelector.vue
Normal file
@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="custom-popover-wrapper">
|
||||
<div class="choice-btn" :class="{ active: showPopover }" @click.stop="togglePopover">
|
||||
<img :src="currentIcon" alt="" style="width: 16px;">
|
||||
<span>{{ currentLabel }}</span>
|
||||
</div>
|
||||
<Transition name="popover">
|
||||
<div v-show="showPopover" class="custom-popover" @click.stop>
|
||||
<div class="select">
|
||||
<div class="model-group">
|
||||
<div
|
||||
v-for="item in modeOptions"
|
||||
:key="item.value"
|
||||
class="model-item"
|
||||
:class="{ active: modeValue === item.value, disabled: item.disabled }"
|
||||
@click="selectMode(item)"
|
||||
>
|
||||
<img :src="item.icon" alt="" style="width: 16px; height: 16px;">
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import commonModeIcon from '@/assets/dialog/commonMode.svg'
|
||||
import professionalModeIcon from '@/assets/dialog/professionalMode.svg'
|
||||
import remixModeIcon from '@/assets/dialog/remixMode.svg'
|
||||
import editModeIcon from '@/assets/dialog/editMode.svg'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
options: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const showPopover = ref(false)
|
||||
|
||||
const modeValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const iconMap = {
|
||||
'常用模式': commonModeIcon,
|
||||
'专业模式': professionalModeIcon,
|
||||
'Remix模式': remixModeIcon,
|
||||
'编辑模式': editModeIcon
|
||||
}
|
||||
|
||||
const modeOptions = computed(() => {
|
||||
if (props.options.length) return props.options.map(o => ({ ...o, icon: iconMap[o.label] || commonModeIcon }))
|
||||
return [
|
||||
{ value: '常用模式', label: '常用模式', icon: commonModeIcon },
|
||||
{ value: '专业模式', label: '专业模式', icon: professionalModeIcon },
|
||||
{ value: 'Remix模式', label: 'Remix模式', icon: remixModeIcon, disabled: true },
|
||||
{ value: '编辑模式', label: '编辑模式', icon: editModeIcon, disabled: true }
|
||||
]
|
||||
})
|
||||
|
||||
const currentIcon = computed(() => {
|
||||
const found = modeOptions.value.find(m => m.value === modeValue.value)
|
||||
return found?.icon || commonModeIcon
|
||||
})
|
||||
|
||||
const currentLabel = computed(() => {
|
||||
const found = modeOptions.value.find(m => m.value === modeValue.value)
|
||||
return found?.label || modeValue.value
|
||||
})
|
||||
|
||||
const togglePopover = () => { showPopover.value = !showPopover.value }
|
||||
const selectMode = (item) => {
|
||||
if (item.disabled) return
|
||||
modeValue.value = item.value
|
||||
showPopover.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.choice-btn {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E9EAEB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
&:hover, &.active { background: #E9EAEB; }
|
||||
img { filter: brightness(0) drop-shadow(0 0 0 #000F33); }
|
||||
}
|
||||
|
||||
.custom-popover-wrapper { position: relative; display: inline-block; }
|
||||
|
||||
.custom-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.select { padding: 20px 10px; max-height: 510px; overflow-y: auto; }
|
||||
.model-group { margin-bottom: 15px; &:last-child { margin-bottom: 0; } }
|
||||
|
||||
.model-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
transition: all 0.2s ease;
|
||||
color: #666;
|
||||
font-family: "Microsoft YaHei";
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
&:hover:not(.disabled) { background: #f5f6f7; }
|
||||
&.active {
|
||||
background: rgba(0, 15, 51, 0.10);
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
img { filter: brightness(0) drop-shadow(0 0 0 #000F33); }
|
||||
}
|
||||
&.disabled { cursor: not-allowed; opacity: 0.5; }
|
||||
}
|
||||
|
||||
.popover-enter-active, .popover-leave-active { transition: all 0.3s ease; }
|
||||
.popover-enter-from, .popover-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
</style>
|
||||
167
src/platforms/music/controls/pureMusicGroup.vue
Normal file
@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="custom-popover-wrapper">
|
||||
<div class="choice-btn" @click.stop="handleChoiceBtnClick">
|
||||
<div class="text-music">纯音乐</div>
|
||||
<div class="switch-toggle" :class="{ active: isSwitchOn }" @click.stop="toggleSwitch">
|
||||
<div class="switch-slider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="popover">
|
||||
<div v-show="showLyricsPopover && !isSwitchOn" class="custom-popover" @click.stop>
|
||||
<div class="input-wrapper">
|
||||
<textarea v-model="lyricsText" class="lyrics-input" placeholder="输入歌词" @mousedown.stop></textarea>
|
||||
</div>
|
||||
<div class="button-wrapper">
|
||||
<button class="confirm-btn" @click="confirmLyrics">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Boolean], default: true },
|
||||
lyrics: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:lyrics'])
|
||||
|
||||
const showLyricsPopover = ref(false)
|
||||
const lyricsText = ref('')
|
||||
|
||||
const isSwitchOn = computed(() => {
|
||||
return props.modelValue === true || props.modelValue === 'true' || props.modelValue === '纯音乐模式'
|
||||
})
|
||||
|
||||
const toggleSwitch = () => {
|
||||
if (isSwitchOn.value) {
|
||||
emit('update:modelValue', false)
|
||||
showLyricsPopover.value = true
|
||||
} else {
|
||||
emit('update:modelValue', true)
|
||||
emit('update:lyrics', '')
|
||||
showLyricsPopover.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleChoiceBtnClick = () => {
|
||||
if (isSwitchOn.value) return
|
||||
showLyricsPopover.value = !showLyricsPopover.value
|
||||
}
|
||||
|
||||
const confirmLyrics = () => {
|
||||
emit('update:lyrics', lyricsText.value)
|
||||
showLyricsPopover.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.choice-btn {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E9EAEB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
&:hover { background: #E5E7EB; }
|
||||
}
|
||||
|
||||
.text-music {
|
||||
color: #333;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.switch-toggle {
|
||||
width: 24px;
|
||||
height: 14px;
|
||||
background: #ccc;
|
||||
border-radius: 11px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
&.active { background: #000F33; }
|
||||
.switch-slider {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
&.active .switch-slider { transform: translateX(10px); }
|
||||
}
|
||||
|
||||
.custom-popover-wrapper { position: relative; display: inline-block; }
|
||||
|
||||
.custom-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
z-index: 9999;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.input-wrapper { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.lyrics-input {
|
||||
flex: 1;
|
||||
height: 200px;
|
||||
width: 370px;
|
||||
padding: 15px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #F8F9FA;
|
||||
font-size: 14px;
|
||||
font-family: "Microsoft YaHei";
|
||||
color: #333;
|
||||
outline: none;
|
||||
resize: none;
|
||||
&::placeholder { color: #999; }
|
||||
}
|
||||
|
||||
.button-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
background: #F8F9FA;
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
height: 32px;
|
||||
width: 120px;
|
||||
padding: 0 20px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-family: "Microsoft YaHei";
|
||||
cursor: pointer;
|
||||
background: rgba(0, 15, 51, 0.10);
|
||||
color: #000F33;
|
||||
&:hover { background: #5a62d9; }
|
||||
}
|
||||
|
||||
.popover-enter-active, .popover-leave-active { transition: all 0.3s ease; }
|
||||
.popover-enter-from, .popover-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
</style>
|
||||
147
src/platforms/music/controls/timeControl.vue
Normal file
@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="custom-popover-wrapper">
|
||||
<div class="choice-btn" :class="{ active: showPopover }" @click.stop="togglePopover">
|
||||
<img src="@/assets/display/time.svg" alt="" style="width: 16px;">
|
||||
<span>{{ displayLabel }}</span>
|
||||
</div>
|
||||
<Transition name="popover">
|
||||
<div v-show="showPopover" class="custom-popover" @click.stop>
|
||||
<div class="setting-box">
|
||||
<div class="setting-header">时长({{ min }}s-{{ max }}s)</div>
|
||||
<div class="setting-body">
|
||||
<CustomSlider v-model="sliderValue" :min="sliderMin" :max="max" />
|
||||
<input type="text" class="slider-value" :value="displayValue" @input="handleInput" @keypress="handleKeypress" @mousedown.stop />
|
||||
<button class="restore-btn" @click.stop="restoreDuration">
|
||||
<img :src="restoreIcon" alt="还原" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import restoreIcon from '@/assets/dialog/restore.svg'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number], default: 'Auto' },
|
||||
min: { type: Number, default: 10 },
|
||||
max: { type: Number, default: 240 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const showPopover = ref(false)
|
||||
|
||||
const togglePopover = () => { showPopover.value = !showPopover.value }
|
||||
|
||||
const sliderMin = computed(() => props.min - 1)
|
||||
|
||||
const sliderValue = computed({
|
||||
get: () => props.modelValue === 'Auto' ? props.min - 1 : Number(props.modelValue),
|
||||
set: (v) => {
|
||||
if (v < props.min) emit('update:modelValue', 'Auto')
|
||||
else emit('update:modelValue', v)
|
||||
}
|
||||
})
|
||||
|
||||
const displayLabel = computed(() => {
|
||||
if (props.modelValue === 'Auto') return 'Auto'
|
||||
return `${props.modelValue}s`
|
||||
})
|
||||
|
||||
const displayValue = computed(() => props.modelValue)
|
||||
|
||||
const handleInput = (e) => {
|
||||
const val = e.target.value
|
||||
if (val === 'Auto') { emit('update:modelValue', 'Auto'); return }
|
||||
const n = parseInt(val)
|
||||
if (!isNaN(n) && n >= props.min && n <= props.max) emit('update:modelValue', n)
|
||||
}
|
||||
|
||||
const handleKeypress = (e) => {
|
||||
const cc = e.which || e.keyCode
|
||||
if (cc < 48 || cc > 57) e.preventDefault()
|
||||
}
|
||||
|
||||
const restoreDuration = () => { emit('update:modelValue', 'Auto') }
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.choice-btn {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E9EAEB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
&:hover, &.active { background: #E9EAEB; }
|
||||
}
|
||||
|
||||
.custom-popover-wrapper { position: relative; display: inline-block; }
|
||||
|
||||
.custom-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
z-index: 9999;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.setting-box { margin-bottom: 15px; &:last-child { margin-bottom: 0; } }
|
||||
.setting-header {
|
||||
padding-bottom: 10px;
|
||||
background: #fff;
|
||||
color: #999;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.setting-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
width: 89px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
color: #333;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
&::-webkit-inner-spin-button, &::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
}
|
||||
|
||||
.restore-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-left: 5px;
|
||||
img { width: 18px; height: 18px; }
|
||||
}
|
||||
|
||||
.popover-enter-active, .popover-leave-active { transition: all 0.3s ease; }
|
||||
.popover-enter-from, .popover-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
</style>
|
||||
68
src/platforms/music/imageUploader.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="audio-uploader">
|
||||
<div class="upload-btn" @click="triggerUpload">
|
||||
<img src="@/assets/dialog/referenceDiagram.svg" alt="" style="width: 16px;">
|
||||
<span>{{ uploadedFile ? uploadedFile.name : '参考音频' }}</span>
|
||||
</div>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:action="uploadUrl"
|
||||
:limit="1"
|
||||
:before-upload="beforeUpload"
|
||||
:on-success="handleSuccess"
|
||||
:on-error="handleError"
|
||||
:show-file-list="false"
|
||||
accept="audio/*"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const uploadRef = ref(null)
|
||||
const uploadedFile = ref(null)
|
||||
const uploadUrl = computed(() => import.meta.env.VITE_API_WORKFLOW_UPLOAD)
|
||||
|
||||
const triggerUpload = () => {
|
||||
uploadRef.value?.$el?.querySelector('input[type="file"]')?.click()
|
||||
}
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
uploadedFile.value = file
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSuccess = (response) => {
|
||||
if (response?.data?.url) {
|
||||
emit('update:modelValue', [{ url: response.data.url, name: uploadedFile.value?.name || '参考音频' }])
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
uploadedFile.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.audio-uploader { display: flex; align-items: center; gap: 8px; }
|
||||
.upload-btn {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
&:hover { background: #E5E7EB; }
|
||||
}
|
||||
</style>
|
||||
278
src/platforms/music/index.js
Normal file
@ -0,0 +1,278 @@
|
||||
import { markRaw, reactive, ref, computed } from 'vue'
|
||||
import { registerPlatform } from '@/platforms/registry.js'
|
||||
import { fetchPlatformModels, getPlatformCode, getModelConfig, getModelId, preloadModelConfigs } from '@/utils/modelApi'
|
||||
import { syncParamValues, checkShowWhen } from '@/utils/modelConfigHelper.js'
|
||||
import ModelSelector from './modelSelector.vue'
|
||||
import ImageUploader from './imageUploader.vue'
|
||||
import ModeSelector from './controls/modeSelector.vue'
|
||||
import PureMusicGroup from './controls/pureMusicGroup.vue'
|
||||
import LyricsInput from './controls/lyricsInput.vue'
|
||||
import TimeControl from './controls/timeControl.vue'
|
||||
import ParamGroup from '@/components/ParamGroup/index.vue'
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 由专用控件处理的 ui 类型
|
||||
const handledUis = ['textarea', 'proportion', 'imageUpload', 'hidden', 'quantity']
|
||||
|
||||
export function defineMusicPlatform() {
|
||||
const model = ref('')
|
||||
const modelType = ref('text')
|
||||
const mode = ref('常用模式')
|
||||
const modelConfig = ref(null)
|
||||
const paramValues = reactive({})
|
||||
const promptPlaceholder = ref('描述你想生成的音乐风格和感觉。')
|
||||
const referenceAudio = ref([])
|
||||
const models = ref([])
|
||||
|
||||
// 音乐专用 ref
|
||||
const quantity = ref(1)
|
||||
const duration = ref('Auto')
|
||||
const lyrics = ref('')
|
||||
const randomSeed = ref('')
|
||||
const pureMusic = ref(true)
|
||||
|
||||
const code = computed(() => getPlatformCode('Music'))
|
||||
|
||||
async function loadModels() {
|
||||
models.value = await fetchPlatformModels(code.value)
|
||||
if (!model.value && models.value.length) {
|
||||
const first = models.value.find(m => !m.disabled)
|
||||
if (first) model.value = first.id
|
||||
}
|
||||
if (models.value.length) {
|
||||
const ids = models.value.map(m => m.id)
|
||||
preloadModelConfigs(ids)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig(modelName) {
|
||||
const modelId = await getModelId('Music', modelName)
|
||||
if (!modelId) return null
|
||||
const config = await getModelConfig(modelId)
|
||||
syncMusicDefaults(config)
|
||||
}
|
||||
|
||||
// 音乐平台的 syncDefaults 包装
|
||||
function syncMusicDefaults(config) {
|
||||
modelConfig.value = config
|
||||
if (!config) return
|
||||
|
||||
config.params.forEach((p) => {
|
||||
if (!(p.name in paramValues)) {
|
||||
paramValues[p.name] = p.default ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
// 同步专用 ref
|
||||
const modeParam = config.params.find(p => p.name === 'mode' || p.ui === 'mode')
|
||||
if (modeParam) mode.value = modeParam.default || '常用模式'
|
||||
|
||||
const qtyParam = config.params.find(p => p.ui === 'quantity')
|
||||
if (qtyParam) quantity.value = qtyParam.default || 1
|
||||
|
||||
const durParam = config.params.find(p => p.name === 'duration')
|
||||
if (durParam) duration.value = durParam.default || 'Auto'
|
||||
|
||||
const lyricsParam = config.params.find(p => p.name === 'lyrics')
|
||||
if (lyricsParam) lyrics.value = lyricsParam.default || ''
|
||||
|
||||
const seedParam = config.params.find(p => p.name === 'randomSeed' || p.name === 'seed')
|
||||
if (seedParam) randomSeed.value = seedParam.default || ''
|
||||
|
||||
const pmParam = config.params.find(p => p.name === 'pureMusic')
|
||||
if (pmParam) pureMusic.value = pmParam.default !== undefined ? pmParam.default : true
|
||||
|
||||
if (config.promptPlaceholder) {
|
||||
promptPlaceholder.value = config.promptPlaceholder
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultModel() { return '' }
|
||||
|
||||
function imageUploadLimit() {
|
||||
if (!modelConfig.value) return 0
|
||||
return modelConfig.value.params
|
||||
.filter(p => p.ui === 'imageUpload')
|
||||
.reduce((sum, p) => sum + (p.maxCount || 1), 0)
|
||||
}
|
||||
|
||||
function validateBeforeSubmit() {
|
||||
if (!model.value) return '请选择模型'
|
||||
if (mode.value === '专业模式' && !referenceAudio.value.length) return '请上传参考音频'
|
||||
return null
|
||||
}
|
||||
|
||||
function getUploaderBindings() {
|
||||
return { modelType: mode.value === '专业模式' ? 'image' : 'text', imagesCount: referenceAudio.value.length }
|
||||
}
|
||||
|
||||
function showImageUploader() {
|
||||
return mode.value === '专业模式'
|
||||
}
|
||||
|
||||
function isImageRequired() {
|
||||
return mode.value === '专业模式'
|
||||
}
|
||||
|
||||
function buildTaskBody({ prompt, referenceImages }) {
|
||||
syncMusicParamValues()
|
||||
// 将 prompt 写入 paramValues(如果 config 中有 prompt 参数)
|
||||
const promptParam = modelConfig.value?.params?.find(p => p.ui === 'textarea')
|
||||
if (promptParam) paramValues[promptParam.name] = prompt
|
||||
|
||||
// 将参考音频映射到 imageUpload 参数
|
||||
if (modelConfig.value) {
|
||||
const imageUploadParams = modelConfig.value.params.filter(p => p.ui === 'imageUpload')
|
||||
imageUploadParams.forEach((p, i) => {
|
||||
if (referenceAudio.value[i]) {
|
||||
paramValues[p.name] = referenceAudio.value[i].url
|
||||
}
|
||||
})
|
||||
}
|
||||
return { ...paramValues }
|
||||
}
|
||||
|
||||
function syncMusicParamValues() {
|
||||
if (!modelConfig.value) return
|
||||
const config = modelConfig.value
|
||||
|
||||
const qtyParam = config.params.find(p => p.ui === 'quantity')
|
||||
if (qtyParam) paramValues[qtyParam.name] = quantity.value
|
||||
|
||||
const durParam = config.params.find(p => p.name === 'duration')
|
||||
if (durParam) paramValues[durParam.name] = duration.value
|
||||
|
||||
const lyricsParam = config.params.find(p => p.name === 'lyrics')
|
||||
if (lyricsParam) paramValues[lyricsParam.name] = lyrics.value
|
||||
|
||||
const seedParam = config.params.find(p => p.name === 'randomSeed' || p.name === 'seed')
|
||||
if (seedParam) paramValues[seedParam.name] = randomSeed.value
|
||||
|
||||
const pmParam = config.params.find(p => p.name === 'pureMusic')
|
||||
if (pmParam) paramValues[pmParam.name] = pureMusic.value
|
||||
|
||||
const modeParam = config.params.find(p => p.name === 'mode' || p.ui === 'mode')
|
||||
if (modeParam) paramValues[modeParam.name] = mode.value
|
||||
}
|
||||
|
||||
function fillFromResult(resultData) {
|
||||
if (resultData.mode !== undefined) mode.value = resultData.mode
|
||||
if (resultData.prompt !== undefined) paramValues.prompt = resultData.prompt
|
||||
if (resultData.duration !== undefined) duration.value = resultData.duration
|
||||
if (resultData.lyrics !== undefined) lyrics.value = resultData.lyrics
|
||||
if (resultData.randomSeed !== undefined) randomSeed.value = resultData.randomSeed
|
||||
if (resultData.quantity !== undefined) quantity.value = resultData.quantity
|
||||
if (resultData.pureMusic !== undefined) pureMusic.value = resultData.pureMusic
|
||||
}
|
||||
|
||||
const controls = [
|
||||
{
|
||||
name: 'modeSelector',
|
||||
component: markRaw(ModeSelector),
|
||||
beforeModel: true,
|
||||
show: (config) => !!config?.params?.find(p => p.name === 'mode' || p.ui === 'mode'),
|
||||
props: (config) => {
|
||||
const modeParam = config?.params?.find(p => p.name === 'mode' || p.ui === 'mode')
|
||||
return {
|
||||
modelValue: mode.value,
|
||||
'onUpdate:modelValue': (v) => { mode.value = v },
|
||||
options: modeParam?.options || []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'pureMusicGroup',
|
||||
component: markRaw(PureMusicGroup),
|
||||
beforeModel: false,
|
||||
show: (config) => mode.value === '常用模式' && !!config?.params?.find(p => p.name === 'pureMusic'),
|
||||
props: (config) => ({
|
||||
modelValue: pureMusic.value,
|
||||
'onUpdate:modelValue': (v) => { pureMusic.value = v },
|
||||
lyrics: lyrics.value,
|
||||
'onUpdate:lyrics': (v) => { lyrics.value = v }
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'lyricsInput',
|
||||
component: markRaw(LyricsInput),
|
||||
beforeModel: false,
|
||||
show: (config) => mode.value === '专业模式' && !!config?.params?.find(p => p.name === 'lyrics'),
|
||||
props: (config) => ({
|
||||
modelValue: lyrics.value,
|
||||
'onUpdate:modelValue': (v) => { lyrics.value = v }
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'timeControl',
|
||||
component: markRaw(TimeControl),
|
||||
beforeModel: false,
|
||||
show: (config) => mode.value === '常用模式' && !!config?.params?.find(p => p.name === 'duration'),
|
||||
props: (config) => {
|
||||
const durParam = config?.params?.find(p => p.name === 'duration')
|
||||
return {
|
||||
modelValue: duration.value,
|
||||
'onUpdate:modelValue': (v) => { duration.value = v },
|
||||
min: durParam?.min || 10,
|
||||
max: durParam?.max || 240
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
component: markRaw(Select),
|
||||
beforeModel: false,
|
||||
show: (config) => !!config?.params?.find(p => p.ui === 'quantity'),
|
||||
props: (config) => {
|
||||
const qtyParam = config?.params?.find(p => p.ui === 'quantity')
|
||||
const maxQty = Math.max(...(qtyParam?.options || [1]))
|
||||
const limited = mode.value === '专业模式' ? 1 : maxQty
|
||||
return {
|
||||
modelValue: quantity.value,
|
||||
'onUpdate:modelValue': (v) => { quantity.value = v },
|
||||
options: Array.from({ length: limited }, (_, i) => ({ value: i + 1, label: `${i + 1}条` }))
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'paramGroup',
|
||||
component: markRaw(ParamGroup),
|
||||
beforeModel: false,
|
||||
show: (config) => {
|
||||
if (!config) return false
|
||||
return config.params.some((p) => {
|
||||
if (handledUis.includes(p.ui)) return false
|
||||
if (['mode', 'pureMusic', 'lyrics', 'duration', 'quantity'].includes(p.name)) return false
|
||||
if (!checkShowWhen(p, { ...paramValues, mode: mode.value })) return false
|
||||
return true
|
||||
})
|
||||
},
|
||||
props: (config) => ({
|
||||
config,
|
||||
paramValues,
|
||||
excludeNames: ['mode', 'pureMusic', 'lyrics', 'duration', 'quantity']
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
id: 'Music',
|
||||
label: 'AI音乐2026',
|
||||
ModelSelector: markRaw(ModelSelector),
|
||||
modelSelectorProps: () => ({ models: models.value }),
|
||||
controls,
|
||||
ImageUploader: markRaw(ImageUploader),
|
||||
state: {
|
||||
model, modelType, mode, modelConfig, paramValues,
|
||||
promptPlaceholder, referenceAudio, models,
|
||||
quantity, duration, lyrics, randomSeed, pureMusic
|
||||
},
|
||||
model, modelType, mode, modelConfig, promptPlaceholder,
|
||||
loadModels, loadConfig, getDefaultModel,
|
||||
imageUploadLimit, validateBeforeSubmit,
|
||||
getUploaderBindings, showImageUploader, isImageRequired,
|
||||
buildTaskBody, fillFromResult
|
||||
}
|
||||
}
|
||||
|
||||
registerPlatform('Music', defineMusicPlatform)
|
||||
34
src/platforms/music/modelSelector.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Select
|
||||
:model-value="modelValue"
|
||||
:options="modelOptions"
|
||||
placeholder="选择模型"
|
||||
@update:model-value="(v) => emit('update:modelValue', v)"
|
||||
>
|
||||
<template #prefix>
|
||||
<img src="@/assets/dialog/model.svg" alt="" style="width: 16px; height: 16px;">
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Select from '@/components/Select/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
models: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:typeValue'])
|
||||
|
||||
const modelOptions = computed(() => {
|
||||
const groups = {}
|
||||
props.models.forEach((m) => {
|
||||
const tag = m.tags?.[0] || '默认'
|
||||
if (!groups[tag]) groups[tag] = []
|
||||
groups[tag].push({ value: m.id, label: m.display_name, disabled: m.disabled })
|
||||
})
|
||||
return Object.entries(groups).map(([label, options]) => ({ label, options }))
|
||||
})
|
||||
</script>
|
||||
247
src/platforms/painting/controls/dimension.vue
Normal file
@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<Popover placement="top">
|
||||
<div class="dimension-container">
|
||||
<div class="section">
|
||||
<h3>尺寸 (px)</h3>
|
||||
<div class="size-inputs">
|
||||
<div class="input-group">
|
||||
<label>W</label>
|
||||
<input
|
||||
v-model.number="localWidth"
|
||||
type="number"
|
||||
:min="minW"
|
||||
:max="maxW"
|
||||
@input="onWidthChange"
|
||||
>
|
||||
</div>
|
||||
<div class="lock-icon" :class="{ locked: isLocked }" @click="toggleLock">
|
||||
<img :src="isLocked ? lockIcon : lockNoIcon" alt="约束比例">
|
||||
<span class="tooltip">{{ isLocked ? '解绑比例' : '约束比例' }}</span>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>H</label>
|
||||
<input
|
||||
v-model.number="localHeight"
|
||||
type="number"
|
||||
:min="minH"
|
||||
:max="maxH"
|
||||
@input="onHeightChange"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #reference>
|
||||
<div class="choice-btn">
|
||||
<img src="@/assets/dialog/proportion.svg" alt="" style="width: 16px;">
|
||||
<span>{{ displayText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import lockIcon from '@/assets/dialog/lock.svg'
|
||||
import lockNoIcon from '@/assets/dialog/lockNo.svg'
|
||||
import Popover from '@/components/Popover/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
width: { type: Number, default: 1024 },
|
||||
height: { type: Number, default: 1024 },
|
||||
minW: { type: Number, default: 256 },
|
||||
maxW: { type: Number, default: 6197 },
|
||||
minH: { type: Number, default: 256 },
|
||||
maxH: { type: Number, default: 4096 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:width', 'update:height'])
|
||||
|
||||
const localWidth = ref(props.width)
|
||||
const localHeight = ref(props.height)
|
||||
const isLocked = ref(true)
|
||||
const lastRatio = ref(props.width / props.height)
|
||||
|
||||
const displayText = computed(() => `${localWidth.value} × ${localHeight.value}`)
|
||||
|
||||
watch(() => props.width, (val) => { localWidth.value = val })
|
||||
watch(() => props.height, (val) => { localHeight.value = val })
|
||||
|
||||
const toggleLock = () => {
|
||||
isLocked.value = !isLocked.value
|
||||
if (isLocked.value) {
|
||||
lastRatio.value = localWidth.value / localHeight.value
|
||||
}
|
||||
}
|
||||
|
||||
const clamp = (val, min, max) => Math.max(min, Math.min(max, Math.round(val)))
|
||||
|
||||
const onWidthChange = () => {
|
||||
localWidth.value = clamp(localWidth.value, props.minW, props.maxW)
|
||||
if (isLocked.value) {
|
||||
localHeight.value = clamp(Math.round(localWidth.value / lastRatio.value), props.minH, props.maxH)
|
||||
}
|
||||
emit('update:width', localWidth.value)
|
||||
emit('update:height', localHeight.value)
|
||||
}
|
||||
|
||||
const onHeightChange = () => {
|
||||
localHeight.value = clamp(localHeight.value, props.minH, props.maxH)
|
||||
if (isLocked.value) {
|
||||
localWidth.value = clamp(Math.round(localHeight.value * lastRatio.value), props.minW, props.maxW)
|
||||
}
|
||||
emit('update:width', localWidth.value)
|
||||
emit('update:height', localHeight.value)
|
||||
}
|
||||
|
||||
watch([localWidth, localHeight], () => {
|
||||
if (!isLocked.value) {
|
||||
lastRatio.value = localWidth.value / localHeight.value
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.choice-btn {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
span {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.choice-btn:hover {
|
||||
background: #e9eaeb;
|
||||
}
|
||||
|
||||
.dimension-container {
|
||||
padding: 20px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.section {
|
||||
border-radius: 20px;
|
||||
|
||||
h3 {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.size-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
|
||||
label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 12px;
|
||||
transform: translateY(-50%);
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 12px 12px 12px 30px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: #f5f6f7;
|
||||
text-align: right;
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lock-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: #333;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
|
||||
.tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
&.locked {
|
||||
background: #f5f6f7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -37,7 +37,7 @@
|
||||
<div class="size-inputs">
|
||||
<div class="input-group">
|
||||
<label>W</label>
|
||||
<input type="number" v-model.number="width" @input="updateWidth" :disabled="isLocked">
|
||||
<input v-model.number="width" type="number" :disabled="isLocked" @input="updateWidth">
|
||||
</div>
|
||||
<div class="lock-icon" :class="{ locked: isLocked }" @click="toggleLock">
|
||||
<img :src="isLocked ? lockIcon : lockNoIcon" alt="约束比例">
|
||||
@ -45,7 +45,7 @@
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>H</label>
|
||||
<input type="number" v-model.number="height" @input="updateHeight" :disabled="isLocked">
|
||||
<input v-model.number="height" type="number" :disabled="isLocked" @input="updateHeight">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -60,9 +60,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Popover from '@/components/Popover/index.vue'
|
||||
import lockIcon from '@/assets/dialog/lock.svg'
|
||||
import lockNoIcon from '@/assets/dialog/lockNo.svg'
|
||||
import Popover from '@/components/Popover/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@ -95,7 +95,7 @@ const props = defineProps({
|
||||
},
|
||||
allowCustom: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
default: true
|
||||
},
|
||||
resolutionOptions: {
|
||||
type: Array,
|
||||
@ -461,5 +461,4 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
background: #f5f6f7;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
47
src/platforms/painting/controls/quality.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<Select
|
||||
v-model="model"
|
||||
:options="options"
|
||||
width="auto"
|
||||
class="quality-select"
|
||||
position="top"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="quality-label">画质</span>
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: 'medium' },
|
||||
options: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const model = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.quality-select {
|
||||
:deep(.select-header) {
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
&:hover { background: #e9eaeb; }
|
||||
}
|
||||
:deep(.select-text) { font-size: 14px; }
|
||||
}
|
||||
.quality-label {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@ -19,8 +19,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
v-show="false"
|
||||
ref="uploadRef"
|
||||
:action="uploadurl"
|
||||
multiple
|
||||
:limit="limit"
|
||||
235
src/platforms/painting/index.js
Normal file
@ -0,0 +1,235 @@
|
||||
import { markRaw, reactive, ref } from 'vue'
|
||||
import { fetchPlatformModels, getModelConfig, getModelId, getPlatformCode, preloadModelConfigs } from '@/utils/modelApi'
|
||||
import { syncDefaults as _syncDefaults, syncParamValues as _syncParamValues, checkShowWhen, getDimConfig } from '@/utils/modelConfigHelper.js'
|
||||
import { registerPlatform } from '../registry.js'
|
||||
import DimensionInput from './controls/dimension.vue'
|
||||
import PaintingProportion from './controls/proportion.vue'
|
||||
import QualitySelect from './controls/quality.vue'
|
||||
import Quantity from './controls/quantity.vue'
|
||||
import ImageUploader from './imageUploader.vue'
|
||||
import PaintingModelSelector from './modelSelector.vue'
|
||||
|
||||
export function definePaintingPlatform() {
|
||||
const model = ref('Flux 2')
|
||||
const modelType = ref('text')
|
||||
const proportion = ref('1:1')
|
||||
const resolution = ref('2k')
|
||||
const customWidth = ref(1024)
|
||||
const customHight = ref(1024)
|
||||
const dimWidth = ref(1024)
|
||||
const dimHeight = ref(1024)
|
||||
const quantity = ref(1)
|
||||
const quality = ref('medium')
|
||||
const modelConfig = ref(null)
|
||||
const promptPlaceholder = ref('描述你想生成的画面和动作。')
|
||||
const paramValues = reactive({})
|
||||
|
||||
const state = {
|
||||
model,
|
||||
modelType,
|
||||
proportion,
|
||||
resolution,
|
||||
customWidth,
|
||||
customHight,
|
||||
dimWidth,
|
||||
dimHeight,
|
||||
quantity,
|
||||
quality,
|
||||
paramValues,
|
||||
modelConfig
|
||||
}
|
||||
|
||||
// state 对象供 helper 函数使用
|
||||
const paintingState = {
|
||||
modelConfig,
|
||||
paramValues,
|
||||
proportion,
|
||||
resolution,
|
||||
quantity,
|
||||
quality,
|
||||
customWidth,
|
||||
customHight,
|
||||
dimWidth,
|
||||
dimHeight,
|
||||
promptPlaceholder
|
||||
}
|
||||
|
||||
function syncDefaults(config) {
|
||||
_syncDefaults(config, paintingState)
|
||||
}
|
||||
|
||||
function syncParamValues() {
|
||||
_syncParamValues(modelConfig.value, paintingState)
|
||||
}
|
||||
|
||||
const controls = [
|
||||
{
|
||||
name: 'proportion',
|
||||
component: markRaw(PaintingProportion),
|
||||
show: (config) => !!config?.params?.find((p) => p.ui === 'proportion'),
|
||||
props: (config) => {
|
||||
const ratioParam = config?.params?.find((p) => p.ui === 'proportion')
|
||||
const resParam = config?.params?.find((p) => p.ui === 'resolution')
|
||||
return {
|
||||
'modelValue': proportion.value,
|
||||
'onUpdate:modelValue': (v) => { proportion.value = v },
|
||||
'resolution': resolution.value,
|
||||
'onUpdate:resolution': (v) => { resolution.value = v },
|
||||
'width': customWidth.value,
|
||||
'onUpdate:width': (v) => { customWidth.value = v },
|
||||
'height': customHight.value,
|
||||
'onUpdate:height': (v) => { customHight.value = v },
|
||||
'proportionOptions': ratioParam?.options
|
||||
?.filter((o) => o !== 'custom')
|
||||
.map((o) => ({ value: o, label: o })) || [],
|
||||
'resolutionOptions': resParam?.options
|
||||
?.map((o) => ({ value: o, label: o.toUpperCase() })) || [],
|
||||
'allowCustom': ratioParam?.options?.includes('custom') || false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'dimension',
|
||||
component: markRaw(DimensionInput),
|
||||
show: (config) => {
|
||||
const hasDim = config?.params?.find((p) =>
|
||||
(p.ui === 'dimension' || p.ui === 'dimensionWidth') && checkShowWhen(p, paramValues)
|
||||
)
|
||||
return !!hasDim
|
||||
},
|
||||
props: (config) => {
|
||||
const dc = getDimConfig(config)
|
||||
return {
|
||||
'width': dimWidth.value,
|
||||
'onUpdate:width': (v) => { dimWidth.value = v },
|
||||
'height': dimHeight.value,
|
||||
'onUpdate:height': (v) => { dimHeight.value = v },
|
||||
'minW': dc?.config?.width?.min || dc?.wParam?.min || 256,
|
||||
'maxW': dc?.config?.width?.max || dc?.wParam?.max || 6197,
|
||||
'minH': dc?.config?.height?.min || dc?.hParam?.min || 256,
|
||||
'maxH': dc?.config?.height?.max || dc?.hParam?.max || 4096
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
component: markRaw(QualitySelect),
|
||||
show: (config) => !!config?.params?.find((p) => p.name === 'quality'),
|
||||
props: (config) => {
|
||||
const q = config?.params?.find((p) => p.name === 'quality')
|
||||
return {
|
||||
'modelValue': quality.value,
|
||||
'onUpdate:modelValue': (v) => { quality.value = v },
|
||||
'options': q?.options?.map((o) => ({ value: o, label: o })) || []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
component: markRaw(Quantity),
|
||||
show: (config) => !!config?.params?.find((p) => p.ui === 'quantity'),
|
||||
props: (config) => {
|
||||
const qtyParam = config?.params?.find((p) => p.ui === 'quantity')
|
||||
return {
|
||||
'modelValue': quantity.value,
|
||||
'onUpdate:modelValue': (v) => { quantity.value = v },
|
||||
'max': qtyParam?.options?.length ? Math.max(...qtyParam.options) : 4
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const platform = {
|
||||
id: 'painting',
|
||||
label: 'AI绘画2026',
|
||||
ModelSelector: markRaw(PaintingModelSelector),
|
||||
modelSelectorProps: null,
|
||||
controls,
|
||||
ImageUploader: markRaw(ImageUploader),
|
||||
state,
|
||||
model,
|
||||
modelType,
|
||||
modelConfig,
|
||||
promptPlaceholder,
|
||||
|
||||
async loadModels() {
|
||||
const code = getPlatformCode('Painting')
|
||||
const models = await fetchPlatformModels(code)
|
||||
if (models?.length) {
|
||||
const modelIds = models.map((m) => m.id)
|
||||
await preloadModelConfigs(modelIds)
|
||||
}
|
||||
return models
|
||||
},
|
||||
|
||||
async loadConfig(modelName, _modelType) {
|
||||
const modelId = await getModelId('Painting', modelName)
|
||||
if (!modelId) return null
|
||||
const config = await getModelConfig(modelId)
|
||||
syncDefaults(config)
|
||||
return config
|
||||
},
|
||||
|
||||
getDefaultModel() {
|
||||
return 'Flux 2'
|
||||
},
|
||||
|
||||
validateBeforeSubmit() {
|
||||
return null // 无阻塞,返回 null 表示通过
|
||||
},
|
||||
|
||||
getUploaderBindings() {
|
||||
return { limit: this.imageUploadLimit() }
|
||||
},
|
||||
|
||||
showImageUploader() {
|
||||
if (modelType.value !== 'text') return true
|
||||
return modelConfig.value?.inputType === 'image' || modelConfig.value?.inputType === 'both'
|
||||
},
|
||||
|
||||
imageUploadLimit() {
|
||||
if (!modelConfig.value) return 4
|
||||
const imageParam = modelConfig.value.params.find((p) => p.ui === 'imageUpload')
|
||||
return imageParam?.maxCount || modelConfig.value.maxImages || 4
|
||||
},
|
||||
|
||||
isImageRequired() {
|
||||
return !!(modelConfig.value?.params?.find((p) => p.ui === 'imageUpload'))
|
||||
},
|
||||
|
||||
buildTaskBody(shared) {
|
||||
syncParamValues()
|
||||
const modelParams = { ...paramValues }
|
||||
if (shared.prompt.value) modelParams.prompt = shared.prompt.value
|
||||
return modelParams
|
||||
},
|
||||
|
||||
fillFromResult(resultData) {
|
||||
if (resultData.model !== undefined) model.value = resultData.model
|
||||
if (resultData.modelType !== undefined) modelType.value = resultData.modelType
|
||||
if (resultData.proportion !== undefined) proportion.value = resultData.proportion
|
||||
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
|
||||
if (resultData.customWidth !== undefined) customWidth.value = resultData.customWidth
|
||||
if (resultData.customHight !== undefined) customHight.value = resultData.customHight
|
||||
if (resultData.quantity !== undefined) quantity.value = resultData.quantity
|
||||
if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams)
|
||||
const dc = getDimConfig(modelConfig.value)
|
||||
if (dc?.type === 'split') {
|
||||
if (paramValues[dc.wParam.name] !== undefined) dimWidth.value = paramValues[dc.wParam.name]
|
||||
if (paramValues[dc.hParam.name] !== undefined) dimHeight.value = paramValues[dc.hParam.name]
|
||||
} else if (dc?.type === 'combined') {
|
||||
if (paramValues[dc.paramName]) {
|
||||
const parsed = dc.config.parse(paramValues[dc.paramName])
|
||||
dimWidth.value = parsed.width
|
||||
dimHeight.value = parsed.height
|
||||
}
|
||||
}
|
||||
if (paramValues.quality !== undefined) quality.value = paramValues.quality
|
||||
}
|
||||
}
|
||||
|
||||
return platform
|
||||
}
|
||||
|
||||
// 自注册
|
||||
registerPlatform('Painting', definePaintingPlatform)
|
||||
@ -14,11 +14,10 @@
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
|
||||
import { getModelConfig } from '@/config/models/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: 'Flux 2' },
|
||||
typeValue: { type: String, default: 'text' },
|
||||
typeValue: { type: String, default: 'text' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:typeValue'])
|
||||
@ -28,7 +27,7 @@ const platformModels = ref([])
|
||||
const categoryMap = [
|
||||
{ tag: 'text', label: '生成模型', inputType: 'text' },
|
||||
{ tag: 'edit', label: '编辑模型', inputType: 'image' },
|
||||
{ tag: 'vision', label: '视觉理解模型', inputType: 'vision' },
|
||||
{ tag: 'vision', label: '视觉理解模型', inputType: 'vision' }
|
||||
]
|
||||
|
||||
function parseValue(encoded) {
|
||||
@ -44,14 +43,14 @@ function encodeValue(tag, modelName) {
|
||||
|
||||
function findTagForModel(modelName) {
|
||||
for (const cat of categoryMap) {
|
||||
const model = platformModels.value.find(m => (m.display_name || m.name) === modelName && m.tags?.includes(cat.tag))
|
||||
const model = platformModels.value.find((m) => (m.display_name || m.name) === modelName && m.tags?.includes(cat.tag))
|
||||
if (model) return cat.tag
|
||||
}
|
||||
return 'text'
|
||||
}
|
||||
|
||||
function tagToInputType(tag) {
|
||||
const cat = categoryMap.find(c => c.tag === tag)
|
||||
const cat = categoryMap.find((c) => c.tag === tag)
|
||||
return cat?.inputType || 'text'
|
||||
}
|
||||
|
||||
@ -67,7 +66,7 @@ const selectValue = computed({
|
||||
if (!parsed) return
|
||||
emit('update:modelValue', parsed.modelName)
|
||||
emit('update:typeValue', tagToInputType(parsed.tag))
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// 从 API 加载模型列表
|
||||
@ -89,26 +88,26 @@ const modelGroups = computed(() => {
|
||||
if (models.length === 0) return []
|
||||
|
||||
return categoryMap
|
||||
.filter(cat => models.some(m => m.tags?.includes(cat.tag)))
|
||||
.map(cat => ({
|
||||
.filter((cat) => models.some((m) => m.tags?.includes(cat.tag)))
|
||||
.map((cat) => ({
|
||||
label: cat.label,
|
||||
options: models
|
||||
.filter(m => m.tags?.includes(cat.tag))
|
||||
.map(m => ({
|
||||
.filter((m) => m.tags?.includes(cat.tag))
|
||||
.map((m) => ({
|
||||
value: `${cat.tag}::${m.display_name || m.name}`,
|
||||
label: m.display_name || m.name,
|
||||
disabled: m.disabled || false,
|
||||
disabled: m.disabled || false
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })),
|
||||
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }))
|
||||
}))
|
||||
})
|
||||
|
||||
// 模型列表加载后自动纠正不可用模型
|
||||
watch(platformModels, (models) => {
|
||||
if (models.length === 0) return
|
||||
const currentModel = models.find(m => (m.display_name || m.name) === props.modelValue || m.id === props.modelValue)
|
||||
const currentModel = models.find((m) => (m.display_name || m.name) === props.modelValue || m.id === props.modelValue)
|
||||
if (!currentModel || currentModel.disabled) {
|
||||
const firstEnabled = models.find(m => !m.disabled)
|
||||
const firstEnabled = models.find((m) => !m.disabled)
|
||||
if (firstEnabled) {
|
||||
emit('update:modelValue', firstEnabled.display_name || firstEnabled.name)
|
||||
emit('update:typeValue', tagToInputType(findTagForModel(firstEnabled.display_name || firstEnabled.name)))
|
||||
@ -121,9 +120,9 @@ watch(() => props.modelValue, (newValue) => {
|
||||
if (!newValue) return
|
||||
const models = platformModels.value
|
||||
if (models.length === 0) return
|
||||
const currentModel = models.find(m => (m.display_name || m.name) === newValue)
|
||||
const currentModel = models.find((m) => (m.display_name || m.name) === newValue)
|
||||
if (currentModel && currentModel.disabled) {
|
||||
const firstEnabled = models.find(m => !m.disabled)
|
||||
const firstEnabled = models.find((m) => !m.disabled)
|
||||
if (firstEnabled) {
|
||||
emit('update:modelValue', firstEnabled.display_name || firstEnabled.name)
|
||||
emit('update:typeValue', tagToInputType(findTagForModel(firstEnabled.display_name || firstEnabled.name)))
|
||||
24
src/platforms/registry.js
Normal file
@ -0,0 +1,24 @@
|
||||
/** 平台注册表:id → definePlatform 工厂函数(key 统一为小写) */
|
||||
const registry = {}
|
||||
|
||||
/** 注册平台 */
|
||||
export function registerPlatform(id, factory) {
|
||||
const key = id.toLowerCase()
|
||||
if (registry[key]) {
|
||||
console.warn(`平台 "${id}" 已注册,将被覆盖`)
|
||||
}
|
||||
registry[key] = factory
|
||||
}
|
||||
|
||||
/** 根据平台类型创建平台实例 */
|
||||
export function createPlatform(type) {
|
||||
const key = type.toLowerCase()
|
||||
const factory = registry[key]
|
||||
if (!factory) throw new Error(`未注册的平台: ${type}`)
|
||||
return factory()
|
||||
}
|
||||
|
||||
/** 获取所有已注册平台 ID */
|
||||
export function getRegisteredPlatforms() {
|
||||
return Object.keys(registry)
|
||||
}
|
||||
@ -6,11 +6,13 @@
|
||||
position="top"
|
||||
>
|
||||
<template #prefix>
|
||||
<img :src="selectedIcon" alt="" style="width: 20px;">
|
||||
<component v-if="!isStr(selectedIcon)" :is="selectedIcon" style="width: 20px;" />
|
||||
<img v-else :src="selectedIcon" alt="" style="width: 20px;">
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<div class="option-content-custom">
|
||||
<img :src="option.icon" alt="" style="width: 20px;">
|
||||
<component v-if="!isStr(option.icon)" :is="option.icon" style="width: 20px;" />
|
||||
<img v-else :src="option.icon" alt="" style="width: 20px;">
|
||||
<span v-if="option.labelText" class="option-label-text">{{ option.labelText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@ -18,12 +20,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
|
||||
import { ChatLineSquare, Picture, VideoCamera } from '@element-plus/icons-vue'
|
||||
import videoPattern2 from '@/assets/dialog/videoPattern2.svg'
|
||||
import videoPattern4 from '@/assets/dialog/videoPattern4.svg'
|
||||
import videoPattern5 from '@/assets/dialog/videoPattern5.svg'
|
||||
import videoPattern6 from '@/assets/dialog/videoPattern6.svg'
|
||||
import Select from '@/components/Select/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@ -40,17 +42,20 @@ const quantity = computed({
|
||||
})
|
||||
|
||||
const quantityOptions = [
|
||||
{ value: '文生视频', label: '文生视频', labelText: '文生视频', icon: videoPattern2 },
|
||||
{ value: '文生视频', label: '文生视频', labelText: '文生视频', icon: markRaw(ChatLineSquare) },
|
||||
{ value: '图生视频', label: '图生视频', labelText: '图生视频', icon: markRaw(Picture) },
|
||||
{ value: '首尾帧', label: '首尾帧', labelText: '首尾帧', icon: videoPattern2 },
|
||||
{ value: '数字人', label: '数字人', labelText: '数字人', icon: videoPattern2 },
|
||||
{ value: '数字人', label: '数字人', labelText: '数字人', icon: markRaw(VideoCamera) },
|
||||
{ value: '全能参考', label: '全能参考', labelText: '全能参考', icon: videoPattern4 },
|
||||
{ value: '智能多帧', label: '智能多帧', labelText: '智能多帧', icon: videoPattern5 },
|
||||
{ value: '智能多帧', label: '智能多帧', labelText: '智能多帧', icon: videoPattern5, disabled: true },
|
||||
{ value: '主体参考', label: '主体参考', labelText: '主体参考', icon: videoPattern6 }
|
||||
]
|
||||
|
||||
const isStr = (v) => typeof v === 'string'
|
||||
|
||||
const selectedIcon = computed(() => {
|
||||
const option = quantityOptions.find(opt => opt.value === quantity.value)
|
||||
return option ? option.icon : videoPattern1
|
||||
const option = quantityOptions.find((opt) => opt.value === quantity.value)
|
||||
return option ? option.icon : videoPattern2
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -25,9 +25,9 @@
|
||||
</div>
|
||||
<el-upload
|
||||
v-for="i in maxImages"
|
||||
v-show="false"
|
||||
:key="i"
|
||||
:ref="el => setUploadRef(el, i - 1)"
|
||||
v-show="false"
|
||||
:action="uploadurl"
|
||||
:limit="1"
|
||||
:before-upload="beforeUpload"
|
||||
@ -76,6 +76,12 @@ const getFrameLabel = (index) => {
|
||||
if (props.modelType === 'digitalHuman') {
|
||||
return ''
|
||||
}
|
||||
if (props.modelType === 'imageToVideo' || props.modelType === 'allReference') {
|
||||
return '参考图'
|
||||
}
|
||||
if (props.modelType === 'subjectReference') {
|
||||
return '主体'
|
||||
}
|
||||
return index === 0 ? '首帧' : '尾帧'
|
||||
}
|
||||
|
||||
@ -83,6 +89,12 @@ const getUploadText = (i) => {
|
||||
if (props.modelType === 'digitalHuman') {
|
||||
return '参考内容'
|
||||
}
|
||||
if (props.modelType === 'imageToVideo' || props.modelType === 'allReference') {
|
||||
return '参考图'
|
||||
}
|
||||
if (props.modelType === 'subjectReference') {
|
||||
return '主体'
|
||||
}
|
||||
return i === 1 ? '首帧' : '尾帧'
|
||||
}
|
||||
|
||||
253
src/platforms/video/index.js
Normal file
@ -0,0 +1,253 @@
|
||||
import { markRaw, reactive, ref } from 'vue'
|
||||
import { fetchPlatformModels, getModelConfig, getModelId, getPlatformCode, preloadModelConfigs } from '@/utils/modelApi'
|
||||
import { syncDefaults as _syncDefaults, syncParamValues as _syncParamValues } from '@/utils/modelConfigHelper.js'
|
||||
import { registerPlatform } from '../registry.js'
|
||||
import Pattern from './controls/pattern.vue'
|
||||
import VideoProportion from './controls/proportion.vue'
|
||||
import Time from './controls/time.vue'
|
||||
import VideoImageUploader from './imageUploader.vue'
|
||||
import VideoModelSelector from './modelSelector.vue'
|
||||
import ParamGroup from '@/components/ParamGroup/index.vue'
|
||||
|
||||
export function defineVideoPlatform() {
|
||||
const model = ref('LTX2.0')
|
||||
const modelType = ref('text')
|
||||
const proportion = ref('16:9')
|
||||
const resolution = ref('1k')
|
||||
const duration = ref(5)
|
||||
const videoPattern = ref('文生视频')
|
||||
|
||||
const resolutionOptions = ref([
|
||||
{ value: '1k', label: '标清 1K' },
|
||||
{ value: '2k', label: '高清 2K' },
|
||||
{ value: '4k', label: '超清 4K' }
|
||||
])
|
||||
const proportionOptions = ref([
|
||||
{ value: '智能', label: '智能' },
|
||||
{ value: '21:9', label: '21:9' },
|
||||
{ value: '16:9', label: '16:9' },
|
||||
{ value: '4:3', label: '4:3' },
|
||||
{ value: '1:1', label: '1:1' },
|
||||
{ value: '3:4', label: '3:4' },
|
||||
{ value: '9:16', label: '9:16' }
|
||||
])
|
||||
const durationOptions = ref([])
|
||||
const promptPlaceholder = ref('描述你想生成的画面和动作。')
|
||||
|
||||
// params 驱动(与 Painting 统一)
|
||||
const paramValues = reactive({})
|
||||
const modelConfig = ref(null)
|
||||
const quality = ref('medium')
|
||||
const customWidth = ref(1024)
|
||||
const customHight = ref(1024)
|
||||
const dimWidth = ref(1024)
|
||||
const dimHeight = ref(1024)
|
||||
const quantity = ref(1)
|
||||
|
||||
const state = {
|
||||
model,
|
||||
modelType,
|
||||
proportion,
|
||||
resolution,
|
||||
duration,
|
||||
videoPattern,
|
||||
resolutionOptions,
|
||||
proportionOptions,
|
||||
durationOptions,
|
||||
modelConfig,
|
||||
paramValues
|
||||
}
|
||||
|
||||
const paintingCompatState = {
|
||||
modelConfig,
|
||||
paramValues,
|
||||
proportion,
|
||||
resolution,
|
||||
quantity,
|
||||
quality,
|
||||
customWidth,
|
||||
customHight,
|
||||
dimWidth,
|
||||
dimHeight,
|
||||
promptPlaceholder,
|
||||
duration
|
||||
}
|
||||
|
||||
function syncDefaults(config) {
|
||||
_syncDefaults(config, paintingCompatState)
|
||||
}
|
||||
|
||||
function syncParamValues() {
|
||||
_syncParamValues(modelConfig.value, paintingCompatState)
|
||||
}
|
||||
|
||||
const controls = [
|
||||
{
|
||||
name: 'pattern',
|
||||
component: markRaw(Pattern),
|
||||
beforeModel: true,
|
||||
show: () => true,
|
||||
props: () => ({
|
||||
'modelValue': videoPattern.value,
|
||||
'onUpdate:modelValue': (v) => { videoPattern.value = v }
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'proportion',
|
||||
component: markRaw(VideoProportion),
|
||||
show: (config) => !!config?.params?.find((p) => p.ui === 'proportion'),
|
||||
props: (config) => {
|
||||
const ratioParam = config?.params?.find((p) => p.ui === 'proportion')
|
||||
const resParam = config?.params?.find((p) => p.ui === 'resolution' || (p.ui === 'select' && p.name === 'resolution'))
|
||||
return {
|
||||
'modelValue': proportion.value,
|
||||
'onUpdate:modelValue': (v) => { proportion.value = v },
|
||||
'resolution': resolution.value,
|
||||
'onUpdate:resolution': (v) => { resolution.value = v },
|
||||
'proportionOptions': (ratioParam?.options || []).map((o) => ({ value: o, label: o })),
|
||||
'resolutionOptions': (resParam?.options || []).map((o) => ({ value: o, label: o }))
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'time',
|
||||
component: markRaw(Time),
|
||||
show: (config) => !!config?.params?.find((p) => p.name === 'duration'),
|
||||
props: (config) => {
|
||||
const durationParam = config?.params?.find((p) => p.name === 'duration')
|
||||
let options = []
|
||||
if (durationParam) {
|
||||
if (durationParam.ui === 'select' && durationParam.options) {
|
||||
options = durationParam.options.map((o) => ({ value: Number(o), label: `${o}s` }))
|
||||
} else if (durationParam.ui === 'number') {
|
||||
const min = durationParam.min || 1
|
||||
const max = durationParam.max || 16
|
||||
for (let i = min; i <= max; i++) {
|
||||
options.push({ value: i, label: `${i}s` })
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
'modelValue': duration.value,
|
||||
'onUpdate:modelValue': (v) => { duration.value = v },
|
||||
'options': options.length > 0 ? options : durationOptions.value
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'paramGroup',
|
||||
component: markRaw(ParamGroup),
|
||||
show: (config) => {
|
||||
if (!config?.params) return false
|
||||
const handledUis = ['textarea', 'proportion', 'resolution', 'dimension', 'dimensionWidth', 'dimensionHeight', 'quantity', 'imageUpload', 'hidden', 'number']
|
||||
return config.params.some((p) => {
|
||||
if (handledUis.includes(p.ui)) return false
|
||||
if (p.name === 'resolution') return false
|
||||
if (p.name === 'duration') return false
|
||||
return true
|
||||
})
|
||||
},
|
||||
props: (config) => ({
|
||||
config,
|
||||
paramValues,
|
||||
excludeNames: ['resolution', 'duration']
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
const platform = {
|
||||
id: 'video',
|
||||
label: 'AI视频2026',
|
||||
ModelSelector: markRaw(VideoModelSelector),
|
||||
modelSelectorProps: () => ({ videoPattern: videoPattern.value }),
|
||||
controls,
|
||||
ImageUploader: markRaw(VideoImageUploader),
|
||||
state,
|
||||
model,
|
||||
modelType,
|
||||
modelConfig,
|
||||
promptPlaceholder,
|
||||
|
||||
async loadModels() {
|
||||
const code = getPlatformCode('Video')
|
||||
const models = await fetchPlatformModels(code)
|
||||
if (models?.length) {
|
||||
const modelIds = models.map((m) => m.id)
|
||||
await preloadModelConfigs(modelIds)
|
||||
}
|
||||
return models
|
||||
},
|
||||
|
||||
async loadConfig(modelName, _modelType) {
|
||||
const modelId = await getModelId('Video', modelName)
|
||||
if (!modelId) return null
|
||||
const config = await getModelConfig(modelId)
|
||||
syncDefaults(config)
|
||||
return config
|
||||
},
|
||||
|
||||
getDefaultModel() {
|
||||
return '' // 模型列表加载后由 modelSelector 自动纠错设置
|
||||
},
|
||||
|
||||
validateBeforeSubmit() {
|
||||
if (model.value === 'Seedance 2.0') {
|
||||
return '敬请期待 Seedance 2.0'
|
||||
}
|
||||
return null // 通过
|
||||
},
|
||||
|
||||
getUploaderBindings() {
|
||||
return { modelType: modelType.value, imagesCount: this.imageUploadLimit() }
|
||||
},
|
||||
|
||||
showImageUploader() {
|
||||
return modelConfig.value?.inputType === 'image' || modelConfig.value?.inputType === 'both'
|
||||
},
|
||||
|
||||
imageUploadLimit() {
|
||||
if (!modelConfig.value) return 4
|
||||
const imageParams = modelConfig.value.params.filter((p) => p.ui === 'imageUpload')
|
||||
if (imageParams.length > 0) {
|
||||
return imageParams.reduce((sum, p) => sum + (p.maxCount || 1), 0)
|
||||
}
|
||||
return modelConfig.value.maxImages || 4
|
||||
},
|
||||
|
||||
isImageRequired() {
|
||||
return !!(modelConfig.value?.params?.find((p) => p.ui === 'imageUpload'))
|
||||
},
|
||||
|
||||
buildTaskBody(shared) {
|
||||
syncParamValues()
|
||||
const modelParams = { ...paramValues }
|
||||
if (shared.prompt.value) modelParams.prompt = shared.prompt.value
|
||||
|
||||
// 将上传的参考图映射到 imageUpload 参数(如首尾帧模型的 firstImageUrl / lastImageUrl)
|
||||
if (shared.referenceImages?.value?.length > 0) {
|
||||
const imageParams = modelConfig.value?.params?.filter((p) => p.ui === 'imageUpload') || []
|
||||
imageParams.forEach((p, i) => {
|
||||
if (shared.referenceImages.value[i]) {
|
||||
modelParams[p.name] = shared.referenceImages.value[i].url || shared.referenceImages.value[i]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return modelParams
|
||||
},
|
||||
|
||||
fillFromResult(resultData) {
|
||||
if (resultData.model !== undefined) model.value = resultData.model
|
||||
if (resultData.modelType !== undefined) modelType.value = resultData.modelType
|
||||
if (resultData.proportion !== undefined) proportion.value = resultData.proportion
|
||||
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
|
||||
if (resultData.duration !== undefined) duration.value = resultData.duration
|
||||
if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern
|
||||
if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams)
|
||||
}
|
||||
}
|
||||
|
||||
return platform
|
||||
}
|
||||
|
||||
registerPlatform('Video', defineVideoPlatform)
|
||||
@ -13,6 +13,7 @@
|
||||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@ -31,36 +32,32 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:typeValue'])
|
||||
|
||||
const videoConfig = ref({})
|
||||
const platformModels = ref([])
|
||||
|
||||
const fetchConfig = async () => {
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_API_MODEL_RESOURCE}/static/public/Platform/AIGC_modelConfig/video.json`
|
||||
const response = await fetch(url)
|
||||
const data = await response.json()
|
||||
videoConfig.value = data
|
||||
const code = getPlatformCode('Video')
|
||||
const models = await fetchPlatformModels(code)
|
||||
platformModels.value = models || []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch video config:', error)
|
||||
console.error('加载视频模型列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadModels()
|
||||
|
||||
fetchConfig()
|
||||
|
||||
watch(() => videoConfig.value, (newConfig) => {
|
||||
const models = newConfig[props.videoPattern] || []
|
||||
if (models.length > 0) {
|
||||
const enabledModels = models.filter(m => !m.disabled)
|
||||
// 模型列表加载完成后,如果当前模型不可用则自动纠错
|
||||
watch(() => platformModels.value, (models) => {
|
||||
const tagModels = models.filter((m) => m.tags?.includes(props.videoPattern))
|
||||
const enabledModels = tagModels.filter((m) => !m.disabled)
|
||||
if (enabledModels.length > 0) {
|
||||
const currentModelExists = enabledModels.find(m => m.value === props.modelValue)
|
||||
const currentModelExists = enabledModels.find((m) => m.id === props.modelValue)
|
||||
if (!currentModelExists) {
|
||||
model.value = enabledModels[0].value
|
||||
}
|
||||
model.value = enabledModels[0].id
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
|
||||
const model = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
@ -71,43 +68,53 @@ const model = computed({
|
||||
})
|
||||
|
||||
const modelGroups = computed(() => {
|
||||
const models = videoConfig.value[props.videoPattern] || []
|
||||
return models
|
||||
const models = platformModels.value.filter((m) => m.tags?.includes(props.videoPattern))
|
||||
return models.map((m) => ({
|
||||
value: m.id, // UUID 唯一标识
|
||||
label: m.display_name || m.name,
|
||||
disabled: m.disabled || false
|
||||
}))
|
||||
})
|
||||
|
||||
const getModelType = (value) => {
|
||||
switch (value) {
|
||||
case '文生视频':
|
||||
return 'text'
|
||||
case '图生视频':
|
||||
return 'imageToVideo'
|
||||
case '首尾帧':
|
||||
return 'image'
|
||||
case '数字人':
|
||||
return 'digitalHuman'
|
||||
case '全能参考':
|
||||
return 'allReference'
|
||||
case '主体参考':
|
||||
return 'subjectReference'
|
||||
default:
|
||||
return 'text'
|
||||
}
|
||||
}
|
||||
|
||||
// pattern 切换时自动纠错
|
||||
watch(() => props.videoPattern, (newPattern) => {
|
||||
const models = videoConfig.value[newPattern] || []
|
||||
if (models.length > 0) {
|
||||
const enabledModels = models.filter(m => !m.disabled)
|
||||
const tagModels = platformModels.value.filter((m) => m.tags?.includes(newPattern))
|
||||
const enabledModels = tagModels.filter((m) => !m.disabled)
|
||||
if (enabledModels.length > 0) {
|
||||
const currentModelExists = enabledModels.find(m => m.value === props.modelValue)
|
||||
const currentModelExists = enabledModels.find((m) => m.id === props.modelValue)
|
||||
if (!currentModelExists) {
|
||||
model.value = enabledModels[0].value
|
||||
}
|
||||
model.value = enabledModels[0].id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 外部 modelValue 变化时检查是否被禁用
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
const models = videoConfig.value[props.videoPattern] || []
|
||||
const currentModel = models.find(m => m.value === newValue)
|
||||
const tagModels = platformModels.value.filter((m) => m.tags?.includes(props.videoPattern))
|
||||
const currentModel = tagModels.find((m) => m.id === newValue)
|
||||
if (currentModel && currentModel.disabled) {
|
||||
const enabledModels = models.filter(m => !m.disabled)
|
||||
const enabledModels = tagModels.filter((m) => !m.disabled)
|
||||
if (enabledModels.length > 0) {
|
||||
model.value = enabledModels[0].value
|
||||
model.value = enabledModels[0].id
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
@ -1,5 +1,5 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useDisplayStore, useUserStore } from '@/stores'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { getToken, setToken } from '@/utils/auth'
|
||||
|
||||
const routes = [
|
||||
@ -30,7 +30,7 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from) => {
|
||||
if(to.query.token){
|
||||
if (to.query.token) {
|
||||
setToken(to.query.token)
|
||||
} else {
|
||||
// 检查是否有 token
|
||||
|
||||
@ -28,7 +28,7 @@ const DisplayStoreSetup = () => {
|
||||
}
|
||||
|
||||
const updateItemToSuccess = (taskId, fileUrls) => {
|
||||
const index = tempList.value.findIndex(item => item.id === taskId)
|
||||
const index = tempList.value.findIndex((item) => item.id === taskId)
|
||||
if (index !== -1) {
|
||||
tempList.value[index].status = 'success'
|
||||
tempList.value[index].files = Array.isArray(fileUrls) ? fileUrls : [fileUrls]
|
||||
@ -56,7 +56,7 @@ const DisplayStoreSetup = () => {
|
||||
}
|
||||
|
||||
const deleteHistoryItem = (id) => {
|
||||
const index = tempList.value.findIndex(item => item.id === id)
|
||||
const index = tempList.value.findIndex((item) => item.id === id)
|
||||
if (index !== -1) {
|
||||
tempList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
const ParamStoreSetup = () => {
|
||||
|
||||
return {
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import outPlatform from '@/config/index'
|
||||
|
||||
// 构造任务 body
|
||||
export async function createTask(data) {
|
||||
// Painting 使用新架构:直接使用动态模型参数
|
||||
if (data.type === 'Painting') {
|
||||
return data.modelParams || {}
|
||||
}
|
||||
|
||||
// Video 继续使用旧 workflow 适配器
|
||||
const payload = await outPlatform[data.platform].Playload(data)
|
||||
return payload
|
||||
}
|
||||
|
||||
// 获取结果
|
||||
export async function getTask(result) {
|
||||
if (result.code === 0 && result.msg === 'success' && Array.isArray(result.data) && result.data.length > 0) {
|
||||
const urls = result.data.map(item => item.fileUrl)
|
||||
return { type: true, urls: urls }
|
||||
}
|
||||
return { type: false, message: result.data.exception_message || '生成失败' }
|
||||
}
|
||||
@ -7,7 +7,7 @@ export async function generateFilename(url, prefix = 'image') {
|
||||
|
||||
// 如果URL中没有文件名或扩展名,根据类型生成
|
||||
if (!filename || !filename.includes('.')) {
|
||||
const timestamp = new Date().getTime()
|
||||
const timestamp = Date.now()
|
||||
// 根据URL内容推断文件类型,否则默认为png
|
||||
const extension = url.includes('.jpg') || url.includes('.jpeg')
|
||||
? '.jpg'
|
||||
@ -21,7 +21,7 @@ export async function generateFilename(url, prefix = 'image') {
|
||||
} catch (error) {
|
||||
console.error('URL解析失败:', error)
|
||||
// 如果URL解析失败,生成默认文件名
|
||||
const timestamp = new Date().getTime()
|
||||
const timestamp = Date.now()
|
||||
return `${prefix}_${timestamp}.png`
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { fetchPlatformModels as fetchModelsRaw } from '@/apis/display'
|
||||
import { fetchPlatformModels as fetchModelsRaw, requestModelConfig, requestModelConfigsBatch } from '@/apis/display'
|
||||
|
||||
const CACHE_PREFIX = 'platform_models_'
|
||||
const CACHE_TTL = 30 * 1000 // 30秒有效期
|
||||
@ -44,6 +44,8 @@ export function getPlatformCode(type) {
|
||||
return 'ai_painting_talk'
|
||||
case 'Video':
|
||||
return 'ai_video_talk'
|
||||
case 'Music':
|
||||
return 'ai_music_talk'
|
||||
default:
|
||||
return 'ai_painting_talk'
|
||||
}
|
||||
@ -93,10 +95,89 @@ export async function getModelId(type, modelName) {
|
||||
const code = getPlatformCode(type)
|
||||
const models = await fetchPlatformModels(code)
|
||||
|
||||
const found = models.find(m => m.name === modelName || m.display_name === modelName)
|
||||
const found = models.find((m) => m.id === modelName || m.name === modelName || m.display_name === modelName)
|
||||
return found?.id || ''
|
||||
}
|
||||
|
||||
// ==================== 模型配置缓存 ====================
|
||||
|
||||
const CONFIG_CACHE_PREFIX = 'model_config_'
|
||||
const CONFIG_CACHE_TTL = 60 * 1000 // 60 秒
|
||||
const pendingConfigRequests = new Map()
|
||||
|
||||
/**
|
||||
* 批量预加载模型配置到缓存
|
||||
* @param {string[]} modelIds - 模型 UUID 列表
|
||||
*/
|
||||
export async function preloadModelConfigs(modelIds) {
|
||||
if (!modelIds.length) return
|
||||
const result = await requestModelConfigsBatch(modelIds)
|
||||
const data = result?.data || {}
|
||||
const now = Date.now()
|
||||
modelIds.forEach((id) => {
|
||||
const config = data[id]
|
||||
if (config) {
|
||||
const cacheEntry = { config, timestamp: now }
|
||||
try {
|
||||
localStorage.setItem(CONFIG_CACHE_PREFIX + id, JSON.stringify(cacheEntry))
|
||||
} catch {
|
||||
// localStorage 满时静默失败
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个模型配置(优先读缓存,未命中调 API)
|
||||
* @param {string} modelId - 模型 UUID
|
||||
* @returns {Promise<object | null>} 模型配置对象
|
||||
*/
|
||||
export async function getModelConfig(modelId) {
|
||||
if (!modelId) return null
|
||||
|
||||
// 1. 读缓存
|
||||
try {
|
||||
const cached = localStorage.getItem(CONFIG_CACHE_PREFIX + modelId)
|
||||
if (cached) {
|
||||
const { config, timestamp } = JSON.parse(cached)
|
||||
if (Date.now() - timestamp < CONFIG_CACHE_TTL) {
|
||||
return config
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 缓存解析失败,走 API
|
||||
}
|
||||
|
||||
// 2. 并发去重
|
||||
if (pendingConfigRequests.has(modelId)) {
|
||||
return pendingConfigRequests.get(modelId)
|
||||
}
|
||||
|
||||
// 3. 调单条 API
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const result = await requestModelConfig(modelId)
|
||||
const config = result?.data
|
||||
if (config) {
|
||||
const cacheEntry = { config, timestamp: Date.now() }
|
||||
try {
|
||||
localStorage.setItem(CONFIG_CACHE_PREFIX + modelId, JSON.stringify(cacheEntry))
|
||||
} catch {
|
||||
// 静默
|
||||
}
|
||||
}
|
||||
return config || null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
pendingConfigRequests.delete(modelId)
|
||||
}
|
||||
})()
|
||||
|
||||
pendingConfigRequests.set(modelId, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
// 清除所有平台模型缓存
|
||||
export function clearPlatformModelCache() {
|
||||
const keysToRemove = []
|
||||
@ -106,5 +187,5 @@ export function clearPlatformModelCache() {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key))
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key))
|
||||
}
|
||||
|
||||
@ -1,95 +0,0 @@
|
||||
const STORAGE_PREFIX = 'model_config_'
|
||||
|
||||
function getTodayDateString() {
|
||||
const today = new Date()
|
||||
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getStorageKey(modelName, modelType) {
|
||||
return `${STORAGE_PREFIX}${modelType}_${modelName}`
|
||||
}
|
||||
|
||||
function getConfigFromStorage(modelName, modelType) {
|
||||
try {
|
||||
const key = getStorageKey(modelName, modelType)
|
||||
const stored = localStorage.getItem(key)
|
||||
|
||||
if (!stored) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = JSON.parse(stored)
|
||||
const todayStr = getTodayDateString()
|
||||
|
||||
if (data.storageDate !== todayStr) {
|
||||
localStorage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
|
||||
return data.config
|
||||
} catch (error) {
|
||||
console.error('从localStorage读取配置失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfigToStorage(modelName, modelType, config) {
|
||||
try {
|
||||
const key = getStorageKey(modelName, modelType)
|
||||
const data = {
|
||||
config,
|
||||
storageDate: getTodayDateString(),
|
||||
timestamp: Date.now()
|
||||
}
|
||||
localStorage.setItem(key, JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('保存配置到localStorage失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchModelConfig(type, modelName, modelType) {
|
||||
const cachedConfig = getConfigFromStorage(modelName, modelType)
|
||||
|
||||
if (cachedConfig) {
|
||||
console.log(`从缓存加载模型配置: ${modelName}`)
|
||||
return cachedConfig
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_API_MODEL_RESOURCE}/static/public/Platform/${type}/workflows/${modelType}/${modelName}.json`
|
||||
console.log(`从远程获取模型配置: ${url}`)
|
||||
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const config = await response.json()
|
||||
|
||||
saveConfigToStorage(modelName, modelType, config)
|
||||
|
||||
return config
|
||||
} catch (error) {
|
||||
console.error('获取模型配置失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function clearModelConfigCache(modelName, modelType) {
|
||||
const key = getStorageKey(modelName, modelType)
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
|
||||
export function clearAllModelConfigCache() {
|
||||
const keysToRemove = []
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key && key.startsWith(STORAGE_PREFIX)) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key))
|
||||
}
|
||||
166
src/utils/modelConfigHelper.js
Normal file
@ -0,0 +1,166 @@
|
||||
// 模型配置共享工具函数
|
||||
// 供 Painting / Video descriptor 使用
|
||||
|
||||
/**
|
||||
* 检测 dimension 配置模式
|
||||
* @param {object | null} config - 模型配置对象
|
||||
* @returns {object | null}
|
||||
* - combined: { type: 'combined', config: dimension子对象, paramName: string }
|
||||
* - split: { type: 'split', wParam: 宽度参数, hParam: 高度参数 }
|
||||
* - null: 无 dimension 参数
|
||||
*/
|
||||
export function getDimConfig(config) {
|
||||
if (!config) return null
|
||||
const dimParam = config.params.find((p) => p.ui === 'dimension')
|
||||
if (dimParam) return { type: 'combined', config: dimParam.dimension, paramName: dimParam.name }
|
||||
const wParam = config.params.find((p) => p.ui === 'dimensionWidth')
|
||||
const hParam = config.params.find((p) => p.ui === 'dimensionHeight')
|
||||
if (wParam && hParam) return { type: 'split', wParam, hParam }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 showWhen 条件是否满足
|
||||
* @param {object} param - 参数定义(可能含 showWhen)
|
||||
* @param {object} paramValues - 当前所有参数值
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function checkShowWhen(param, paramValues) {
|
||||
if (!param.showWhen) return true
|
||||
return Object.entries(param.showWhen).every(([key, expected]) => {
|
||||
return paramValues[key] === expected
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 API 返回的 config 同步到响应式 state
|
||||
*
|
||||
* state 对象需包含以下属性(均为 ref 或 reactive):
|
||||
* modelConfig, paramValues, proportion, resolution, quantity, quality,
|
||||
* customWidth, customHight, dimWidth, dimHeight, promptPlaceholder
|
||||
*/
|
||||
export function syncDefaults(config, state) {
|
||||
const {
|
||||
modelConfig,
|
||||
paramValues,
|
||||
proportion,
|
||||
resolution,
|
||||
quantity,
|
||||
quality,
|
||||
customWidth,
|
||||
customHight,
|
||||
dimWidth,
|
||||
dimHeight,
|
||||
promptPlaceholder
|
||||
} = state
|
||||
|
||||
modelConfig.value = config
|
||||
if (!config) return
|
||||
|
||||
// 1. dimension.separator → 生成 parse/format(在遍历 params 之前完成)
|
||||
config.params.forEach((p) => {
|
||||
if (p.ui === 'dimension' && p.dimension?.separator && !p.dimension.parse) {
|
||||
const sep = p.dimension.separator
|
||||
p.dimension.parse = (val) => {
|
||||
const parts = (val || '').split(sep)
|
||||
return { width: Number.parseInt(parts[0]) || 0, height: Number.parseInt(parts[1]) || 0 }
|
||||
}
|
||||
p.dimension.format = (w, h) => `${w}${sep}${h}`
|
||||
}
|
||||
})
|
||||
|
||||
// 2. 初始化 paramValues(已存在的 key 保留,避免切换模型时丢失值)
|
||||
config.params.forEach((p) => {
|
||||
if (!(p.name in paramValues)) {
|
||||
paramValues[p.name] = p.default ?? (p.name === 'outputFormat' ? 'png' : '')
|
||||
}
|
||||
})
|
||||
|
||||
// 3. 同步专用 ref
|
||||
const ratioParam = config.params.find((p) => p.ui === 'proportion')
|
||||
if (ratioParam) proportion.value = ratioParam.default || '1:1'
|
||||
|
||||
const resParam = config.params.find((p) => p.ui === 'resolution' || (p.ui === 'select' && p.name === 'resolution'))
|
||||
if (resParam) resolution.value = resParam.default || '2k'
|
||||
|
||||
const qtyParam = config.params.find((p) => p.ui === 'quantity')
|
||||
if (qtyParam) quantity.value = qtyParam.default || 1
|
||||
|
||||
const cwParam = config.params.find((p) => p.name === 'customWidth')
|
||||
if (cwParam) customWidth.value = cwParam.default || 1024
|
||||
|
||||
const chParam = config.params.find((p) => p.name === 'customHight')
|
||||
if (chParam) customHight.value = chParam.default || 1024
|
||||
|
||||
const qualityParam = config.params.find((p) => p.name === 'quality')
|
||||
if (qualityParam) quality.value = qualityParam.default || 'medium'
|
||||
|
||||
const durationParam = config.params.find((p) => p.name === 'duration')
|
||||
if (durationParam && state.duration) state.duration.value = durationParam.default ?? 5
|
||||
|
||||
// 4. dimension 初始化
|
||||
const dc = getDimConfig(config)
|
||||
if (dc?.type === 'split') {
|
||||
dimWidth.value = dc.wParam.default || 1024
|
||||
dimHeight.value = dc.hParam.default || 1024
|
||||
} else if (dc?.type === 'combined') {
|
||||
const dimParam = config.params.find((p) => p.name === dc.paramName)
|
||||
const raw = dimParam?.default || ''
|
||||
const parsed = dc.config.parse(raw)
|
||||
dimWidth.value = parsed.width
|
||||
dimHeight.value = parsed.height
|
||||
}
|
||||
|
||||
// 5. promptPlaceholder 同步
|
||||
if (config.promptPlaceholder) {
|
||||
promptPlaceholder.value = config.promptPlaceholder
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将专用 ref 的当前值回写到 paramValues
|
||||
* (在 buildTaskBody 之前调用)
|
||||
*/
|
||||
export function syncParamValues(config, state) {
|
||||
const {
|
||||
paramValues,
|
||||
proportion,
|
||||
resolution,
|
||||
quantity,
|
||||
customWidth,
|
||||
customHight,
|
||||
dimWidth,
|
||||
dimHeight,
|
||||
quality
|
||||
} = state
|
||||
|
||||
const ratioParam = config?.params?.find((p) => p.ui === 'proportion')
|
||||
if (ratioParam) paramValues[ratioParam.name] = proportion.value
|
||||
|
||||
const resParam = config?.params?.find((p) => p.ui === 'resolution' || (p.ui === 'select' && p.name === 'resolution'))
|
||||
if (resParam) paramValues[resParam.name] = resolution.value
|
||||
|
||||
const qtyParam = config?.params?.find((p) => p.ui === 'quantity')
|
||||
if (qtyParam) paramValues[qtyParam.name] = quantity.value
|
||||
|
||||
if (config?.params?.find((p) => p.name === 'customWidth')) {
|
||||
paramValues.customWidth = customWidth.value
|
||||
}
|
||||
if (config?.params?.find((p) => p.name === 'customHight')) {
|
||||
paramValues.customHight = customHight.value
|
||||
}
|
||||
if (config?.params?.find((p) => p.name === 'quality')) {
|
||||
paramValues.quality = quality.value
|
||||
}
|
||||
|
||||
const durationParam = config?.params?.find((p) => p.name === 'duration')
|
||||
if (durationParam && state.duration) paramValues[durationParam.name] = state.duration.value
|
||||
|
||||
const dc = getDimConfig(config)
|
||||
if (dc?.type === 'split') {
|
||||
paramValues[dc.wParam.name] = dimWidth.value
|
||||
paramValues[dc.hParam.name] = dimHeight.value
|
||||
} else if (dc?.type === 'combined') {
|
||||
paramValues[dc.paramName] = dc.config.format(dimWidth.value, dimHeight.value)
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,8 @@
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { h } from 'vue'
|
||||
import { useDisplayStore, useUserStore } from '@/stores'
|
||||
import { createTask } from '@/utils/createTask'
|
||||
import { userError } from '@/utils/tokenError'
|
||||
import { requestCreateTask, requestTaskStatus } from '@/apis/display'
|
||||
import { useDisplayStore, useUserStore } from '@/stores'
|
||||
import { userError } from '@/utils/tokenError'
|
||||
|
||||
export function getChargeType(chargeType) {
|
||||
switch (chargeType) {
|
||||
@ -11,6 +10,8 @@ export function getChargeType(chargeType) {
|
||||
return 1
|
||||
case 'Video':
|
||||
return 4
|
||||
case 'Music':
|
||||
return 5
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
@ -57,7 +58,6 @@ export function websocketSuccess() {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 当前活跃的轮询定时器集合,用于页面卸载时清理
|
||||
const activePollIntervals = new Set()
|
||||
|
||||
@ -90,8 +90,7 @@ export async function generate(data, generateData) {
|
||||
}
|
||||
|
||||
try {
|
||||
// 通过 createTask 获取 body 内容(RunningHub workflow payload)
|
||||
const body = await createTask(data)
|
||||
const body = data.body
|
||||
|
||||
// 构造请求体
|
||||
const requestBody = {
|
||||
@ -140,7 +139,7 @@ export async function generate(data, generateData) {
|
||||
useDisplay.isSubGerenate = false
|
||||
|
||||
// 提取结果 URL
|
||||
const urls = taskData.outputs?.map(img => img.url) || []
|
||||
const urls = taskData.outputs?.map((img) => img.url) || []
|
||||
if (urls.length > 0) {
|
||||
useDisplay.updateItemToSuccess(taskId, urls)
|
||||
websocketSuccess()
|
||||
@ -165,7 +164,6 @@ export async function generate(data, generateData) {
|
||||
|
||||
// 5 秒后先做第一次轮询
|
||||
setTimeout(pollTask, 5000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建任务失败:', error)
|
||||
useDisplay.isSubGerenate = false
|
||||
|
||||
@ -1,21 +1,20 @@
|
||||
<template>
|
||||
<div style="width: 100%;display: flex;justify-content: center;align-items: center;transform: rotate(180deg);">
|
||||
<div class="primary-box" :class="{ 'none-primary-box': props.item.status === 'none' }">
|
||||
|
||||
<div class="prompt-container" ref="promptContainerRef">
|
||||
<div class="prompt-wrapper" ref="promptWrapperRef">
|
||||
<div class="prompt" ref="promptRef" :class="{ 'expanded': isHovering }" @mouseenter="isHovering = true" @mouseleave="isHovering = false">
|
||||
<div ref="promptContainerRef" class="prompt-container">
|
||||
<div ref="promptWrapperRef" class="prompt-wrapper">
|
||||
<div ref="promptRef" class="prompt" :class="{ expanded: isHovering }" @mouseenter="isHovering = true" @mouseleave="isHovering = false">
|
||||
<span class="prompt-text">
|
||||
{{ props.item.generateData.prompt || '生成图片' }}
|
||||
<i-ep-DocumentCopy class="Copy" @click.stop="copyPrompt"/>
|
||||
{{ props.item.generateData.prompt || (props.item.type === 'Music' ? '生成音频' : '生成图片') }}
|
||||
<i-ep-DocumentCopy class="Copy" @click.stop="copyPrompt" />
|
||||
</span>
|
||||
<div class="generate-data internal" v-show="!isHovering && !showExternalGenerateData">
|
||||
<div :style="{ visibility: !isHovering && !showExternalGenerateData ? 'visible' : 'hidden' }" class="generate-data internal">
|
||||
<div class="detailed-data first-detailed-data">{{ props.item.generateData.model }}</div>
|
||||
<div class="detailed-data">{{ props.item.generateData.proportion }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="generate-data external" v-show="!isHovering && showExternalGenerateData">
|
||||
<div v-show="!isHovering && showExternalGenerateData" class="generate-data external">
|
||||
<div class="detailed-data first-detailed-data">{{ props.item.generateData.model }}</div>
|
||||
<div class="detailed-data">{{ props.item.generateData.proportion }}</div>
|
||||
</div>
|
||||
@ -49,7 +48,7 @@
|
||||
|
||||
<!-- 已完成 图片 -->
|
||||
<div v-if="props.item.status === 'success' && props.item.type === 'Painting'" class="box success-box">
|
||||
<div v-for="(file, index) in props.item.files" :key="index" class="one-box" :class="{ 'collected': isCollected(file) }" @mouseenter="hoverIndex = index" @mouseleave="hoverIndex = -1">
|
||||
<div v-for="(file, index) in props.item.files" :key="index" class="one-box" :class="{ collected: isCollected(file) }" @mouseenter="hoverIndex = index" @mouseleave="hoverIndex = -1">
|
||||
<!-- <img :src="file" alt="index" class="img" /> -->
|
||||
<Img :src="file" alt="index" class="img" />
|
||||
|
||||
@ -65,7 +64,7 @@
|
||||
placement="top"
|
||||
:hide-after="0"
|
||||
>
|
||||
<div @click.stop="AIbrush(file, index)" class="bottom-brush">
|
||||
<div class="bottom-brush" @click.stop="AIbrush(file, index)">
|
||||
<img :src="brush" />
|
||||
</div>
|
||||
</el-tooltip>
|
||||
@ -74,7 +73,7 @@
|
||||
|
||||
<!-- 已完成 视频 -->
|
||||
<div v-if="props.item.status === 'success' && props.item.type === 'Video'" class="box success-box">
|
||||
<div class="one-box" :class="{ 'collected': isCollected(props.item.files[0]) }" @mouseenter="hoverIndex = 0" @mouseleave="hoverIndex = -1">
|
||||
<div class="one-box" :class="{ collected: isCollected(props.item.files[0]) }" @mouseenter="hoverIndex = 0" @mouseleave="hoverIndex = -1">
|
||||
<!-- <img :src="file" alt="index" class="img" /> -->
|
||||
<video :src="props.item.files[0]" class="video" controls playsinline />
|
||||
|
||||
@ -86,6 +85,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已完成 音乐 -->
|
||||
<div v-if="props.item.status === 'success' && props.item.type === 'Music'" class="box success-box">
|
||||
<div v-for="(file, index) in props.item.files" :key="index" class="one-box" :class="{ collected: isCollected(file) }" @mouseenter="hoverIndex = index" @mouseleave="hoverIndex = -1">
|
||||
<AudioPlayer :audio-url="file" :audio-title="props.item.generateData.prompt || '生成音频'" :card-index="index" />
|
||||
|
||||
<div class="left-top">
|
||||
<div v-show="hoverIndex === index" class="left-top-btn download-btn" @click="downloadImage(file, 'audio')"><img src="@/assets/display/download.svg" /></div>
|
||||
<span v-if="hoverIndex === index" class="line" />
|
||||
<div class="left-top-btn collect-btn" @click="addCollection(file)"><img :src="isCollected(file) ? collectionActiveIcon : collectionIcon" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.item.status === 'success'" class="bottom-btn-group" style="margin-top: 8px;">
|
||||
<div v-for="(item, index) in bottomBtnGroup" :key="index" class="bottom-btn" @click="item.click()">
|
||||
<img :src="item.icon" />
|
||||
@ -97,16 +109,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { cancelOrCollect, deleteGenerateHistory } from '@/apis/display'
|
||||
import againGenerateIcon from '@/assets/display/againGenerate.svg'
|
||||
import brush from '@/assets/display/brush.svg'
|
||||
import collectionIcon from '@/assets/display/collection.svg'
|
||||
import collectionActiveIcon from '@/assets/display/collection-active.svg'
|
||||
import collectionIcon from '@/assets/display/collection.svg'
|
||||
import deleteImageIcon from '@/assets/display/deleteImage.svg'
|
||||
import reEditIcon from '@/assets/display/reEdit.svg'
|
||||
import Img from '@/components/Img/index.vue'
|
||||
import { useDisplayStore, useParamStore, useUserStore } from '@/stores'
|
||||
import { downloadImage } from '@/utils/downloadImage.js'
|
||||
import reEditIcon from '@/assets/display/reEdit.svg'
|
||||
import againGenerateIcon from '@/assets/display/againGenerate.svg'
|
||||
import deleteImageIcon from '@/assets/display/deleteImage.svg'
|
||||
import Img from '@/components/Img/index.vue'
|
||||
import { cancelOrCollect, deleteGenerateHistory } from '@/apis/display'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
@ -149,7 +161,7 @@ const checkTextOverflow = () => {
|
||||
showExternalGenerateData.value = lineCount >= 3
|
||||
|
||||
if (!isHovering.value) {
|
||||
if(lineCount >= 3){
|
||||
if (lineCount >= 3) {
|
||||
promptContainerRef.value.style.height = `${twoLineHeight}px`
|
||||
} else {
|
||||
promptContainerRef.value.style.height = `${actualHeight}px`
|
||||
@ -194,6 +206,7 @@ const isCollected = (url) => {
|
||||
|
||||
const generateStatusText = computed(() => {
|
||||
if (props.item.status === 'generate') {
|
||||
if (props.item.type === 'Music') return '音乐生成中...'
|
||||
return '正在生成中...'
|
||||
}
|
||||
return ''
|
||||
@ -208,7 +221,7 @@ const AIbrush = (file, index) => {
|
||||
}
|
||||
|
||||
const reEdit = () => {
|
||||
if(props.item.generateData?.modelType === 'edit'){
|
||||
if (props.item.generateData?.modelType === 'edit') {
|
||||
ElMessage.error('画笔生成的任务不能重新编辑')
|
||||
return
|
||||
}
|
||||
@ -217,7 +230,7 @@ const reEdit = () => {
|
||||
}
|
||||
|
||||
const againGenerate = () => {
|
||||
if(props.item.generateData?.modelType === 'edit'){
|
||||
if (props.item.generateData?.modelType === 'edit') {
|
||||
ElMessage.error('画笔生成的任务不能再次生成')
|
||||
return
|
||||
}
|
||||
@ -276,7 +289,7 @@ const addCollection = async (url) => {
|
||||
const res = await cancelOrCollect({
|
||||
taskId: props.item.id,
|
||||
userId: useUser.userInfo.id,
|
||||
url: url,
|
||||
url
|
||||
})
|
||||
if (res.success) {
|
||||
ElMessage.success(res.message || '操作成功')
|
||||
@ -292,7 +305,7 @@ const addCollection = async (url) => {
|
||||
|
||||
const copyPrompt = async () => {
|
||||
try {
|
||||
const promptText = props.item.generateData.prompt || '生成图片'
|
||||
const promptText = props.item.generateData.prompt || (props.item.type === 'Music' ? '生成音频' : '生成图片')
|
||||
await navigator.clipboard.writeText(promptText)
|
||||
ElMessage.success('提示词已复制到剪贴板')
|
||||
} catch (error) {
|
||||
@ -502,12 +515,14 @@ const copyPrompt = async () => {
|
||||
}
|
||||
.one-box:hover{
|
||||
.left-top,.bottom-brush{
|
||||
display:flex
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
.one-box.collected{
|
||||
.left-top{
|
||||
display:flex
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
.success-box{
|
||||
@ -532,7 +547,10 @@ const copyPrompt = async () => {
|
||||
}
|
||||
|
||||
.left-top,.bottom-brush{
|
||||
display: none;
|
||||
display: flex;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
</div>
|
||||
<span class="line"></span>
|
||||
<div class="btn">
|
||||
<Select v-model="selectedFavorite" :options="favoriteOptions" width="auto" >
|
||||
<Select v-model="selectedFavorite" :options="favoriteOptions" width="auto">
|
||||
<template #prefix>
|
||||
<i-ep-Star />
|
||||
</template>
|
||||
@ -43,8 +43,8 @@
|
||||
</div>
|
||||
|
||||
<VirtualScroller
|
||||
ref="scrollerRef"
|
||||
v-if="props.if"
|
||||
ref="scrollerRef"
|
||||
:items="list"
|
||||
key-field="id"
|
||||
:estimated-height="300"
|
||||
@ -66,17 +66,17 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDisplayStore, useParamStore, useUserStore } from '@/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Set from './components/set.vue'
|
||||
import RefreshOverlay from './components/RefreshOverlay.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { requestTaskHistory } from '@/apis/display'
|
||||
import Canvas from '@/components/canvas/index.vue'
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import { VirtualScroller } from '@/components/virtual-scroller'
|
||||
import Canvas from '@/components/canvas/index.vue'
|
||||
import { requestTaskHistory } from '@/apis/display'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getChargeType } from '@/utils/taskPolling'
|
||||
import { useDisplayStore, useParamStore, useUserStore } from '@/stores'
|
||||
import { getPlatformCode } from '@/utils/modelApi'
|
||||
import { getChargeType } from '@/utils/taskPolling'
|
||||
import RefreshOverlay from './components/RefreshOverlay.vue'
|
||||
import Set from './components/set.vue'
|
||||
|
||||
const props = defineProps({
|
||||
if: {
|
||||
@ -105,7 +105,6 @@ const isInitializing = ref(true)
|
||||
const { canvasVisible, canvasImage, canvasReferenceImages, canvasSource } = storeToRefs(useDisplay)
|
||||
|
||||
const chargeType = computed(() => getChargeType(props.type))
|
||||
// console.log(chargeType.value)
|
||||
|
||||
const timeOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
@ -138,21 +137,40 @@ const toggleDisplay = (newValue, oldValue) => {
|
||||
|
||||
const conversion = (newlist) => {
|
||||
const temp = newlist.map((item) => {
|
||||
const files = item.fileUrl ? item.fileUrl.split(',').filter(url => url.trim()) : []
|
||||
const generateData = JSON.parse(item.result || '{}')
|
||||
// 从 outputs 扁平数组提取 URL
|
||||
const files = item.outputs?.map((o) => o.url) || []
|
||||
const request = item.request || {}
|
||||
const generateData = {
|
||||
model: item.model_name || '',
|
||||
modelType: request.modelType || '',
|
||||
prompt: request.prompt || '',
|
||||
proportion: request.aspectRatio || '',
|
||||
referenceImages: request.referenceImages || [],
|
||||
quantity: request.imageNum || files.length || 1,
|
||||
resolution: request.resolution || '',
|
||||
customWidth: request.customWidth,
|
||||
customHight: request.customHight,
|
||||
duration: request.duration || '',
|
||||
videoPattern: request.videoPattern || '',
|
||||
modelParams: { ...request }
|
||||
}
|
||||
// 将 API status 映射为 UI 展示状态
|
||||
let uiStatus = 'success'
|
||||
if (item.status === 'failed' || item.status === 'cancelled') uiStatus = 'error'
|
||||
else if (item.status === 'queued' || item.status === 'processing') uiStatus = 'generate'
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
taskId: item.taskId,
|
||||
taskId: item.id,
|
||||
type: props.type,
|
||||
collection: item.collection,
|
||||
status: 'success',
|
||||
generateData: generateData,
|
||||
time: item.createTime,
|
||||
files: files,
|
||||
status: uiStatus,
|
||||
generateData,
|
||||
time: item.created_at || '',
|
||||
files,
|
||||
collectStatus: item.collectStatus || {}
|
||||
}
|
||||
})
|
||||
console.log(temp)
|
||||
return temp
|
||||
}
|
||||
|
||||
@ -167,6 +185,7 @@ const fetchHistory = async (isLoadMore = false) => {
|
||||
const result = await requestTaskHistory({
|
||||
user_id: userStore.userInfo.id,
|
||||
platform_code: getPlatformCode(props.type),
|
||||
status: 'completed',
|
||||
page: pageToFetch,
|
||||
pageSize: 10
|
||||
})
|
||||
@ -216,13 +235,13 @@ const fetchHistory = async (isLoadMore = false) => {
|
||||
}
|
||||
|
||||
hasMoreData.value = dataList.length === 10
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取历史失败:', error)
|
||||
ElMessage({
|
||||
message: '获取历史失败',
|
||||
type: 'warning'
|
||||
})
|
||||
isInitializing.value = false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@ -244,8 +263,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'
|
||||
}
|
||||
}
|
||||
|
||||
@ -358,5 +379,4 @@ onBeforeUnmount(() => {
|
||||
background-color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import display from './display/index.vue'
|
||||
import { useDisplayStore } from '@/stores'
|
||||
import display from './display/index.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const useDisplay = useDisplayStore()
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<div class="icon-wrapper">
|
||||
<div class="lock-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C9.243 2 7 4.243 7 7V10H6C4.897 10 4 10.897 4 12V20C4 21.103 4.897 22 6 22H18C19.103 22 20 21.103 20 20V12C20 10.897 19.103 10 18 10H17V7C17 4.243 14.757 2 12 2ZM12 4C13.654 4 15 5.346 15 7V10H9V7C9 5.346 10.346 4 12 4ZM6 12H18V20H6V12Z" fill="currentColor"/>
|
||||
<path d="M12 2C9.243 2 7 4.243 7 7V10H6C4.897 10 4 10.897 4 12V20C4 21.103 4.897 22 6 22H18C19.103 22 20 21.103 20 20V12C20 10.897 19.103 10 18 10H17V7C17 4.243 14.757 2 12 2ZM12 4C13.654 4 15 5.346 15 7V10H9V7C9 5.346 10.346 4 12 4ZM6 12H18V20H6V12Z" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||