Compare commits

...

34 Commits

Author SHA1 Message Date
79afa037e2 chore: 移除调试文件 bug.txt/out.txt,忽略 .superpowers 与运维目录 2026-06-12 19:22:00 +08:00
2d12c5a20b feat: 新增 Music 音乐生成平台,遵循 Platform Descriptor 模式
基于旧项目 ai_music_v2.0 迁移,与 Painting/Video 统一架构:HTTP 轮询 + suanli 后端、
API 驱动配置、mode 独立 ref 驱动控件显隐。新增 AudioPlayer/CustomSlider 通用组件,
dialogBox/set.vue/taskPolling/modelApi 完成集成适配。
2026-06-12 19:20:18 +08:00
61867e4f59 feat: Video 平台新增图生视频/全能参考/主体参考三种生成模式
- pattern 控件改为支持 Element Plus 图标组件,部分选项改用图标替代 SVG
- 新增 isStr 函数区分图标组件与 SVG 图片路径
- 智能多帧选项暂时标记为 disabled
- modelSelector 增加新模式→modelType 映射(imageToVideo/allReference/subjectReference)
- imageUploader 增加新模式标签文本(参考图/主体)
- 平台注册表 key 统一转为小写,支持大小写不敏感查找
- 更新 workflow 上传地址
2026-06-12 16:07:36 +08:00
45a80b83ff docs: 修正 CLAUDE.md 目录结构、依赖描述与架构说明的准确性
- 修正 model-configs/ 路径(项目根目录,非 src/ 下)
- 标注 encrypt.ts 为死代码(依赖未安装,无引用)
- 修正 vue-element-plus-x 描述为"提供 Sender 输入框组件"
- 目录树新增 config/plugins.js、vite.config.js、src/assets/
- 请求拦截器路由表改为说明前缀来自环境变量
- 新增 VirtualScroller 自定义实现 vs npm 包冗余依赖说明
- 修正轮询间隔为"首次 5s + 后续 20s"
2026-06-10 16:51:36 +08:00
ab4e0591a9 fix: 修复虚拟列表悬浮时的字体抖动与闪烁问题
- Img 组件:移除 .img-element:hover scale(1.02) 放大效果
- VirtualScroller:移除 .virtual-scroller-item 的 backface-visibility 和 perspective,消除 GPU 合成层导致的文字抗锯齿切换
- set.vue:叠加按钮从 display:none 改为 opacity 控制显隐,避免 repaint 触发文字重光栅化
- set.vue:prompt 内 generate-data 从 v-show 改为 visibility:hidden,避免悬浮时布局变化导致的无限循环闪烁
2026-06-10 16:51:30 +08:00
6879b08fe3 style: 放宽输入框容器最大宽度至 1024px,避免参数控件挤压发送按钮 2026-06-10 15:12:26 +08:00
b8ff25a8d7 feat: Video 平台控件配置驱动化 + UUID 模型标识 + 首尾帧双图上传
- Video 控件(proportion/time/ParamGroup)改为 config 驱动,根据 API 参数 schema 动态渲染选项
- 模型选择器改用 UUID(m.id)作为内部标识,避免同名 display_name 冲突导致错误模型配置
- getModelId 查找优先级:id → name → display_name,向下兼容
- imageUploadLimit 累加所有 imageUpload 参数 maxCount,支持首尾帧等双图模型
- buildTaskBody 将 referenceImages 按索引映射到 imageUpload 参数名
- 新增 ParamGroup(动态参数容器)+ SwitchControl(纯 CSS 开关)共享组件
- modelConfigHelper 扩展 resolution/duration 同步支持
- Select 组件 dropdown-item 添加 flex-shrink:0 防止 flex 压缩
- dialogBox 支持 beforeModel 控件分组渲染
2026-06-10 15:07:37 +08:00
0eee8b1f7f chore: 将 docs/ 加入 .gitignore,从版本追踪中移除 2026-06-09 18:28:40 +08:00
d4ef09247c chore: 删除临时上线说明文档 2026-06-09 18:27:11 +08:00
e98ff3a2c4 chore: 代码格式化统一(空格、换行、属性排序、LF规范化) 2026-06-09 18:26:37 +08:00
b964c826ce docs: 更新 CLAUDE.md 反映后端化改造
- 删除 models/、createTask.js、modelConfig.js 引用
- 新增 modelConfigHelper.js 共享工具说明
- 更新数据流描述为 API 驱动
- 新增 number ui 类型和 showWhen 机制
- 新增模型配置缓存(60s TTL)
- 删除过时的 displayNameMap 节
- 接口速查新增 config 相关 API
2026-06-09 18:16:35 +08:00
18e7dbc6ed fix: 画质选择器弹出方向改为向上,与其他控件保持一致 2026-06-09 18:14:15 +08:00
5c24de354b refactor: 删除旧模型配置文件
- 删除 src/platforms/painting/models/(9 个硬编码 JS)
- 删除 src/utils/modelConfig.js(Video 旧远程 JSON 加载)
配置已全部迁移至后端 API。
2026-06-09 18:09:25 +08:00
2cd3f8fad6 refactor: 移除 createTask 透传层,taskPolling 直接读 data.body 2026-06-09 18:09:04 +08:00
fe1ce00f66 feat: Video 平台接入模型配置 API 2026-06-09 18:05:02 +08:00
308581e2e4 feat: Painting 平台接入模型配置 API
将模型配置从代码内硬编码切换为后端 API 动态加载,使用 modelConfigHelper 共享工具函数。
2026-06-09 18:00:16 +08:00
33094e675c feat: 新增模型配置缓存层(60s TTL + 并发去重) 2026-06-09 17:58:08 +08:00
2207720438 feat: 新增模型配置 API(批量 + 单条) 2026-06-09 17:56:59 +08:00
af7debd54c feat: 新增 modelConfigHelper 共享工具函数 2026-06-09 17:55:51 +08:00
025ce0de9f fix: 修复滚动时输入框行数不收缩的问题
根因:Element Plus 的 ElInput 不支持 autosize prop 动态更新 — 只在 mount 时读取,
prop 变化后不会重新计算 textarea 高度。dialogBox 重构时去掉了 Sender 的 :key,
导致 Sender_variant 切换后 ElInput 保持初始行数不变。

修复:恢复 Sender 的 :key="useDisplay.Sender_variant",variant 切换时强制重挂载,
使 ElInput 以正确的 minRows/maxRows 初始化。

CLAUDE.md 补充:VirtualScroller 坐标系统映射表、输入框滚动收缩完整链路、
Element Plus autosize 非响应式陷阱。
2026-06-09 16:03:12 +08:00
481afadd2b fix: VirtualScroller 滚动锚定防抖 + platform 方法引用修复 + CLAUDE.md 更新
- VirtualScroller: measureItem 高度变化时,对可视区上方项的累积 delta 通过微任务延迟补偿 scrollTop,避免同步调整导致的画面抖动
- VirtualScroller: 新增独立测试页 test.html + test-data.js,用于验证虚拟滚动行为
- platform: 修复 painting/video 中 imageUploadLimit() 调用方式为 this.imageUploadLimit()
- display: 修复 Sender_variant 在非 pageTop/pageBottom 中间状态时未设置的问题,补充 isInitializing 异常状态重置
- CLAUDE.md: 补充 VirtualScroller 180deg 旋转机制说明、模型切换完整链路、反旋转注意事项
2026-06-09 15:52:31 +08:00
72e4acf956 docs: 更新 CLAUDE.md 以反映 Platform Descriptor 架构重构
- 新增 Platform Descriptor 模式完整说明(接口、控件描述符、自注册)
- 更新目录结构(src/config/ → src/platforms/)
- 合并 Painting/Video 数据流为统一描述
- 更新 dialogBox 说明为通用编排壳
- 修正所有已删除/移动文件的路径引用
2026-06-09 14:53:07 +08:00
ac7a592618 docs: 补充 dimension.separator 字段说明
JSON 无法携带 JS 函数,dimension 类型需通过 separator 字段
让前端在运行时生成等价的 parse/format 逻辑。
2026-06-09 14:39:54 +08:00
3d5d356700 docs: 重写模型参数后端化方案,对齐新平台架构
更新文档以反映平台重构后的架构变化:
- 新增当前架构(Platform Descriptor 模式)概述
- 新增目标架构与数据流设计
- 新增模型配置 API 格式规范(含 ui 字段映射表)
- 新增分阶段迁移步骤
- 保留 RunningHub API 参考作为附录
2026-06-09 12:57:45 +08:00
73f7bd888e chore: 删除旧架构代码(config/models、runninghub、dialogBox 旧组件)
- 删除 dialogBox 下 8 个子组件目录(model/proportion/dimension/quantity/pattern/Time/imageUploader/videoImageUploader)
- 删除 src/config/models/(模型配置已迁移至 src/platforms/painting/models/)
- 删除 src/config/runninghub/ 及 config/index.js(Video 不再使用旧适配器)
- 更新 Painting 平台的 getModelConfig 导入路径为 ./models/index.js
- 移除 modelSelector.vue 中未使用的 getModelConfig 导入
- 保留 src/utils/modelConfig.js(Video 描述符仍需要 fetchModelConfig 加载远程配置)
2026-06-09 12:42:57 +08:00
bcd83fc0a8 fix: 恢复 dialogBox 中 @open-canvas 画布编辑事件绑定 2026-06-09 12:01:49 +08:00
3507eddfb3 fix: 移除 dialogBox 中残留的平台分支,统一为 descriptor 接口方法 2026-06-09 11:55:22 +08:00
ec81dce28a refactor: dialogBox 重构为通用平台编排壳,委托所有平台特定逻辑 2026-06-09 11:51:29 +08:00
615afbc211 feat: 新增 Video 平台包(descriptor + 控件迁移) 2026-06-09 11:42:00 +08:00
184fd6dd8c fix: 移除 Painting descriptor 中未使用的 computed import 2026-06-09 11:40:01 +08:00
705a7a7ebf feat: 新增 Painting 平台包(descriptor + 控件迁移) 2026-06-09 11:34:23 +08:00
d2a04613d5 fix: registry 重复注册时添加警告日志 2026-06-09 11:30:49 +08:00
1fa28d10db feat: 新增平台注册表基础设施 2026-06-09 11:28:33 +08:00
a1134d85ad 新增 DimensionInput 共享组件,修复多个模型参数 UI 渲染缺陷,补充后端化方案文档
- 新增 DimensionInput 组件(Popover + W/H 数字输入 + 比例锁),支持 combined(单字段 W*H)和 split(独立 width/height)两种模式
- 修复 jimeng/qwen/qwen-edit 尺寸参数不显示:改用 dimension/dimensionWidth/dimensionHeight 替代 number/select
- 修复 GPT-Image-2/GPT-Image-2 I2I quality 选择器不显示:通过 Select 组件承载 ui: 'select'
- 修复 jimeng/GPT-Image-2 误显示 quantity:showQuantity 移除 fallback,仅匹配 ui: 'quantity'
- 新增 docs/模型参数后端化方案.md:API 设计、数据库设计、前后端迁移步骤
- 更新 CLAUDE.md:补充新 UI 类型映射、dimension 模式说明、displayNameMap bug 标注
- 删除废弃文件 Vidu Q3-T2V.json、modelConfig 空目录
2026-06-08 18:36:53 +08:00
96 changed files with 5164 additions and 3502 deletions

View File

@ -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 *)"
]
}
}

View File

@ -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
View File

@ -23,3 +23,8 @@ dist-ssr
*.sln
*.sw?
TEST/
docs/
.superpowers/
运维/
bug.txt
out.txt

343
CLAUDE.md
View File

@ -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-configVue 支持,无 TypeScript
@ -13,122 +14,282 @@ npx eslint . # 代码检查(@antfu/eslint-configVue 支持,无 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 # 用户认证、信息(含 sessionIdpinia 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 用于 Paintingvideo.vue 用于 Video
│ │ ├── imageUploader/ # 图片上传Painting
│ │ ├── videoImageUploader/ # 视频图片上传Video
│ │ ├── quantity/ # 生成数量选择器(支持 1-6上限由模型配置派生
│ │ ├── Time/ # 视频时长选择器
│ │ └── pattern/ # 视频模式选择器
│ ├── dialogBox/ # 通用编排壳(核心交互入口)
│ │ └── index.vue # <component :is> 动态渲染平台控件,不含平台分支
│ ├── Popover/ # 自定义弹出层Teleport to bodyposition: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 返回 modelParamsVideo 走 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` 组件(共用 Popoveroptions 可含 `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` 前将专用 refproportion/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()` 做第一层(判断是否渲染 ParamGroupParamGroup 内部 `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` 按需加载

View File

@ -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
View File

@ -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']
}
}

View File

@ -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: [

View 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
}

View 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
}

View 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"
}

View 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
}

View 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
View File

@ -1,65 +0,0 @@
接口文档
根据平台编码获取可学官方模型
请求
GET /suanli/v1/platforms/:code/models
┌──────┬────────┬──────┬─────────────────────────────────────────────────────┐
│ 参数 │ 类型 │ 必填 │ 说明 │
├──────┼────────┼──────┼─────────────────────────────────────────────────────┤
│ code │ string │ 是 │ 平台编码platform_identifiers.codeURL 路径参数 │
└──────┴────────┴──────┴─────────────────────────────────────────────────────┘
请求头
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": "平台不存在或已禁用"
}

View File

@ -43,5 +43,5 @@ export const getUserInfo = () => {
}
export const checkUsertoken = () => {
return service.post(`/login/validateToken`)
return service.post('/login/validateToken')
}

View File

@ -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`)
}

View 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

View 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

View 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

View File

@ -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

View 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

View 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

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View 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

View 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>

View 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>

View File

@ -93,10 +93,6 @@ onUnmounted(() => {
transition: transform 0.3s ease;
}
.img-element:hover {
transform: scale(1.02);
}
.fullscreen-icon {
position: absolute;
top: 8px;

View 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>

View File

@ -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

View File

@ -313,6 +313,7 @@ onBeforeUnmount(() => {
box-sizing: border-box;
border-radius: 5px;
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
color: #666;

View 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>

View File

@ -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)

View File

@ -1,414 +1,185 @@
<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())"
/>
</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"
/>
<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"
/>
<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>
<template #action-list>
<div style="display: flex; align-items: center; gap: 8px; height: 100%;">
<el-button v-if="isgerenate" round color="#626aef">
<i-ep-loading style="animation: spin 1s linear infinite;" />
<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)
if (!newModel) return
if (props.type !== 'Painting') {
await loadVideoModelConfig(newModel, newModelType)
//
watch(
[() => platform.value.model.value, () => platform.value.modelType.value],
async ([newModel, newModelType]) => {
if (!newModel) return
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>

View File

@ -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。

View File

@ -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>

View File

@ -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>

View File

@ -1,27 +1,41 @@
<template>
<div class="virtual-scroller" :style="containerStyle">
<div class="virtual-scroller-wrapper" :style="wrapperStyle">
<div
ref="containerRef"
class="virtual-scroller"
:style="containerStyle"
>
<div
ref="wrapperRef"
class="virtual-scroller-wrapper"
:style="wrapperStyle"
>
<div
ref="scrollContainerRef"
class="virtual-scroller-container"
:style="containerInnerStyle"
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)
const containerStyle = computed(() => {
return {
height: '100%',
width: '100%',
position: 'relative'
}
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(() => ({
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,33 +243,88 @@ 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 newHeights = new Map(itemHeights.value)
newHeights.set(key, height)
itemHeights.value = newHeights
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
}
}
}
@ -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))
}
}
itemHeights.value = newHeights
nextTick(() => {
observeVisibleItems()
})
}
nextTick(observeItems)
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;
&::-webkit-scrollbar {
display: none;
width: 6px;
height: 6px;
}
&-container {
contain: layout style;
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
&::-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;
}
}

View File

@ -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
}
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
}
if (i >= startIndex) {
if (offset + height > currentScrollTop + viewportHeight) {
endIndex = Math.min(len - 1, i + bufferCount)
break
}
endIndex = i
}
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 }
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 {

View 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: ''
}
]

View 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>

View File

@ -1,4 +0,0 @@
import * as runninghub from './runninghub/index.js'
// import * as suno from './suno.js'
export default { runninghub }

View File

@ -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 }
]
}

View File

@ -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 }
]
}

View File

@ -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',
},
],
}

View File

@ -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',
},
],
}

View File

@ -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',
},
],
}

View File

@ -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
}

View File

@ -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',
},
],
}

View File

@ -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',
},
],
}

View File

@ -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',
},
],
}

View File

@ -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',
},
],
}

View File

@ -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',
},
],
}

View File

@ -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 }
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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)

View 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>

View 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>

View File

@ -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>

View 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>

View File

@ -19,8 +19,8 @@
</div>
</div>
<el-upload
ref="uploadRef"
v-show="false"
ref="uploadRef"
:action="uploadurl"
multiple
:limit="limit"

View 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)

View File

@ -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
View 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)
}

View File

@ -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>

View File

@ -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 ? '首帧' : '尾帧'
}

View 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)

View File

@ -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)
if (enabledModels.length > 0) {
const currentModelExists = enabledModels.find(m => m.value === props.modelValue)
if (!currentModelExists) {
model.value = enabledModels[0].value
}
//
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.id === props.modelValue)
if (!currentModelExists) {
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)
if (enabledModels.length > 0) {
const currentModelExists = enabledModels.find(m => m.value === props.modelValue)
if (!currentModelExists) {
model.value = enabledModels[0].value
}
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.id === props.modelValue)
if (!currentModelExists) {
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 })

View File

@ -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

View File

@ -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)
}

View File

@ -1,5 +1,4 @@
const ParamStoreSetup = () => {
return {
}
}

View File

@ -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 || '生成失败' }
}

View File

@ -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`
}
}

View File

@ -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))
}

View File

@ -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))
}

View 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)
}
}

View File

@ -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

View File

@ -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;

View File

@ -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>

View File

@ -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()

View File

@ -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>