Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d922b66ff | |||
| 7379488839 | |||
| 4d76899488 | |||
| 79afa037e2 | |||
| 2d12c5a20b | |||
| 61867e4f59 | |||
| 45a80b83ff | |||
| ab4e0591a9 | |||
| 6879b08fe3 | |||
| b8ff25a8d7 | |||
| 0eee8b1f7f | |||
| d4ef09247c | |||
| e98ff3a2c4 | |||
| b964c826ce | |||
| 18e7dbc6ed | |||
| 5c24de354b | |||
| 2cd3f8fad6 | |||
| fe1ce00f66 | |||
| 308581e2e4 | |||
| 33094e675c | |||
| 2207720438 | |||
| af7debd54c | |||
| 025ce0de9f | |||
| 481afadd2b | |||
| 72e4acf956 | |||
| ac7a592618 | |||
| 3d5d356700 | |||
| 73f7bd888e | |||
| bcd83fc0a8 | |||
| 3507eddfb3 | |||
| ec81dce28a | |||
| 615afbc211 | |||
| 184fd6dd8c | |||
| 705a7a7ebf | |||
| d2a04613d5 | |||
| 1fa28d10db | |||
| a1134d85ad | |||
| b81c1f858e | |||
| 16d1496283 | |||
| f0008aedde | |||
| 4f7357eefc | |||
| 239b32fb95 | |||
| 791c56a46b | |||
| 2b1e7385e0 | |||
| 5da5496492 | |||
| 72267ab2c9 |
14
.claude/settings.local.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add *)",
|
||||
"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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
5
.codegraph/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
@ -3,16 +3,12 @@ VITE_BASE = '/'
|
||||
|
||||
# 主服务
|
||||
VITE_API_PREFIX = '/api'
|
||||
VITE_API_BASE_URL = 'http://test.xueai.art/api' # http://huanda.xueai.art http://106.54.11.219/api 43.248.131.153:8003
|
||||
VITE_API_WS_URL = 'ws://test.xueai.art/api'
|
||||
|
||||
# 支付服务
|
||||
VITE_API_PAY_PREFIX = '/pay'
|
||||
VITE_API_PAY_TARGET = 'http://test.xueai.art' # http://43.248.133.202 test.xueai.art
|
||||
VITE_API_BASE_URL = 'http://test.xueai.art/newapi/api' # http://huanda.xueai.art http://106.54.11.219/api 43.248.131.153:8003
|
||||
|
||||
# 任务处理模块
|
||||
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_WS = 'ws://43.248.97.19:4000/testworkflow'
|
||||
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'
|
||||
|
||||
# 是否开启开发者工具
|
||||
VITE_OPEN_DEVTOOLS = false
|
||||
|
||||
@ -6,21 +6,15 @@ VITE_BUILD_MOCK = false
|
||||
|
||||
# 主服务
|
||||
VITE_API_PREFIX = '/api'
|
||||
VITE_API_BASE_URL = 'https://sxwz.xueai.art/api'
|
||||
VITE_API_WS_URL = 'wss://sxwz.xueai.art/api'
|
||||
|
||||
# 支付服务
|
||||
VITE_API_PAY_PREFIX = '/pay'
|
||||
VITE_API_PAY_TARGET = 'https://sxwz.xueai.art' # http://43.248.133.202
|
||||
VITE_API_BASE_URL = 'https://sxwz.xueai.art/newapi/api'
|
||||
|
||||
# 任务处理模块
|
||||
VITE_API_WORKFLOW_UPLOAD = 'https://designtools.xueai.art/workflow/file/upload' # https://sxwz.xueai.art/workflow https://designtools.xueai.art/workflow
|
||||
VITE_API_WORKFLOW_WS = 'wss://talkingdraw.xueai.art/testworkflow'
|
||||
|
||||
# 模型资源
|
||||
VITE_API_MODEL_RESOURCE = 'https://resources.xueai.art/AIGC'
|
||||
VITE_API_WORKFLOW_UPLOAD = 'https://resources.xueai.art/AIGC/Temp/uploadImage' # https://sxwz.xueai.art/workflow https://designtools.xueai.art/workflow
|
||||
VITE_API_TASK_PREFIX = '/suanli'
|
||||
VITE_API_TASK_TARGET = 'https://sxwz.xueai.art'
|
||||
|
||||
# 是否开启KKFileView
|
||||
FILE_OPEN_PREVIEW = false
|
||||
# KKFileView服务器地址
|
||||
# FILE_VIEW_SERVER_URL = 'http://192.168.122.209:8012'
|
||||
# FILE_VIEW_SERVER_URL = 'http://192.168.122.209:8012'.
|
||||
|
||||
|
||||
6
.gitignore
vendored
@ -22,3 +22,9 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
TEST/
|
||||
docs/
|
||||
.superpowers/
|
||||
运维/
|
||||
bug.txt
|
||||
talk.txt
|
||||
|
||||
430
CLAUDE.md
@ -5,74 +5,404 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
pnpm dev # 启动 Vite 开发服务器
|
||||
pnpm install # 安装依赖
|
||||
pnpm dev # 启动 Vite 开发服务器(默认 http://localhost:5173)
|
||||
pnpm build # 生产构建
|
||||
pnpm preview # 预览生产构建
|
||||
npx eslint . # 代码检查(@antfu/eslint-config,Vue 支持,无 TypeScript)
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
Vue 3 (Composition API) + Vite 7 + Pinia + Vue Router + Element Plus + Less + pnpm
|
||||
Vue 3 (Composition API) + Vite 7 + Pinia + Vue Router + Element Plus + `vue-element-plus-x`(提供 Sender 输入框组件) + Less + pnpm
|
||||
|
||||
## 架构概览
|
||||
|
||||
这是一个 AI 绘画/视频生成的前端操作平台,通过 WebSocket 连接后端和第三方 AI 平台(RunningHub)提交生成任务并接收结果。
|
||||
AI 绘画/视频/音乐生成前端操作平台,通过 HTTP 接口对接算力调度后端(suanli),提交生成任务并轮询结果。
|
||||
|
||||
**核心架构:Platform Descriptor 模式。** Painting、Video 和 Music 是三个独立的平台包,通过统一的注册表动态加载。dialogBox 是通用编排壳,不包含任何平台特定逻辑。
|
||||
|
||||
### 关键目录
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.js # 入口:创建 Vue 应用,安装 Pinia/Router/VueVirtualScroller
|
||||
├── router/index.js # 路由定义 + token 验证守卫
|
||||
├── stores/ # Pinia 状态管理
|
||||
│ ├── user.js # 用户认证、信息、免费次数
|
||||
│ ├── display.js # 生成历史列表、UI 状态(滚动、画布等)
|
||||
│ └── param.js # 占位 store(当前为空)
|
||||
├── apis/ # HTTP API 模块
|
||||
│ ├── auth/ # 登录/登出/用户信息/验证码
|
||||
│ └── display/ # 获取历史列表/收藏/删除
|
||||
├── components/ # 通用组件
|
||||
│ ├── dialogBox/ # 生成参数输入面板(核心交互入口),含模型选择、比例、上传等子组件
|
||||
│ ├── virtual-scroller/# 虚拟滚动列表组件(自定义实现)
|
||||
│ ├── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘)
|
||||
│ └── ...
|
||||
├── views/ # 页面
|
||||
│ ├── home/index.vue # 主页面容器(dialogBox + display)
|
||||
│ ├── home/display/ # 历史记录展示区
|
||||
│ └── login/ # 登录页(跳转外部登录)
|
||||
├── utils/
|
||||
│ ├── request.js # Axios 实例,拦截器处理 token 和不同服务的 baseURL 路由
|
||||
│ ├── websocket.js # WebSocket 生成任务的核心流程(心跳、提交流程、结果处理)
|
||||
│ ├── createTask.js # 根据配置构造任务 payload
|
||||
│ ├── modelConfig.js # 从远程 JSON 加载模型配置,localStorage 每日缓存
|
||||
│ ├── auth.ts # token 存取工具(localStorage)
|
||||
│ └── encrypt.ts # 加密工具(Base64/MD5/RSA/AES)
|
||||
├── model-configs/ # 运维参考:模型参数配置 JSON 文件(hailuo, ltx, vidu 等,位于项目根目录)
|
||||
├── config/
|
||||
│ ├── index.js # 平台配置入口
|
||||
│ └── runninghub/ # RunningHub 平台适配器:Payload 构造和 Result 解析
|
||||
└── config/ # 项目根目录下
|
||||
└── plugins.js # Vite 插件配置(自动导入/组件注册/图标)
|
||||
│ └── 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/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
|
||||
│ └── music/ # Music 平台
|
||||
│ ├── index.js # defineMusicPlatform()
|
||||
│ ├── modelSelector.vue
|
||||
│ ├── imageUploader.vue
|
||||
│ └── controls/
|
||||
│ ├── modeSelector.vue # 模式选择(常用/专业/Remix/编辑)
|
||||
│ ├── pureMusicGroup.vue # 纯音乐开关 + 歌词输入弹窗
|
||||
│ ├── lyricsInput.vue # 专业模式歌词输入
|
||||
│ └── timeControl.vue # 时长滑块(常用模式)
|
||||
├── stores/ # Pinia 状态管理
|
||||
│ ├── user.js # 用户认证、信息(含 sessionId),pinia persist 持久化 token
|
||||
│ ├── display.js # 生成历史列表、UI 状态(滚动、画布等)
|
||||
│ └── param.js # 参数 store(当前为空)
|
||||
├── apis/ # HTTP API 层:纯请求封装,不含业务逻辑
|
||||
│ ├── auth/ # 认证相关(登录、token 校验、用户信息、验证码)
|
||||
│ └── display/ # 任务创建/轮询/历史、平台模型、收藏/删除
|
||||
├── components/
|
||||
│ ├── dialogBox/ # 通用编排壳(核心交互入口)
|
||||
│ │ └── index.vue # <component :is> 动态渲染平台控件,不含平台分支
|
||||
│ ├── Popover/ # 自定义弹出层(Teleport to body,position:fixed + fit-content 宽度)
|
||||
│ ├── Select/ # 自定义下拉选择器(分组选项,与 Popover 协调互斥开关)
|
||||
│ ├── ParamGroup/ # 动态参数容器:遍历 config.params 中未被专用控件处理的 select/switch 参数
|
||||
│ ├── SwitchControl/ # 纯 CSS 布尔开关(不用外部组件库)
|
||||
│ ├── Img/ # 图片包装组件(点击全屏查看,Teleport 实现)
|
||||
│ ├── virtual-scroller/ # 虚拟滚动列表(自定义实现)。reverse 模式用 180deg 旋转实现底部锚定,slot 内容须反旋转
|
||||
│ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘,undo/redo)
|
||||
├── views/ # 页面(home、login)
|
||||
└── 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)
|
||||
```
|
||||
|
||||
### 核心数据流
|
||||
### Platform Descriptor 模式
|
||||
|
||||
1. 用户在 `dialogBox` 中设置参数(模型、提示词、比例、上传图片等)
|
||||
2. 点击生成 → `websocket.js:generate()` 被调用
|
||||
3. 先通过 `createTask.js` 调用 `config/runninghub` 的 `Playload()` 构造任务数据(从远程 JSON 加载 workflow 配置)
|
||||
4. 建立 WebSocket 连接,经过握手协议(`please give me taskId` → `OK! Please continue.`)提交任务
|
||||
5. 任务排队中 → `displayStore.addGeneratingItem()` 在前端列表中插入 "生成中" 条目
|
||||
6. 完成后 WebSocket 关闭(code=1000 reason=success) → `getTask()` 解析结果 URL → `updateItemToSuccess()` 更新列表
|
||||
每个平台通过 `defineXxxPlatform()` 工厂函数返回标准接口:
|
||||
|
||||
```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)
|
||||
|
||||
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) { ... }, // 从历史结果回填参数
|
||||
}
|
||||
```
|
||||
|
||||
控件通过 `ctrl.props(config)` 接收 v-model 绑定对:
|
||||
```js
|
||||
props: (config) => ({
|
||||
modelValue: proportion.value,
|
||||
'onUpdate:modelValue': (v) => { proportion.value = v },
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
**自注册:** 每个平台文件底部调用 `registerPlatform('Painting', definePaintingPlatform)`,在 import 时自动注册。dialogBox 通过 `createPlatform(props.type)` 获取实例。注册表内部将 key 统一转为小写,因此 `'Painting'` 和 `'painting'` 等效。
|
||||
|
||||
### dialogBox 通用编排壳
|
||||
|
||||
`src/components/dialogBox/index.vue` 是纯编排组件,不含任何平台分支:
|
||||
|
||||
- **平台切换**:`const platform = computed(() => createPlatform(props.type))`,切换时重置默认模型并加载模型列表
|
||||
- **控件渲染**:`visibleControls = platform.controls.filter(c => c.show(getCurrentConfig()))`,按 `beforeModel` 拆分为两组,`beforeModel` 控件 → ModelSelector → 其余控件,用 `<component :is>` + `v-bind="ctrl.props(...)"` 渲染
|
||||
- **配置获取**:`getCurrentConfig()` 返回 `platform.modelConfig?.value`
|
||||
- **模型切换**:`watch([model, modelType])` → `platform.loadConfig()` → `syncDefaults()` 将 schema 写入响应式 state → controls 的 `show()`/`props()` 读取 state → `visibleControls` 自动更新 → UI 重渲染
|
||||
- **任务发起**:`handleStart()` 调用 `platform.validateBeforeSubmit()` → `platform.buildTaskBody()` → `generate()`
|
||||
- **参数回填**:`fillParamsFromResult()` 委托给 `platform.fillFromResult()`
|
||||
|
||||
### 统一数据流(Painting + Video)
|
||||
|
||||
两个平台现已统一,通过后端 API 获取模型参数配置:
|
||||
|
||||
1. 用户选择模型 → `platform.loadConfig(modelName, modelType)` → 调用 `GET /suanli/v1/models/:id/config`(优先 60s 缓存)加载参数 schema
|
||||
2. 参数 schema 驱动 `controls` 渲染 UI,用户填写参数
|
||||
3. 用户点击发送 → `handleStart()` → `platform.buildTaskBody({ prompt, referenceImages })` 返回扁平 `modelParams`
|
||||
4. `taskPolling.js` 直接读取 `data.body` → `getModelId(type, modelName)` 查找 UUID → POST `/suanli/v1/tasks`(`X-Session-Id` header)
|
||||
5. 20s 间隔轮询直至完成/失败
|
||||
|
||||
### 模型标识与查找
|
||||
|
||||
API 返回的模型对象包含三个标识字段:
|
||||
|
||||
| 字段 | 示例 | 用途 |
|
||||
|------|------|------|
|
||||
| `id` | `e7e1e743-7621-403d-b8fb-2b7f4fa1b4fc` | UUID 主键,**唯一且稳定**,用于 API 调用和内部追踪 |
|
||||
| `name` | `vidu-text-to-video-q3-turbo` | 内部标识名,通常也唯一 |
|
||||
| `display_name` | `Vidu q3-turbo` | 用户可见的显示名,**可能重复**(不同 pattern 下的同名模型) |
|
||||
|
||||
**Video 平台使用 `id`(UUID)作为 `model.value`**,避免 `display_name` 冲突导致的模型查找错误。`modelSelector.vue` 中 `modelGroups` 的 `value` 设为 `m.id`,`label` 仍用 `display_name` 显示。
|
||||
|
||||
`getModelId(type, modelName)` 查找优先级:`m.id === modelName` → `m.name === modelName` → `m.display_name === modelName`,向下兼容旧的 name/display_name 调用。
|
||||
|
||||
Video 的 `getDefaultModel()` 返回 `''`,不硬编码 UUID;模型列表加载后由 `modelSelector` 的 watcher 自动选择第一个可用模型。
|
||||
|
||||
### 模型参数配置
|
||||
|
||||
模型参数配置通过后端 API `GET /suanli/v1/models/:id/config` 获取(60s localStorage 缓存),替代了旧的本地硬编码。参数通过 `ui` 字段映射到前端控件:
|
||||
|
||||
| `ui` 值 | 控件 | 说明 |
|
||||
|---------|------|------|
|
||||
| `textarea` | Sender 内置 textarea | prompt 输入框 |
|
||||
| `proportion` | `PaintingProportion` / `VideoProportion` | 比例选择 Popover(`options` 含 `custom` 时可自定义宽高) |
|
||||
| `resolution` | proportion 控件内部 | 分辨率子选项,与 proportion 共用 Popover |
|
||||
| `dimension` | `DimensionInput` | **组合模式**:单字段 `"W*H"` 格式,后端传 `dimension.separator`,前端生成 parse/format |
|
||||
| `dimensionWidth` + `dimensionHeight` | `DimensionInput` | **拆分模式**:两个独立字段(含 `min`/`max`),共享比例锁 |
|
||||
| `number` | proportion 控件内部 | 自定义宽高(如 Flux 的 customWidth/customHight),配合 `showWhen` 条件显示 |
|
||||
| `select` | `Select`(通过 ParamGroup 或专用控件) | 通用下拉。专用控件直接使用 Select,其余由 ParamGroup 动态渲染 |
|
||||
| `switch` | `SwitchControl`(通过 ParamGroup) | 布尔开关,纯 CSS 实现,不用外部组件库 |
|
||||
| `quantity` | `Quantity` | 生成数量,`options` 必须为数字数组,上限由 `Math.max()` 派生 |
|
||||
| `imageUpload` | `ImageUploader` | 参考图上传,`maxCount` 控制上限 |
|
||||
| `hidden` | 无 | 静默写入默认值 |
|
||||
|
||||
**dimension 模式区分**:`getDimConfig()`(来自 `modelConfigHelper.js`)自动检测 `ui: 'dimension'`(组合)或 `ui: 'dimensionWidth'`(拆分),两种模式共用 `DimensionInput` 组件。
|
||||
|
||||
**条件显示**:`showWhen: { aspectRatio: 'custom' }` 使参数仅在 proportion 选 `custom` 时显示。`checkShowWhen()` 在 controls 的 `show()` 回调中调用,因直接读取 reactive 的 `paramValues[key]`,computed 自动追踪依赖。
|
||||
|
||||
### modelConfigHelper 共享工具
|
||||
|
||||
`src/utils/modelConfigHelper.js` 提供 Painting/Video 双平台共用的 4 个纯函数:
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `syncDefaults(config, state)` | 将 API 返回的 config 同步到响应式 state。处理 `paramValues` 初始化、专用 ref 同步(proportion/resolution/quantity/quality/duration/dimension)、`promptPlaceholder` 同步。`resolution` 同时匹配 `ui:"resolution"` 和 `ui:"select"` |
|
||||
| `syncParamValues(config, state)` | 在 `buildTaskBody` 前将专用 ref(proportion/resolution/quantity/quality/duration/dimension)回写到 `paramValues` |
|
||||
| `getDimConfig(config)` | 检测 combined/split 模式 |
|
||||
| `checkShowWhen(param, paramValues)` | 检查 `showWhen` 条件 |
|
||||
|
||||
`state` 参数对象需包含 `modelConfig, paramValues, proportion, resolution, quantity, quality, customWidth, customHight, dimWidth, dimHeight, promptPlaceholder`。Video 平台额外包含 `duration`(用于 syncDefaults 同步时长默认值)。各平台通过包装函数适配。
|
||||
|
||||
### ParamGroup 动态参数渲染
|
||||
|
||||
当 API 返回的 params 中包含无专用控件的类型(如 `select`、`switch`),由 `ParamGroup` 统一处理:
|
||||
|
||||
- **容器定位**:作为平台 controls 数组的最后一项,`show()` 检查是否存在未被 `handledUis` 覆盖的参数
|
||||
- **`handledUis`**:`['textarea', 'proportion', 'resolution', 'dimension', 'dimensionWidth', 'dimensionHeight', 'quantity', 'imageUpload', 'hidden', 'number']` — 这些类型的参数由专用控件处理,ParamGroup 跳过
|
||||
- **`excludeNames` prop**:额外按参数名排除(如 `resolution`、`duration`),因为 VideoProportion/Time 已处理这些参数(即使其 `ui` 类型不在 handledUis 中)
|
||||
- **双层过滤**:平台 `show()` 做第一层(判断是否渲染 ParamGroup),ParamGroup 内部 `dynamicParams` 做第二层(判断具体渲染哪些参数)。**两层必须保持一致**,否则会出现重复控件
|
||||
|
||||
```js
|
||||
// Video 平台的 ParamGroup 配置
|
||||
{
|
||||
name: 'paramGroup',
|
||||
component: markRaw(ParamGroup),
|
||||
show: (config) => {
|
||||
// 第一层:有未被处理的参数才显示
|
||||
return config.params.some((p) => {
|
||||
if (handledUis.includes(p.ui)) return false
|
||||
if (p.name === 'resolution') return false
|
||||
if (p.name === 'duration') return false
|
||||
return true
|
||||
})
|
||||
},
|
||||
props: (config) => ({
|
||||
config,
|
||||
paramValues,
|
||||
excludeNames: ['resolution', 'duration'] // 第二层过滤
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Music 平台特有行为
|
||||
|
||||
Music 平台与 Painting/Video 的关键差异:
|
||||
|
||||
- **模式驱动控件显隐**:`mode` ref(常用模式/专业模式/Remix模式/编辑模式)决定大部分控件的 `show()`。切换模式时 `visibleControls` 自动更新。
|
||||
- **纯音乐/歌词互斥**:常用模式下 `pureMusicGroup` 控件内含纯音乐开关 + 歌词输入弹窗。开关开启时清空歌词;关闭时弹出歌词输入框。`buildTaskBody` 中 `pureMusic` 为 true 时 `lyrics` 为空字符串。
|
||||
- **参考音频**:`imageUploader` 用于上传音频文件(非图片),仅在专业模式下显示(`showImageUploader()` → `mode === '专业模式'`),且为必填(`isImageRequired()` → true)。
|
||||
- **数量限制**:专业模式下 `quantity` 强制为 1(`props()` 中做 `Math.min(mode === '专业模式' ? 1 : maxQty)`)。
|
||||
- **Music 独有控件**:
|
||||
|
||||
| 控件 | `name` | `beforeModel` | 作用 |
|
||||
|------|--------|---------------|------|
|
||||
| `ModeSelector` | `modeSelector` | `true`(在模型选择器前) | 常用/专业/Remix/编辑 四选一 |
|
||||
| `PureMusicGroup` | `pureMusicGroup` | `false` | 纯音乐开关 + 歌词输入(仅常用模式显示) |
|
||||
| `LyricsInput` | `lyricsInput` | `false` | 歌词输入(仅专业模式显示) |
|
||||
| `TimeControl` | `timeControl` | `false` | 时长滑块 min~max(仅常用模式显示) |
|
||||
|
||||
- **`handledUis`**(Music ParamGroup 过滤用):`['textarea', 'proportion', 'imageUpload', 'hidden', 'quantity']`,额外按名称排除 `['mode', 'pureMusic', 'lyrics', 'duration', 'quantity']`。
|
||||
|
||||
### 计费类型映射(`getChargeType`)
|
||||
|
||||
`taskPolling.js` 中的 `getChargeType(chargeType)` 将平台类型映射为计费数字:
|
||||
|
||||
| 平台 | 计费码 |
|
||||
|------|--------|
|
||||
| Painting | 1 |
|
||||
| Video | 4 |
|
||||
| Music | 5 |
|
||||
| 其他 | 2 |
|
||||
|
||||
### 组件规范
|
||||
|
||||
**禁止在项目组件中使用外部 UI 库**(Element Plus 等),图标除外。自定义组件使用项目自研的 `Select`、`Popover` 或纯 CSS 实现。`SwitchControl` 即为一例——纯 CSS 滑动开关,不依赖 `el-switch`。
|
||||
|
||||
### `$attrs` 穿透注意
|
||||
|
||||
向子组件传递的 prop 如果子组件未声明,会通过 `$attrs` 穿透到根元素。**所有通过 `v-model` 传递的值,子组件必须声明对应的 prop。**
|
||||
|
||||
### API 层设计原则
|
||||
|
||||
- `src/apis/` 只做纯 HTTP 调用(`service.get/post/delete`),不含缓存、localStorage、业务逻辑
|
||||
- 缓存、数据转换等业务逻辑放在 `src/utils/` 中
|
||||
|
||||
### 关键注意事项
|
||||
|
||||
- **`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 并发去重。
|
||||
- **模型配置缓存**:`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`。
|
||||
|
||||
### 接口速查
|
||||
|
||||
| 函数 | 端点 | 用途 |
|
||||
|------|------|------|
|
||||
| `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(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` | 删除历史记录 |
|
||||
|
||||
### 任务响应格式
|
||||
|
||||
```json
|
||||
// GET /suanli/v1/tasks/:id 返回结构
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"task_id": "uuid",
|
||||
"status": "completed", // queued | processing | completed | failed
|
||||
"outputs": [ // ⚠️ 扁平数组,不是 { images: [...] }
|
||||
{ "url": "https://...", "type": "png" }
|
||||
],
|
||||
"vendor_error": "..." // 仅 failed 时有值
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 请求拦截器路由
|
||||
|
||||
拦截器统一设置 `Authorization: <token>`(不带 Bearer 前缀),根据请求 URL 前缀(由环境变量 `VITE_API_TASK_PREFIX` / `VITE_API_PAY_PREFIX` / `VITE_API_AIGC_PREFIX` 定义)切换后端:
|
||||
|
||||
| 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_` 开头的变量会被暴露给客户端代码。
|
||||
|
||||
### 平台编码映射
|
||||
|
||||
| 类型 | 平台编码 |
|
||||
|------|----------|
|
||||
| Painting | `ai_painting_talk` |
|
||||
| Video | `ai_video_talk` |
|
||||
| Music | `ai_music_talk` |
|
||||
|
||||
映射函数 `getPlatformCode()` 位于 `utils/modelApi.js`。
|
||||
|
||||
### 自动导入
|
||||
|
||||
- `unplugin-auto-import` 自动导入 Vue/VRouter/Pinia API,无需在 `.vue` 文件中手动 `import { ref, computed, watch } from 'vue'`
|
||||
- `unplugin-vue-components` 自动注册 `src/components/` 下的组件和 Element Plus 组件
|
||||
- `unplugin-auto-import`:自动导入 Vue/Router/Pinia API
|
||||
- `unplugin-vue-components`:自动注册 `src/components/` 下的组件和 Element Plus 组件,**生成 `components.d.ts`(勿手动编辑)**
|
||||
- Element Plus 图标通过 `unplugin-icons` 按需加载
|
||||
|
||||
### 环境变量
|
||||
|
||||
有两套环境文件。`VITE_API_BASE_URL` 定义主 API 地址,请求拦截器根据 URL 前缀自动切换不同的后端服务(主服务/支付服务/AIGC 工作流服务)。`VITE_API_WORKFLOW_WS` 定义 WebSocket 地址。
|
||||
|
||||
### 路由守卫
|
||||
|
||||
`src/router/index.js` 的 `beforeEach` 守卫检查 token 存在性和有效性(调用 `/auth/check/token`),无效则跳转 `/login`。支持通过 URL query `?token=xxx` 传入 token。
|
||||
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
13
components.d.ts
vendored
@ -11,7 +11,9 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AudioPlayer: typeof import('./src/components/AudioPlayer/index.vue')['default']
|
||||
Canvas: typeof import('./src/components/canvas/index.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']
|
||||
@ -23,21 +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']
|
||||
Pattern: typeof import('./src/components/dialogBox/pattern/index.vue')['default']
|
||||
ParamGroup: typeof import('./src/components/ParamGroup/index.vue')['default']
|
||||
Popover: typeof import('./src/components/Popover/index.vue')['default']
|
||||
Quantity: typeof import('./src/components/dialogBox/quantity/index.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Select: typeof import('./src/components/Select/index.vue')['default']
|
||||
Time: typeof import('./src/components/dialogBox/Time/index.vue')['default']
|
||||
Video: typeof import('./src/components/dialogBox/model/video.vue')['default']
|
||||
VideoImageUploader: typeof import('./src/components/dialogBox/videoImageUploader/index.vue')['default']
|
||||
SwitchControl: typeof import('./src/components/SwitchControl/index.vue')['default']
|
||||
VirtualScroller: typeof import('./src/components/virtual-scroller/VirtualScroller.vue')['default']
|
||||
'VirtualScroller copy': typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default']
|
||||
'VirtualScroller copy 2': typeof import('./src/components/virtual-scroller/VirtualScroller copy 2.vue')['default']
|
||||
'VirtualScroller copy 3': typeof import('./src/components/virtual-scroller/VirtualScroller copy 3.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import Icons from 'unplugin-icons/vite'
|
||||
import IconsResolver from 'unplugin-icons/resolver'
|
||||
import Icons from 'unplugin-icons/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
|
||||
export const autoImportConfig = AutoImport({
|
||||
imports: [
|
||||
|
||||
65
out.txt
@ -1,65 +0,0 @@
|
||||
接口文档
|
||||
|
||||
根据平台编码获取可学官方模型
|
||||
|
||||
请求
|
||||
|
||||
GET /suanli/v1/platforms/:code/models
|
||||
|
||||
┌──────┬────────┬──────┬─────────────────────────────────────────────────────┐
|
||||
│ 参数 │ 类型 │ 必填 │ 说明 │
|
||||
├──────┼────────┼──────┼─────────────────────────────────────────────────────┤
|
||||
│ code │ string │ 是 │ 平台编码(platform_identifiers.code),URL 路径参数 │
|
||||
└──────┴────────┴──────┴─────────────────────────────────────────────────────┘
|
||||
|
||||
请求头
|
||||
|
||||
Authorization: <token>
|
||||
|
||||
▎ 无需 Bearer 前缀。
|
||||
|
||||
响应
|
||||
|
||||
成功
|
||||
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"platform": {
|
||||
"id": "uuid",
|
||||
"code": "openai",
|
||||
"name": "OpenAI"
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "gpt-4",
|
||||
"display_name": "GPT-4",
|
||||
"category": "llm",
|
||||
"billing_unit": "token",
|
||||
"unit_price": 0.03,
|
||||
"billing_mode": "post",
|
||||
"plugin_code": null,
|
||||
"endpoint": null,
|
||||
"sort_order": 1,
|
||||
"is_public": 1,
|
||||
"owner_org_id": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
┌───────────────┬──────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 字段 │ 说明 │
|
||||
├───────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ data.platform │ 平台信息 │
|
||||
├───────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ data.models │ 该平台下 owner_type=platform 且 status=active 的模型列表,按 sort_order、created_at 排序 │
|
||||
└───────────────┴──────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
平台不存在或已禁用
|
||||
|
||||
{
|
||||
"code": 1,
|
||||
"message": "平台不存在或已禁用"
|
||||
}
|
||||
@ -38,14 +38,10 @@ export function logout() {
|
||||
|
||||
/** @desc 获取用户信息 */
|
||||
export const getUserInfo = () => {
|
||||
return service.get(`${BASE_URL}/user/info`)
|
||||
}
|
||||
|
||||
/** @desc 获取路由信息 */
|
||||
export const getUserRoute = () => {
|
||||
return service.get(`${BASE_URL}/route`)
|
||||
return service.get(`/sysUser/currentUser
|
||||
`)
|
||||
}
|
||||
|
||||
export const checkUsertoken = () => {
|
||||
return service.get(`${BASE_URL}/check/token`)
|
||||
return service.post('/login/validateToken')
|
||||
}
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import service from '@/utils/request'
|
||||
|
||||
// 获取生成历史列表
|
||||
export function getGenerateHistoryList(query) {
|
||||
return service.get('/taskRecordHistory', { params: query })
|
||||
}
|
||||
// ==================== 历史记录 API(axios) ====================
|
||||
|
||||
// 取消或收藏
|
||||
export function cancelOrCollect(query) {
|
||||
@ -15,7 +12,38 @@ export function deleteGenerateHistory(query) {
|
||||
return service.delete('/taskRecordHistory/delete', { params: query })
|
||||
}
|
||||
|
||||
// 获取免费次数
|
||||
export function getFreeTimes(id) {
|
||||
return service.get('/plantformBalance/userBalances', { params: { id } })
|
||||
}
|
||||
// ==================== 任务 API(axios,经由 /suanli 前缀路由到算力调度后端) ====================
|
||||
|
||||
// 创建生成任务(HTTP POST /suanli/v1/tasks)
|
||||
export function requestCreateTask(body, sessionId) {
|
||||
return service.post('/suanli/v1/tasks', body, {
|
||||
headers: { 'X-Session-Id': sessionId }
|
||||
})
|
||||
}
|
||||
|
||||
// 查询任务状态 / 获取历史任务结果(HTTP GET /suanli/v1/tasks/:id)
|
||||
export function requestTaskStatus(taskId) {
|
||||
return service.get(`/suanli/v1/tasks/${taskId}`)
|
||||
}
|
||||
|
||||
// 获取历史任务列表(HTTP GET /suanli/v1/tasks/history,支持平台筛选和分页)
|
||||
export function requestTaskHistory(params) {
|
||||
return service.get('/suanli/v1/tasks/history', { params })
|
||||
}
|
||||
|
||||
// ==================== 平台模型 API ====================
|
||||
|
||||
// 获取平台模型列表(原始 HTTP 调用,不含缓存逻辑)
|
||||
export function fetchPlatformModels(code) {
|
||||
return service.get(`/suanli/v1/platforms/${code}/models`)
|
||||
}
|
||||
|
||||
// 批量获取模型配置(POST /suanli/v1/models/configs)
|
||||
export function requestModelConfigsBatch(modelIds) {
|
||||
return service.post('/suanli/v1/models/configs', { modelIds })
|
||||
}
|
||||
|
||||
// 单条查询模型配置(GET /suanli/v1/models/:modelId/config)
|
||||
export function requestModelConfig(modelId) {
|
||||
return service.get(`/suanli/v1/models/${modelId}/config`)
|
||||
}
|
||||
|
||||
3
src/assets/dialog/beautify.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.1403 9.92944C16.1733 10.4175 16.3717 11.6017 17.48 12.4494C18.3647 13.1261 19.3661 13.0687 20 12.9268C17.5782 13.8572 17.4411 15.861 17.5801 16.9878C16.6857 14.0517 13.4579 14.6885 12.8697 14.8314C15.9283 14.0108 16.1412 10.8064 16.1403 9.92944ZM4.7575 2.8575C5.125 2.945 6.04278 2.93042 6.91486 2.30236C7.61 1.80069 7.77333 0.753611 7.80931 0.25C7.95806 2.26153 9.42319 3.11514 10.2856 3.26972C7.85889 3.26 7.66056 5.93458 7.64597 6.30305C7.74028 3.82097 5.40792 3.05972 4.7575 2.8575ZM2.72653 7.9218C2.83754 8.09622 2.9846 8.24485 3.15783 8.3577C3.33106 8.47056 3.52644 8.54503 3.73083 8.57611C4.19458 8.64319 4.55236 8.41472 4.7575 8.23194C4.04972 9.07583 4.4075 9.84389 4.68653 10.2308C3.73861 9.30722 2.64583 10.2775 2.5 10.4156C3.47903 9.48222 2.90542 8.24653 2.72653 7.9218ZM13.4113 9.95861L11.9724 8.51194L13.7194 6.86792C13.818 6.77493 13.9494 6.72485 14.0848 6.72868C14.2203 6.73251 14.3487 6.78993 14.4418 6.88833L15.1418 7.62917C15.1878 7.67765 15.2238 7.73474 15.2478 7.79716C15.2717 7.85957 15.2831 7.9261 15.2813 7.99292C15.2795 8.05974 15.2645 8.12555 15.2372 8.18659C15.21 8.24762 15.1709 8.30268 15.1224 8.34861L13.4113 9.95861ZM11.4396 9.01458L12.8785 10.4603L5.28444 17.611C5.08028 17.8044 4.75653 17.7957 4.56208 17.5915L3.86208 16.8507C3.81595 16.8023 3.77984 16.7452 3.75584 16.6828C3.73184 16.6203 3.72041 16.5538 3.72222 16.4869C3.72402 16.4201 3.73903 16.3542 3.76637 16.2932C3.79371 16.2321 3.83285 16.1771 3.88153 16.1312L11.4396 9.01458Z" fill="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
6
src/assets/dialog/commonMode.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="2.5" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
|
||||
<rect x="13" y="1.3614" width="5" height="5" rx="0.5" transform="rotate(45 13 1.3614)" stroke="#666666"/>
|
||||
<rect x="2.5" y="10" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
|
||||
<rect x="10" y="10" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 431 B |
4
src/assets/dialog/editMode.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 9V15C16 15.5523 15.5523 16 15 16H3C2.44772 16 2 15.5523 2 15V3C2 2.44772 2.44772 2 3 2H9" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M16 2L14 4L10 8L9 9" stroke="#666666" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 320 B |
@ -1,3 +1,5 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.25 15H22.5H24C25.6569 15 27 16.3431 27 18C27 19.6569 25.6569 21 24 21H22.5H20.25M15.75 15H13.5H12C10.3431 15 9 16.3431 9 18C9 19.6569 10.3431 21 12 21H13.5H15.75" stroke="#BBBBBB" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<rect width="36" height="36" rx="10" fill="#F8F9FA"/>
|
||||
<path d="M20.25 15H22.5H24C25.6569 15 27 16.3431 27 18C27 19.6569 25.6569 21 24 21H22.5H20.25M15.75 15H13.5H12C10.3431 15 9 16.3431 9 18C9 19.6569 10.3431 21 12 21H13.5H15.75" stroke="#666666" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M16 18H20" stroke="#666666" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 340 B After Width: | Height: | Size: 475 B |
6
src/assets/dialog/lyrics.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="2.5" width="13" height="13" rx="0.5" stroke="#333333"/>
|
||||
<path d="M7 6H11" stroke="#333333" stroke-linecap="round"/>
|
||||
<path d="M7 12H11" stroke="#333333" stroke-linecap="round"/>
|
||||
<path d="M6 9H12" stroke="#333333" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 357 B |
8
src/assets/dialog/professionalMode.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="2.5" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
|
||||
<rect x="2.5" y="10" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
|
||||
<path d="M10 3.25H16" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M10 7.25H16" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M10 10.75H16" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M10 14.75H16" stroke="#666666" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 510 B |
11
src/assets/dialog/randomSeed.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.5 3.5L7.5 0.5L14.5 3.5L7.5 6.5L0.5 3.5Z" stroke="#666666" stroke-linejoin="round"/>
|
||||
<path d="M0.5 12.5V3.5L7.5 6.5V15.5L0.5 12.5Z" stroke="#666666" stroke-linejoin="round"/>
|
||||
<path d="M7.5 6.5L14.5 3.5V12.5L7.5 15.5V6.5Z" stroke="#666666" stroke-linejoin="round"/>
|
||||
<ellipse cx="7.4498" cy="3.55078" rx="1.15" ry="0.75" fill="#666666"/>
|
||||
<ellipse cx="4.4498" cy="3.55078" rx="1.15" ry="0.75" fill="#666666"/>
|
||||
<ellipse cx="10.4498" cy="3.55078" rx="1.15" ry="0.75" fill="#666666"/>
|
||||
<ellipse cx="12.25" cy="7.85" rx="1.15" ry="0.75" transform="rotate(-90 12.25 7.85)" fill="#666666"/>
|
||||
<ellipse cx="10.25" cy="10.85" rx="1.15" ry="0.75" transform="rotate(-90 10.25 10.85)" fill="#666666"/>
|
||||
<ellipse cx="3.84961" cy="9.65078" rx="1.15" ry="0.75" transform="rotate(-90 3.84961 9.65078)" fill="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 911 B |
9
src/assets/dialog/remixMode.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="2.5" width="13" height="13" rx="0.5" stroke="#666666"/>
|
||||
<path d="M7 6V12" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M8 9C8 9.55228 7.55228 10 7 10C6.44772 10 6 9.55228 6 9C6 8.44772 6.44772 8 7 8C7.55228 8 8 8.44772 8 9Z" fill="white"/>
|
||||
<path d="M7.5 9C7.5 8.72386 7.27614 8.5 7 8.5C6.72386 8.5 6.5 8.72386 6.5 9C6.5 9.27614 6.72386 9.5 7 9.5C7.27614 9.5 7.5 9.27614 7.5 9ZM8.5 9C8.5 9.82843 7.82843 10.5 7 10.5C6.17157 10.5 5.5 9.82843 5.5 9C5.5 8.17157 6.17157 7.5 7 7.5C7.82843 7.5 8.5 8.17157 8.5 9Z" fill="#666666"/>
|
||||
<path d="M11 6V12" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M12 11C12 11.5523 11.5523 12 11 12C10.4477 12 10 11.5523 10 11C10 10.4477 10.4477 10 11 10C11.5523 10 12 10.4477 12 11Z" fill="white"/>
|
||||
<path d="M11.5 11C11.5 10.7239 11.2761 10.5 11 10.5C10.7239 10.5 10.5 10.7239 10.5 11C10.5 11.2761 10.7239 11.5 11 11.5C11.2761 11.5 11.5 11.2761 11.5 11ZM12.5 11C12.5 11.8284 11.8284 12.5 11 12.5C10.1716 12.5 9.5 11.8284 9.5 11C9.5 10.1716 10.1716 9.5 11 9.5C11.8284 9.5 12.5 10.1716 12.5 11Z" fill="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
src/assets/dialog/restore.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 4.47053H4.47052M15.1761 4.47053H12.7056M12.7056 4.47053V4C12.7056 2.89543 11.8102 2 10.7056 2H6.47053C5.36596 2 4.47052 2.89543 4.47052 4V4.47053M12.7056 4.47053H4.47052" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M3.64746 6.94141V14C3.64746 15.1046 4.54289 16 5.64746 16H11.5296C12.6341 16 13.5296 15.1046 13.5296 14V6.94141M6.94149 7.76491V14.353M10.2355 7.76491V14.353" stroke="#666666" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 538 B |
BIN
src/assets/display/background1.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/display/background2.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/display/background3.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/display/background4.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src/assets/display/image1.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src/assets/display/image2.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src/assets/display/image3.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
src/assets/display/image4.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
4
src/assets/display/time.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="9" cy="9" r="6.5" stroke="black"/>
|
||||
<path d="M9 6V10" stroke="black" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 208 B |
267
src/components/AudioPlayer/index.vue
Normal file
@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div class="audio-placeholder" :style="{ backgroundImage: `url(${backgroundImage})` }">
|
||||
<div class="audio-left">
|
||||
<img :src="coverImage" alt="音频封面" class="audio-cover" />
|
||||
</div>
|
||||
|
||||
<div class="audio-center">
|
||||
<div class="audio-title">{{ audioTitle }}</div>
|
||||
<div class="audio-progress">
|
||||
<div class="progress-bar" @click="handleProgressClick">
|
||||
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
|
||||
</div>
|
||||
<div class="progress-time">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="audio-right">
|
||||
<div class="play-button" @click.stop="handlePlayPause">
|
||||
<svg v-if="!isPlaying" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 5V19L19 12L8 5Z" fill="white"/>
|
||||
</svg>
|
||||
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import background1 from '@/assets/display/background1.png'
|
||||
import background2 from '@/assets/display/background2.png'
|
||||
import background3 from '@/assets/display/background3.png'
|
||||
import background4 from '@/assets/display/background4.png'
|
||||
import image1 from '@/assets/display/image1.png'
|
||||
import image2 from '@/assets/display/image2.png'
|
||||
import image3 from '@/assets/display/image3.png'
|
||||
import image4 from '@/assets/display/image4.png'
|
||||
|
||||
const props = defineProps({
|
||||
audioUrl: { type: String, default: '' },
|
||||
audioTitle: { type: String, default: '我的音乐' },
|
||||
cardIndex: { type: Number, default: 0 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['play', 'pause', 'ended'])
|
||||
|
||||
const backgroundImages = [background1, background2, background3, background4]
|
||||
const coverImages = [image1, image2, image3, image4]
|
||||
|
||||
const backgroundImage = computed(() => backgroundImages[props.cardIndex % 4])
|
||||
const coverImage = computed(() => coverImages[props.cardIndex % 4])
|
||||
|
||||
const audioRef = ref(null)
|
||||
const isPlaying = ref(false)
|
||||
const isPlayPending = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
|
||||
let timeupdateHandler = null
|
||||
let loadedmetadataHandler = null
|
||||
let endedHandler = null
|
||||
|
||||
const progressPercentage = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
})
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
if (!seconds || isNaN(seconds)) return '00:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const handleProgressClick = (event) => {
|
||||
if (!audioRef.value || duration.value === 0) return
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const clickX = event.clientX - rect.left
|
||||
const percentage = Math.max(0, Math.min(1, clickX / rect.width))
|
||||
const newTime = percentage * duration.value
|
||||
audioRef.value.currentTime = newTime
|
||||
currentTime.value = newTime
|
||||
}
|
||||
|
||||
const setupAudio = (url) => {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.pause()
|
||||
audioRef.value.removeEventListener('timeupdate', timeupdateHandler)
|
||||
audioRef.value.removeEventListener('loadedmetadata', loadedmetadataHandler)
|
||||
audioRef.value.removeEventListener('ended', endedHandler)
|
||||
audioRef.value.src = ''
|
||||
audioRef.value.load()
|
||||
audioRef.value = null
|
||||
}
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
duration.value = 0
|
||||
if (!url) return
|
||||
|
||||
audioRef.value = new Audio(url)
|
||||
audioRef.value.crossOrigin = 'anonymous'
|
||||
timeupdateHandler = () => {
|
||||
if (audioRef.value) currentTime.value = audioRef.value.currentTime
|
||||
}
|
||||
loadedmetadataHandler = () => {
|
||||
if (audioRef.value) duration.value = audioRef.value.duration || 0
|
||||
}
|
||||
endedHandler = () => {
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
emit('ended')
|
||||
}
|
||||
audioRef.value.addEventListener('timeupdate', timeupdateHandler)
|
||||
audioRef.value.addEventListener('loadedmetadata', loadedmetadataHandler)
|
||||
audioRef.value.addEventListener('ended', endedHandler)
|
||||
}
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (!audioRef.value) {
|
||||
setupAudio(props.audioUrl)
|
||||
}
|
||||
if (!audioRef.value) return
|
||||
|
||||
if (isPlaying.value) {
|
||||
audioRef.value.pause()
|
||||
isPlaying.value = false
|
||||
isPlayPending.value = false
|
||||
emit('pause')
|
||||
} else if (!isPlayPending.value) {
|
||||
isPlayPending.value = true
|
||||
audioRef.value.play().then(() => {
|
||||
isPlaying.value = true
|
||||
isPlayPending.value = false
|
||||
emit('play')
|
||||
}).catch((error) => {
|
||||
console.error('播放失败:', error)
|
||||
isPlaying.value = false
|
||||
isPlayPending.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { if (props.audioUrl) setupAudio(props.audioUrl) })
|
||||
onUnmounted(() => {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.pause()
|
||||
audioRef.value.removeEventListener('timeupdate', timeupdateHandler)
|
||||
audioRef.value.removeEventListener('loadedmetadata', loadedmetadataHandler)
|
||||
audioRef.value.removeEventListener('ended', endedHandler)
|
||||
audioRef.value.src = ''
|
||||
audioRef.value.load()
|
||||
audioRef.value = null
|
||||
}
|
||||
})
|
||||
watch(() => props.audioUrl, (newUrl, oldUrl) => {
|
||||
if (newUrl === oldUrl) return
|
||||
setupAudio(newUrl)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.audio-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 80px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
|
||||
.audio-left {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.audio-cover {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 5px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
|
||||
.audio-title {
|
||||
color: white;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.audio-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
&:hover {
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: white;
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-time {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audio-right {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.play-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
svg { width: 20px; height: 20px; fill: white; }
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
168
src/components/CustomSlider/index.vue
Normal file
@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="custom-slider" ref="sliderRef" @mousedown="handleMouseDown" @mouseleave="handleMouseLeave">
|
||||
<div class="slider-tooltip" v-show="isDragging && showTooltip" :style="{ left: fillPercentage + '%' }">{{ displayValue }}</div>
|
||||
<div class="slider-track"></div>
|
||||
<div class="slider-fill" :style="{ width: fillPercentage + '%' }"></div>
|
||||
<div class="slider-thumb" :style="{ left: fillPercentage + '%' }" @mousedown="handleThumbMouseDown"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Number, String],
|
||||
default: 0
|
||||
},
|
||||
min: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
step: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
showTooltip: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'mousedown', 'mouseup', 'mouseleave'])
|
||||
|
||||
const sliderRef = ref(null)
|
||||
const isDragging = ref(false)
|
||||
|
||||
const fillPercentage = computed(() => {
|
||||
if (props.modelValue === 'Auto' || typeof props.modelValue !== 'number') return 0
|
||||
const value = props.modelValue
|
||||
const percentage = ((value - props.min) / (props.max - props.min)) * 100
|
||||
return Math.max(0, Math.min(100, percentage))
|
||||
})
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (props.modelValue === 'Auto' || typeof props.modelValue !== 'number') return 'Auto'
|
||||
const decimalPlaces = props.step < 0.1 ? 2 : 1
|
||||
return props.modelValue.toFixed(decimalPlaces)
|
||||
})
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
isDragging.value = true
|
||||
emit('mousedown', e)
|
||||
updateValue(e.clientX)
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
const handleThumbMouseDown = (e) => {
|
||||
isDragging.value = true
|
||||
emit('mousedown', e)
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isDragging.value) return
|
||||
updateValue(e.clientX)
|
||||
}
|
||||
|
||||
const handleMouseUp = (e) => {
|
||||
isDragging.value = false
|
||||
emit('mouseup', e)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
const handleMouseLeave = (e) => {
|
||||
emit('mouseleave', e)
|
||||
}
|
||||
|
||||
const updateValue = (clientX) => {
|
||||
if (!sliderRef.value) return
|
||||
|
||||
const rect = sliderRef.value.getBoundingClientRect()
|
||||
const x = clientX - rect.left
|
||||
const percentage = Math.max(0, Math.min(1, x / rect.width))
|
||||
const rawValue = props.min + percentage * (props.max - props.min)
|
||||
const steppedValue = Math.round(rawValue / props.step) * props.step
|
||||
const value = Math.max(props.min, Math.min(props.max, steppedValue))
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.custom-slider {
|
||||
position: relative;
|
||||
width: 244px;
|
||||
height: 2px;
|
||||
cursor: pointer;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #F8F9FA;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.slider-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: #000F33;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #ffffff;
|
||||
border: 2px solid #000F33;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-tooltip {
|
||||
position: absolute;
|
||||
top: -35px;
|
||||
transform: translateX(-50%);
|
||||
background: #000F33;
|
||||
color: #fff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: "Microsoft YaHei";
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 5px solid #000F33;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -93,10 +93,6 @@ onUnmounted(() => {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.img-element:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.fullscreen-icon {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
|
||||
69
src/components/ParamGroup/index.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<template v-for="param in dynamicParams" :key="param.name">
|
||||
<Select
|
||||
v-if="param.ui === 'select' || param.type === 'select'"
|
||||
:model-value="paramValues[param.name]"
|
||||
:options="(param.options || []).map(o => (typeof o === 'object' ? o : { value: o, label: String(o) }))"
|
||||
class="param-select"
|
||||
position="top"
|
||||
@update:model-value="(v) => paramValues[param.name] = v"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="param-label">{{ param.label || param.name }}</span>
|
||||
</template>
|
||||
</Select>
|
||||
<SwitchControl
|
||||
v-else-if="param.ui === 'switch' || param.type === 'boolean' || param.type === 'Boolean'"
|
||||
:model-value="paramValues[param.name] === true || paramValues[param.name] === 'true'"
|
||||
:label="param.label || param.name"
|
||||
@update:model-value="(v) => paramValues[param.name] = v"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import SwitchControl from '@/components/SwitchControl/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
config: { type: Object, default: null },
|
||||
paramValues: { type: Object, default: () => ({}) },
|
||||
excludeNames: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const handledUis = ['textarea', 'proportion', 'resolution', 'dimension', 'dimensionWidth', 'dimensionHeight', 'quantity', 'imageUpload', 'hidden', 'number']
|
||||
|
||||
const dynamicParams = computed(() => {
|
||||
if (!props.config?.params) return []
|
||||
return props.config.params.filter((p) => {
|
||||
if (handledUis.includes(p.ui)) return false
|
||||
if (props.excludeNames.includes(p.name)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.param-select {
|
||||
:deep(.select-header) {
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
&:hover { background: #e9eaeb; }
|
||||
}
|
||||
:deep(.select-text) { font-size: 14px; }
|
||||
:deep(.dropdown-menu) { min-width: 136px; }
|
||||
:deep(.dropdown-item) {
|
||||
min-width: 80px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
.param-label {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="custom-popover" ref="popoverRef">
|
||||
<div class="popover-trigger" ref="triggerRef" @click.stop="togglePopover">
|
||||
<div ref="popoverRef" class="custom-popover">
|
||||
<div ref="triggerRef" class="popover-trigger" @click.stop="togglePopover">
|
||||
<slot name="reference" />
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
@ -47,20 +47,28 @@ const contentRef = ref(null)
|
||||
const visible = ref(props.modelValue)
|
||||
const position = ref({ top: 0, left: 0 })
|
||||
const popoverId = ref(Math.random().toString(36).substr(2, 9))
|
||||
let resizeObserver = null
|
||||
|
||||
if (!window.__currentOpenPopoverId__) {
|
||||
window.__currentOpenPopoverId__ = null
|
||||
}
|
||||
|
||||
const contentStyle = computed(() => ({
|
||||
width: typeof props.width === 'number' ? `${props.width}px` : props.width,
|
||||
...position.value
|
||||
}))
|
||||
const contentStyle = computed(() => {
|
||||
const w = typeof props.width === 'number' ? `${props.width}px` : props.width
|
||||
const isAuto = w === 'auto'
|
||||
return {
|
||||
...position.value,
|
||||
width: isAuto ? 'fit-content' : w,
|
||||
maxWidth: isAuto ? '600px' : w,
|
||||
minWidth: isAuto ? '0' : w
|
||||
}
|
||||
})
|
||||
|
||||
const togglePopover = async () => {
|
||||
if (visible.value) {
|
||||
visible.value = false
|
||||
window.__currentOpenPopoverId__ = null
|
||||
stopResizeObserver()
|
||||
} else {
|
||||
if (window.__currentOpenPopoverId__ && window.__currentOpenPopoverId__ !== popoverId.value) {
|
||||
window.dispatchEvent(new CustomEvent('close-other-popovers', { detail: { excludeId: popoverId.value } }))
|
||||
@ -72,20 +80,21 @@ const togglePopover = async () => {
|
||||
window.__currentOpenPopoverId__ = popoverId.value
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
startResizeObserver()
|
||||
}
|
||||
emit('update:modelValue', visible.value)
|
||||
}
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!triggerRef.value || !contentRef.value) return
|
||||
|
||||
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||
const contentRect = contentRef.value.getBoundingClientRect()
|
||||
const gap = 25
|
||||
|
||||
|
||||
let top = 0
|
||||
let left = 0
|
||||
|
||||
|
||||
switch (props.placement) {
|
||||
case 'top':
|
||||
top = triggerRect.top - contentRect.height - gap
|
||||
@ -104,27 +113,44 @@ const updatePosition = () => {
|
||||
left = triggerRect.right + gap
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
position.value = {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`
|
||||
}
|
||||
}
|
||||
|
||||
const startResizeObserver = () => {
|
||||
if (!contentRef.value) return
|
||||
stopResizeObserver()
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updatePosition()
|
||||
})
|
||||
resizeObserver.observe(contentRef.value)
|
||||
}
|
||||
|
||||
const stopResizeObserver = () => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (e) => {
|
||||
if (!visible.value) return
|
||||
|
||||
|
||||
const triggerEl = popoverRef.value
|
||||
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
|
||||
stopResizeObserver()
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
}
|
||||
@ -132,12 +158,14 @@ const handleClickOutside = (e) => {
|
||||
const handleCloseOtherPopovers = (e) => {
|
||||
if (e.detail.excludeId !== popoverId.value) {
|
||||
visible.value = false
|
||||
stopResizeObserver()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseOtherSelects = () => {
|
||||
visible.value = false
|
||||
window.__currentOpenPopoverId__ = null
|
||||
stopResizeObserver()
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, async (val) => {
|
||||
@ -145,6 +173,9 @@ watch(() => props.modelValue, async (val) => {
|
||||
if (val) {
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
startResizeObserver()
|
||||
} else {
|
||||
stopResizeObserver()
|
||||
}
|
||||
})
|
||||
|
||||
@ -162,6 +193,7 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
window.removeEventListener('close-other-popovers', handleCloseOtherPopovers)
|
||||
window.removeEventListener('close-other-selects', handleCloseOtherSelects)
|
||||
stopResizeObserver()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@ -261,6 +261,8 @@ onBeforeUnmount(() => {
|
||||
border: 1px solid #e8e8e8;
|
||||
animation: fadeIn 0.2s ease;
|
||||
gap: 10px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@ -311,6 +313,7 @@ onBeforeUnmount(() => {
|
||||
box-sizing: border-box;
|
||||
border-radius: 5px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
@ -375,7 +378,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.option-group {
|
||||
/* margin-bottom: 10px; */
|
||||
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
81
src/components/SwitchControl/index.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div
|
||||
class="switch-control"
|
||||
:class="{ active: modelValue }"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="switch-label">{{ label }}</span>
|
||||
<span class="switch-track">
|
||||
<span class="switch-thumb" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
label: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
function toggle() {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.switch-control {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #e9eaeb;
|
||||
}
|
||||
}
|
||||
.switch-label {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.switch-track {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: #c0c4cc;
|
||||
transition: background 0.25s;
|
||||
flex-shrink: 0;
|
||||
|
||||
.active & {
|
||||
background: #000F33;
|
||||
}
|
||||
}
|
||||
.switch-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.25s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.active & {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -9,8 +9,8 @@
|
||||
</div>
|
||||
<div class="close-btn" @click="handleClose">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="13" viewBox="0 0 12 13" fill="none">
|
||||
<path d="M1.28809 1.01469L10.9873 11.0052" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M10.6846 1.02413L0.985354 11.0147" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M1.28809 1.01469L10.9873 11.0052" stroke="#666666" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M10.6846 1.02413L0.985354 11.0147" stroke="#666666" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@ -32,8 +32,8 @@
|
||||
ref="editableDivRef"
|
||||
contenteditable="true"
|
||||
class="custom-textarea"
|
||||
@input="handleInput"
|
||||
:data-placeholder="!inputText ? '请输入提示词或使用圆形/矩形工具' : ''"
|
||||
@input="handleInput"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@ -48,7 +48,7 @@
|
||||
@click="currentShape = 'rectangle'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<rect x="1.84961" y="1.84998" width="14.3" height="14.3" rx="1.5" :stroke="currentShape === 'rectangle' ? '#000F33' : '#888888'"/>
|
||||
<rect x="1.84961" y="1.84998" width="14.3" height="14.3" rx="1.5" :stroke="currentShape === 'rectangle' ? '#000F33' : '#888888'" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@ -59,7 +59,7 @@
|
||||
@click="currentShape = 'circle'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<circle cx="9.00039" cy="9.00002" r="7.6" :stroke="currentShape === 'circle' ? '#000F33' : '#888888'"/>
|
||||
<circle cx="9.00039" cy="9.00002" r="7.6" :stroke="currentShape === 'circle' ? '#000F33' : '#888888'" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@ -68,22 +68,22 @@
|
||||
<!-- 上一步 -->
|
||||
<div class="shape-btn" @click="undo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path d="M1.64645 3.64645C1.45118 3.84171 1.45118 4.15829 1.64645 4.35355L4.82843 7.53553C5.02369 7.7308 5.34027 7.7308 5.53553 7.53553C5.7308 7.34027 5.7308 7.02369 5.53553 6.82843L2.70711 4L5.53553 1.17157C5.7308 0.976311 5.7308 0.659728 5.53553 0.464466C5.34027 0.269204 5.02369 0.269204 4.82843 0.464466L1.64645 3.64645ZM2 14.5C1.72386 14.5 1.5 14.7239 1.5 15C1.5 15.2761 1.72386 15.5 2 15.5V15V14.5ZM2 4V4.5H10.5V4V3.5H2V4ZM10.5 15V14.5H2V15V15.5H10.5V15ZM16 9.5H15.5C15.5 12.2614 13.2614 14.5 10.5 14.5V15V15.5C13.8137 15.5 16.5 12.8137 16.5 9.5H16ZM16 9.5H16.5C16.5 6.18629 13.8137 3.5 10.5 3.5V4V4.5C13.2614 4.5 15.5 6.73858 15.5 9.5H16Z" fill="#000F33"/>
|
||||
<path d="M1.64645 3.64645C1.45118 3.84171 1.45118 4.15829 1.64645 4.35355L4.82843 7.53553C5.02369 7.7308 5.34027 7.7308 5.53553 7.53553C5.7308 7.34027 5.7308 7.02369 5.53553 6.82843L2.70711 4L5.53553 1.17157C5.7308 0.976311 5.7308 0.659728 5.53553 0.464466C5.34027 0.269204 5.02369 0.269204 4.82843 0.464466L1.64645 3.64645ZM2 14.5C1.72386 14.5 1.5 14.7239 1.5 15C1.5 15.2761 1.72386 15.5 2 15.5V15V14.5ZM2 4V4.5H10.5V4V3.5H2V4ZM10.5 15V14.5H2V15V15.5H10.5V15ZM16 9.5H15.5C15.5 12.2614 13.2614 14.5 10.5 14.5V15V15.5C13.8137 15.5 16.5 12.8137 16.5 9.5H16ZM16 9.5H16.5C16.5 6.18629 13.8137 3.5 10.5 3.5V4V4.5C13.2614 4.5 15.5 6.73858 15.5 9.5H16Z" fill="#000F33" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 下一步 -->
|
||||
<div class="shape-btn" @click="redo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path d="M16.3536 3.64645C16.5488 3.84171 16.5488 4.15829 16.3536 4.35355L13.1716 7.53553C12.9763 7.7308 12.6597 7.7308 12.4645 7.53553C12.2692 7.34027 12.2692 7.02369 12.4645 6.82843L15.2929 4L12.4645 1.17157C12.2692 0.976311 12.2692 0.659728 12.4645 0.464466C12.6597 0.269204 12.9763 0.269204 13.1716 0.464466L16.3536 3.64645ZM16 13.5C16.2761 13.5 16.5 13.7239 16.5 14C16.5 14.2761 16.2761 14.5 16 14.5V14V13.5ZM16 4V4.5H8V4V3.5H16V4ZM8 14V13.5H16V14V14.5H8V14ZM3 9H3.5C3.5 11.4853 5.51472 13.5 8 13.5V14V14.5C4.96243 14.5 2.5 12.0376 2.5 9H3ZM3 9H2.5C2.5 5.96243 4.96243 3.5 8 3.5V4V4.5C5.51472 4.5 3.5 6.51472 3.5 9H3Z" fill="#000F33"/>
|
||||
<path d="M16.3536 3.64645C16.5488 3.84171 16.5488 4.15829 16.3536 4.35355L13.1716 7.53553C12.9763 7.7308 12.6597 7.7308 12.4645 7.53553C12.2692 7.34027 12.2692 7.02369 12.4645 6.82843L15.2929 4L12.4645 1.17157C12.2692 0.976311 12.2692 0.659728 12.4645 0.464466C12.6597 0.269204 12.9763 0.269204 13.1716 0.464466L16.3536 3.64645ZM16 13.5C16.2761 13.5 16.5 13.7239 16.5 14C16.5 14.2761 16.2761 14.5 16 14.5V14V13.5ZM16 4V4.5H8V4V3.5H16V4ZM8 14V13.5H16V14V14.5H8V14ZM3 9H3.5C3.5 11.4853 5.51472 13.5 8 13.5V14V14.5C4.96243 14.5 2.5 12.0376 2.5 9H3ZM3 9H2.5C2.5 5.96243 4.96243 3.5 8 3.5V4V4.5C5.51472 4.5 3.5 6.51472 3.5 9H3Z" fill="#000F33" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 删除 -->
|
||||
<div class="shape-btn" @click="deleteShape">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path d="M1.7998 3.60002H4.4998M16.1998 3.60002H13.4998M13.4998 3.60002V2.90003C13.4998 1.79546 12.6044 0.900024 11.4998 0.900024H6.4998C5.39523 0.900024 4.4998 1.79545 4.4998 2.90002V3.60002M13.4998 3.60002H4.4998" stroke="#000F33" stroke-linecap="round"/>
|
||||
<path d="M3.59961 6.29999V14.2C3.59961 15.3046 4.49504 16.2 5.59961 16.2H12.3996C13.5042 16.2 14.3996 15.3046 14.3996 14.2V6.29999M7.19961 7.19999V14.4M10.7996 7.19999V14.4" stroke="#000F33" stroke-linecap="round"/>
|
||||
<path d="M1.7998 3.60002H4.4998M16.1998 3.60002H13.4998M13.4998 3.60002V2.90003C13.4998 1.79546 12.6044 0.900024 11.4998 0.900024H6.4998C5.39523 0.900024 4.4998 1.79545 4.4998 2.90002V3.60002M13.4998 3.60002H4.4998" stroke="#000F33" stroke-linecap="round" />
|
||||
<path d="M3.59961 6.29999V14.2C3.59961 15.3046 4.49504 16.2 5.59961 16.2H12.3996C13.5042 16.2 14.3996 15.3046 14.3996 14.2V6.29999M7.19961 7.19999V14.4M10.7996 7.19999V14.4" stroke="#000F33" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@ -103,8 +103,8 @@
|
||||
<span class="brush-panel-title">请输入替换内容描述</span>
|
||||
<div class="brush-panel-close" @click="closeBrushPanel">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="13" viewBox="0 0 12 13" fill="none">
|
||||
<path d="M1.28809 1.01468L10.9873 11.0052" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M10.6846 1.02411L0.985354 11.0147" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M1.28809 1.01468L10.9873 11.0052" stroke="#666666" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M10.6846 1.02411L0.985354 11.0147" stroke="#666666" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@ -114,8 +114,8 @@
|
||||
ref="brushTextareaRef"
|
||||
contenteditable="true"
|
||||
class="brush-textarea"
|
||||
@input="handleBrushInput"
|
||||
:data-placeholder="!currentShapeDescription ? '请输入描述...' : ''"
|
||||
@input="handleBrushInput"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@ -135,7 +135,7 @@
|
||||
<img :src="img" alt="参考图" />
|
||||
<div class="reference-image-delete" @click.stop="removeReferenceImage(index)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="7" height="7" viewBox="0 0 7 7" fill="none">
|
||||
<path d="M0.5 0.5L6.5 6.5M6.5 0.5L0.5 6.5" stroke="white" stroke-linecap="round"/>
|
||||
<path d="M0.5 0.5L6.5 6.5M6.5 0.5L0.5 6.5" stroke="white" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@ -145,7 +145,7 @@
|
||||
@click="handleUploadReference"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||
<path d="M7.49316 0C7.76931 3.42279e-07 7.99316 0.223857 7.99316 0.5V6.99316H14.5C14.7761 6.99316 15 7.21702 15 7.49316C14.9999 7.76925 14.7761 7.99316 14.5 7.99316H7.99316V14.5C7.99316 14.7761 7.76931 15 7.49316 15C7.21702 15 6.99316 14.7761 6.99316 14.5V7.99316H0.5C0.223898 7.99316 6.59601e-05 7.76925 0 7.49316C0 7.21702 0.223858 6.99316 0.5 6.99316H6.99316V0.5C6.99316 0.223857 7.21702 1.20706e-08 7.49316 0Z" fill="#000F33"/>
|
||||
<path d="M7.49316 0C7.76931 3.42279e-07 7.99316 0.223857 7.99316 0.5V6.99316H14.5C14.7761 6.99316 15 7.21702 15 7.49316C14.9999 7.76925 14.7761 7.99316 14.5 7.99316H7.99316V14.5C7.99316 14.7761 7.76931 15 7.49316 15C7.21702 15 6.99316 14.7761 6.99316 14.5V7.99316H0.5C0.223898 7.99316 6.59601e-05 7.76925 0 7.49316C0 7.21702 0.223858 6.99316 0.5 6.99316H6.99316V0.5C6.99316 0.223857 7.21702 1.20706e-08 7.49316 0Z" fill="#000F33" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@ -160,9 +160,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { generate } from '@/utils/websocket'
|
||||
import { useDisplayStore, useUserStore } from '@/stores'
|
||||
import { useDisplayStore } from '@/stores'
|
||||
import { getModelId } from '@/utils/modelApi'
|
||||
import request from '@/utils/request'
|
||||
import { generate } from '@/utils/taskPolling'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
@ -239,11 +240,11 @@ const updateCurrentEditingContent = (description) => {
|
||||
const colorIndex = currentEditingShapeIndex.value >= 0 ? currentEditingShapeIndex.value : 0
|
||||
const colorName = colorNames[colorIndex % colorNames.length]
|
||||
const isFirstShape = shapes.value.length === 0 || (currentEditingShapeIndex.value === 0 && shapes.value.length === 1)
|
||||
|
||||
|
||||
const currentShape = currentEditingShapeIndex.value >= 0 ? shapes.value[currentEditingShapeIndex.value] : null
|
||||
const shapeType = currentShape ? currentShape.type : 'rectangle'
|
||||
const shapeWord = shapeType === 'circle' ? '圈' : '框'
|
||||
|
||||
|
||||
if (selectedReferenceImages.value.length > 0) {
|
||||
const imageIndex = allReferenceImages.value.indexOf(selectedReferenceImages.value[0]) + 2
|
||||
const prefix = isFirstShape ? '' : ','
|
||||
@ -267,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 = ''
|
||||
@ -283,19 +284,19 @@ watch(() => props.visible, (newVal) => {
|
||||
const initCanvas = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
const container = canvas.parentElement
|
||||
const containerWidth = container.clientWidth
|
||||
const containerHeight = container.clientHeight
|
||||
|
||||
|
||||
if (currentImage.value) {
|
||||
let imageUrl = currentImage.value
|
||||
|
||||
|
||||
if (!imageUrl.startsWith('data:')) {
|
||||
imageUrl = imageUrl.replace('https://sxwz.xueai.art', 'https://talkingdraw.xueai.art')
|
||||
}
|
||||
|
||||
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
@ -309,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)
|
||||
@ -362,7 +363,7 @@ const handleMouseDown = (e) => {
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isDrawing.value) return
|
||||
|
||||
|
||||
const canvas = canvasRef.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
const ratio = canvas.width / canvas.offsetWidth
|
||||
@ -372,15 +373,15 @@ const handleMouseMove = (e) => {
|
||||
const savedStartY = startY.value
|
||||
const savedCurrentX = currentX
|
||||
const savedCurrentY = currentY
|
||||
|
||||
|
||||
if (bgImage.value) {
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
const currentShapeData = {
|
||||
type: currentShape.value,
|
||||
startX: savedStartX,
|
||||
@ -395,10 +396,10 @@ 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)
|
||||
})
|
||||
|
||||
|
||||
const currentShapeData = {
|
||||
type: currentShape.value,
|
||||
startX: startX.value,
|
||||
@ -413,34 +414,34 @@ const handleMouseMove = (e) => {
|
||||
|
||||
const handleMouseUp = (e) => {
|
||||
if (!isDrawing.value) return
|
||||
|
||||
|
||||
const canvas = canvasRef.value
|
||||
const ratio = canvas.width / canvas.offsetWidth
|
||||
const endX = e.offsetX * ratio
|
||||
const endY = e.offsetY * ratio
|
||||
|
||||
|
||||
if (shapes.value.length >= maxShapes) {
|
||||
ElMessage.warning('最多只能画5笔')
|
||||
isDrawing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const colorIndex = shapes.value.length
|
||||
const newShape = {
|
||||
type: currentShape.value,
|
||||
startX: startX.value,
|
||||
startY: startY.value,
|
||||
endX: endX,
|
||||
endY: endY,
|
||||
endX,
|
||||
endY,
|
||||
color: shapeColors[colorIndex],
|
||||
description: '',
|
||||
referenceImages: []
|
||||
}
|
||||
shapes.value.push(newShape)
|
||||
currentEditingShapeIndex.value = shapes.value.length - 1
|
||||
|
||||
|
||||
isDrawing.value = false
|
||||
|
||||
|
||||
brushPanelVisible.value = true
|
||||
isPanelOpen.value = true
|
||||
currentShapeDescription.value = ''
|
||||
@ -451,7 +452,7 @@ const saveHistory = () => {
|
||||
history.value = history.value.slice(0, historyIndex.value + 1)
|
||||
history.value.push([...shapes.value])
|
||||
historyIndex.value = history.value.length - 1
|
||||
|
||||
|
||||
promptHistory.value = promptHistory.value.slice(0, promptHistoryIndex.value + 1)
|
||||
promptHistory.value.push(inputText.value)
|
||||
promptHistoryIndex.value = promptHistory.value.length - 1
|
||||
@ -462,7 +463,7 @@ const undo = () => {
|
||||
historyIndex.value--
|
||||
shapes.value = historyIndex.value >= 0 ? [...history.value[historyIndex.value]] : []
|
||||
redrawCanvas()
|
||||
|
||||
|
||||
promptHistoryIndex.value--
|
||||
inputText.value = promptHistoryIndex.value >= 0 ? promptHistory.value[promptHistoryIndex.value] : ''
|
||||
if (editableDivRef.value) {
|
||||
@ -476,7 +477,7 @@ const redo = () => {
|
||||
historyIndex.value++
|
||||
shapes.value = [...history.value[historyIndex.value]]
|
||||
redrawCanvas()
|
||||
|
||||
|
||||
promptHistoryIndex.value++
|
||||
inputText.value = promptHistory.value[promptHistoryIndex.value]
|
||||
if (editableDivRef.value) {
|
||||
@ -492,11 +493,11 @@ const deleteShape = () => {
|
||||
promptHistoryIndex.value = -1
|
||||
history.value = []
|
||||
historyIndex.value = -1
|
||||
|
||||
|
||||
if (editableDivRef.value) {
|
||||
editableDivRef.value.innerHTML = ''
|
||||
}
|
||||
|
||||
|
||||
redrawCanvas()
|
||||
}
|
||||
|
||||
@ -504,17 +505,17 @@ const redrawCanvas = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
|
||||
if (bgImage.value) {
|
||||
ctx.drawImage(bgImage.value, 0, 0)
|
||||
} else {
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
shapes.value.forEach(shape => {
|
||||
|
||||
shapes.value.forEach((shape) => {
|
||||
drawShape(ctx, shape)
|
||||
})
|
||||
}
|
||||
@ -522,14 +523,14 @@ const redrawCanvas = () => {
|
||||
const drawShape = (ctx, shape) => {
|
||||
ctx.strokeStyle = shape.color || '#ff0000'
|
||||
ctx.lineWidth = 2
|
||||
|
||||
|
||||
if (shape.type === 'rectangle') {
|
||||
const width = shape.endX - shape.startX
|
||||
const height = shape.endY - shape.startY
|
||||
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)
|
||||
@ -552,7 +553,7 @@ const removeReferenceImage = (index) => {
|
||||
const selectReferenceImage = (index) => {
|
||||
const img = allReferenceImages.value[index]
|
||||
const existingIndex = selectedReferenceImages.value.indexOf(img)
|
||||
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
selectedReferenceImages.value = []
|
||||
} else {
|
||||
@ -568,14 +569,14 @@ const handleUploadReference = () => {
|
||||
input.onchange = async (e) => {
|
||||
const files = Array.from(e.target.files)
|
||||
const remainingSlots = 5 - allReferenceImages.value.length
|
||||
|
||||
|
||||
if (remainingSlots <= 0) {
|
||||
ElMessage.warning('最多只能上传5张图片')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const filesToUpload = files.slice(0, remainingSlots)
|
||||
|
||||
|
||||
const readFileAsDataURL = (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
@ -584,7 +585,7 @@ const handleUploadReference = () => {
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const imageDataUrls = await Promise.all(filesToUpload.map(readFileAsDataURL))
|
||||
allReferenceImages.value.push(...imageDataUrls)
|
||||
}
|
||||
@ -596,7 +597,7 @@ const closeBrushPanel = () => {
|
||||
shapes.value.splice(currentEditingShapeIndex.value, 1)
|
||||
redrawCanvas()
|
||||
}
|
||||
|
||||
|
||||
brushPanelVisible.value = false
|
||||
isPanelOpen.value = false
|
||||
currentShapeDescription.value = ''
|
||||
@ -610,7 +611,7 @@ const handleBrushConfirm = () => {
|
||||
shapes.value[currentEditingShapeIndex.value].description = currentShapeDescription.value
|
||||
shapes.value[currentEditingShapeIndex.value].referenceImages = [...selectedReferenceImages.value]
|
||||
}
|
||||
|
||||
|
||||
if (currentEditingContent.value) {
|
||||
if (editableDivRef.value) {
|
||||
const currentContent = editableDivRef.value.innerHTML
|
||||
@ -620,9 +621,9 @@ const handleBrushConfirm = () => {
|
||||
inputText.value += currentEditingContent.value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
saveHistory()
|
||||
|
||||
|
||||
brushPanelVisible.value = false
|
||||
isPanelOpen.value = false
|
||||
currentShapeDescription.value = ''
|
||||
@ -634,10 +635,10 @@ const getImageAspectRatio = () => {
|
||||
if (!bgImage.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
const width = bgImage.value.width
|
||||
const height = bgImage.value.height
|
||||
|
||||
|
||||
const aspectRatios = [
|
||||
{ ratio: '4:3', value: 4 / 3 },
|
||||
{ ratio: '16:9', value: 16 / 9 },
|
||||
@ -647,12 +648,12 @@ const getImageAspectRatio = () => {
|
||||
{ ratio: '2:3', value: 2 / 3 },
|
||||
{ ratio: '3:2', value: 3 / 2 }
|
||||
]
|
||||
|
||||
|
||||
const currentRatio = width / height
|
||||
|
||||
|
||||
let closest = aspectRatios[0]
|
||||
let minDiff = Math.abs(currentRatio - closest.value)
|
||||
|
||||
|
||||
for (const item of aspectRatios) {
|
||||
const diff = Math.abs(currentRatio - item.value)
|
||||
if (diff < minDiff) {
|
||||
@ -660,7 +661,7 @@ const getImageAspectRatio = () => {
|
||||
closest = item
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
@ -674,34 +675,34 @@ const handleSend = async () => {
|
||||
ElMessage.error('请输入提示词')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
isSending.value = true
|
||||
|
||||
|
||||
try {
|
||||
const canvas = canvasRef.value
|
||||
const imageData = canvas.toDataURL('image/png')
|
||||
|
||||
|
||||
const imgs = []
|
||||
if (imageData) {
|
||||
imgs.push({ name: 'image_1', url: imageData })
|
||||
}
|
||||
|
||||
|
||||
allReferenceImages.value.forEach((img, index) => {
|
||||
imgs.push({ name: `image_${index + 2}`, url: img })
|
||||
})
|
||||
|
||||
|
||||
const uploadImg = async (imgItem) => {
|
||||
if (!imgItem.url.startsWith('data:') && !imgItem.url.startsWith('blob:')) {
|
||||
return imgItem
|
||||
}
|
||||
|
||||
|
||||
const response = await fetch(imgItem.url)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], `${imgItem.name}.png`, { type: 'image/png' })
|
||||
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
|
||||
try {
|
||||
const result = await request({
|
||||
url: import.meta.env.VITE_API_WORKFLOW_UPLOAD,
|
||||
@ -720,11 +721,13 @@ const handleSend = async () => {
|
||||
return imgItem
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const uploadedImgs = await Promise.all(imgs.map(uploadImg))
|
||||
|
||||
|
||||
const proportion = getImageAspectRatio()
|
||||
|
||||
|
||||
const modelId = await getModelId(props.type, 'GPT')
|
||||
|
||||
const generateData = {
|
||||
model: 'GPT-image2.0',
|
||||
modelType: 'edit',
|
||||
@ -737,23 +740,23 @@ const handleSend = async () => {
|
||||
AIGC: 'Painting',
|
||||
platform: 'runninghub',
|
||||
modelName: 'GPT',
|
||||
modelId,
|
||||
quantity: 1,
|
||||
free: useUserStore().freeTimes,
|
||||
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,
|
||||
result: JSON.stringify(generateData)
|
||||
request: JSON.stringify(generateData)
|
||||
}
|
||||
|
||||
|
||||
emit('send', {
|
||||
image: imageData,
|
||||
text: inputText.value,
|
||||
shapes: shapes.value
|
||||
})
|
||||
|
||||
|
||||
await generate(data, generateData)
|
||||
handleClose()
|
||||
} finally {
|
||||
|
||||
@ -1,268 +1,186 @@
|
||||
<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="modelType !== 'text'" class="upload-img-container">
|
||||
<div v-show="showUploader" class="upload-img-container">
|
||||
<div class="reference-diagram">
|
||||
<ImageUploader
|
||||
v-if="props.type === 'Painting'"
|
||||
ref="referenceDiagramRef"
|
||||
v-model="referenceImages"
|
||||
:limit="4"
|
||||
@open-canvas="handleOpenCanvas"
|
||||
/>
|
||||
<VideoImageUploader
|
||||
v-else-if="props.type === 'Video'"
|
||||
ref="referenceDiagramRef"
|
||||
v-model="referenceImages"
|
||||
:model-type="modelType"
|
||||
:images-count="modelDisplayConfig?.display?.images || 1"
|
||||
<component
|
||||
:is="platform.ImageUploader"
|
||||
v-if="platform.ImageUploader"
|
||||
ref="referenceDiagramRef"
|
||||
v-model="referenceImages"
|
||||
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-model="proportion"
|
||||
v-model:resolution="resolution"
|
||||
:proportion-options="proportionOptions"
|
||||
:resolution-options="resolutionOptions"
|
||||
<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-model="quantity" />
|
||||
</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 paintingProportion from './proportion/painting.vue'
|
||||
import videoProportion from './proportion/video.vue'
|
||||
import paintingModel from './model/painting.vue'
|
||||
import videoModel from './model/video.vue'
|
||||
import Quantity from './quantity/index.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 { Sender } from 'vue-element-plus-x'
|
||||
import { useDisplayStore, useUserStore } from '@/stores'
|
||||
import { generate } from '@/utils/websocket'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchModelConfig } from '@/utils/modelConfig'
|
||||
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 useUser = useUserStore()
|
||||
|
||||
const isgerenate = ref(false)
|
||||
|
||||
const model = ref() // 模型
|
||||
const modelType = ref('text')
|
||||
|
||||
const modelDisplayConfig = ref(null)
|
||||
const promptPlaceholder = ref('描述你想生成的画面和动作。') // 提示词占位符
|
||||
|
||||
const prompt = ref('') // 提示词
|
||||
const proportion = ref('16:9') // 比例
|
||||
const resolution = ref('1k') // 分辨率
|
||||
const prompt = ref('')
|
||||
const referenceImages = ref([])
|
||||
|
||||
// 绘画
|
||||
const quantity = ref(1) // 生成数量
|
||||
const platform = computed(() => createPlatform(props.type))
|
||||
|
||||
// 视频
|
||||
const duration = ref(5) // 时间
|
||||
const videoPattern = ref('文生视频') // 视频模式下,默认值为'文生视频'
|
||||
const getCurrentConfig = () => {
|
||||
return platform.value.modelConfig?.value ?? platform.value.modelDisplayConfig?.value ?? null
|
||||
}
|
||||
|
||||
const resolutionOptions = ref([])
|
||||
const proportionOptions = ref([])
|
||||
const durationOptions = ref([])
|
||||
const visibleControls = computed(() => {
|
||||
const config = getCurrentConfig()
|
||||
return platform.value.controls.filter((c) => c.show(config))
|
||||
})
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const beforeModelControls = computed(() => visibleControls.value.filter((c) => c.beforeModel))
|
||||
const afterModelControls = computed(() => visibleControls.value.filter((c) => !c.beforeModel))
|
||||
|
||||
const showUploader = computed(() => {
|
||||
return platform.value.showImageUploader()
|
||||
})
|
||||
|
||||
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 loadModelConfig = 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
|
||||
|
||||
return config
|
||||
} catch (error) {
|
||||
console.error('加载模型配置失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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 (modelType.value === 'image' && !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 })
|
||||
})
|
||||
|
||||
|
||||
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,
|
||||
duration: duration.value,
|
||||
videoPattern: videoPattern.value
|
||||
modelParams: body,
|
||||
...(p.getGenerateDataExtras?.() || {})
|
||||
}
|
||||
|
||||
const data = {
|
||||
type: currentType,
|
||||
modelType: currentModelType,
|
||||
AIGC: currentType,
|
||||
platform: 'runninghub',
|
||||
modelName: model.value,
|
||||
quantity: quantity.value,
|
||||
free: useUser.freeTimes,
|
||||
params: [
|
||||
{ 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,
|
||||
result: JSON.stringify(generateData)
|
||||
|
||||
const data = {
|
||||
type: props.type,
|
||||
modelType: p.modelType.value,
|
||||
modelName: p.model.value,
|
||||
modelId: modelId || '',
|
||||
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.duration !== undefined) duration.value = resultData.duration
|
||||
if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
fillParamsFromResult,
|
||||
handleStart
|
||||
})
|
||||
defineExpose({ fillParamsFromResult, handleStart })
|
||||
|
||||
const handleContainerClick = () => {
|
||||
if (useDisplay.Sender_variant === 'default') {
|
||||
@ -271,49 +189,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 && newModelType) {
|
||||
await loadModelConfig(newModel, newModelType)
|
||||
// 模型变更 → 加载配置
|
||||
watch(
|
||||
[() => platform.value.model.value, () => platform.value.modelType.value],
|
||||
async ([newModel, newModelType]) => {
|
||||
if (!newModel) return
|
||||
await platform.value.loadConfig(newModel, newModelType)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 平台切换 → 设置默认模型 + 预加载模型列表
|
||||
watch(() => props.type, (newType) => {
|
||||
if (newType === 'Video') {
|
||||
model.value = 'LTX2.0'
|
||||
} else {
|
||||
model.value = 'flux'
|
||||
}
|
||||
const chargeType = newType === 'Painting' ? 1 : 4
|
||||
useUser.fetchFreeTimes(chargeType)
|
||||
const p = createPlatform(newType)
|
||||
p.model.value = p.getDefaultModel()
|
||||
p.loadModels()
|
||||
}, { immediate: true })
|
||||
|
||||
// 组件挂载时获取免费次数
|
||||
onMounted(async () => {
|
||||
const chargeType = props.type === 'Painting' ? 1 : 4
|
||||
await useUser.fetchFreeTimes(chargeType)
|
||||
console.log('免费次数', useUser.freeTimes)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 输入区域 */
|
||||
.input-container {
|
||||
width: 50%;
|
||||
max-width: 880px;
|
||||
max-width: 1024px;
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
z-index: 100;
|
||||
@ -348,20 +249,14 @@ onMounted(async () => {
|
||||
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;
|
||||
@ -377,35 +272,31 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
@ -416,27 +307,28 @@ onMounted(async () => {
|
||||
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;
|
||||
@ -449,10 +341,9 @@ onMounted(async () => {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.upload-btn:hover{
|
||||
background: #E5E7EB;
|
||||
}
|
||||
/* 圆形按钮 */
|
||||
|
||||
.upload-btn:hover { background: #E5E7EB; }
|
||||
|
||||
.circle-btn {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
@ -468,18 +359,10 @@ onMounted(async () => {
|
||||
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);
|
||||
@ -495,7 +378,7 @@ onMounted(async () => {
|
||||
transform: translate(-50%, 100%);
|
||||
}
|
||||
|
||||
.gerenate{
|
||||
.gerenate {
|
||||
display: inline-flex;
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
@ -505,7 +388,6 @@ onMounted(async () => {
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 15, 51, 0.10);
|
||||
cursor: pointer;
|
||||
|
||||
color: #000F33;
|
||||
text-align: center;
|
||||
font-family: "Microsoft YaHei";
|
||||
@ -514,11 +396,9 @@ onMounted(async () => {
|
||||
font-weight: 700;
|
||||
line-height: normal;
|
||||
}
|
||||
.isprompt{
|
||||
|
||||
.isprompt {
|
||||
color: #ffffff;
|
||||
background-color: #000F33;
|
||||
}
|
||||
// .gerenate:hover{
|
||||
// background: rgba(0, 15, 51, 0.20);
|
||||
// }
|
||||
</style>
|
||||
|
||||
@ -1,167 +0,0 @@
|
||||
<template>
|
||||
<Select
|
||||
v-model="model"
|
||||
:grouped-options="modelGroups"
|
||||
class="model-select"
|
||||
position="top"
|
||||
>
|
||||
<template #prefix>
|
||||
<img src="@/assets/dialog/model.svg" alt="" style="width: 16px;">
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: 'flux'
|
||||
},
|
||||
typeValue: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:typeValue'])
|
||||
|
||||
const paintingConfig = ref({
|
||||
generate: [],
|
||||
edit: [],
|
||||
vision: []
|
||||
})
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_API_MODEL_RESOURCE}/static/public/Platform/AIGC_modelConfig/painting.json`
|
||||
const response = await fetch(url)
|
||||
const data = await response.json()
|
||||
paintingConfig.value = data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch painting config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fetchConfig()
|
||||
|
||||
watch(() => paintingConfig.value, (newConfig) => {
|
||||
const allModels = [
|
||||
...(newConfig.generate || []),
|
||||
...(newConfig.edit || []),
|
||||
...(newConfig.vision || [])
|
||||
]
|
||||
if (allModels.length > 0) {
|
||||
const enabledModels = allModels.filter(m => !m.disabled)
|
||||
if (enabledModels.length > 0) {
|
||||
const currentModelExists = enabledModels.find(m => m.value === props.modelValue)
|
||||
if (!currentModelExists) {
|
||||
const firstEnabled = enabledModels[0].value
|
||||
emit('update:modelValue', firstEnabled)
|
||||
const newType = getModelType(firstEnabled)
|
||||
emit('update:typeValue', newType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
|
||||
const model = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value)
|
||||
const newType = getModelType(value)
|
||||
emit('update:typeValue', newType)
|
||||
}
|
||||
})
|
||||
|
||||
const generateModels = computed(() => paintingConfig.value.generate || [])
|
||||
const editModels = computed(() => paintingConfig.value.edit || [])
|
||||
const visionModels = computed(() => paintingConfig.value.vision || [])
|
||||
|
||||
const modelGroups = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: '生成模型',
|
||||
options: generateModels.value
|
||||
},
|
||||
{
|
||||
label: '编辑模型',
|
||||
options: editModels.value
|
||||
},
|
||||
{
|
||||
label: '视觉理解模型',
|
||||
options: visionModels.value
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const getModelType = (value) => {
|
||||
if (generateModels.value.find(m => m.value === value)) {
|
||||
return 'text'
|
||||
}
|
||||
if (editModels.value.find(m => m.value === value)) {
|
||||
return 'image'
|
||||
}
|
||||
if (visionModels.value.find(m => m.value === value)) {
|
||||
return 'vision'
|
||||
}
|
||||
return 'text'
|
||||
}
|
||||
|
||||
const getFirstEnabledModel = () => {
|
||||
const allModels = [...generateModels.value, ...editModels.value, ...visionModels.value]
|
||||
const firstEnabled = allModels.find(m => !m.disabled)
|
||||
return firstEnabled ? firstEnabled.value : ''
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
const allModels = [...generateModels.value, ...editModels.value, ...visionModels.value]
|
||||
const currentModel = allModels.find(m => m.value === newValue)
|
||||
if (currentModel && currentModel.disabled) {
|
||||
const firstEnabled = getFirstEnabledModel()
|
||||
if (firstEnabled) {
|
||||
emit('update:modelValue', firstEnabled)
|
||||
const newType = getModelType(firstEnabled)
|
||||
emit('update:typeValue', newType)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.model-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) {
|
||||
max-height: 510px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.dropdown-item) {
|
||||
min-width: 120px;
|
||||
|
||||
&.active {
|
||||
background: rgba(0, 15, 51, 0.10);
|
||||
color: #000F33;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,222 +0,0 @@
|
||||
# VirtualScroller 虚拟滚动组件
|
||||
|
||||
一个高性能的虚拟滚动组件,支持未知高度子组件渲染和滚动方向反转功能。
|
||||
|
||||
## 特性
|
||||
|
||||
- 🚀 **高性能虚拟滚动** - 仅渲染可视区域内的元素,支持大数据量渲染
|
||||
- 🔄 **滚动方向反转** - 通过双重 CSS 旋转实现向上滚动效果
|
||||
- 📏 **未知高度支持** - 动态测量子组件高度,无需预设固定高度
|
||||
- 🎯 **精确滚动控制** - 提供滚动到指定索引、顶部、底部等 API
|
||||
- 📱 **响应式设计** - 适配不同屏幕尺寸
|
||||
- ⚡ **60fps 流畅滚动** - 优化的渲染策略确保流畅体验
|
||||
|
||||
## 安装
|
||||
|
||||
组件位于 `src/components/virtual-scroller/` 目录下,无需额外安装依赖。
|
||||
|
||||
## 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<VirtualScroller
|
||||
:data="list"
|
||||
:estimated-height="100"
|
||||
:buffer="5"
|
||||
class="scroller"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div class="item" style="transform: rotate(180deg)">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { VirtualScroller } from '@/components/virtual-scroller'
|
||||
|
||||
const list = ref([
|
||||
{ id: 1, name: 'Item 1' },
|
||||
{ id: 2, name: 'Item 2' },
|
||||
])
|
||||
|
||||
const handleScroll = (event) => {
|
||||
console.log('滚动事件', event)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| 属性名 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `data` | `Array` | `[]` | 数据源数组(必填) |
|
||||
| `itemKey` | `string \| Function` | `'id'` | 用于标识每个项目的键名或函数 |
|
||||
| `estimatedHeight` | `number` | `100` | 预估的项目高度(像素) |
|
||||
| `buffer` | `number` | `3` | 可视区域外预渲染的项目数量 |
|
||||
| `height` | `string \| number` | `'100%'` | 滚动容器高度 |
|
||||
| `width` | `string \| number` | `'100%'` | 滚动容器宽度 |
|
||||
| `renderMode` | `'default' \| 'top'` | `'default'` | 渲染模式,`top` 模式会自动滚动到页面底部 |
|
||||
|
||||
## Events
|
||||
|
||||
| 事件名 | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| `scroll` | `event: Event` | 滚动事件,包含 `distanceToPageTop`、`distanceToPageBottom`、`isAtPageTop`、`isAtPageBottom` 属性 |
|
||||
| `scroll-start` | - | 滚动到**页面顶部**时触发 |
|
||||
| `scroll-end` | - | 滚动到**页面底部**时触发 |
|
||||
|
||||
## Expose Methods
|
||||
|
||||
通过 `ref` 可以访问以下方法:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const scrollerRef = ref(null)
|
||||
|
||||
// 滚动到指定索引
|
||||
scrollerRef.value.scrollToIndex(10)
|
||||
|
||||
// 滚动到页面底部(最新数据位置)
|
||||
scrollerRef.value.scrollToBottom()
|
||||
|
||||
// 滚动到页面顶部(最旧数据位置)
|
||||
scrollerRef.value.scrollToTop()
|
||||
|
||||
// 判断是否在页面底部
|
||||
const atBottom = scrollerRef.value.isAtPageBottom()
|
||||
|
||||
// 判断是否在页面顶部
|
||||
const atTop = scrollerRef.value.isAtPageTop()
|
||||
</script>
|
||||
```
|
||||
|
||||
| 方法名 | 参数 | 返回值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `scrollToIndex` | `index: number, behavior?: ScrollBehavior` | `void` | 滚动到指定索引的项目 |
|
||||
| `scrollToBottom` | `behavior?: ScrollBehavior` | `void` | 滚动到**页面底部**(最新数据) |
|
||||
| `scrollToTop` | `behavior?: ScrollBehavior` | `void` | 滚动到**页面顶部**(最旧数据) |
|
||||
| `getScrollElement` | - | `HTMLElement \| null` | 获取滚动容器 DOM 元素 |
|
||||
| `getVisibleIndices` | - | `number[]` | 获取当前可视项目的索引数组 |
|
||||
| `resetMeasurements` | - | `void` | 重置所有高度测量缓存 |
|
||||
| `isAtPageBottom` | - | `boolean` | 判断是否在页面底部 |
|
||||
| `isAtPageTop` | - | `boolean` | 判断是否在页面顶部 |
|
||||
|
||||
## 滚动方向反转原理
|
||||
|
||||
组件通过 CSS `transform: rotate(180deg)` 实现滚动方向反转:
|
||||
|
||||
1. **容器旋转**:滚动容器应用 `transform: rotate(180deg)`
|
||||
2. **内容反向旋转**:子组件内部应用 `transform: rotate(180deg)` 抵消旋转
|
||||
|
||||
### 坐标映射关系
|
||||
|
||||
由于容器旋转 180 度,坐标系统发生反转:
|
||||
|
||||
| 页面概念 | 组件内部 scrollTop |
|
||||
|----------|-------------------|
|
||||
| 页面顶部(最旧数据) | `scrollTop = scrollHeight - clientHeight` |
|
||||
| 页面底部(最新数据) | `scrollTop = 0` |
|
||||
|
||||
### 滚轮方向处理
|
||||
|
||||
组件内部处理了滚轮方向映射:
|
||||
- 用户**向上**滚动滚轮 → 页面内容**向上**滚动
|
||||
- 用户**向下**滚动滚轮 → 页面内容**向下**滚动
|
||||
|
||||
## 使用示例
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="container">
|
||||
<VirtualScroller
|
||||
ref="scrollerRef"
|
||||
:data="messageList"
|
||||
:estimated-height="80"
|
||||
:buffer="5"
|
||||
height="600px"
|
||||
render-mode="top"
|
||||
@scroll="handleScroll"
|
||||
@scroll-start="loadMore"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<MessageItem
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
style="transform: rotate(180deg)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { VirtualScroller } from '@/components/virtual-scroller'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
|
||||
const scrollerRef = ref(null)
|
||||
const messageList = ref([])
|
||||
const page = ref(1)
|
||||
|
||||
const fetchMessages = async () => {
|
||||
const response = await fetch(`/api/messages?page=${page.value}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (page.value === 1) {
|
||||
messageList.value = data
|
||||
} else {
|
||||
messageList.value = [...data, ...messageList.value]
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
page.value++
|
||||
fetchMessages()
|
||||
}
|
||||
|
||||
const handleScroll = (event) => {
|
||||
const { distanceToPageTop, distanceToPageBottom, isAtPageTop, isAtPageBottom } = event
|
||||
console.log('距离页面顶部:', distanceToPageTop)
|
||||
console.log('距离页面底部:', distanceToPageBottom)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMessages()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## 性能优化建议
|
||||
|
||||
1. **合理设置 `estimatedHeight`**:预估高度越接近实际高度,重排越少
|
||||
2. **适当调整 `buffer`**:较大的 buffer 会预渲染更多元素,减少白屏但增加内存占用
|
||||
3. **使用唯一的 `itemKey`**:确保每个项目有唯一标识,避免不必要的重渲染
|
||||
4. **避免复杂计算**:在插槽中避免复杂计算,使用计算属性或缓存
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 子组件显示倒置怎么办?
|
||||
|
||||
A: 在子组件上添加 `style="transform: rotate(180deg)"` 来抵消容器的旋转。
|
||||
|
||||
### Q: 如何判断是否滚动到页面底部?
|
||||
|
||||
A: 使用 `isAtPageBottom()` 方法或监听 `scroll-end` 事件。
|
||||
|
||||
### Q: scrollToBottom 和 scrollToTop 的方向?
|
||||
|
||||
A:
|
||||
- `scrollToBottom()` - 滚动到**页面底部**(最新数据位置)
|
||||
- `scrollToTop()` - 滚动到**页面顶部**(最旧数据位置)
|
||||
|
||||
## 浏览器兼容性
|
||||
|
||||
- Chrome >= 64
|
||||
- Firefox >= 69
|
||||
- Safari >= 13.1
|
||||
- Edge >= 79
|
||||
|
||||
需要浏览器支持 `ResizeObserver` API。
|
||||
@ -1,615 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="virtual-scroller"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
class="virtual-scroller-wrapper"
|
||||
:style="wrapperStyle"
|
||||
>
|
||||
<div
|
||||
ref="renderContainerRef"
|
||||
class="virtual-scroller-render-container"
|
||||
:style="renderContainerStyle"
|
||||
@scroll.passive="handleScroll"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<div class="virtual-scroller-spacer" :style="{ height: `${totalHeight}px` }"></div>
|
||||
<div
|
||||
class="virtual-scroller-bottom-placeholder"
|
||||
:style="bottomPlaceholderStyle"
|
||||
>
|
||||
<slot name="bottom-placeholder" />
|
||||
</div>
|
||||
<div
|
||||
v-for="renderItem in visibleItems"
|
||||
:key="getItemKey(renderItem.item, renderItem.index)"
|
||||
:ref="el => setItemRef(el, renderItem.index)"
|
||||
class="virtual-scroller-item"
|
||||
:style="getItemStyle(renderItem)"
|
||||
:data-index="renderItem.index"
|
||||
>
|
||||
<slot
|
||||
name="default"
|
||||
:item="renderItem.item"
|
||||
:index="renderItem.index"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
itemKey: {
|
||||
type: [String, Function],
|
||||
default: 'id'
|
||||
},
|
||||
keyField: {
|
||||
type: String,
|
||||
default: 'id'
|
||||
},
|
||||
estimatedHeight: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
buffer: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
bufferSize: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: '100%'
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: '100%'
|
||||
},
|
||||
renderMode: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['default', 'top'].includes(value)
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'reverse',
|
||||
validator: (value) => ['normal', 'reverse'].includes(value)
|
||||
},
|
||||
bottomPlaceholderHeight: {
|
||||
type: Number,
|
||||
default: 350
|
||||
}
|
||||
})
|
||||
|
||||
const computedData = computed(() => {
|
||||
return props.data.length > 0 ? props.data : props.items
|
||||
})
|
||||
|
||||
const computedItemKey = computed(() => {
|
||||
if (typeof props.itemKey === 'function') return props.itemKey
|
||||
if (props.itemKey !== 'id') return props.itemKey
|
||||
return props.keyField
|
||||
})
|
||||
|
||||
const computedBuffer = computed(() => {
|
||||
return props.buffer !== 3 ? props.buffer : props.bufferSize
|
||||
})
|
||||
|
||||
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end', 'visible-change'])
|
||||
|
||||
const containerRef = ref(null)
|
||||
const wrapperRef = ref(null)
|
||||
const renderContainerRef = ref(null)
|
||||
const itemRefs = new Map()
|
||||
const itemHeights = ref(new Map())
|
||||
const resizeObserver = ref(null)
|
||||
const scrollTop = ref(0)
|
||||
const isScrolling = ref(false)
|
||||
const scrollTimeout = ref(null)
|
||||
const isInitialized = ref(false)
|
||||
const pendingScrollToBottom = ref(false)
|
||||
const previousDataLength = ref(0)
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
return {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
let height = 0
|
||||
const len = computedData.value.length
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
height += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
height += props.bottomPlaceholderHeight
|
||||
|
||||
return height
|
||||
})
|
||||
|
||||
const getItemPosition = (index) => {
|
||||
let offset = 0
|
||||
|
||||
for (let i = 0; i < index; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
offset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
const height = itemHeights.value.get(index) ?? props.estimatedHeight
|
||||
|
||||
return { offset, height }
|
||||
}
|
||||
|
||||
const containerHeight = computed(() => {
|
||||
if (!renderContainerRef.value) return 0
|
||||
return renderContainerRef.value.clientHeight
|
||||
})
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
if (!renderContainerRef.value || computedData.value.length === 0) {
|
||||
return { start: 0, end: 0, offset: 0 }
|
||||
}
|
||||
|
||||
const viewportHeight = containerHeight.value
|
||||
const currentScrollTop = scrollTop.value
|
||||
const bufferCount = computedBuffer.value
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = computedData.value.length - 1
|
||||
let startOffset = 0
|
||||
|
||||
let offset = 0
|
||||
for (let i = 0; i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
if (offset + height > currentScrollTop) {
|
||||
startIndex = Math.max(0, i - bufferCount)
|
||||
break
|
||||
}
|
||||
|
||||
offset += height
|
||||
}
|
||||
|
||||
startOffset = 0
|
||||
for (let i = 0; i < startIndex; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
startOffset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
offset = startOffset
|
||||
endIndex = startIndex
|
||||
for (let i = startIndex; i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
offset += height
|
||||
|
||||
if (offset > currentScrollTop + viewportHeight) {
|
||||
endIndex = Math.min(computedData.value.length - 1, i + bufferCount)
|
||||
break
|
||||
}
|
||||
|
||||
endIndex = i
|
||||
}
|
||||
|
||||
return { start: Math.min(startIndex, endIndex), end: Math.max(startIndex, endIndex), offset: startOffset }
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
const { start, end, offset } = visibleRange.value
|
||||
const items = []
|
||||
|
||||
let currentOffset = offset
|
||||
|
||||
for (let i = start; i <= end && i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
items.push({
|
||||
item: computedData.value[i],
|
||||
index: i,
|
||||
offset: currentOffset + props.bottomPlaceholderHeight,
|
||||
height
|
||||
})
|
||||
|
||||
currentOffset += height
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const wrapperStyle = computed(() => ({
|
||||
direction: 'rtl',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
scrollbarWidth: 'auto',
|
||||
overflow: 'hidden',
|
||||
transform: 'rotate(180deg)',
|
||||
width: '100%'
|
||||
}))
|
||||
|
||||
const renderContainerStyle = computed(() => ({
|
||||
direction: 'ltr',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%'
|
||||
}))
|
||||
|
||||
const bottomPlaceholderStyle = computed(() => ({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: `${props.bottomPlaceholderHeight}px`,
|
||||
transform: `translateY(0px)`,
|
||||
zIndex: 1
|
||||
}))
|
||||
|
||||
const getItemKey = (item, index) => {
|
||||
const keyField = computedItemKey.value
|
||||
if (typeof keyField === 'function') {
|
||||
return keyField(item, index)
|
||||
}
|
||||
if (typeof keyField === 'string' && item && typeof item === 'object') {
|
||||
return item[keyField] ?? index
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
const getItemStyle = (renderItem) => {
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${renderItem.offset}px)`,
|
||||
willChange: 'transform'
|
||||
}
|
||||
}
|
||||
|
||||
const setItemRef = (el, index) => {
|
||||
if (el) {
|
||||
itemRefs.set(index, el)
|
||||
} else {
|
||||
itemRefs.delete(index)
|
||||
}
|
||||
}
|
||||
|
||||
const measureItem = (index, element) => {
|
||||
if (!element) return
|
||||
|
||||
const firstChild = element.firstElementChild
|
||||
const targetElement = firstChild || element
|
||||
|
||||
const height = targetElement.getBoundingClientRect().height
|
||||
|
||||
if (height > 0) {
|
||||
const cachedHeight = itemHeights.value.get(index)
|
||||
if (cachedHeight !== height) {
|
||||
const newHeights = new Map(itemHeights.value)
|
||||
newHeights.set(index, height)
|
||||
itemHeights.value = newHeights
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setupResizeObserver = () => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
}
|
||||
|
||||
resizeObserver.value = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const index = parseInt(entry.target.dataset.index, 10)
|
||||
if (!isNaN(index)) {
|
||||
measureItem(index, entry.target)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleWheel = (event) => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
const { deltaY } = event
|
||||
const el = renderContainerRef.value
|
||||
|
||||
el.scrollBy({
|
||||
top: -deltaY,
|
||||
behavior: 'instant'
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleScroll = (event) => {
|
||||
const target = event.target
|
||||
scrollTop.value = target.scrollTop
|
||||
isScrolling.value = true
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
scrollTimeout.value = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, 150)
|
||||
|
||||
const st = target.scrollTop
|
||||
const scrollHeight = target.scrollHeight
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
const distanceToContainerTop = st
|
||||
const distanceToContainerBottom = scrollHeight - st - clientHeight
|
||||
|
||||
const distanceToPageTop = distanceToContainerBottom
|
||||
const distanceToPageBottom = distanceToContainerTop
|
||||
const isAtPageTop = distanceToPageTop <= 0
|
||||
const isAtPageBottom = distanceToPageBottom <= 0
|
||||
|
||||
emit('scroll', {
|
||||
target,
|
||||
scrollTop: st,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
distanceToPageTop,
|
||||
distanceToPageBottom,
|
||||
isAtPageTop,
|
||||
isAtPageBottom
|
||||
})
|
||||
|
||||
if (isAtPageTop) {
|
||||
emit('scroll-start')
|
||||
}
|
||||
|
||||
if (isAtPageBottom) {
|
||||
emit('scroll-end')
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToIndex = (index, behavior = 'auto') => {
|
||||
if (!renderContainerRef.value || index < 0 || index >= computedData.value.length) return
|
||||
|
||||
const position = getItemPosition(index)
|
||||
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: position.offset,
|
||||
behavior
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToBottom = (behavior = 'smooth') => {
|
||||
if (!renderContainerRef.value) {
|
||||
pendingScrollToBottom.value = true
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: 0,
|
||||
behavior
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToTop = (behavior = 'smooth') => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
const scrollHeight = renderContainerRef.value.scrollHeight
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: scrollHeight,
|
||||
behavior
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const getScrollElement = () => renderContainerRef.value
|
||||
|
||||
const getVisibleIndices = () => {
|
||||
const { start, end } = visibleRange.value
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
}
|
||||
|
||||
const resetMeasurements = () => {
|
||||
itemHeights.value = new Map()
|
||||
itemRefs.clear()
|
||||
}
|
||||
|
||||
const isAtPageBottom = () => {
|
||||
if (!renderContainerRef.value) return false
|
||||
const { scrollTop } = renderContainerRef.value
|
||||
return scrollTop <= 0
|
||||
}
|
||||
|
||||
const isAtPageTop = () => {
|
||||
if (!renderContainerRef.value) return false
|
||||
const { scrollTop, scrollHeight, clientHeight } = renderContainerRef.value
|
||||
return scrollHeight - scrollTop - clientHeight <= 0
|
||||
}
|
||||
|
||||
const observeVisibleItems = () => {
|
||||
if (!resizeObserver.value) return
|
||||
|
||||
resizeObserver.value.disconnect()
|
||||
|
||||
for (const [index, element] of itemRefs) {
|
||||
if (element) {
|
||||
resizeObserver.value.observe(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => computedData.value, (newData, oldData) => {
|
||||
const oldLength = oldData?.length || 0
|
||||
const newLength = newData.length
|
||||
|
||||
if (newLength !== oldLength) {
|
||||
const newHeights = new Map()
|
||||
|
||||
const minLen = Math.min(oldLength, newLength)
|
||||
for (let i = 0; i < minLen; i++) {
|
||||
if (itemHeights.value.has(i)) {
|
||||
newHeights.set(i, itemHeights.value.get(i))
|
||||
}
|
||||
}
|
||||
|
||||
itemHeights.value = newHeights
|
||||
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
})
|
||||
}
|
||||
|
||||
previousDataLength.value = newLength
|
||||
}, { deep: false })
|
||||
|
||||
watch(visibleItems, (newItems) => {
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
})
|
||||
if (newItems.length > 0) {
|
||||
const firstItem = newItems[0]
|
||||
const lastItem = newItems[newItems.length - 1]
|
||||
emit('visible-change', firstItem.index, lastItem.index)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
setupResizeObserver()
|
||||
isInitialized.value = true
|
||||
previousDataLength.value = computedData.value.length
|
||||
|
||||
nextTick(() => {
|
||||
if (pendingScrollToBottom.value) {
|
||||
pendingScrollToBottom.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
observeVisibleItems()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
}
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
itemRefs.clear()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
scrollToIndex,
|
||||
scrollToItem: scrollToIndex,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
getScrollElement,
|
||||
getVisibleIndices,
|
||||
resetMeasurements,
|
||||
containerRef,
|
||||
isAtPageBottom,
|
||||
isAtPageTop
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.virtual-scroller {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-scroller-wrapper {
|
||||
contain: content;
|
||||
|
||||
}
|
||||
|
||||
.virtual-scroller-spacer {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-scroller-placeholder {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-scroller-render-container {
|
||||
contain: layout style;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-scroller-item {
|
||||
contain: layout style;
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.virtual-scroller-bottom-placeholder {
|
||||
contain: layout style;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,615 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="virtual-scroller"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
class="virtual-scroller-wrapper"
|
||||
:style="wrapperStyle"
|
||||
>
|
||||
<div
|
||||
ref="renderContainerRef"
|
||||
class="virtual-scroller-render-container"
|
||||
:style="renderContainerStyle"
|
||||
@scroll.passive="handleScroll"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<div class="virtual-scroller-spacer" :style="{ height: `${totalHeight}px` }"></div>
|
||||
<div
|
||||
class="virtual-scroller-bottom-placeholder"
|
||||
:style="bottomPlaceholderStyle"
|
||||
>
|
||||
<slot name="bottom-placeholder" />
|
||||
</div>
|
||||
<div
|
||||
v-for="renderItem in visibleItems"
|
||||
:key="getItemKey(renderItem.item, renderItem.index)"
|
||||
:ref="el => setItemRef(el, renderItem.index)"
|
||||
class="virtual-scroller-item"
|
||||
:style="getItemStyle(renderItem)"
|
||||
:data-index="renderItem.index"
|
||||
>
|
||||
<slot
|
||||
name="default"
|
||||
:item="renderItem.item"
|
||||
:index="renderItem.index"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
itemKey: {
|
||||
type: [String, Function],
|
||||
default: 'id'
|
||||
},
|
||||
keyField: {
|
||||
type: String,
|
||||
default: 'id'
|
||||
},
|
||||
estimatedHeight: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
buffer: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
bufferSize: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: '100%'
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: '100%'
|
||||
},
|
||||
renderMode: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['default', 'top'].includes(value)
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'reverse',
|
||||
validator: (value) => ['normal', 'reverse'].includes(value)
|
||||
},
|
||||
bottomPlaceholderHeight: {
|
||||
type: Number,
|
||||
default: 350
|
||||
}
|
||||
})
|
||||
|
||||
const computedData = computed(() => {
|
||||
return props.data.length > 0 ? props.data : props.items
|
||||
})
|
||||
|
||||
const computedItemKey = computed(() => {
|
||||
if (typeof props.itemKey === 'function') return props.itemKey
|
||||
if (props.itemKey !== 'id') return props.itemKey
|
||||
return props.keyField
|
||||
})
|
||||
|
||||
const computedBuffer = computed(() => {
|
||||
return props.buffer !== 3 ? props.buffer : props.bufferSize
|
||||
})
|
||||
|
||||
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end', 'visible-change'])
|
||||
|
||||
const containerRef = ref(null)
|
||||
const wrapperRef = ref(null)
|
||||
const renderContainerRef = ref(null)
|
||||
const itemRefs = new Map()
|
||||
const itemHeights = ref(new Map())
|
||||
const resizeObserver = ref(null)
|
||||
const scrollTop = ref(0)
|
||||
const isScrolling = ref(false)
|
||||
const scrollTimeout = ref(null)
|
||||
const isInitialized = ref(false)
|
||||
const pendingScrollToBottom = ref(false)
|
||||
const previousDataLength = ref(0)
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
return {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
let height = 0
|
||||
const len = computedData.value.length
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
height += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
height += props.bottomPlaceholderHeight
|
||||
|
||||
return height
|
||||
})
|
||||
|
||||
const getItemPosition = (index) => {
|
||||
let offset = 0
|
||||
|
||||
for (let i = 0; i < index; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
offset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
const height = itemHeights.value.get(index) ?? props.estimatedHeight
|
||||
|
||||
return { offset, height }
|
||||
}
|
||||
|
||||
const containerHeight = computed(() => {
|
||||
if (!renderContainerRef.value) return 0
|
||||
return renderContainerRef.value.clientHeight
|
||||
})
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
if (!renderContainerRef.value || computedData.value.length === 0) {
|
||||
return { start: 0, end: 0, offset: 0 }
|
||||
}
|
||||
|
||||
const viewportHeight = containerHeight.value
|
||||
const currentScrollTop = scrollTop.value
|
||||
const bufferCount = computedBuffer.value
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = computedData.value.length - 1
|
||||
let startOffset = 0
|
||||
|
||||
let offset = 0
|
||||
for (let i = 0; i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
if (offset + height > currentScrollTop) {
|
||||
startIndex = Math.max(0, i - bufferCount)
|
||||
break
|
||||
}
|
||||
|
||||
offset += height
|
||||
}
|
||||
|
||||
startOffset = 0
|
||||
for (let i = 0; i < startIndex; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
startOffset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
offset = startOffset
|
||||
endIndex = startIndex
|
||||
for (let i = startIndex; i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
offset += height
|
||||
|
||||
if (offset > currentScrollTop + viewportHeight) {
|
||||
endIndex = Math.min(computedData.value.length - 1, i + bufferCount)
|
||||
break
|
||||
}
|
||||
|
||||
endIndex = i
|
||||
}
|
||||
|
||||
return { start: Math.min(startIndex, endIndex), end: Math.max(startIndex, endIndex), offset: startOffset }
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
const { start, end, offset } = visibleRange.value
|
||||
const items = []
|
||||
|
||||
let currentOffset = offset
|
||||
|
||||
for (let i = start; i <= end && i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
items.push({
|
||||
item: computedData.value[i],
|
||||
index: i,
|
||||
offset: currentOffset + props.bottomPlaceholderHeight,
|
||||
height
|
||||
})
|
||||
|
||||
currentOffset += height
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const wrapperStyle = computed(() => ({
|
||||
direction: 'rtl',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
scrollbarWidth: 'auto',
|
||||
overflow: 'hidden',
|
||||
transform: 'rotate(180deg)',
|
||||
width: '100%'
|
||||
}))
|
||||
|
||||
const renderContainerStyle = computed(() => ({
|
||||
direction: 'ltr',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%'
|
||||
}))
|
||||
|
||||
const bottomPlaceholderStyle = computed(() => ({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: `${props.bottomPlaceholderHeight}px`,
|
||||
transform: `translateY(0px)`,
|
||||
zIndex: 1
|
||||
}))
|
||||
|
||||
const getItemKey = (item, index) => {
|
||||
const keyField = computedItemKey.value
|
||||
if (typeof keyField === 'function') {
|
||||
return keyField(item, index)
|
||||
}
|
||||
if (typeof keyField === 'string' && item && typeof item === 'object') {
|
||||
return item[keyField] ?? index
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
const getItemStyle = (renderItem) => {
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${renderItem.offset}px)`,
|
||||
willChange: 'transform'
|
||||
}
|
||||
}
|
||||
|
||||
const setItemRef = (el, index) => {
|
||||
if (el) {
|
||||
itemRefs.set(index, el)
|
||||
} else {
|
||||
itemRefs.delete(index)
|
||||
}
|
||||
}
|
||||
|
||||
const measureItem = (index, element) => {
|
||||
if (!element) return
|
||||
|
||||
const firstChild = element.firstElementChild
|
||||
const targetElement = firstChild || element
|
||||
|
||||
const height = Math.ceil(targetElement.offsetHeight)
|
||||
|
||||
if (height > 0) {
|
||||
const cachedHeight = itemHeights.value.get(index)
|
||||
if (cachedHeight !== height) {
|
||||
const newHeights = new Map(itemHeights.value)
|
||||
newHeights.set(index, height)
|
||||
itemHeights.value = newHeights
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setupResizeObserver = () => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
}
|
||||
|
||||
resizeObserver.value = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const index = parseInt(entry.target.dataset.index, 10)
|
||||
if (!isNaN(index)) {
|
||||
measureItem(index, entry.target)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleWheel = (event) => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
const { deltaY } = event
|
||||
const el = renderContainerRef.value
|
||||
|
||||
el.scrollBy({
|
||||
top: -deltaY,
|
||||
behavior: 'instant'
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleScroll = (event) => {
|
||||
const target = event.target
|
||||
scrollTop.value = target.scrollTop
|
||||
isScrolling.value = true
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
scrollTimeout.value = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, 150)
|
||||
|
||||
const st = target.scrollTop
|
||||
const scrollHeight = target.scrollHeight
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
const distanceToContainerTop = st
|
||||
const distanceToContainerBottom = scrollHeight - st - clientHeight
|
||||
|
||||
const distanceToPageTop = distanceToContainerBottom
|
||||
const distanceToPageBottom = distanceToContainerTop
|
||||
const isAtPageTop = distanceToPageTop <= 0
|
||||
const isAtPageBottom = distanceToPageBottom <= 0
|
||||
|
||||
emit('scroll', {
|
||||
target,
|
||||
scrollTop: st,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
distanceToPageTop,
|
||||
distanceToPageBottom,
|
||||
isAtPageTop,
|
||||
isAtPageBottom
|
||||
})
|
||||
|
||||
if (isAtPageTop) {
|
||||
emit('scroll-start')
|
||||
}
|
||||
|
||||
if (isAtPageBottom) {
|
||||
emit('scroll-end')
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToIndex = (index, behavior = 'auto') => {
|
||||
if (!renderContainerRef.value || index < 0 || index >= computedData.value.length) return
|
||||
|
||||
const position = getItemPosition(index)
|
||||
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: position.offset,
|
||||
behavior
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToBottom = (behavior = 'smooth') => {
|
||||
if (!renderContainerRef.value) {
|
||||
pendingScrollToBottom.value = true
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: 0,
|
||||
behavior
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToTop = (behavior = 'smooth') => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
const scrollHeight = renderContainerRef.value.scrollHeight
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: scrollHeight,
|
||||
behavior
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const getScrollElement = () => renderContainerRef.value
|
||||
|
||||
const getVisibleIndices = () => {
|
||||
const { start, end } = visibleRange.value
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
}
|
||||
|
||||
const resetMeasurements = () => {
|
||||
itemHeights.value = new Map()
|
||||
itemRefs.clear()
|
||||
}
|
||||
|
||||
const isAtPageBottom = () => {
|
||||
if (!renderContainerRef.value) return false
|
||||
const { scrollTop } = renderContainerRef.value
|
||||
return scrollTop <= 0
|
||||
}
|
||||
|
||||
const isAtPageTop = () => {
|
||||
if (!renderContainerRef.value) return false
|
||||
const { scrollTop, scrollHeight, clientHeight } = renderContainerRef.value
|
||||
return scrollHeight - scrollTop - clientHeight <= 0
|
||||
}
|
||||
|
||||
const observeVisibleItems = () => {
|
||||
if (!resizeObserver.value) return
|
||||
|
||||
resizeObserver.value.disconnect()
|
||||
|
||||
for (const [index, element] of itemRefs) {
|
||||
if (element) {
|
||||
resizeObserver.value.observe(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => computedData.value, (newData, oldData) => {
|
||||
const oldLength = oldData?.length || 0
|
||||
const newLength = newData.length
|
||||
|
||||
if (newLength !== oldLength) {
|
||||
const newHeights = new Map()
|
||||
|
||||
const minLen = Math.min(oldLength, newLength)
|
||||
for (let i = 0; i < minLen; i++) {
|
||||
if (itemHeights.value.has(i)) {
|
||||
newHeights.set(i, itemHeights.value.get(i))
|
||||
}
|
||||
}
|
||||
|
||||
itemHeights.value = newHeights
|
||||
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
})
|
||||
}
|
||||
|
||||
previousDataLength.value = newLength
|
||||
}, { deep: false })
|
||||
|
||||
watch(visibleItems, (newItems) => {
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
})
|
||||
if (newItems.length > 0) {
|
||||
const firstItem = newItems[0]
|
||||
const lastItem = newItems[newItems.length - 1]
|
||||
emit('visible-change', firstItem.index, lastItem.index)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
setupResizeObserver()
|
||||
isInitialized.value = true
|
||||
previousDataLength.value = computedData.value.length
|
||||
|
||||
nextTick(() => {
|
||||
if (pendingScrollToBottom.value) {
|
||||
pendingScrollToBottom.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
observeVisibleItems()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
}
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
itemRefs.clear()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
scrollToIndex,
|
||||
scrollToItem: scrollToIndex,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
getScrollElement,
|
||||
getVisibleIndices,
|
||||
resetMeasurements,
|
||||
containerRef,
|
||||
isAtPageBottom,
|
||||
isAtPageTop
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.virtual-scroller {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-scroller-wrapper {
|
||||
contain: content;
|
||||
|
||||
}
|
||||
|
||||
.virtual-scroller-spacer {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-scroller-placeholder {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-scroller-render-container {
|
||||
contain: layout style;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-scroller-item {
|
||||
contain: layout style;
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.virtual-scroller-bottom-placeholder {
|
||||
contain: layout style;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,27 +1,41 @@
|
||||
<template>
|
||||
<div class="virtual-scroller" :style="containerStyle">
|
||||
<div class="virtual-scroller-wrapper" :style="wrapperStyle">
|
||||
<div
|
||||
ref="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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,29 +133,30 @@ const containerStyle = computed(() => {
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
heightVersion.value // 依赖追踪:measureItem 直接 mutate Map 时通过此版本号触发重算
|
||||
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 }
|
||||
}
|
||||
|
||||
@ -173,73 +169,72 @@ const visibleRange = computed(() => {
|
||||
if (!renderContainerRef.value || computedData.value.length === 0) {
|
||||
return { start: 0, end: 0, offset: 0 }
|
||||
}
|
||||
|
||||
|
||||
heightVersion.value // 依赖追踪:measureItem 直接 mutate Map 时通过此版本号触发重算
|
||||
const viewportHeight = containerHeight.value
|
||||
const currentScrollTop = scrollTop.value
|
||||
const bufferCount = computedBuffer.value
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = computedData.value.length - 1
|
||||
let startOffset = 0
|
||||
|
||||
const len = computedData.value.length
|
||||
|
||||
// 第一趟:定位首个可见项索引
|
||||
let offset = 0
|
||||
for (let i = 0; i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
let firstVisibleIdx = 0
|
||||
for (let i = 0; i < len; i++) {
|
||||
const height = itemHeights.value.get(i) ?? props.estimatedHeight
|
||||
if (offset + height > currentScrollTop) {
|
||||
startIndex = Math.max(0, i - bufferCount)
|
||||
firstVisibleIdx = i
|
||||
break
|
||||
}
|
||||
|
||||
offset += height
|
||||
}
|
||||
|
||||
startOffset = 0
|
||||
for (let i = 0; i < startIndex; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
startOffset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
endIndex = i
|
||||
|
||||
if (i >= startIndex) {
|
||||
if (offset + height > currentScrollTop + viewportHeight) {
|
||||
endIndex = Math.min(len - 1, i + bufferCount)
|
||||
break
|
||||
}
|
||||
endIndex = i
|
||||
}
|
||||
|
||||
offset += height
|
||||
}
|
||||
|
||||
return { start: Math.min(startIndex, endIndex), end: Math.max(startIndex, endIndex), offset: startOffset }
|
||||
|
||||
return { start: startIndex, end: 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
|
||||
})
|
||||
|
||||
@ -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,28 +296,46 @@ 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
|
||||
|
||||
|
||||
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 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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -332,10 +344,10 @@ 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)
|
||||
const index = Number.parseInt(entry.target.dataset.index, 10)
|
||||
if (!isNaN(index)) {
|
||||
measureItem(index, entry.target)
|
||||
}
|
||||
@ -345,53 +357,34 @@ const setupResizeObserver = () => {
|
||||
|
||||
const handleWheel = (event) => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
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,
|
||||
@ -402,11 +395,11 @@ const handleScroll = (event) => {
|
||||
isAtPageTop,
|
||||
isAtPageBottom
|
||||
})
|
||||
|
||||
|
||||
if (isAtPageTop) {
|
||||
emit('scroll-start')
|
||||
}
|
||||
|
||||
|
||||
if (isAtPageBottom) {
|
||||
emit('scroll-end')
|
||||
}
|
||||
@ -414,9 +407,9 @@ const handleScroll = (event) => {
|
||||
|
||||
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
|
||||
@ -428,10 +421,10 @@ const scrollToBottom = (behavior = 'smooth') => {
|
||||
pendingScrollToBottom.value = true
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: 0,
|
||||
behavior
|
||||
@ -441,10 +434,10 @@ const scrollToBottom = (behavior = 'smooth') => {
|
||||
|
||||
const scrollToTop = (behavior = 'smooth') => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!renderContainerRef.value) return
|
||||
|
||||
|
||||
const scrollHeight = renderContainerRef.value.scrollHeight
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -492,31 +486,28 @@ const observeVisibleItems = () => {
|
||||
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()
|
||||
cleanupExtraItems(newItems)
|
||||
})
|
||||
if (newItems.length > 0) {
|
||||
const firstItem = newItems[0]
|
||||
@ -525,56 +516,16 @@ 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) {
|
||||
pendingScrollToBottom.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
|
||||
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({
|
||||
@ -611,56 +552,54 @@ defineExpose({
|
||||
<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;
|
||||
}
|
||||
|
||||
194
src/components/virtual-scroller/test-data.js
Normal file
@ -0,0 +1,194 @@
|
||||
window.TEST_DATA = [
|
||||
{
|
||||
id: '839217090555557410',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/27/69ef43d23888d39e4ed1d062.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '839211834861958673',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/27/69ef3eed3888d39e4ed1d061.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'BananaPro'
|
||||
},
|
||||
{
|
||||
id: '839209605929121287',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/27/69ef3cd93888d39e4ed1d060.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '837370053866304564',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/22/69e88ba23888d39e4ed1d05c.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '837360015437214709',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/22/69e882493888d39e4ed1d05b.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '836534979084169461',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/20/69e581e93888d39e4ed1d025.png',
|
||||
prompt: '<div data-v-43afc57f="" class="prompt-container" style="width: 1118px; font-size: 14px; background-color: rgb(255, 255, 255); height: 39px;"><div data-v-43afc57f="" class="prompt-wrapper" style="width: 1118px;"><div data-v-43afc57f="" class="prompt expanded" style="max-height: none; overflow: visible;"><span data-v-43afc57f="" class="prompt-text">将图1红色框内的【苹果】替换为【火龙果】</span></div><div><span data-v-43afc57f="" class="prompt-text"><br></span></div></div></div><div data-v-43afc57f="" class="box success-box" style="width: 1118px; font-size: 14px; background-color: rgb(255, 255, 255);"><div data-v-43afc57f="" class="one-box"></div></div>',
|
||||
model: 'banana'
|
||||
},
|
||||
{
|
||||
id: '835464458670191734',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/17/69e19ce93888d39e4ed1cfaf.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '835463648116749398',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/17/69e19c273888d39e4ed1cfac.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '835463392293565520',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/17/69e19bea3888d39e4ed1cfab.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '832562717234575283',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/9/69d70e733888d39e4ed1cf66.png,http://test.xueai.art/file/2026/4/9/69d70e733888d39e4ed1cf67.png,http://test.xueai.art/file/2026/4/9/69d70e733888d39e4ed1cf68.png,http://test.xueai.art/file/2026/4/9/69d70e743888d39e4ed1cf69.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '839217090555557410',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/27/69ef43d23888d39e4ed1d062.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '839211834861958673',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/27/69ef3eed3888d39e4ed1d061.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'BananaPro'
|
||||
},
|
||||
{
|
||||
id: '839209605929121287',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/27/69ef3cd93888d39e4ed1d060.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '837370053866304564',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/22/69e88ba23888d39e4ed1d05c.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '837360015437214709',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/22/69e882493888d39e4ed1d05b.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '836534979084169461',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/20/69e581e93888d39e4ed1d025.png',
|
||||
prompt: '<div data-v-43afc57f="" class="prompt-container" style="width: 1118px; font-size: 14px; background-color: rgb(255, 255, 255); height: 39px;"><div data-v-43afc57f="" class="prompt-wrapper" style="width: 1118px;"><div data-v-43afc57f="" class="prompt expanded" style="max-height: none; overflow: visible;"><span data-v-43afc57f="" class="prompt-text">将图1红色框内的【苹果】替换为【火龙果】</span></div><div><span data-v-43afc57f="" class="prompt-text"><br></span></div></div></div><div data-v-43afc57f="" class="box success-box" style="width: 1118px; font-size: 14px; background-color: rgb(255, 255, 255);"><div data-v-43afc57f="" class="one-box"></div></div>',
|
||||
model: 'banana'
|
||||
},
|
||||
{
|
||||
id: '835464458670191734',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/17/69e19ce93888d39e4ed1cfaf.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '835463648116749398',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/17/69e19c273888d39e4ed1cfac.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '835463392293565520',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/17/69e19bea3888d39e4ed1cfab.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '832562717234575283',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/9/69d70e733888d39e4ed1cf66.png,http://test.xueai.art/file/2026/4/9/69d70e733888d39e4ed1cf67.png,http://test.xueai.art/file/2026/4/9/69d70e733888d39e4ed1cf68.png,http://test.xueai.art/file/2026/4/9/69d70e743888d39e4ed1cf69.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '830138209152283808',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/2/69ce3c743888d39e4ed1cf4f.png',
|
||||
prompt: '将图1红色框内的【苹果】替换为【火龙果】',
|
||||
model: 'banana'
|
||||
},
|
||||
{
|
||||
id: '830136945106498711',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/2/69ce3b463888d39e4ed1cf4e.png',
|
||||
prompt: '一个女孩在树下吃苹果',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '830083758811001839',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/4/2/69ce09be3888d39e4ed1cf48.png',
|
||||
prompt: '一个女孩在树下吃苹果',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '829393290267734386',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/3/31/69cb86b13888d39e4ed1cf32.png',
|
||||
prompt: '将图1红色框内的【女孩】替换为【图2中的女孩】',
|
||||
model: 'banana'
|
||||
},
|
||||
{
|
||||
id: '829389466203337022',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/3/31/69cb83223888d39e4ed1cf31.png',
|
||||
prompt: '将图1红色框内的【<span style="font-size: 14px;">女孩</span>】替换为【图2中的女孩】',
|
||||
model: 'banana'
|
||||
},
|
||||
{
|
||||
id: '829388114303660338',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/3/31/69cb81df3888d39e4ed1cf30.png',
|
||||
prompt: '将图1红色框内的【女孩】替换为【男孩】',
|
||||
model: 'banana'
|
||||
},
|
||||
{
|
||||
id: '829381253919682782',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/3/31/69cb7b7c3888d39e4ed1cf2d.png',
|
||||
prompt: '将图1红色框内的【女孩】替换为【图2中的男孩】',
|
||||
model: 'banana'
|
||||
},
|
||||
{
|
||||
id: '829324561060212843',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/3/31/69cb46af3888d39e4ed1cf29.png',
|
||||
prompt: '一个女孩在树下吃苹果',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '829319226454978647',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/3/31/69cb41b73888d39e4ed1cf25.png,http://test.xueai.art/file/2026/3/31/69cb41b73888d39e4ed1cf26.png,http://test.xueai.art/file/2026/3/31/69cb41b73888d39e4ed1cf27.png,http://test.xueai.art/file/2026/3/31/69cb41b73888d39e4ed1cf28.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '829317957644464188',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/3/31/69cb40893888d39e4ed1cf20.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '829305227994738709',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/3/31/69cb34ae3888d39e4ed1cf1e.png',
|
||||
prompt: '这是一幅治愈写实风格的插画,画面中展现了一处城市石砌民居的顶层露台。石砌民居带有木质门、铁栅栏窗台、锈色金属屋顶,空调外机挂在墙边,金属防护栏环绕四周,外置楼梯延伸下来。露台上摆放着竹制躺椅和木质桌椅,旁边还有小凳。彩色水桶,一个是红色,一个是蓝色,放置在角落,花盆里栽种着金桔,水仙花、蝴蝶兰,整齐排列,大量花卉在露台上盛放。露台旁有一棵枝叶繁茂、遮天蔽日的大树,绿意浓郁,与建筑共生。周围还摆放着一些绿植盆栽。远处是城市的高楼,整个场景被绿意环绕,明亮柔和的光线洒下,充满了都市生活与自然融合感的顶层露台风情。Moebius (Jean Giraud)风格',
|
||||
model: 'flux'
|
||||
},
|
||||
{
|
||||
id: '829068575099597628',
|
||||
fileUrl: 'http://test.xueai.art/file/2026/3/30/69ca58473888d39e0bb8728b.png',
|
||||
prompt: '',
|
||||
model: ''
|
||||
}
|
||||
]
|
||||
700
src/components/virtual-scroller/test.html
Normal file
@ -0,0 +1,700 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VirtualScroller 测试页</title>
|
||||
<style>
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
#app {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 控制面板 */
|
||||
.control-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.control-panel button {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 6px;
|
||||
background: #0f3460;
|
||||
color: #e94560;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.control-panel button:hover {
|
||||
background: #e94560;
|
||||
color: #fff;
|
||||
border-color: #e94560;
|
||||
}
|
||||
.control-panel .stat {
|
||||
font-size: 12px;
|
||||
color: #a0a0b0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.control-panel .stat span {
|
||||
color: #e94560;
|
||||
font-weight: 600;
|
||||
}
|
||||
.control-panel .divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #0f3460;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* 虚拟滚动容器 */
|
||||
.test-scroller-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 消息卡片 */
|
||||
.message-card {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.message-card .card-img-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 274px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #0f3460;
|
||||
position: relative;
|
||||
}
|
||||
.message-card .card-img-wrap img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.message-card .card-img-wrap .img-placeholder {
|
||||
width: 100%;
|
||||
min-height: 150px;
|
||||
background: linear-gradient(135deg, #0f3460, #16213e);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #a0a0b0;
|
||||
}
|
||||
.message-card .card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.message-card .card-model {
|
||||
font-size: 11px;
|
||||
color: #e94560;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.message-card .card-prompt {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #ccc;
|
||||
word-break: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.message-card .card-index {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 底部加载提示 */
|
||||
.bottom-loader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.bottom-loader .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #e94560;
|
||||
animation: pulse 1.4s infinite ease-in-out both;
|
||||
}
|
||||
.bottom-loader .dot:nth-child(1) { animation-delay: -0.32s; }
|
||||
.bottom-loader .dot:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 80%, 100% { transform: scale(0); opacity: 0.3; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 多图网格 */
|
||||
.multi-img-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
.multi-img-grid img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar { width: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="control-panel">
|
||||
<button @click="addSingleItem">+ 添加 1 条</button>
|
||||
<button @click="addBatchItems">+ 批量添加 10 条</button>
|
||||
<button @click="scrollToBottom">↓ 滚到底部</button>
|
||||
<button @click="scrollToTop">↑ 滚到顶部</button>
|
||||
<span class="divider"></span>
|
||||
<span class="stat">总数: <span>{{ stats.total }}</span></span>
|
||||
<span class="stat">可见范围: <span>{{ stats.firstVisible }} - {{ stats.lastVisible }}</span></span>
|
||||
<span class="stat">可见数: <span>{{ stats.visibleCount }}</span></span>
|
||||
<span class="stat">测量项目: <span>{{ stats.measuredCount }}</span></span>
|
||||
<span class="stat">高度版本: <span>{{ stats.heightVersion }}</span></span>
|
||||
</div>
|
||||
|
||||
<div class="test-scroller-wrap">
|
||||
<virtual-scroller
|
||||
ref="scrollerRef"
|
||||
:items="displayItems"
|
||||
:estimated-height="180"
|
||||
:buffer-size="2"
|
||||
direction="reverse"
|
||||
:bottom-placeholder-height="60"
|
||||
@visible-change="onVisibleChange"
|
||||
>
|
||||
<!-- 消息卡片 -->
|
||||
<template #default="{ item }">
|
||||
<div class="message-card">
|
||||
<div class="card-img-wrap">
|
||||
<img
|
||||
v-if="item.firstImageUrl"
|
||||
:src="item.firstImageUrl"
|
||||
@load="onImgLoad"
|
||||
@error="onImgError"
|
||||
referrerpolicy="no-referrer"
|
||||
style="width:100%;height:auto;display:block;"
|
||||
>
|
||||
<div v-else class="img-placeholder">无图片</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-model">{{ item.model || 'Unknown' }}</div>
|
||||
<div class="card-prompt">{{ item.prompt }}</div>
|
||||
<div class="card-index">#{{ item._idx }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 底部占位 -->
|
||||
<template #bottom-placeholder>
|
||||
<div class="bottom-loader">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<span>下拉加载更多</span>
|
||||
</div>
|
||||
</template>
|
||||
</virtual-scroller>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./test-data.js"></script>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script>
|
||||
const { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount, defineComponent, createApp } = Vue
|
||||
|
||||
// ===================== VirtualScroller 组件 =====================
|
||||
const VirtualScroller = defineComponent({
|
||||
name: 'VirtualScroller',
|
||||
props: {
|
||||
items: { type: Array, default: () => [] },
|
||||
itemKey: { type: [String, Function], default: 'id' },
|
||||
keyField: { type: String, default: 'id' },
|
||||
estimatedHeight: { type: Number, default: 100 },
|
||||
buffer: { type: Number, default: 3 },
|
||||
bufferSize: { type: Number, default: 3 },
|
||||
direction: { type: String, default: 'reverse' },
|
||||
bottomPlaceholderHeight: { type: Number, default: 350 },
|
||||
},
|
||||
emits: ['scroll', 'scroll-start', 'scroll-end', 'visible-change', 'height-version-change'],
|
||||
setup(props, { emit, expose }) {
|
||||
const containerRef = ref(null)
|
||||
const renderContainerRef = ref(null)
|
||||
const itemHeights = ref(new Map())
|
||||
const heightVersion = ref(0)
|
||||
const resizeObserver = ref(null)
|
||||
const scrollTop = ref(0)
|
||||
const pendingScrollToBottom = ref(false)
|
||||
|
||||
// 推算 buffer
|
||||
const computedBuffer = computed(() =>
|
||||
props.buffer !== 3 ? props.buffer : props.bufferSize
|
||||
)
|
||||
|
||||
// 容器高度
|
||||
const containerHeight = computed(() =>
|
||||
renderContainerRef.value ? renderContainerRef.value.clientHeight : 0
|
||||
)
|
||||
|
||||
// 总高度
|
||||
const totalHeight = computed(() => {
|
||||
heightVersion.value // 依赖追踪
|
||||
let h = 0
|
||||
for (let i = 0; i < props.items.length; i++) {
|
||||
h += itemHeights.value.get(i) ?? props.estimatedHeight
|
||||
}
|
||||
return h + props.bottomPlaceholderHeight
|
||||
})
|
||||
|
||||
// key 提取
|
||||
const getItemKey = (item, index) => {
|
||||
const kf = typeof props.itemKey === 'function' ? props.itemKey : (props.itemKey !== 'id' ? props.itemKey : props.keyField)
|
||||
if (typeof kf === 'function') return kf(item, index)
|
||||
if (typeof kf === 'string' && item && typeof item === 'object') return item[kf] ?? index
|
||||
return index
|
||||
}
|
||||
|
||||
// 可见范围(两趟扫描)
|
||||
const visibleRange = computed(() => {
|
||||
if (!renderContainerRef.value || props.items.length === 0) {
|
||||
return { start: 0, end: 0, offset: 0 }
|
||||
}
|
||||
heightVersion.value
|
||||
const viewH = containerHeight.value
|
||||
const st = scrollTop.value
|
||||
const buf = computedBuffer.value
|
||||
const len = props.items.length
|
||||
|
||||
// 第一趟:定位首个可见项
|
||||
let offset = 0
|
||||
let firstVisible = 0
|
||||
for (let i = 0; i < len; i++) {
|
||||
const h = itemHeights.value.get(i) ?? props.estimatedHeight
|
||||
if (offset + h > st) { firstVisible = i; break }
|
||||
offset += h
|
||||
}
|
||||
|
||||
const startIdx = Math.max(0, firstVisible - buf)
|
||||
|
||||
// 第二趟:计算 startOffset + 定位 endIndex
|
||||
let startOffset = 0
|
||||
let endIdx = len - 1
|
||||
offset = 0
|
||||
for (let i = 0; i < len; i++) {
|
||||
const h = itemHeights.value.get(i) ?? props.estimatedHeight
|
||||
if (i < startIdx) { startOffset += h }
|
||||
if (i >= startIdx) {
|
||||
if (offset + h > st + viewH) {
|
||||
endIdx = Math.min(len - 1, i + buf)
|
||||
break
|
||||
}
|
||||
endIdx = i
|
||||
}
|
||||
offset += h
|
||||
}
|
||||
|
||||
return { start: startIdx, end: endIdx, offset: startOffset }
|
||||
})
|
||||
|
||||
// 可见项列表
|
||||
const visibleItems = computed(() => {
|
||||
const { start, end, offset } = visibleRange.value
|
||||
const items = []
|
||||
let curOffset = offset
|
||||
for (let i = start; i <= end && i < props.items.length; i++) {
|
||||
const h = itemHeights.value.get(i) ?? props.estimatedHeight
|
||||
items.push({
|
||||
item: props.items[i],
|
||||
index: i,
|
||||
offset: curOffset + props.bottomPlaceholderHeight,
|
||||
height: h,
|
||||
})
|
||||
curOffset += h
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
// 测量单项高度(直接 mutate Map + 版本号)
|
||||
// 滚动锚定:累积可视区上方项的高度变化量,在微任务中统一补偿
|
||||
let pendingScrollDelta = 0
|
||||
let scrollAdjustPending = false
|
||||
|
||||
const measureItem = (index, element) => {
|
||||
if (!element) return
|
||||
const firstChild = element.firstElementChild
|
||||
const target = firstChild || element
|
||||
const h = Math.ceil(target.offsetHeight)
|
||||
if (h > 0) {
|
||||
const cached = itemHeights.value.get(index)
|
||||
if (cached !== h) {
|
||||
const oldH = cached ?? props.estimatedHeight
|
||||
const delta = h - oldH
|
||||
const prevStart = visibleRange.value.start
|
||||
|
||||
itemHeights.value.set(index, h)
|
||||
heightVersion.value++
|
||||
|
||||
if (delta !== 0 && index < prevStart) {
|
||||
pendingScrollDelta += delta
|
||||
if (!scrollAdjustPending) {
|
||||
scrollAdjustPending = true
|
||||
Promise.resolve().then(() => {
|
||||
scrollAdjustPending = false
|
||||
const container = renderContainerRef.value
|
||||
if (container && pendingScrollDelta !== 0) {
|
||||
container.scrollTop += pendingScrollDelta
|
||||
pendingScrollDelta = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ResizeObserver
|
||||
const setupResizeObserver = () => {
|
||||
if (resizeObserver.value) resizeObserver.value.disconnect()
|
||||
resizeObserver.value = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const idx = parseInt(entry.target.dataset.index, 10)
|
||||
if (!isNaN(idx)) measureItem(idx, entry.target)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 观察可见项(基于 visibleItems + querySelector,不依赖外部 Map)
|
||||
const observeVisibleItems = () => {
|
||||
if (!resizeObserver.value || !renderContainerRef.value) return
|
||||
resizeObserver.value.disconnect()
|
||||
for (const vItem of visibleItems.value) {
|
||||
const el = renderContainerRef.value.querySelector(`[data-index="${vItem.index}"]`)
|
||||
if (el) resizeObserver.value.observe(el)
|
||||
}
|
||||
}
|
||||
|
||||
// 滚轮处理(反向容器中取反 deltaY)
|
||||
const handleWheel = (event) => {
|
||||
if (!renderContainerRef.value) return
|
||||
renderContainerRef.value.scrollBy({ top: -event.deltaY, behavior: 'instant' })
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// 滚动事件
|
||||
const handleScroll = (event) => {
|
||||
const t = event.target
|
||||
scrollTop.value = t.scrollTop
|
||||
|
||||
const st = t.scrollTop
|
||||
const sh = t.scrollHeight
|
||||
const ch = t.clientHeight
|
||||
|
||||
emit('scroll', {
|
||||
target: t,
|
||||
scrollTop: st,
|
||||
scrollHeight: sh,
|
||||
clientHeight: ch,
|
||||
distanceToPageTop: sh - st - ch,
|
||||
distanceToPageBottom: st,
|
||||
isAtPageTop: sh - st - ch <= 0,
|
||||
isAtPageBottom: st <= 0,
|
||||
})
|
||||
|
||||
if (sh - st - ch <= 0) emit('scroll-start')
|
||||
if (st <= 0) emit('scroll-end')
|
||||
}
|
||||
|
||||
// ===== 暴露方法 =====
|
||||
const scrollToBottom = (behavior = 'smooth') => {
|
||||
if (!renderContainerRef.value) { pendingScrollToBottom.value = true; return }
|
||||
requestAnimationFrame(() => {
|
||||
renderContainerRef.value?.scrollTo({ top: 0, behavior })
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToTop = (behavior = 'smooth') => {
|
||||
if (!renderContainerRef.value) return
|
||||
requestAnimationFrame(() => {
|
||||
if (!renderContainerRef.value) return
|
||||
renderContainerRef.value.scrollTo({ top: renderContainerRef.value.scrollHeight, behavior })
|
||||
})
|
||||
}
|
||||
|
||||
const resetMeasurements = () => {
|
||||
itemHeights.value = new Map()
|
||||
heightVersion.value++
|
||||
}
|
||||
|
||||
// 监听数据变化
|
||||
watch(() => props.items, (newData, oldData) => {
|
||||
const oldLen = oldData?.length || 0
|
||||
const newLen = newData.length
|
||||
if (newLen !== oldLen) {
|
||||
const newHeights = new Map()
|
||||
const minLen = Math.min(oldLen, newLen)
|
||||
for (let i = 0; i < minLen; i++) {
|
||||
if (itemHeights.value.has(i)) newHeights.set(i, itemHeights.value.get(i))
|
||||
}
|
||||
itemHeights.value = newHeights
|
||||
heightVersion.value++
|
||||
nextTick(() => observeVisibleItems())
|
||||
}
|
||||
}, { deep: false })
|
||||
|
||||
// 监听可见项变化
|
||||
watch(visibleItems, (newItems) => {
|
||||
nextTick(() => observeVisibleItems())
|
||||
if (newItems.length > 0) {
|
||||
emit('visible-change', newItems[0].index, newItems[newItems.length - 1].index)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
setupResizeObserver()
|
||||
nextTick(() => {
|
||||
if (pendingScrollToBottom.value) {
|
||||
pendingScrollToBottom.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
observeVisibleItems()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver.value) resizeObserver.value.disconnect()
|
||||
})
|
||||
|
||||
expose({
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
resetMeasurements,
|
||||
getScrollElement: () => renderContainerRef.value,
|
||||
heightVersion,
|
||||
itemHeights,
|
||||
})
|
||||
|
||||
// 模板渲染函数
|
||||
return {
|
||||
containerRef, renderContainerRef,
|
||||
totalHeight, visibleItems, getItemKey,
|
||||
handleScroll, handleWheel,
|
||||
bottomPlaceholderHeight: computed(() => props.bottomPlaceholderHeight),
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div ref="containerRef" style="height:100%;width:100%;position:relative;">
|
||||
<div style="direction:rtl;height:100%;position:relative;overflow:hidden;transform:rotate(180deg);width:100%;">
|
||||
<div
|
||||
ref="renderContainerRef"
|
||||
@scroll.passive="handleScroll"
|
||||
@wheel="handleWheel"
|
||||
style="direction:ltr;display:flex;flex-direction:column;justify-content:flex-end;bottom:0;left:0;overflow-x:hidden;overflow-y:auto;position:absolute;right:0;top:0;width:100%;contain:layout style;"
|
||||
>
|
||||
<div style="flex-shrink:0;width:100%;" :style="{ height: totalHeight + 'px' }"></div>
|
||||
<div :style="{ position:'absolute',left:0,right:0,top:0,width:'100%',height:bottomPlaceholderHeight+'px',zIndex:1 }">
|
||||
<slot name="bottom-placeholder" />
|
||||
</div>
|
||||
<div
|
||||
v-for="vItem in visibleItems"
|
||||
:key="getItemKey(vItem.item, vItem.index)"
|
||||
:data-index="vItem.index"
|
||||
:style="{ position:'absolute',left:0,right:0,top:0,width:'100%',transform:'translateY('+vItem.offset+'px)',willChange:'transform',contain:'layout style' }"
|
||||
>
|
||||
<slot name="default" :item="vItem.item" :index="vItem.index" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
// ===================== 测试应用 =====================
|
||||
const app = createApp({
|
||||
components: { VirtualScroller },
|
||||
setup() {
|
||||
// 初始化测试数据
|
||||
const rawData = window.TEST_DATA || []
|
||||
const baseItems = rawData.map((d, i) => ({
|
||||
...d,
|
||||
id: d.id || ('item-' + i),
|
||||
_idx: i,
|
||||
firstImageUrl: (d.fileUrl || '').split(',')[0].trim(),
|
||||
}))
|
||||
|
||||
const displayItems = ref([...baseItems])
|
||||
const scrollerRef = ref(null)
|
||||
let counter = baseItems.length
|
||||
|
||||
const stats = reactive({
|
||||
total: displayItems.value.length,
|
||||
firstVisible: '-',
|
||||
lastVisible: '-',
|
||||
visibleCount: 0,
|
||||
measuredCount: 0,
|
||||
heightVersion: 0,
|
||||
})
|
||||
|
||||
// 定时同步统计
|
||||
setInterval(() => {
|
||||
stats.total = displayItems.value.length
|
||||
if (scrollerRef.value) {
|
||||
stats.heightVersion = scrollerRef.value.heightVersion?.value ?? 0
|
||||
stats.measuredCount = scrollerRef.value.itemHeights?.value?.size ?? 0
|
||||
}
|
||||
}, 500)
|
||||
|
||||
// 可见范围变化
|
||||
const onVisibleChange = (first, last) => {
|
||||
stats.firstVisible = first
|
||||
stats.lastVisible = last
|
||||
stats.visibleCount = last - first + 1
|
||||
}
|
||||
|
||||
// 生成一条新消息
|
||||
const makeItem = () => {
|
||||
const tpl = baseItems[counter % baseItems.length]
|
||||
return {
|
||||
...tpl,
|
||||
id: 'new-' + (counter + 1) + '-' + Date.now(),
|
||||
_idx: counter,
|
||||
firstImageUrl: tpl.firstImageUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// 添加单条
|
||||
const addSingleItem = () => {
|
||||
counter++
|
||||
displayItems.value = [...displayItems.value, makeItem()]
|
||||
nextTick(() => scrollerRef.value?.scrollToBottom('smooth'))
|
||||
}
|
||||
|
||||
// 批量添加
|
||||
const addBatchItems = () => {
|
||||
const newItems = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
counter++
|
||||
newItems.push(makeItem())
|
||||
}
|
||||
displayItems.value = [...displayItems.value, ...newItems]
|
||||
nextTick(() => scrollerRef.value?.scrollToBottom('smooth'))
|
||||
}
|
||||
|
||||
const scrollToBottom = () => scrollerRef.value?.scrollToBottom('smooth')
|
||||
const scrollToTop = () => scrollerRef.value?.scrollToTop('smooth')
|
||||
|
||||
const onImgLoad = () => {
|
||||
// 图片加载后 ResizeObserver 会自动重新测量,无需额外处理
|
||||
}
|
||||
const onImgError = (e) => {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => scrollerRef.value?.scrollToBottom('auto'))
|
||||
})
|
||||
|
||||
return {
|
||||
displayItems, scrollerRef, stats,
|
||||
onVisibleChange, addSingleItem, addBatchItems,
|
||||
scrollToBottom, scrollToTop, onImgLoad, onImgError,
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="height:100vh;display:flex;flex-direction:column;">
|
||||
<div class="control-panel">
|
||||
<button @click="addSingleItem">+ 添加 1 条</button>
|
||||
<button @click="addBatchItems">+ 批量添加 10 条</button>
|
||||
<button @click="scrollToBottom">↓ 滚到底部</button>
|
||||
<button @click="scrollToTop">↑ 滚到顶部</button>
|
||||
<span class="divider"></span>
|
||||
<span class="stat">总数: <span>{{ stats.total }}</span></span>
|
||||
<span class="stat">可见范围: <span>{{ stats.firstVisible }} - {{ stats.lastVisible }}</span></span>
|
||||
<span class="stat">可见数: <span>{{ stats.visibleCount }}</span></span>
|
||||
<span class="stat">测量项目: <span>{{ stats.measuredCount }}</span></span>
|
||||
<span class="stat">高度版本: <span>{{ stats.heightVersion }}</span></span>
|
||||
</div>
|
||||
<div style="flex:1;min-height:0;">
|
||||
<virtual-scroller
|
||||
ref="scrollerRef"
|
||||
:items="displayItems"
|
||||
:estimated-height="180"
|
||||
:buffer-size="2"
|
||||
direction="reverse"
|
||||
:bottom-placeholder-height="60"
|
||||
@visible-change="onVisibleChange"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="message-card">
|
||||
<div class="card-img-wrap">
|
||||
<img
|
||||
v-if="item.firstImageUrl"
|
||||
:src="item.firstImageUrl"
|
||||
@load="onImgLoad"
|
||||
@error="onImgError"
|
||||
referrerpolicy="no-referrer"
|
||||
style="width:100%;height:auto;display:block;"
|
||||
>
|
||||
<div v-else class="img-placeholder">无图片</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-model">{{ item.model || 'Unknown' }}</div>
|
||||
<div class="card-prompt">{{ item.prompt }}</div>
|
||||
<div class="card-index">#{{ item._idx }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #bottom-placeholder>
|
||||
<div class="bottom-loader">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<span>下拉加载更多</span>
|
||||
</div>
|
||||
</template>
|
||||
</virtual-scroller>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,4 +0,0 @@
|
||||
import * as runninghub from './runninghub/index.js'
|
||||
// import * as suno from './suno.js'
|
||||
|
||||
export default { runninghub }
|
||||
@ -1,17 +0,0 @@
|
||||
{
|
||||
"generate": [
|
||||
{ "value": "flux", "label": "flux" },
|
||||
{ "value": "zImage", "label": "Z-image" },
|
||||
{ "value": "jimeng", "label": "jimeng" },
|
||||
{ "value": "QwenImage", "label": "QwenImage" }
|
||||
],
|
||||
"edit": [
|
||||
{ "value": "BananaPro", "label": "Banana-Pro" },
|
||||
{ "value": "Qwen-image", "label": "Qwen-image" },
|
||||
{ "value": "Kontext", "label": "Kontext" },
|
||||
{ "value": "Jimeng_4.0", "label": "Jimeng.4.0" }
|
||||
],
|
||||
"vision": [
|
||||
{ "value": "Qwen3.5plus", "label": "Qwen3.5plus", "disabled": true }
|
||||
]
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
"文生视频": [
|
||||
{ "value": "LTX2.0", "label": "LTX2.0 T2V" },
|
||||
{ "value": "viduQ3-T2V", "label": "viduQ3 T2V" }
|
||||
],
|
||||
"首尾帧": [
|
||||
{ "value": "Hailuo-02-fast", "label": "海螺 fast" },
|
||||
{ "value": "LTX2.0-I2V", "label": "LTX2.0 I2V" },
|
||||
{ "value": "LTX2.3-T2V", "label": "LTX2.3 T2V", "disabled": true },
|
||||
{ "value": "ViduQ3-turbo", "label": "ViduQ3-turbo" }
|
||||
],
|
||||
"数字人": [
|
||||
{ "value": "FlashHead", "label": "FlashHead" }
|
||||
],
|
||||
"全能参考": [
|
||||
{ "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true }
|
||||
],
|
||||
"智能多帧": [
|
||||
{ "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true }
|
||||
],
|
||||
"主体参考": [
|
||||
{ "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true }
|
||||
]
|
||||
}
|
||||
@ -1,113 +0,0 @@
|
||||
import { fetchModelConfig } from '@/utils/modelConfig'
|
||||
|
||||
function getWidthHeight({ proportion, resolution }) {
|
||||
let baseSize
|
||||
switch (resolution) {
|
||||
case '1k':
|
||||
baseSize = 1024
|
||||
break
|
||||
case '2k':
|
||||
baseSize = 2048
|
||||
break
|
||||
case '4k':
|
||||
baseSize = 4096
|
||||
break
|
||||
default:
|
||||
baseSize = 2048
|
||||
}
|
||||
|
||||
if (proportion === '智能') {
|
||||
return { width: baseSize, height: baseSize }
|
||||
}
|
||||
|
||||
const [w, h] = String(proportion).split(':').map(Number)
|
||||
const aspectRatio = w / h
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
return {
|
||||
width: baseSize,
|
||||
height: Math.round(baseSize / aspectRatio)
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
height: baseSize,
|
||||
width: Math.round(baseSize * aspectRatio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function Playload(data) {
|
||||
try {
|
||||
const json = await fetchModelConfig(data.type, data.modelName, data.modelType)
|
||||
|
||||
const nodeInfoList = []
|
||||
const proportionParam = data.params.find(param => param.name === 'proportion') || {data: 0}
|
||||
const resolutionParam = data.params.find(param => param.name === 'resolution') || {data: 0}
|
||||
|
||||
if (Array.isArray(data.imgs) && data.imgs.length > 0 && (data.modelType === 'image' || data.modelType === 'edit')) {
|
||||
for (const key of data.imgs) {
|
||||
if (json.nodeInfoList[key.name]) {
|
||||
console.log(key)
|
||||
json.nodeInfoList[key.name].fieldValue = key.url
|
||||
nodeInfoList.push(json.nodeInfoList[key.name])
|
||||
}
|
||||
if (json.imageIndex && json.imageIndex[key.name]) {
|
||||
json.imageIndex[key.name].fieldValue = key.index
|
||||
nodeInfoList.push(json.imageIndex[key.name])
|
||||
}
|
||||
}
|
||||
if (json.nodeInfoList.index) {
|
||||
json.nodeInfoList.index.fieldValue = data.imgs.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(data.params)) {
|
||||
for (const key of data.params) {
|
||||
if (json.nodeInfoList[key.name]) {
|
||||
console.log(key)
|
||||
json.nodeInfoList[key.name].fieldValue = key.data
|
||||
nodeInfoList.push(json.nodeInfoList[key.name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((json.nodeInfoList.width || json.nodeInfoList.height) && (proportionParam.data && resolutionParam.data)) {
|
||||
const { width, height } = getWidthHeight({
|
||||
proportion: proportionParam.data,
|
||||
resolution: resolutionParam.data
|
||||
})
|
||||
json.nodeInfoList.width.fieldValue = width
|
||||
json.nodeInfoList.height.fieldValue = height
|
||||
nodeInfoList.push(json.nodeInfoList.width, json.nodeInfoList.height)
|
||||
}
|
||||
|
||||
if (Array.isArray(json.seed)) {
|
||||
const min = Math.pow(10, 0)
|
||||
const max = Math.pow(10, 9) - 1
|
||||
const randomNum = Math.floor(Math.random() * (max - min + 1)) + min
|
||||
json.seed.map((seedItem) => {
|
||||
seedItem.fieldValue = randomNum
|
||||
nodeInfoList.push(seedItem)
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(json.must)) {
|
||||
nodeInfoList.push(...json.must)
|
||||
}
|
||||
|
||||
return {
|
||||
workflowId: json.workflowId,
|
||||
nodeInfoList
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取 JSON 文件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function result(result) {
|
||||
if (result.code === 0 && result.msg === 'success') {
|
||||
return { type: true, url: result.data[0].fileUrl }
|
||||
}
|
||||
return { type: false, message: result.data.exception_message }
|
||||
}
|
||||
120
src/platforms/music/controls/lyricsInput.vue
Normal file
@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="custom-popover-wrapper">
|
||||
<div class="choice-btn" :class="{ active: showPopover }" @click.stop="togglePopover">
|
||||
<img src="@/assets/dialog/lyrics.svg" alt="" style="width: 16px;">
|
||||
<span>歌词</span>
|
||||
</div>
|
||||
|
||||
<Transition name="popover">
|
||||
<div v-show="showPopover" class="custom-popover" @click.stop>
|
||||
<div class="input-wrapper">
|
||||
<textarea v-model="localLyrics" class="lyrics-input" placeholder="输入歌词" @mousedown.stop></textarea>
|
||||
</div>
|
||||
<div class="button-wrapper">
|
||||
<button class="confirm-btn" @click="handleConfirm">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const showPopover = ref(false)
|
||||
const localLyrics = ref(props.modelValue)
|
||||
|
||||
const togglePopover = () => {
|
||||
showPopover.value = !showPopover.value
|
||||
if (showPopover.value) localLyrics.value = props.modelValue
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('update:modelValue', localLyrics.value)
|
||||
showPopover.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.choice-btn {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
&:hover, &.active { background: #E9EAEB; }
|
||||
}
|
||||
|
||||
.custom-popover-wrapper { position: relative; display: inline-block; }
|
||||
|
||||
.custom-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
z-index: 9999;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.input-wrapper { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.lyrics-input {
|
||||
flex: 1;
|
||||
height: 200px;
|
||||
width: 370px;
|
||||
padding: 15px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #F8F9FA;
|
||||
font-size: 14px;
|
||||
font-family: "Microsoft YaHei";
|
||||
color: #333;
|
||||
outline: none;
|
||||
resize: none;
|
||||
&::placeholder { color: #999; }
|
||||
}
|
||||
|
||||
.button-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
background: #F8F9FA;
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
height: 32px;
|
||||
width: 120px;
|
||||
padding: 0 20px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-family: "Microsoft YaHei";
|
||||
cursor: pointer;
|
||||
background: rgba(0, 15, 51, 0.10);
|
||||
color: #000F33;
|
||||
&:hover { background: #5a62d9; }
|
||||
}
|
||||
|
||||
.popover-enter-active, .popover-leave-active { transition: all 0.3s ease; }
|
||||
.popover-enter-from, .popover-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
</style>
|
||||
145
src/platforms/music/controls/modeSelector.vue
Normal file
@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="custom-popover-wrapper">
|
||||
<div class="choice-btn" :class="{ active: showPopover }" @click.stop="togglePopover">
|
||||
<img :src="currentIcon" alt="" style="width: 16px;">
|
||||
<span>{{ currentLabel }}</span>
|
||||
</div>
|
||||
<Transition name="popover">
|
||||
<div v-show="showPopover" class="custom-popover" @click.stop>
|
||||
<div class="select">
|
||||
<div class="model-group">
|
||||
<div
|
||||
v-for="item in modeOptions"
|
||||
:key="item.value"
|
||||
class="model-item"
|
||||
:class="{ active: modeValue === item.value, disabled: item.disabled }"
|
||||
@click="selectMode(item)"
|
||||
>
|
||||
<img :src="item.icon" alt="" style="width: 16px; height: 16px;">
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import commonModeIcon from '@/assets/dialog/commonMode.svg'
|
||||
import professionalModeIcon from '@/assets/dialog/professionalMode.svg'
|
||||
import remixModeIcon from '@/assets/dialog/remixMode.svg'
|
||||
import editModeIcon from '@/assets/dialog/editMode.svg'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
options: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const showPopover = ref(false)
|
||||
|
||||
const modeValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const iconMap = {
|
||||
'常用模式': commonModeIcon,
|
||||
'专业模式': professionalModeIcon,
|
||||
'Remix模式': remixModeIcon,
|
||||
'编辑模式': editModeIcon
|
||||
}
|
||||
|
||||
const modeOptions = computed(() => {
|
||||
if (props.options.length) return props.options.map(o => ({ ...o, icon: iconMap[o.label] || commonModeIcon }))
|
||||
return [
|
||||
{ value: '常用模式', label: '常用模式', icon: commonModeIcon },
|
||||
{ value: '专业模式', label: '专业模式', icon: professionalModeIcon },
|
||||
{ value: 'Remix模式', label: 'Remix模式', icon: remixModeIcon, disabled: true },
|
||||
{ value: '编辑模式', label: '编辑模式', icon: editModeIcon, disabled: true }
|
||||
]
|
||||
})
|
||||
|
||||
const currentIcon = computed(() => {
|
||||
const found = modeOptions.value.find(m => m.value === modeValue.value)
|
||||
return found?.icon || commonModeIcon
|
||||
})
|
||||
|
||||
const currentLabel = computed(() => {
|
||||
const found = modeOptions.value.find(m => m.value === modeValue.value)
|
||||
return found?.label || modeValue.value
|
||||
})
|
||||
|
||||
const togglePopover = () => { showPopover.value = !showPopover.value }
|
||||
const selectMode = (item) => {
|
||||
if (item.disabled) return
|
||||
modeValue.value = item.value
|
||||
showPopover.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.choice-btn {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E9EAEB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
&:hover, &.active { background: #E9EAEB; }
|
||||
img { filter: brightness(0) drop-shadow(0 0 0 #000F33); }
|
||||
}
|
||||
|
||||
.custom-popover-wrapper { position: relative; display: inline-block; }
|
||||
|
||||
.custom-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.select { padding: 20px 10px; max-height: 510px; overflow-y: auto; }
|
||||
.model-group { margin-bottom: 15px; &:last-child { margin-bottom: 0; } }
|
||||
|
||||
.model-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
transition: all 0.2s ease;
|
||||
color: #666;
|
||||
font-family: "Microsoft YaHei";
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
&:hover:not(.disabled) { background: #f5f6f7; }
|
||||
&.active {
|
||||
background: rgba(0, 15, 51, 0.10);
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
img { filter: brightness(0) drop-shadow(0 0 0 #000F33); }
|
||||
}
|
||||
&.disabled { cursor: not-allowed; opacity: 0.5; }
|
||||
}
|
||||
|
||||
.popover-enter-active, .popover-leave-active { transition: all 0.3s ease; }
|
||||
.popover-enter-from, .popover-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
</style>
|
||||
167
src/platforms/music/controls/pureMusicGroup.vue
Normal file
@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="custom-popover-wrapper">
|
||||
<div class="choice-btn" @click.stop="handleChoiceBtnClick">
|
||||
<div class="text-music">纯音乐</div>
|
||||
<div class="switch-toggle" :class="{ active: isSwitchOn }" @click.stop="toggleSwitch">
|
||||
<div class="switch-slider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="popover">
|
||||
<div v-show="showLyricsPopover && !isSwitchOn" class="custom-popover" @click.stop>
|
||||
<div class="input-wrapper">
|
||||
<textarea v-model="lyricsText" class="lyrics-input" placeholder="输入歌词" @mousedown.stop></textarea>
|
||||
</div>
|
||||
<div class="button-wrapper">
|
||||
<button class="confirm-btn" @click="confirmLyrics">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Boolean], default: true },
|
||||
lyrics: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:lyrics'])
|
||||
|
||||
const showLyricsPopover = ref(false)
|
||||
const lyricsText = ref('')
|
||||
|
||||
const isSwitchOn = computed(() => {
|
||||
return props.modelValue === true || props.modelValue === 'true' || props.modelValue === '纯音乐模式'
|
||||
})
|
||||
|
||||
const toggleSwitch = () => {
|
||||
if (isSwitchOn.value) {
|
||||
emit('update:modelValue', false)
|
||||
showLyricsPopover.value = true
|
||||
} else {
|
||||
emit('update:modelValue', true)
|
||||
emit('update:lyrics', '')
|
||||
showLyricsPopover.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleChoiceBtnClick = () => {
|
||||
if (isSwitchOn.value) return
|
||||
showLyricsPopover.value = !showLyricsPopover.value
|
||||
}
|
||||
|
||||
const confirmLyrics = () => {
|
||||
emit('update:lyrics', lyricsText.value)
|
||||
showLyricsPopover.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.choice-btn {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E9EAEB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
&:hover { background: #E5E7EB; }
|
||||
}
|
||||
|
||||
.text-music {
|
||||
color: #333;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.switch-toggle {
|
||||
width: 24px;
|
||||
height: 14px;
|
||||
background: #ccc;
|
||||
border-radius: 11px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
&.active { background: #000F33; }
|
||||
.switch-slider {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
&.active .switch-slider { transform: translateX(10px); }
|
||||
}
|
||||
|
||||
.custom-popover-wrapper { position: relative; display: inline-block; }
|
||||
|
||||
.custom-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
z-index: 9999;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.input-wrapper { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.lyrics-input {
|
||||
flex: 1;
|
||||
height: 200px;
|
||||
width: 370px;
|
||||
padding: 15px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #F8F9FA;
|
||||
font-size: 14px;
|
||||
font-family: "Microsoft YaHei";
|
||||
color: #333;
|
||||
outline: none;
|
||||
resize: none;
|
||||
&::placeholder { color: #999; }
|
||||
}
|
||||
|
||||
.button-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
background: #F8F9FA;
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
height: 32px;
|
||||
width: 120px;
|
||||
padding: 0 20px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-family: "Microsoft YaHei";
|
||||
cursor: pointer;
|
||||
background: rgba(0, 15, 51, 0.10);
|
||||
color: #000F33;
|
||||
&:hover { background: #5a62d9; }
|
||||
}
|
||||
|
||||
.popover-enter-active, .popover-leave-active { transition: all 0.3s ease; }
|
||||
.popover-enter-from, .popover-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
</style>
|
||||
147
src/platforms/music/controls/timeControl.vue
Normal file
@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="custom-popover-wrapper">
|
||||
<div class="choice-btn" :class="{ active: showPopover }" @click.stop="togglePopover">
|
||||
<img src="@/assets/display/time.svg" alt="" style="width: 16px;">
|
||||
<span>{{ displayLabel }}</span>
|
||||
</div>
|
||||
<Transition name="popover">
|
||||
<div v-show="showPopover" class="custom-popover" @click.stop>
|
||||
<div class="setting-box">
|
||||
<div class="setting-header">时长({{ min }}s-{{ max }}s)</div>
|
||||
<div class="setting-body">
|
||||
<CustomSlider v-model="sliderValue" :min="sliderMin" :max="max" />
|
||||
<input type="text" class="slider-value" :value="displayValue" @input="handleInput" @keypress="handleKeypress" @mousedown.stop />
|
||||
<button class="restore-btn" @click.stop="restoreDuration">
|
||||
<img :src="restoreIcon" alt="还原" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import restoreIcon from '@/assets/dialog/restore.svg'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number], default: 'Auto' },
|
||||
min: { type: Number, default: 10 },
|
||||
max: { type: Number, default: 240 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const showPopover = ref(false)
|
||||
|
||||
const togglePopover = () => { showPopover.value = !showPopover.value }
|
||||
|
||||
const sliderMin = computed(() => props.min - 1)
|
||||
|
||||
const sliderValue = computed({
|
||||
get: () => props.modelValue === 'Auto' ? props.min - 1 : Number(props.modelValue),
|
||||
set: (v) => {
|
||||
if (v < props.min) emit('update:modelValue', 'Auto')
|
||||
else emit('update:modelValue', v)
|
||||
}
|
||||
})
|
||||
|
||||
const displayLabel = computed(() => {
|
||||
if (props.modelValue === 'Auto') return 'Auto'
|
||||
return `${props.modelValue}s`
|
||||
})
|
||||
|
||||
const displayValue = computed(() => props.modelValue)
|
||||
|
||||
const handleInput = (e) => {
|
||||
const val = e.target.value
|
||||
if (val === 'Auto') { emit('update:modelValue', 'Auto'); return }
|
||||
const n = parseInt(val)
|
||||
if (!isNaN(n) && n >= props.min && n <= props.max) emit('update:modelValue', n)
|
||||
}
|
||||
|
||||
const handleKeypress = (e) => {
|
||||
const cc = e.which || e.keyCode
|
||||
if (cc < 48 || cc > 57) e.preventDefault()
|
||||
}
|
||||
|
||||
const restoreDuration = () => { emit('update:modelValue', 'Auto') }
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.choice-btn {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E9EAEB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
&:hover, &.active { background: #E9EAEB; }
|
||||
}
|
||||
|
||||
.custom-popover-wrapper { position: relative; display: inline-block; }
|
||||
|
||||
.custom-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
z-index: 9999;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.setting-box { margin-bottom: 15px; &:last-child { margin-bottom: 0; } }
|
||||
.setting-header {
|
||||
padding-bottom: 10px;
|
||||
background: #fff;
|
||||
color: #999;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.setting-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
width: 89px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
color: #333;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
&::-webkit-inner-spin-button, &::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
}
|
||||
|
||||
.restore-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-left: 5px;
|
||||
img { width: 18px; height: 18px; }
|
||||
}
|
||||
|
||||
.popover-enter-active, .popover-leave-active { transition: all 0.3s ease; }
|
||||
.popover-enter-from, .popover-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
</style>
|
||||
68
src/platforms/music/imageUploader.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="audio-uploader">
|
||||
<div class="upload-btn" @click="triggerUpload">
|
||||
<img src="@/assets/dialog/referenceDiagram.svg" alt="" style="width: 16px;">
|
||||
<span>{{ uploadedFile ? uploadedFile.name : '参考音频' }}</span>
|
||||
</div>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:action="uploadUrl"
|
||||
:limit="1"
|
||||
:before-upload="beforeUpload"
|
||||
:on-success="handleSuccess"
|
||||
:on-error="handleError"
|
||||
:show-file-list="false"
|
||||
accept="audio/*"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const uploadRef = ref(null)
|
||||
const uploadedFile = ref(null)
|
||||
const uploadUrl = computed(() => import.meta.env.VITE_API_WORKFLOW_UPLOAD)
|
||||
|
||||
const triggerUpload = () => {
|
||||
uploadRef.value?.$el?.querySelector('input[type="file"]')?.click()
|
||||
}
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
uploadedFile.value = file
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSuccess = (response) => {
|
||||
if (response?.data?.url) {
|
||||
emit('update:modelValue', [{ url: response.data.url, name: uploadedFile.value?.name || '参考音频' }])
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
uploadedFile.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.audio-uploader { display: flex; align-items: center; gap: 8px; }
|
||||
.upload-btn {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
&:hover { background: #E5E7EB; }
|
||||
}
|
||||
</style>
|
||||
306
src/platforms/music/index.js
Normal file
@ -0,0 +1,306 @@
|
||||
import { computed, markRaw, reactive, ref } from 'vue'
|
||||
import ParamGroup from '@/components/ParamGroup/index.vue'
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import { registerPlatform } from '@/platforms/registry.js'
|
||||
import { fetchPlatformModels, getModelConfig, getModelId, getPlatformCode, preloadModelConfigs } from '@/utils/modelApi'
|
||||
import { checkShowWhen } from '@/utils/modelConfigHelper.js'
|
||||
import LyricsInput from './controls/lyricsInput.vue'
|
||||
import ModeSelector from './controls/modeSelector.vue'
|
||||
import PureMusicGroup from './controls/pureMusicGroup.vue'
|
||||
import TimeControl from './controls/timeControl.vue'
|
||||
import ImageUploader from './imageUploader.vue'
|
||||
import ModelSelector from './modelSelector.vue'
|
||||
|
||||
// 由专用控件处理的 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 }
|
||||
}
|
||||
|
||||
// 附加平台专属字段到 generateData,用于任务列表展示和再次生成/重新编辑
|
||||
function getGenerateDataExtras() {
|
||||
return {
|
||||
pureMusic: pureMusic.value,
|
||||
mode: mode.value
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
getGenerateDataExtras
|
||||
}
|
||||
}
|
||||
|
||||
registerPlatform('Music', defineMusicPlatform)
|
||||
34
src/platforms/music/modelSelector.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Select
|
||||
:model-value="modelValue"
|
||||
:options="modelOptions"
|
||||
placeholder="选择模型"
|
||||
@update:model-value="(v) => emit('update:modelValue', v)"
|
||||
>
|
||||
<template #prefix>
|
||||
<img src="@/assets/dialog/model.svg" alt="" style="width: 16px; height: 16px;">
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Select from '@/components/Select/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
models: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:typeValue'])
|
||||
|
||||
const modelOptions = computed(() => {
|
||||
const groups = {}
|
||||
props.models.forEach((m) => {
|
||||
const tag = m.tags?.[0] || '默认'
|
||||
if (!groups[tag]) groups[tag] = []
|
||||
groups[tag].push({ value: m.id, label: m.display_name, disabled: m.disabled })
|
||||
})
|
||||
return Object.entries(groups).map(([label, options]) => ({ label, options }))
|
||||
})
|
||||
</script>
|
||||
247
src/platforms/painting/controls/dimension.vue
Normal file
@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<Popover placement="top">
|
||||
<div class="dimension-container">
|
||||
<div class="section">
|
||||
<h3>尺寸 (px)</h3>
|
||||
<div class="size-inputs">
|
||||
<div class="input-group">
|
||||
<label>W</label>
|
||||
<input
|
||||
v-model.number="localWidth"
|
||||
type="number"
|
||||
:min="minW"
|
||||
:max="maxW"
|
||||
@input="onWidthChange"
|
||||
>
|
||||
</div>
|
||||
<div class="lock-icon" :class="{ locked: isLocked }" @click="toggleLock">
|
||||
<img :src="isLocked ? lockIcon : lockNoIcon" alt="约束比例">
|
||||
<span class="tooltip">{{ isLocked ? '解绑比例' : '约束比例' }}</span>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>H</label>
|
||||
<input
|
||||
v-model.number="localHeight"
|
||||
type="number"
|
||||
:min="minH"
|
||||
:max="maxH"
|
||||
@input="onHeightChange"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #reference>
|
||||
<div class="choice-btn">
|
||||
<img src="@/assets/dialog/proportion.svg" alt="" style="width: 16px;">
|
||||
<span>{{ displayText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import lockIcon from '@/assets/dialog/lock.svg'
|
||||
import lockNoIcon from '@/assets/dialog/lockNo.svg'
|
||||
import Popover from '@/components/Popover/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
width: { type: Number, default: 1024 },
|
||||
height: { type: Number, default: 1024 },
|
||||
minW: { type: Number, default: 256 },
|
||||
maxW: { type: Number, default: 6197 },
|
||||
minH: { type: Number, default: 256 },
|
||||
maxH: { type: Number, default: 4096 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:width', 'update:height'])
|
||||
|
||||
const localWidth = ref(props.width)
|
||||
const localHeight = ref(props.height)
|
||||
const isLocked = ref(true)
|
||||
const lastRatio = ref(props.width / props.height)
|
||||
|
||||
const displayText = computed(() => `${localWidth.value} × ${localHeight.value}`)
|
||||
|
||||
watch(() => props.width, (val) => { localWidth.value = val })
|
||||
watch(() => props.height, (val) => { localHeight.value = val })
|
||||
|
||||
const toggleLock = () => {
|
||||
isLocked.value = !isLocked.value
|
||||
if (isLocked.value) {
|
||||
lastRatio.value = localWidth.value / localHeight.value
|
||||
}
|
||||
}
|
||||
|
||||
const clamp = (val, min, max) => Math.max(min, Math.min(max, Math.round(val)))
|
||||
|
||||
const onWidthChange = () => {
|
||||
localWidth.value = clamp(localWidth.value, props.minW, props.maxW)
|
||||
if (isLocked.value) {
|
||||
localHeight.value = clamp(Math.round(localWidth.value / lastRatio.value), props.minH, props.maxH)
|
||||
}
|
||||
emit('update:width', localWidth.value)
|
||||
emit('update:height', localHeight.value)
|
||||
}
|
||||
|
||||
const onHeightChange = () => {
|
||||
localHeight.value = clamp(localHeight.value, props.minH, props.maxH)
|
||||
if (isLocked.value) {
|
||||
localWidth.value = clamp(Math.round(localHeight.value * lastRatio.value), props.minW, props.maxW)
|
||||
}
|
||||
emit('update:width', localWidth.value)
|
||||
emit('update:height', localHeight.value)
|
||||
}
|
||||
|
||||
watch([localWidth, localHeight], () => {
|
||||
if (!isLocked.value) {
|
||||
lastRatio.value = localWidth.value / localHeight.value
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.choice-btn {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
span {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.choice-btn:hover {
|
||||
background: #e9eaeb;
|
||||
}
|
||||
|
||||
.dimension-container {
|
||||
padding: 20px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.section {
|
||||
border-radius: 20px;
|
||||
|
||||
h3 {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.size-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
|
||||
label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 12px;
|
||||
transform: translateY(-50%);
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 12px 12px 12px 30px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: #f5f6f7;
|
||||
text-align: right;
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lock-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: #333;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
|
||||
.tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
&.locked {
|
||||
background: #f5f6f7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<Popover placement="top" :width="400">
|
||||
<Popover placement="top">
|
||||
<div class="proportion-container">
|
||||
<div class="section">
|
||||
<h3>选择比例</h3>
|
||||
<div class="proportion-options">
|
||||
<div
|
||||
v-for="item in proportionOptions"
|
||||
<div
|
||||
v-for="item in proportionOptions"
|
||||
:key="item.value"
|
||||
class="proportion-item"
|
||||
:class="{ active: proportion === item.value }"
|
||||
@ -20,8 +20,8 @@
|
||||
<div v-if="resolutionOptions.length > 0" class="section">
|
||||
<h3>选择分辨率</h3>
|
||||
<div class="resolution-options">
|
||||
<div
|
||||
v-for="item in resolutionOptions"
|
||||
<div
|
||||
v-for="item in resolutionOptions"
|
||||
:key="item.value"
|
||||
class="resolution-item"
|
||||
:class="{ active: resolution === item.value }"
|
||||
@ -32,12 +32,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div v-if="allowCustom" class="section">
|
||||
<h3>尺寸(px)</h3>
|
||||
<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: {
|
||||
@ -73,6 +73,14 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '2k'
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 2048
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 2048
|
||||
},
|
||||
proportionOptions: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
@ -85,6 +93,10 @@ const props = defineProps({
|
||||
{ value: '9:16', label: '9:16' }
|
||||
]
|
||||
},
|
||||
allowCustom: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
resolutionOptions: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
@ -107,8 +119,8 @@ const resolution = computed({
|
||||
set: (value) => emit('update:resolution', value)
|
||||
})
|
||||
|
||||
const width = ref(2048)
|
||||
const height = ref(2048)
|
||||
const width = ref(props.width)
|
||||
const height = ref(props.height)
|
||||
const isLocked = ref(true)
|
||||
|
||||
const toggleLock = () => {
|
||||
@ -154,7 +166,7 @@ const updateDimensionsByResolution = (resolutionValue) => {
|
||||
default:
|
||||
baseSize = 2048
|
||||
}
|
||||
|
||||
|
||||
if (proportion.value === '智能') {
|
||||
width.value = baseSize
|
||||
height.value = baseSize
|
||||
@ -205,7 +217,7 @@ const getProportionStyle = (value) => {
|
||||
const [w, h] = value.split(':').map(Number)
|
||||
const aspectRatio = w / h
|
||||
const baseSize = 20
|
||||
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
return {
|
||||
'--width': `${baseSize}px`,
|
||||
@ -244,16 +256,17 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
|
||||
.proportion-container{
|
||||
padding: 20px;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.section{
|
||||
margin-bottom: 20px;
|
||||
border-radius: 20px;
|
||||
|
||||
|
||||
&:last-child{
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
h3{
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
@ -265,7 +278,7 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
.proportion-options{
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
margin-bottom: 16px;
|
||||
background-color: #F8F9FA;
|
||||
padding: 5px;
|
||||
@ -274,19 +287,19 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
|
||||
.proportion-item{
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 5px;
|
||||
text-align: bottom;
|
||||
color: #999;
|
||||
|
||||
|
||||
&::before{
|
||||
content: '';
|
||||
width: var(--width, 20px);
|
||||
@ -296,11 +309,11 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid #999;
|
||||
}
|
||||
|
||||
|
||||
&:hover{
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
|
||||
&.active{
|
||||
color: #000F33;
|
||||
background: #ffffff;
|
||||
@ -328,13 +341,12 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
// background: #f5f5f5;
|
||||
color: #666;
|
||||
|
||||
|
||||
&:hover{
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
|
||||
&.active{
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
@ -350,8 +362,9 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
|
||||
.input-group{
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
|
||||
|
||||
label{
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@ -361,8 +374,9 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
color: #666;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
input{
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 12px 12px 12px 30px;
|
||||
@ -372,17 +386,17 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
background: #f5f6f7;
|
||||
text-align: right;
|
||||
-moz-appearance: textfield;
|
||||
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
&:focus{
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
&:disabled{
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
@ -400,12 +414,12 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
|
||||
img{
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
|
||||
.tooltip{
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
@ -423,7 +437,7 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
margin-bottom: 5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.tooltip::after{
|
||||
content: '';
|
||||
position: absolute;
|
||||
@ -433,19 +447,18 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
border: 5px solid transparent;
|
||||
border-top-color: #333;
|
||||
}
|
||||
|
||||
|
||||
&:hover{
|
||||
opacity: 0.8;
|
||||
|
||||
|
||||
.tooltip{
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
&.locked{
|
||||
background: #f5f6f7;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
47
src/platforms/painting/controls/quality.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<Select
|
||||
v-model="model"
|
||||
:options="options"
|
||||
width="auto"
|
||||
class="quality-select"
|
||||
position="top"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="quality-label">画质</span>
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: 'medium' },
|
||||
options: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const model = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.quality-select {
|
||||
:deep(.select-header) {
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
&:hover { background: #e9eaeb; }
|
||||
}
|
||||
:deep(.select-text) { font-size: 14px; }
|
||||
}
|
||||
.quality-label {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@ -18,6 +18,10 @@ const props = defineProps({
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 4
|
||||
}
|
||||
})
|
||||
|
||||
@ -28,12 +32,9 @@ const quantity = computed({
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const quantityOptions = [
|
||||
{ value: 1, label: '1 张' },
|
||||
{ value: 2, label: '2 张' },
|
||||
{ value: 3, label: '3 张' },
|
||||
{ value: 4, label: '4 张' }
|
||||
]
|
||||
const quantityOptions = computed(() =>
|
||||
Array.from({ length: props.max }, (_, i) => ({ value: i + 1, label: `${i + 1} 张` }))
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@ -44,20 +45,20 @@ const quantityOptions = [
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
|
||||
|
||||
&:hover {
|
||||
background: #e9eaeb;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
:deep(.select-text) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
:deep(.dropdown-menu) {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
|
||||
:deep(.dropdown-item) {
|
||||
min-width: 80px;
|
||||
justify-content: center;
|
||||
@ -19,8 +19,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
v-show="false"
|
||||
ref="uploadRef"
|
||||
:action="uploadurl"
|
||||
multiple
|
||||
:limit="limit"
|
||||
@ -61,9 +61,9 @@ watch(() => props.modelValue, async (newVal) => {
|
||||
if (isUploading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
imageList.value = [...newVal]
|
||||
|
||||
|
||||
const newPreviewList = []
|
||||
for (const img of newVal) {
|
||||
let previewImg = { ...img }
|
||||
@ -109,25 +109,25 @@ const beforeUpload = (rawFile) => {
|
||||
|
||||
const handleSuccess = (response, uploadFile) => {
|
||||
ElMessage.success('上传成功')
|
||||
|
||||
|
||||
isUploading.value = true
|
||||
|
||||
|
||||
const localUrl = URL.createObjectURL(uploadFile.raw)
|
||||
|
||||
|
||||
const newImage = {
|
||||
uid: uploadFile.uid,
|
||||
url: response.url
|
||||
}
|
||||
imageList.value.push(newImage)
|
||||
emit('update:modelValue', [...imageList.value])
|
||||
|
||||
|
||||
const newPreview = {
|
||||
uid: uploadFile.uid,
|
||||
url: localUrl,
|
||||
serverUrl: response.url
|
||||
}
|
||||
localPreviewList.value.push(newPreview)
|
||||
|
||||
|
||||
nextTick(() => {
|
||||
isUploading.value = false
|
||||
})
|
||||
@ -146,7 +146,7 @@ const handleDelete = (index) => {
|
||||
if (previewItem && previewItem.url && previewItem.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewItem.url)
|
||||
}
|
||||
|
||||
|
||||
localPreviewList.value.splice(index, 1)
|
||||
imageList.value.splice(index, 1)
|
||||
emit('update:modelValue', [...imageList.value])
|
||||
@ -155,14 +155,14 @@ const handleDelete = (index) => {
|
||||
const handleImageClick = (clickedIndex) => {
|
||||
const clickedImage = localPreviewList.value[clickedIndex]
|
||||
if (!clickedImage) return
|
||||
|
||||
|
||||
const otherImages = localPreviewList.value
|
||||
.filter((_, index) => index !== clickedIndex)
|
||||
.map((img, index) => ({
|
||||
...img,
|
||||
displayIndex: index + 2
|
||||
}))
|
||||
|
||||
|
||||
emit('open-canvas', {
|
||||
mainImage: clickedImage,
|
||||
referenceImages: otherImages
|
||||
235
src/platforms/painting/index.js
Normal file
@ -0,0 +1,235 @@
|
||||
import { markRaw, reactive, ref } from 'vue'
|
||||
import { fetchPlatformModels, getModelConfig, getModelId, getPlatformCode, preloadModelConfigs } from '@/utils/modelApi'
|
||||
import { syncDefaults as _syncDefaults, syncParamValues as _syncParamValues, checkShowWhen, getDimConfig } from '@/utils/modelConfigHelper.js'
|
||||
import { registerPlatform } from '../registry.js'
|
||||
import DimensionInput from './controls/dimension.vue'
|
||||
import PaintingProportion from './controls/proportion.vue'
|
||||
import QualitySelect from './controls/quality.vue'
|
||||
import Quantity from './controls/quantity.vue'
|
||||
import ImageUploader from './imageUploader.vue'
|
||||
import PaintingModelSelector from './modelSelector.vue'
|
||||
|
||||
export function definePaintingPlatform() {
|
||||
const model = ref('Flux 2')
|
||||
const modelType = ref('text')
|
||||
const proportion = ref('1:1')
|
||||
const resolution = ref('2k')
|
||||
const customWidth = ref(1024)
|
||||
const customHight = ref(1024)
|
||||
const dimWidth = ref(1024)
|
||||
const dimHeight = ref(1024)
|
||||
const quantity = ref(1)
|
||||
const quality = ref('medium')
|
||||
const modelConfig = ref(null)
|
||||
const promptPlaceholder = ref('描述你想生成的画面和动作。')
|
||||
const paramValues = reactive({})
|
||||
|
||||
const state = {
|
||||
model,
|
||||
modelType,
|
||||
proportion,
|
||||
resolution,
|
||||
customWidth,
|
||||
customHight,
|
||||
dimWidth,
|
||||
dimHeight,
|
||||
quantity,
|
||||
quality,
|
||||
paramValues,
|
||||
modelConfig
|
||||
}
|
||||
|
||||
// state 对象供 helper 函数使用
|
||||
const paintingState = {
|
||||
modelConfig,
|
||||
paramValues,
|
||||
proportion,
|
||||
resolution,
|
||||
quantity,
|
||||
quality,
|
||||
customWidth,
|
||||
customHight,
|
||||
dimWidth,
|
||||
dimHeight,
|
||||
promptPlaceholder
|
||||
}
|
||||
|
||||
function syncDefaults(config) {
|
||||
_syncDefaults(config, paintingState)
|
||||
}
|
||||
|
||||
function syncParamValues() {
|
||||
_syncParamValues(modelConfig.value, paintingState)
|
||||
}
|
||||
|
||||
const controls = [
|
||||
{
|
||||
name: 'proportion',
|
||||
component: markRaw(PaintingProportion),
|
||||
show: (config) => !!config?.params?.find((p) => p.ui === 'proportion'),
|
||||
props: (config) => {
|
||||
const ratioParam = config?.params?.find((p) => p.ui === 'proportion')
|
||||
const resParam = config?.params?.find((p) => p.ui === 'resolution')
|
||||
return {
|
||||
'modelValue': proportion.value,
|
||||
'onUpdate:modelValue': (v) => { proportion.value = v },
|
||||
'resolution': resolution.value,
|
||||
'onUpdate:resolution': (v) => { resolution.value = v },
|
||||
'width': customWidth.value,
|
||||
'onUpdate:width': (v) => { customWidth.value = v },
|
||||
'height': customHight.value,
|
||||
'onUpdate:height': (v) => { customHight.value = v },
|
||||
'proportionOptions': ratioParam?.options
|
||||
?.filter((o) => o !== 'custom')
|
||||
.map((o) => ({ value: o, label: o })) || [],
|
||||
'resolutionOptions': resParam?.options
|
||||
?.map((o) => ({ value: o, label: o.toUpperCase() })) || [],
|
||||
'allowCustom': ratioParam?.options?.includes('custom') || false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'dimension',
|
||||
component: markRaw(DimensionInput),
|
||||
show: (config) => {
|
||||
const hasDim = config?.params?.find((p) =>
|
||||
(p.ui === 'dimension' || p.ui === 'dimensionWidth') && checkShowWhen(p, paramValues)
|
||||
)
|
||||
return !!hasDim
|
||||
},
|
||||
props: (config) => {
|
||||
const dc = getDimConfig(config)
|
||||
return {
|
||||
'width': dimWidth.value,
|
||||
'onUpdate:width': (v) => { dimWidth.value = v },
|
||||
'height': dimHeight.value,
|
||||
'onUpdate:height': (v) => { dimHeight.value = v },
|
||||
'minW': dc?.config?.width?.min || dc?.wParam?.min || 256,
|
||||
'maxW': dc?.config?.width?.max || dc?.wParam?.max || 6197,
|
||||
'minH': dc?.config?.height?.min || dc?.hParam?.min || 256,
|
||||
'maxH': dc?.config?.height?.max || dc?.hParam?.max || 4096
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
component: markRaw(QualitySelect),
|
||||
show: (config) => !!config?.params?.find((p) => p.name === 'quality'),
|
||||
props: (config) => {
|
||||
const q = config?.params?.find((p) => p.name === 'quality')
|
||||
return {
|
||||
'modelValue': quality.value,
|
||||
'onUpdate:modelValue': (v) => { quality.value = v },
|
||||
'options': q?.options?.map((o) => ({ value: o, label: o })) || []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
component: markRaw(Quantity),
|
||||
show: (config) => !!config?.params?.find((p) => p.ui === 'quantity'),
|
||||
props: (config) => {
|
||||
const qtyParam = config?.params?.find((p) => p.ui === 'quantity')
|
||||
return {
|
||||
'modelValue': quantity.value,
|
||||
'onUpdate:modelValue': (v) => { quantity.value = v },
|
||||
'max': qtyParam?.options?.length ? Math.max(...qtyParam.options) : 4
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const platform = {
|
||||
id: 'painting',
|
||||
label: 'AI绘画2026',
|
||||
ModelSelector: markRaw(PaintingModelSelector),
|
||||
modelSelectorProps: null,
|
||||
controls,
|
||||
ImageUploader: markRaw(ImageUploader),
|
||||
state,
|
||||
model,
|
||||
modelType,
|
||||
modelConfig,
|
||||
promptPlaceholder,
|
||||
|
||||
async loadModels() {
|
||||
const code = getPlatformCode('Painting')
|
||||
const models = await fetchPlatformModels(code)
|
||||
if (models?.length) {
|
||||
const modelIds = models.map((m) => m.id)
|
||||
await preloadModelConfigs(modelIds)
|
||||
}
|
||||
return models
|
||||
},
|
||||
|
||||
async loadConfig(modelName, _modelType) {
|
||||
const modelId = await getModelId('Painting', modelName)
|
||||
if (!modelId) return null
|
||||
const config = await getModelConfig(modelId)
|
||||
syncDefaults(config)
|
||||
return config
|
||||
},
|
||||
|
||||
getDefaultModel() {
|
||||
return 'Flux 2'
|
||||
},
|
||||
|
||||
validateBeforeSubmit() {
|
||||
return null // 无阻塞,返回 null 表示通过
|
||||
},
|
||||
|
||||
getUploaderBindings() {
|
||||
return { limit: this.imageUploadLimit() }
|
||||
},
|
||||
|
||||
showImageUploader() {
|
||||
if (modelType.value !== 'text') return true
|
||||
return modelConfig.value?.inputType === 'image' || modelConfig.value?.inputType === 'both'
|
||||
},
|
||||
|
||||
imageUploadLimit() {
|
||||
if (!modelConfig.value) return 4
|
||||
const imageParam = modelConfig.value.params.find((p) => p.ui === 'imageUpload')
|
||||
return imageParam?.maxCount || modelConfig.value.maxImages || 4
|
||||
},
|
||||
|
||||
isImageRequired() {
|
||||
return !!(modelConfig.value?.params?.find((p) => p.ui === 'imageUpload'))
|
||||
},
|
||||
|
||||
buildTaskBody(shared) {
|
||||
syncParamValues()
|
||||
const modelParams = { ...paramValues }
|
||||
if (shared.prompt.value) modelParams.prompt = shared.prompt.value
|
||||
return modelParams
|
||||
},
|
||||
|
||||
fillFromResult(resultData) {
|
||||
if (resultData.model !== undefined) model.value = resultData.model
|
||||
if (resultData.modelType !== undefined) modelType.value = resultData.modelType
|
||||
if (resultData.proportion !== undefined) proportion.value = resultData.proportion
|
||||
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
|
||||
if (resultData.customWidth !== undefined) customWidth.value = resultData.customWidth
|
||||
if (resultData.customHight !== undefined) customHight.value = resultData.customHight
|
||||
if (resultData.quantity !== undefined) quantity.value = resultData.quantity
|
||||
if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams)
|
||||
const dc = getDimConfig(modelConfig.value)
|
||||
if (dc?.type === 'split') {
|
||||
if (paramValues[dc.wParam.name] !== undefined) dimWidth.value = paramValues[dc.wParam.name]
|
||||
if (paramValues[dc.hParam.name] !== undefined) dimHeight.value = paramValues[dc.hParam.name]
|
||||
} else if (dc?.type === 'combined') {
|
||||
if (paramValues[dc.paramName]) {
|
||||
const parsed = dc.config.parse(paramValues[dc.paramName])
|
||||
dimWidth.value = parsed.width
|
||||
dimHeight.value = parsed.height
|
||||
}
|
||||
}
|
||||
if (paramValues.quality !== undefined) quality.value = paramValues.quality
|
||||
}
|
||||
}
|
||||
|
||||
return platform
|
||||
}
|
||||
|
||||
// 自注册
|
||||
registerPlatform('Painting', definePaintingPlatform)
|
||||
167
src/platforms/painting/modelSelector.vue
Normal file
@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<Select
|
||||
v-model="selectValue"
|
||||
:grouped-options="modelGroups"
|
||||
class="model-select"
|
||||
position="top"
|
||||
>
|
||||
<template #prefix>
|
||||
<img src="@/assets/dialog/model.svg" alt="" style="width: 16px;">
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: 'Flux 2' },
|
||||
typeValue: { type: String, default: 'text' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:typeValue'])
|
||||
|
||||
const platformModels = ref([])
|
||||
|
||||
const categoryMap = [
|
||||
{ tag: 'text', label: '生成模型', inputType: 'text' },
|
||||
{ tag: 'edit', label: '编辑模型', inputType: 'image' },
|
||||
{ tag: 'vision', label: '视觉理解模型', inputType: 'vision' }
|
||||
]
|
||||
|
||||
function parseValue(encoded) {
|
||||
if (!encoded) return null
|
||||
const idx = encoded.indexOf('::')
|
||||
if (idx === -1) return null
|
||||
return { tag: encoded.substring(0, idx), modelName: encoded.substring(idx + 2) }
|
||||
}
|
||||
|
||||
function encodeValue(tag, modelName) {
|
||||
return `${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))
|
||||
if (model) return cat.tag
|
||||
}
|
||||
return 'text'
|
||||
}
|
||||
|
||||
function tagToInputType(tag) {
|
||||
const cat = categoryMap.find((c) => c.tag === tag)
|
||||
return cat?.inputType || 'text'
|
||||
}
|
||||
|
||||
// Select 双向绑定值(内部编码)
|
||||
const selectValue = computed({
|
||||
get: () => {
|
||||
if (!props.modelValue) return ''
|
||||
const tag = findTagForModel(props.modelValue)
|
||||
return encodeValue(tag, props.modelValue)
|
||||
},
|
||||
set: (encoded) => {
|
||||
const parsed = parseValue(encoded)
|
||||
if (!parsed) return
|
||||
emit('update:modelValue', parsed.modelName)
|
||||
emit('update:typeValue', tagToInputType(parsed.tag))
|
||||
}
|
||||
})
|
||||
|
||||
// 从 API 加载模型列表
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const code = getPlatformCode('Painting')
|
||||
const models = await fetchPlatformModels(code)
|
||||
platformModels.value = models || []
|
||||
} catch (error) {
|
||||
console.error('加载平台模型列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadModels()
|
||||
|
||||
// 按固定分类分组,value 编码为 tag::displayName
|
||||
const modelGroups = computed(() => {
|
||||
const models = platformModels.value
|
||||
if (models.length === 0) return []
|
||||
|
||||
return categoryMap
|
||||
.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) => ({
|
||||
value: `${cat.tag}::${m.display_name || m.name}`,
|
||||
label: m.display_name || m.name,
|
||||
disabled: m.disabled || false
|
||||
}))
|
||||
.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)
|
||||
if (!currentModel || currentModel.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)))
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 外部改变 modelValue 时校验是否可用
|
||||
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)
|
||||
if (currentModel && currentModel.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)))
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.model-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) {
|
||||
max-height: 510px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.dropdown-item) {
|
||||
min-width: 120px;
|
||||
|
||||
&.active {
|
||||
background: rgba(0, 15, 51, 0.10);
|
||||
color: #000F33;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
24
src/platforms/registry.js
Normal file
@ -0,0 +1,24 @@
|
||||
/** 平台注册表:id → definePlatform 工厂函数(key 统一为小写) */
|
||||
const registry = {}
|
||||
|
||||
/** 注册平台 */
|
||||
export function registerPlatform(id, factory) {
|
||||
const key = id.toLowerCase()
|
||||
if (registry[key]) {
|
||||
console.warn(`平台 "${id}" 已注册,将被覆盖`)
|
||||
}
|
||||
registry[key] = factory
|
||||
}
|
||||
|
||||
/** 根据平台类型创建平台实例 */
|
||||
export function createPlatform(type) {
|
||||
const key = type.toLowerCase()
|
||||
const factory = registry[key]
|
||||
if (!factory) throw new Error(`未注册的平台: ${type}`)
|
||||
return factory()
|
||||
}
|
||||
|
||||
/** 获取所有已注册平台 ID */
|
||||
export function getRegisteredPlatforms() {
|
||||
return Object.keys(registry)
|
||||
}
|
||||
@ -6,11 +6,13 @@
|
||||
position="top"
|
||||
>
|
||||
<template #prefix>
|
||||
<img :src="selectedIcon" alt="" style="width: 20px;">
|
||||
<component v-if="!isStr(selectedIcon)" :is="selectedIcon" style="width: 20px;" />
|
||||
<img v-else :src="selectedIcon" alt="" style="width: 20px;">
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<div class="option-content-custom">
|
||||
<img :src="option.icon" alt="" style="width: 20px;">
|
||||
<component v-if="!isStr(option.icon)" :is="option.icon" style="width: 20px;" />
|
||||
<img v-else :src="option.icon" alt="" style="width: 20px;">
|
||||
<span v-if="option.labelText" class="option-label-text">{{ option.labelText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@ -18,12 +20,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
|
||||
import { ChatLineSquare, Picture, VideoCamera } from '@element-plus/icons-vue'
|
||||
import videoPattern2 from '@/assets/dialog/videoPattern2.svg'
|
||||
import videoPattern4 from '@/assets/dialog/videoPattern4.svg'
|
||||
import videoPattern5 from '@/assets/dialog/videoPattern5.svg'
|
||||
import videoPattern6 from '@/assets/dialog/videoPattern6.svg'
|
||||
import Select from '@/components/Select/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@ -40,17 +42,20 @@ const quantity = computed({
|
||||
})
|
||||
|
||||
const quantityOptions = [
|
||||
{ value: '文生视频', label: '文生视频', labelText: '文生视频', icon: videoPattern2 },
|
||||
{ value: '文生视频', label: '文生视频', labelText: '文生视频', icon: markRaw(ChatLineSquare) },
|
||||
{ value: '图生视频', label: '图生视频', labelText: '图生视频', icon: markRaw(Picture) },
|
||||
{ value: '首尾帧', label: '首尾帧', labelText: '首尾帧', icon: videoPattern2 },
|
||||
{ value: '数字人', label: '数字人', labelText: '数字人', icon: videoPattern2 },
|
||||
{ value: '数字人', label: '数字人', labelText: '数字人', icon: markRaw(VideoCamera) },
|
||||
{ value: '全能参考', label: '全能参考', labelText: '全能参考', icon: videoPattern4 },
|
||||
{ value: '智能多帧', label: '智能多帧', labelText: '智能多帧', icon: videoPattern5 },
|
||||
{ value: '智能多帧', label: '智能多帧', labelText: '智能多帧', icon: videoPattern5, disabled: true },
|
||||
{ value: '主体参考', label: '主体参考', labelText: '主体参考', icon: videoPattern6 }
|
||||
]
|
||||
|
||||
const isStr = (v) => typeof v === 'string'
|
||||
|
||||
const selectedIcon = computed(() => {
|
||||
const option = quantityOptions.find(opt => opt.value === quantity.value)
|
||||
return option ? option.icon : videoPattern1
|
||||
const option = quantityOptions.find((opt) => opt.value === quantity.value)
|
||||
return option ? option.icon : videoPattern2
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -62,20 +67,20 @@ const selectedIcon = computed(() => {
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
|
||||
|
||||
&:hover {
|
||||
background: #e9eaeb;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
:deep(.select-text) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
:deep(.dropdown-menu) {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
|
||||
:deep(.dropdown-item) {
|
||||
min-width: 80px;
|
||||
justify-content: start;
|
||||
@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<Popover placement="top" :width="400">
|
||||
<Popover placement="top">
|
||||
<div class="proportion-container">
|
||||
<div class="section">
|
||||
<h3>选择比例</h3>
|
||||
<div class="proportion-options" :style="{ marginBottom: props.type === 'Video' ? '0px' : '20px' }">
|
||||
<div
|
||||
v-for="item in proportionOptions"
|
||||
<div
|
||||
v-for="item in proportionOptions"
|
||||
:key="item.value"
|
||||
class="proportion-item"
|
||||
:class="{ active: proportion === item.value }"
|
||||
@ -20,8 +20,8 @@
|
||||
<div class="section">
|
||||
<h3>选择分辨率</h3>
|
||||
<div class="resolution-options">
|
||||
<div
|
||||
v-for="item in resolutionOptions"
|
||||
<div
|
||||
v-for="item in resolutionOptions"
|
||||
:key="item.value"
|
||||
class="resolution-item"
|
||||
:class="{ active: resolution === item.value }"
|
||||
@ -107,7 +107,7 @@ const getProportionStyle = (value) => {
|
||||
const [w, h] = value.split(':').map(Number)
|
||||
const aspectRatio = w / h
|
||||
const baseSize = 20
|
||||
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
return {
|
||||
'--width': `${baseSize}px`,
|
||||
@ -142,16 +142,17 @@ const getProportionStyle = (value) => {
|
||||
|
||||
.proportion-container{
|
||||
padding: 20px;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.section{
|
||||
margin-bottom: 20px;
|
||||
border-radius: 20px;
|
||||
|
||||
|
||||
&:last-child{
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
h3{
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
@ -163,7 +164,7 @@ const getProportionStyle = (value) => {
|
||||
.proportion-options{
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
margin-bottom: 16px;
|
||||
background-color: #F8F9FA;
|
||||
padding: 5px;
|
||||
@ -172,19 +173,19 @@ const getProportionStyle = (value) => {
|
||||
|
||||
.proportion-item{
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 5px;
|
||||
text-align: bottom;
|
||||
color: #999;
|
||||
|
||||
|
||||
&::before{
|
||||
content: '';
|
||||
width: var(--width, 20px);
|
||||
@ -194,11 +195,11 @@ const getProportionStyle = (value) => {
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid #999;
|
||||
}
|
||||
|
||||
|
||||
&:hover{
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
|
||||
&.active{
|
||||
color: #000F33;
|
||||
background: #ffffff;
|
||||
@ -227,11 +228,11 @@ const getProportionStyle = (value) => {
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
color: #666;
|
||||
|
||||
|
||||
&:hover{
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
|
||||
&.active{
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
@ -53,20 +53,20 @@ const quantityOptions = computed(() => props.options)
|
||||
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;
|
||||
@ -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 ? '首帧' : '尾帧'
|
||||
}
|
||||
|
||||
@ -90,9 +102,9 @@ watch(() => props.modelValue, async (newVal) => {
|
||||
if (isUploading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
imageList.value = [...newVal]
|
||||
|
||||
|
||||
const newPreviewList = []
|
||||
for (const img of newVal) {
|
||||
let previewImg = { ...img }
|
||||
@ -140,16 +152,16 @@ const beforeUpload = (rawFile) => {
|
||||
|
||||
const handleSuccess = (response, uploadFile, index) => {
|
||||
ElMessage.success('上传成功')
|
||||
|
||||
|
||||
isUploading.value = true
|
||||
|
||||
|
||||
const localUrl = URL.createObjectURL(uploadFile.raw)
|
||||
|
||||
|
||||
const newImage = {
|
||||
uid: uploadFile.uid,
|
||||
url: response.url
|
||||
}
|
||||
|
||||
|
||||
if (imageList.value[index]) {
|
||||
const previewItem = localPreviewList.value[index]
|
||||
if (previewItem && previewItem.url && previewItem.url.startsWith('blob:')) {
|
||||
@ -169,9 +181,9 @@ const handleSuccess = (response, uploadFile, index) => {
|
||||
serverUrl: response.url
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
emit('update:modelValue', [...imageList.value])
|
||||
|
||||
|
||||
nextTick(() => {
|
||||
isUploading.value = false
|
||||
})
|
||||
@ -186,7 +198,7 @@ const handleDelete = (index) => {
|
||||
if (previewItem && previewItem.url && previewItem.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewItem.url)
|
||||
}
|
||||
|
||||
|
||||
localPreviewList.value.splice(index, 1)
|
||||
imageList.value.splice(index, 1)
|
||||
emit('update:modelValue', [...imageList.value])
|
||||
253
src/platforms/video/index.js
Normal file
@ -0,0 +1,253 @@
|
||||
import { markRaw, reactive, ref } from 'vue'
|
||||
import { fetchPlatformModels, getModelConfig, getModelId, getPlatformCode, preloadModelConfigs } from '@/utils/modelApi'
|
||||
import { syncDefaults as _syncDefaults, syncParamValues as _syncParamValues } from '@/utils/modelConfigHelper.js'
|
||||
import { registerPlatform } from '../registry.js'
|
||||
import Pattern from './controls/pattern.vue'
|
||||
import VideoProportion from './controls/proportion.vue'
|
||||
import Time from './controls/time.vue'
|
||||
import VideoImageUploader from './imageUploader.vue'
|
||||
import VideoModelSelector from './modelSelector.vue'
|
||||
import ParamGroup from '@/components/ParamGroup/index.vue'
|
||||
|
||||
export function defineVideoPlatform() {
|
||||
const model = ref('LTX2.0')
|
||||
const modelType = ref('text')
|
||||
const proportion = ref('16:9')
|
||||
const resolution = ref('1k')
|
||||
const duration = ref(5)
|
||||
const videoPattern = ref('文生视频')
|
||||
|
||||
const resolutionOptions = ref([
|
||||
{ value: '1k', label: '标清 1K' },
|
||||
{ value: '2k', label: '高清 2K' },
|
||||
{ value: '4k', label: '超清 4K' }
|
||||
])
|
||||
const proportionOptions = ref([
|
||||
{ value: '智能', label: '智能' },
|
||||
{ value: '21:9', label: '21:9' },
|
||||
{ value: '16:9', label: '16:9' },
|
||||
{ value: '4:3', label: '4:3' },
|
||||
{ value: '1:1', label: '1:1' },
|
||||
{ value: '3:4', label: '3:4' },
|
||||
{ value: '9:16', label: '9:16' }
|
||||
])
|
||||
const durationOptions = ref([])
|
||||
const promptPlaceholder = ref('描述你想生成的画面和动作。')
|
||||
|
||||
// params 驱动(与 Painting 统一)
|
||||
const paramValues = reactive({})
|
||||
const modelConfig = ref(null)
|
||||
const quality = ref('medium')
|
||||
const customWidth = ref(1024)
|
||||
const customHight = ref(1024)
|
||||
const dimWidth = ref(1024)
|
||||
const dimHeight = ref(1024)
|
||||
const quantity = ref(1)
|
||||
|
||||
const state = {
|
||||
model,
|
||||
modelType,
|
||||
proportion,
|
||||
resolution,
|
||||
duration,
|
||||
videoPattern,
|
||||
resolutionOptions,
|
||||
proportionOptions,
|
||||
durationOptions,
|
||||
modelConfig,
|
||||
paramValues
|
||||
}
|
||||
|
||||
const paintingCompatState = {
|
||||
modelConfig,
|
||||
paramValues,
|
||||
proportion,
|
||||
resolution,
|
||||
quantity,
|
||||
quality,
|
||||
customWidth,
|
||||
customHight,
|
||||
dimWidth,
|
||||
dimHeight,
|
||||
promptPlaceholder,
|
||||
duration
|
||||
}
|
||||
|
||||
function syncDefaults(config) {
|
||||
_syncDefaults(config, paintingCompatState)
|
||||
}
|
||||
|
||||
function syncParamValues() {
|
||||
_syncParamValues(modelConfig.value, paintingCompatState)
|
||||
}
|
||||
|
||||
const controls = [
|
||||
{
|
||||
name: 'pattern',
|
||||
component: markRaw(Pattern),
|
||||
beforeModel: true,
|
||||
show: () => true,
|
||||
props: () => ({
|
||||
'modelValue': videoPattern.value,
|
||||
'onUpdate:modelValue': (v) => { videoPattern.value = v }
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'proportion',
|
||||
component: markRaw(VideoProportion),
|
||||
show: (config) => !!config?.params?.find((p) => p.ui === 'proportion'),
|
||||
props: (config) => {
|
||||
const ratioParam = config?.params?.find((p) => p.ui === 'proportion')
|
||||
const resParam = config?.params?.find((p) => p.ui === 'resolution' || (p.ui === 'select' && p.name === 'resolution'))
|
||||
return {
|
||||
'modelValue': proportion.value,
|
||||
'onUpdate:modelValue': (v) => { proportion.value = v },
|
||||
'resolution': resolution.value,
|
||||
'onUpdate:resolution': (v) => { resolution.value = v },
|
||||
'proportionOptions': (ratioParam?.options || []).map((o) => ({ value: o, label: o })),
|
||||
'resolutionOptions': (resParam?.options || []).map((o) => ({ value: o, label: o }))
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'time',
|
||||
component: markRaw(Time),
|
||||
show: (config) => !!config?.params?.find((p) => p.name === 'duration'),
|
||||
props: (config) => {
|
||||
const durationParam = config?.params?.find((p) => p.name === 'duration')
|
||||
let options = []
|
||||
if (durationParam) {
|
||||
if (durationParam.ui === 'select' && durationParam.options) {
|
||||
options = durationParam.options.map((o) => ({ value: Number(o), label: `${o}s` }))
|
||||
} else if (durationParam.ui === 'number') {
|
||||
const min = durationParam.min || 1
|
||||
const max = durationParam.max || 16
|
||||
for (let i = min; i <= max; i++) {
|
||||
options.push({ value: i, label: `${i}s` })
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
'modelValue': duration.value,
|
||||
'onUpdate:modelValue': (v) => { duration.value = v },
|
||||
'options': options.length > 0 ? options : durationOptions.value
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'paramGroup',
|
||||
component: markRaw(ParamGroup),
|
||||
show: (config) => {
|
||||
if (!config?.params) return false
|
||||
const handledUis = ['textarea', 'proportion', 'resolution', 'dimension', 'dimensionWidth', 'dimensionHeight', 'quantity', 'imageUpload', 'hidden', 'number']
|
||||
return config.params.some((p) => {
|
||||
if (handledUis.includes(p.ui)) return false
|
||||
if (p.name === 'resolution') return false
|
||||
if (p.name === 'duration') return false
|
||||
return true
|
||||
})
|
||||
},
|
||||
props: (config) => ({
|
||||
config,
|
||||
paramValues,
|
||||
excludeNames: ['resolution', 'duration']
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
const platform = {
|
||||
id: 'video',
|
||||
label: 'AI视频2026',
|
||||
ModelSelector: markRaw(VideoModelSelector),
|
||||
modelSelectorProps: () => ({ videoPattern: videoPattern.value }),
|
||||
controls,
|
||||
ImageUploader: markRaw(VideoImageUploader),
|
||||
state,
|
||||
model,
|
||||
modelType,
|
||||
modelConfig,
|
||||
promptPlaceholder,
|
||||
|
||||
async loadModels() {
|
||||
const code = getPlatformCode('Video')
|
||||
const models = await fetchPlatformModels(code)
|
||||
if (models?.length) {
|
||||
const modelIds = models.map((m) => m.id)
|
||||
await preloadModelConfigs(modelIds)
|
||||
}
|
||||
return models
|
||||
},
|
||||
|
||||
async loadConfig(modelName, _modelType) {
|
||||
const modelId = await getModelId('Video', modelName)
|
||||
if (!modelId) return null
|
||||
const config = await getModelConfig(modelId)
|
||||
syncDefaults(config)
|
||||
return config
|
||||
},
|
||||
|
||||
getDefaultModel() {
|
||||
return '' // 模型列表加载后由 modelSelector 自动纠错设置
|
||||
},
|
||||
|
||||
validateBeforeSubmit() {
|
||||
if (model.value === 'Seedance 2.0') {
|
||||
return '敬请期待 Seedance 2.0'
|
||||
}
|
||||
return null // 通过
|
||||
},
|
||||
|
||||
getUploaderBindings() {
|
||||
return { modelType: modelType.value, imagesCount: this.imageUploadLimit() }
|
||||
},
|
||||
|
||||
showImageUploader() {
|
||||
return modelConfig.value?.inputType === 'image' || modelConfig.value?.inputType === 'both'
|
||||
},
|
||||
|
||||
imageUploadLimit() {
|
||||
if (!modelConfig.value) return 4
|
||||
const imageParams = modelConfig.value.params.filter((p) => p.ui === 'imageUpload')
|
||||
if (imageParams.length > 0) {
|
||||
return imageParams.reduce((sum, p) => sum + (p.maxCount || 1), 0)
|
||||
}
|
||||
return modelConfig.value.maxImages || 4
|
||||
},
|
||||
|
||||
isImageRequired() {
|
||||
return !!(modelConfig.value?.params?.find((p) => p.ui === 'imageUpload'))
|
||||
},
|
||||
|
||||
buildTaskBody(shared) {
|
||||
syncParamValues()
|
||||
const modelParams = { ...paramValues }
|
||||
if (shared.prompt.value) modelParams.prompt = shared.prompt.value
|
||||
|
||||
// 将上传的参考图映射到 imageUpload 参数(如首尾帧模型的 firstImageUrl / lastImageUrl)
|
||||
if (shared.referenceImages?.value?.length > 0) {
|
||||
const imageParams = modelConfig.value?.params?.filter((p) => p.ui === 'imageUpload') || []
|
||||
imageParams.forEach((p, i) => {
|
||||
if (shared.referenceImages.value[i]) {
|
||||
modelParams[p.name] = shared.referenceImages.value[i].url || shared.referenceImages.value[i]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return modelParams
|
||||
},
|
||||
|
||||
fillFromResult(resultData) {
|
||||
if (resultData.model !== undefined) model.value = resultData.model
|
||||
if (resultData.modelType !== undefined) modelType.value = resultData.modelType
|
||||
if (resultData.proportion !== undefined) proportion.value = resultData.proportion
|
||||
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
|
||||
if (resultData.duration !== undefined) duration.value = resultData.duration
|
||||
if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern
|
||||
if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams)
|
||||
}
|
||||
}
|
||||
|
||||
return platform
|
||||
}
|
||||
|
||||
registerPlatform('Video', defineVideoPlatform)
|
||||
@ -13,6 +13,7 @@
|
||||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@ -31,36 +32,32 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:typeValue'])
|
||||
|
||||
const videoConfig = ref({})
|
||||
const platformModels = ref([])
|
||||
|
||||
const fetchConfig = async () => {
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_API_MODEL_RESOURCE}/static/public/Platform/AIGC_modelConfig/video.json`
|
||||
const response = await fetch(url)
|
||||
const data = await response.json()
|
||||
videoConfig.value = data
|
||||
const code = getPlatformCode('Video')
|
||||
const models = await fetchPlatformModels(code)
|
||||
platformModels.value = models || []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch video config:', error)
|
||||
console.error('加载视频模型列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadModels()
|
||||
|
||||
fetchConfig()
|
||||
|
||||
watch(() => videoConfig.value, (newConfig) => {
|
||||
const models = newConfig[props.videoPattern] || []
|
||||
if (models.length > 0) {
|
||||
const enabledModels = models.filter(m => !m.disabled)
|
||||
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 })
|
||||
@ -121,24 +128,24 @@ watch(() => props.modelValue, (newValue) => {
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
|
||||
|
||||
&:hover {
|
||||
background: #e9eaeb;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
:deep(.select-text) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
:deep(.dropdown-menu) {
|
||||
max-height: 510px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
:deep(.dropdown-item) {
|
||||
min-width: 120px;
|
||||
|
||||
|
||||
&.active {
|
||||
background: rgba(0, 15, 51, 0.10);
|
||||
color: #000F33;
|
||||
@ -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
|
||||
|
||||
@ -8,12 +8,12 @@ const DisplayStoreSetup = () => {
|
||||
const isLoading = ref(false)
|
||||
const currentResultData = ref(null)
|
||||
const dialogBoxRef = ref(null)
|
||||
|
||||
|
||||
const canvasVisible = ref(false)
|
||||
const canvasImage = ref('')
|
||||
const canvasReferenceImages = ref([])
|
||||
const canvasSource = ref('')
|
||||
|
||||
|
||||
const addGeneratingItem = (item) => {
|
||||
const newItem = {
|
||||
id: item.taskId || crypto.randomUUID(),
|
||||
@ -26,29 +26,29 @@ const DisplayStoreSetup = () => {
|
||||
tempList.value.unshift(newItem)
|
||||
return newItem
|
||||
}
|
||||
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const initHistoryList = (historyList) => {
|
||||
tempList.value = historyList
|
||||
currentPage.value = 1
|
||||
hasMoreData.value = true
|
||||
}
|
||||
|
||||
|
||||
const prependHistoryList = (historyList) => {
|
||||
tempList.value = [...historyList, ...tempList.value]
|
||||
}
|
||||
|
||||
|
||||
const appendHistoryList = (historyList) => {
|
||||
tempList.value = [...tempList.value, ...historyList]
|
||||
}
|
||||
|
||||
|
||||
const resetPagination = () => {
|
||||
currentPage.value = 0
|
||||
hasMoreData.value = true
|
||||
@ -56,26 +56,26 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
const refValue = scrollerRef.value
|
||||
|
||||
|
||||
if (!refValue) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (typeof refValue.scrollToBottom === 'function') {
|
||||
await nextTick()
|
||||
refValue.scrollToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const scrollerEl = refValue.$el
|
||||
if (scrollerEl) {
|
||||
const viewport = scrollerEl.querySelector('.vue-recycle-scroller__viewport')
|
||||
@ -83,7 +83,7 @@ const DisplayStoreSetup = () => {
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (typeof refValue.scrollToItem === 'function' && tempList.value && tempList.value.length > 0) {
|
||||
await nextTick()
|
||||
refValue.scrollToItem(tempList.value.length - 1)
|
||||
@ -92,28 +92,28 @@ const DisplayStoreSetup = () => {
|
||||
console.error('滚动出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const setResultData = (data) => {
|
||||
currentResultData.value = data
|
||||
}
|
||||
|
||||
|
||||
const setDialogBoxRef = (ref) => {
|
||||
dialogBoxRef.value = ref
|
||||
}
|
||||
|
||||
|
||||
const triggerGenerateWithResult = async () => {
|
||||
if (dialogBoxRef.value && currentResultData.value) {
|
||||
await dialogBoxRef.value.fillParamsFromResult(currentResultData.value)
|
||||
await dialogBoxRef.value.handleStart()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const fillParamsForEdit = () => {
|
||||
if (dialogBoxRef.value && currentResultData.value) {
|
||||
dialogBoxRef.value.fillParamsFromResult(currentResultData.value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const openCanvas = (data) => {
|
||||
if (typeof data === 'string') {
|
||||
canvasImage.value = data
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
const ParamStoreSetup = () => {
|
||||
|
||||
return {
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import {
|
||||
getUserInfo as getUserInfoApi,
|
||||
logout as logoutApi
|
||||
} from '@/apis/auth'
|
||||
import { getFreeTimes } from '@/apis/display'
|
||||
import { clearToken, getToken, setToken } from '@/utils/auth'
|
||||
|
||||
const storeSetup = () => {
|
||||
@ -34,7 +33,6 @@ const storeSetup = () => {
|
||||
|
||||
const dept = ref({}) // 当前用户所在部门集合
|
||||
const isLogin = ref(false)
|
||||
const freeTimes = ref(0) // 免费次数
|
||||
|
||||
// 重置token
|
||||
const resetToken = () => {
|
||||
@ -45,49 +43,40 @@ const storeSetup = () => {
|
||||
// 检查token有效性
|
||||
const checkTokenValid = async () => {
|
||||
const res = await checkUsertokenApi()
|
||||
console.log('checkTokenValid:', res) // 打印响应数据以进行调试
|
||||
if (res.code === '401' || res.success === false) {
|
||||
// 检查响应数据是否存在,以避免空响应导致的错误
|
||||
console.error('Token is invalid:', res.message)// 打印错误信息以进行调试
|
||||
console.log('checkTokenValid:', res)
|
||||
if (res.code === '401' || res.status === '401' || res.success === false) {
|
||||
console.error('Token is invalid:', res.message)
|
||||
return false
|
||||
}
|
||||
console.log('Token is valid') // 打印成功信息以进行调试
|
||||
console.log('Token is valid')
|
||||
return true
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const getInfo = async () => {
|
||||
const res = await getUserInfoApi()
|
||||
Object.assign(userInfo, res.data)
|
||||
// userInfo.avatar = getAvatar(res.data.avatar, res.data.gender)
|
||||
userInfo.username = res.data.username
|
||||
if (typeof res.data.routers === 'string' && res.data.routers.trim() !== '') {
|
||||
userInfo.routers = res.data.routers.split(',').map((item) => item.trim()) // 补充trim处理更完善
|
||||
// 兼容新旧格式:新格式 data.userInfo 嵌套,旧格式 data 扁平
|
||||
const u = res.data.userInfo || res.data
|
||||
Object.assign(userInfo, u)
|
||||
userInfo.id = u.userId || u.id
|
||||
userInfo.username = u.userName || u.username
|
||||
if (typeof u.routers === 'string' && u.routers.trim() !== '') {
|
||||
userInfo.routers = u.routers.split(',').map((item) => item.trim())
|
||||
} else {
|
||||
userInfo.routers = []
|
||||
}
|
||||
if (res.data.roles && res.data.roles.length) {
|
||||
roles.value = res.data.roles
|
||||
permissions.value = res.data.permissions
|
||||
// 角色和权限在 data 层级(非 userInfo 内)
|
||||
const roleList = res.data.roles || u.roles
|
||||
if (roleList?.length) {
|
||||
roles.value = roleList
|
||||
permissions.value = res.data.permissions || u.permissions || []
|
||||
}
|
||||
}
|
||||
|
||||
// 获取免费次数
|
||||
const fetchFreeTimes = async (chargeType = 1) => {
|
||||
if (userInfo.id) {
|
||||
const res = await getFreeTimes(userInfo.id)
|
||||
const balanceList = res.data || []
|
||||
const target = balanceList.find((item) => item.chargeType === chargeType)
|
||||
freeTimes.value = target?.balance || 0
|
||||
return freeTimes.value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 登录
|
||||
const accountLogin = async (req) => {
|
||||
const res = await accountLoginApi(req)
|
||||
if (res.data == null || res.code === '500' || res.success === false) {
|
||||
if (res.data == null || res.code === '500' || res.status === 500 || res.success === false) {
|
||||
// eslint-disable-next-line no-undef
|
||||
ElMessage({
|
||||
title: '提示',
|
||||
@ -134,14 +123,12 @@ const storeSetup = () => {
|
||||
dept,
|
||||
username,
|
||||
isLogin,
|
||||
freeTimes,
|
||||
accountLogin,
|
||||
logout,
|
||||
logoutCallBack,
|
||||
getInfo,
|
||||
resetToken,
|
||||
checkTokenValid,
|
||||
fetchFreeTimes
|
||||
checkTokenValid
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
import outPlatform from '@/config/index'
|
||||
|
||||
// 处理音频生成任务的数据并返回
|
||||
export async function createTask(data, taskId, token) {
|
||||
console.log(data)
|
||||
const payload = await outPlatform[data.platform].Playload(data)
|
||||
|
||||
return {
|
||||
AIGC: data.AIGC,
|
||||
platform: data.platform,
|
||||
taskType: data.modelType === 'text' ? 1 : 2,
|
||||
modelName: data.modelName,
|
||||
payload,
|
||||
taskId,
|
||||
token,
|
||||
quantity: data.quantity,
|
||||
free: data.free,
|
||||
result: data.result
|
||||
}
|
||||
}
|
||||
|
||||
// 获取结果
|
||||
export async function getTask(result) {
|
||||
if (result.code === 0 && result.msg === 'success' && Array.isArray(result.data) && result.data.length > 0) {
|
||||
const urls = result.data.map(item => item.fileUrl)
|
||||
return { type: true, urls: urls }
|
||||
}
|
||||
return { type: false, message: result.data.exception_message || '生成失败' }
|
||||
}
|
||||
@ -7,7 +7,7 @@ export async function generateFilename(url, prefix = 'image') {
|
||||
|
||||
// 如果URL中没有文件名或扩展名,根据类型生成
|
||||
if (!filename || !filename.includes('.')) {
|
||||
const timestamp = new Date().getTime()
|
||||
const timestamp = Date.now()
|
||||
// 根据URL内容推断文件类型,否则默认为png
|
||||
const extension = url.includes('.jpg') || url.includes('.jpeg')
|
||||
? '.jpg'
|
||||
@ -21,7 +21,7 @@ export async function generateFilename(url, prefix = 'image') {
|
||||
} catch (error) {
|
||||
console.error('URL解析失败:', error)
|
||||
// 如果URL解析失败,生成默认文件名
|
||||
const timestamp = new Date().getTime()
|
||||
const timestamp = Date.now()
|
||||
return `${prefix}_${timestamp}.png`
|
||||
}
|
||||
}
|
||||
|
||||
192
src/utils/modelApi.js
Normal file
@ -0,0 +1,192 @@
|
||||
import { fetchPlatformModels as fetchModelsRaw, requestModelConfig, requestModelConfigsBatch } from '@/apis/display'
|
||||
|
||||
const CACHE_PREFIX = 'platform_models_'
|
||||
const CACHE_TTL = 30 * 1000 // 30秒有效期
|
||||
|
||||
function getCacheKey(code) {
|
||||
return `${CACHE_PREFIX}${code}`
|
||||
}
|
||||
|
||||
function getFromCache(code) {
|
||||
try {
|
||||
const key = getCacheKey(code)
|
||||
const stored = localStorage.getItem(key)
|
||||
if (!stored) return null
|
||||
|
||||
const data = JSON.parse(stored)
|
||||
// 清理旧格式缓存(无 timestamp 字段)或过期缓存
|
||||
if (!data.timestamp || Date.now() - data.timestamp > CACHE_TTL) {
|
||||
localStorage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
return data.models
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveToCache(code, models) {
|
||||
try {
|
||||
const key = getCacheKey(code)
|
||||
localStorage.setItem(key, JSON.stringify({
|
||||
models,
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('保存模型列表缓存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 类型 → 平台编码映射
|
||||
export function getPlatformCode(type) {
|
||||
const normalized = type.charAt(0).toUpperCase() + type.slice(1).toLowerCase()
|
||||
switch (normalized) {
|
||||
case 'Painting':
|
||||
return 'ai_painting_talk'
|
||||
case 'Video':
|
||||
return 'ai_video_talk'
|
||||
case 'Music':
|
||||
return 'ai_music_talk'
|
||||
default:
|
||||
return 'ai_painting_talk'
|
||||
}
|
||||
}
|
||||
|
||||
const pendingRequests = new Map() // 并发请求去重
|
||||
|
||||
// 获取平台模型列表(localStorage 缓存,30秒有效)
|
||||
export async function fetchPlatformModels(code) {
|
||||
const cached = getFromCache(code)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
// 已有进行中的请求则复用,避免并发重复请求
|
||||
if (pendingRequests.has(code)) {
|
||||
return pendingRequests.get(code)
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const result = await fetchModelsRaw(code)
|
||||
|
||||
if (result.code === 0 && result.data?.models) {
|
||||
saveToCache(code, result.data.models)
|
||||
return result.data.models
|
||||
}
|
||||
|
||||
console.error('获取模型列表失败:', result.message)
|
||||
return []
|
||||
} catch (error) {
|
||||
console.error('获取模型列表失败:', error)
|
||||
return []
|
||||
} finally {
|
||||
pendingRequests.delete(code)
|
||||
}
|
||||
})()
|
||||
|
||||
pendingRequests.set(code, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
// 根据模型名称查找 model_id
|
||||
export async function getModelId(type, modelName) {
|
||||
if (!modelName) return ''
|
||||
|
||||
const code = getPlatformCode(type)
|
||||
const models = await fetchPlatformModels(code)
|
||||
|
||||
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 = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key && key.startsWith(CACHE_PREFIX)) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key))
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
const STORAGE_PREFIX = 'model_config_'
|
||||
|
||||
function getTodayDateString() {
|
||||
const today = new Date()
|
||||
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getStorageKey(modelName, modelType) {
|
||||
return `${STORAGE_PREFIX}${modelType}_${modelName}`
|
||||
}
|
||||
|
||||
function getConfigFromStorage(modelName, modelType) {
|
||||
try {
|
||||
const key = getStorageKey(modelName, modelType)
|
||||
const stored = localStorage.getItem(key)
|
||||
|
||||
if (!stored) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = JSON.parse(stored)
|
||||
const todayStr = getTodayDateString()
|
||||
|
||||
if (data.storageDate !== todayStr) {
|
||||
localStorage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
|
||||
return data.config
|
||||
} catch (error) {
|
||||
console.error('从localStorage读取配置失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfigToStorage(modelName, modelType, config) {
|
||||
try {
|
||||
const key = getStorageKey(modelName, modelType)
|
||||
const data = {
|
||||
config,
|
||||
storageDate: getTodayDateString(),
|
||||
timestamp: Date.now()
|
||||
}
|
||||
localStorage.setItem(key, JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('保存配置到localStorage失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchModelConfig(type, modelName, modelType) {
|
||||
const cachedConfig = getConfigFromStorage(modelName, modelType)
|
||||
|
||||
if (cachedConfig) {
|
||||
console.log(`从缓存加载模型配置: ${modelName}`)
|
||||
return cachedConfig
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_API_MODEL_RESOURCE}/static/public/Platform/${type}/workflows/${modelType}/${modelName}.json`
|
||||
console.log(`从远程获取模型配置: ${url}`)
|
||||
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const config = await response.json()
|
||||
|
||||
saveConfigToStorage(modelName, modelType, config)
|
||||
|
||||
return config
|
||||
} catch (error) {
|
||||
console.error('获取模型配置失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function clearModelConfigCache(modelName, modelType) {
|
||||
const key = getStorageKey(modelName, modelType)
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
|
||||
export function clearAllModelConfigCache() {
|
||||
const keysToRemove = []
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key && key.startsWith(STORAGE_PREFIX)) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key))
|
||||
}
|
||||
166
src/utils/modelConfigHelper.js
Normal file
@ -0,0 +1,166 @@
|
||||
// 模型配置共享工具函数
|
||||
// 供 Painting / Video descriptor 使用
|
||||
|
||||
/**
|
||||
* 检测 dimension 配置模式
|
||||
* @param {object | null} config - 模型配置对象
|
||||
* @returns {object | null}
|
||||
* - combined: { type: 'combined', config: dimension子对象, paramName: string }
|
||||
* - split: { type: 'split', wParam: 宽度参数, hParam: 高度参数 }
|
||||
* - null: 无 dimension 参数
|
||||
*/
|
||||
export function getDimConfig(config) {
|
||||
if (!config) return null
|
||||
const dimParam = config.params.find((p) => p.ui === 'dimension')
|
||||
if (dimParam) return { type: 'combined', config: dimParam.dimension, paramName: dimParam.name }
|
||||
const wParam = config.params.find((p) => p.ui === 'dimensionWidth')
|
||||
const hParam = config.params.find((p) => p.ui === 'dimensionHeight')
|
||||
if (wParam && hParam) return { type: 'split', wParam, hParam }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 showWhen 条件是否满足
|
||||
* @param {object} param - 参数定义(可能含 showWhen)
|
||||
* @param {object} paramValues - 当前所有参数值
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function checkShowWhen(param, paramValues) {
|
||||
if (!param.showWhen) return true
|
||||
return Object.entries(param.showWhen).every(([key, expected]) => {
|
||||
return paramValues[key] === expected
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 API 返回的 config 同步到响应式 state
|
||||
*
|
||||
* state 对象需包含以下属性(均为 ref 或 reactive):
|
||||
* modelConfig, paramValues, proportion, resolution, quantity, quality,
|
||||
* customWidth, customHight, dimWidth, dimHeight, promptPlaceholder
|
||||
*/
|
||||
export function syncDefaults(config, state) {
|
||||
const {
|
||||
modelConfig,
|
||||
paramValues,
|
||||
proportion,
|
||||
resolution,
|
||||
quantity,
|
||||
quality,
|
||||
customWidth,
|
||||
customHight,
|
||||
dimWidth,
|
||||
dimHeight,
|
||||
promptPlaceholder
|
||||
} = state
|
||||
|
||||
modelConfig.value = config
|
||||
if (!config) return
|
||||
|
||||
// 1. dimension.separator → 生成 parse/format(在遍历 params 之前完成)
|
||||
config.params.forEach((p) => {
|
||||
if (p.ui === 'dimension' && p.dimension?.separator && !p.dimension.parse) {
|
||||
const sep = p.dimension.separator
|
||||
p.dimension.parse = (val) => {
|
||||
const parts = (val || '').split(sep)
|
||||
return { width: Number.parseInt(parts[0]) || 0, height: Number.parseInt(parts[1]) || 0 }
|
||||
}
|
||||
p.dimension.format = (w, h) => `${w}${sep}${h}`
|
||||
}
|
||||
})
|
||||
|
||||
// 2. 初始化 paramValues(已存在的 key 保留,避免切换模型时丢失值)
|
||||
config.params.forEach((p) => {
|
||||
if (!(p.name in paramValues)) {
|
||||
paramValues[p.name] = p.default ?? (p.name === 'outputFormat' ? 'png' : '')
|
||||
}
|
||||
})
|
||||
|
||||
// 3. 同步专用 ref
|
||||
const ratioParam = config.params.find((p) => p.ui === 'proportion')
|
||||
if (ratioParam) proportion.value = ratioParam.default || '1:1'
|
||||
|
||||
const resParam = config.params.find((p) => p.ui === 'resolution' || (p.ui === 'select' && p.name === 'resolution'))
|
||||
if (resParam) resolution.value = resParam.default || '2k'
|
||||
|
||||
const qtyParam = config.params.find((p) => p.ui === 'quantity')
|
||||
if (qtyParam) quantity.value = qtyParam.default || 1
|
||||
|
||||
const cwParam = config.params.find((p) => p.name === 'customWidth')
|
||||
if (cwParam) customWidth.value = cwParam.default || 1024
|
||||
|
||||
const chParam = config.params.find((p) => p.name === 'customHight')
|
||||
if (chParam) customHight.value = chParam.default || 1024
|
||||
|
||||
const qualityParam = config.params.find((p) => p.name === 'quality')
|
||||
if (qualityParam) quality.value = qualityParam.default || 'medium'
|
||||
|
||||
const durationParam = config.params.find((p) => p.name === 'duration')
|
||||
if (durationParam && state.duration) state.duration.value = durationParam.default ?? 5
|
||||
|
||||
// 4. dimension 初始化
|
||||
const dc = getDimConfig(config)
|
||||
if (dc?.type === 'split') {
|
||||
dimWidth.value = dc.wParam.default || 1024
|
||||
dimHeight.value = dc.hParam.default || 1024
|
||||
} else if (dc?.type === 'combined') {
|
||||
const dimParam = config.params.find((p) => p.name === dc.paramName)
|
||||
const raw = dimParam?.default || ''
|
||||
const parsed = dc.config.parse(raw)
|
||||
dimWidth.value = parsed.width
|
||||
dimHeight.value = parsed.height
|
||||
}
|
||||
|
||||
// 5. promptPlaceholder 同步
|
||||
if (config.promptPlaceholder) {
|
||||
promptPlaceholder.value = config.promptPlaceholder
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将专用 ref 的当前值回写到 paramValues
|
||||
* (在 buildTaskBody 之前调用)
|
||||
*/
|
||||
export function syncParamValues(config, state) {
|
||||
const {
|
||||
paramValues,
|
||||
proportion,
|
||||
resolution,
|
||||
quantity,
|
||||
customWidth,
|
||||
customHight,
|
||||
dimWidth,
|
||||
dimHeight,
|
||||
quality
|
||||
} = state
|
||||
|
||||
const ratioParam = config?.params?.find((p) => p.ui === 'proportion')
|
||||
if (ratioParam) paramValues[ratioParam.name] = proportion.value
|
||||
|
||||
const resParam = config?.params?.find((p) => p.ui === 'resolution' || (p.ui === 'select' && p.name === 'resolution'))
|
||||
if (resParam) paramValues[resParam.name] = resolution.value
|
||||
|
||||
const qtyParam = config?.params?.find((p) => p.ui === 'quantity')
|
||||
if (qtyParam) paramValues[qtyParam.name] = quantity.value
|
||||
|
||||
if (config?.params?.find((p) => p.name === 'customWidth')) {
|
||||
paramValues.customWidth = customWidth.value
|
||||
}
|
||||
if (config?.params?.find((p) => p.name === 'customHight')) {
|
||||
paramValues.customHight = customHight.value
|
||||
}
|
||||
if (config?.params?.find((p) => p.name === 'quality')) {
|
||||
paramValues.quality = quality.value
|
||||
}
|
||||
|
||||
const durationParam = config?.params?.find((p) => p.name === 'duration')
|
||||
if (durationParam && state.duration) paramValues[durationParam.name] = state.duration.value
|
||||
|
||||
const dc = getDimConfig(config)
|
||||
if (dc?.type === 'split') {
|
||||
paramValues[dc.wParam.name] = dimWidth.value
|
||||
paramValues[dc.hParam.name] = dimHeight.value
|
||||
} else if (dc?.type === 'combined') {
|
||||
paramValues[dc.paramName] = dc.config.format(dimWidth.value, dimHeight.value)
|
||||
}
|
||||
}
|
||||
@ -29,21 +29,19 @@ const StatusCodeMessage = {
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
if (!config.headers) {
|
||||
config.headers = {}
|
||||
}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
if (!config.headers) {
|
||||
config.headers = {}
|
||||
}
|
||||
// console.log(config.baseURL)
|
||||
if (config.url?.startsWith(import.meta.env.VITE_API_PAY_PREFIX)) { // 支付服务路由
|
||||
|
||||
// 统一 Auth 头不带 Bearer 前缀
|
||||
if (token) config.headers.Authorization = token
|
||||
|
||||
if (config.url?.startsWith(import.meta.env.VITE_API_TASK_PREFIX)) { // 算力调度后端
|
||||
config.baseURL = import.meta.env.VITE_API_TASK_TARGET
|
||||
} else if (config.url?.startsWith(import.meta.env.VITE_API_PAY_PREFIX)) { // 支付服务路由
|
||||
config.baseURL = import.meta.env.VITE_API_PAY_TARGET
|
||||
} else if (config.url?.startsWith(import.meta.env.VITE_API_AIGC_PREFIX)) { // 资源服务路由
|
||||
// config.url = config.url.replace(import.meta.env.VITE_API_AIGC_PREFIX, '')
|
||||
config.baseURL = import.meta.env.VITE_API_AIGC_TARGET
|
||||
} else if (config.url?.startsWith(import.meta.env.VITE_API_MUSIC_WORKFLOW_PREFIX)) { // 音频生成平台工作流服务路由
|
||||
config.url = config.url.replace(import.meta.env.VITE_API_MUSIC_WORKFLOW_PREFIX, '')
|
||||
config.baseURL = import.meta.env.VITE_API_MUSIC_WORKFLOW_TARGET
|
||||
}
|
||||
return config
|
||||
},
|
||||
@ -57,11 +55,11 @@ service.interceptors.request.use(
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
const { data } = response
|
||||
const { success, code, msg } = data
|
||||
if (success || code === 0) {
|
||||
const { success, code, status, msg, message } = data
|
||||
if (success || code === 0 || status === 0) {
|
||||
console.log('msg: \n', msg)
|
||||
return response.data
|
||||
} else if (code === 401 && response.config.url !== '/auth/check/token`') { // 判断code=401时进行页面刷新,但是不对检验token这个路由的请求判断,防止出现死循环
|
||||
} else if (code === 401 && response.config.url !== '/login/validateToken`') { // 判断code=401时进行页面刷新,但是不对检验token这个路由的请求判断,防止出现死循环
|
||||
userError()
|
||||
}
|
||||
console.log('CodeMessage: \n', StatusCodeMessage[code])
|
||||
|
||||
194
src/utils/taskPolling.js
Normal file
@ -0,0 +1,194 @@
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { h } from 'vue'
|
||||
import { requestCreateTask, requestTaskStatus } from '@/apis/display'
|
||||
import { useDisplayStore, useUserStore } from '@/stores'
|
||||
import { getPlatformCode } from '@/utils/modelApi'
|
||||
import { userError } from '@/utils/tokenError'
|
||||
|
||||
export function getChargeType(chargeType) {
|
||||
const normalized = chargeType.charAt(0).toUpperCase() + chargeType.slice(1).toLowerCase()
|
||||
switch (normalized) {
|
||||
case 'Painting':
|
||||
return 1
|
||||
case 'Video':
|
||||
return 4
|
||||
case 'Music':
|
||||
return 5
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
export function websocketError(code, msg) {
|
||||
let message
|
||||
switch (code) {
|
||||
case 1006:
|
||||
message = '用户身份验证失败'
|
||||
userError()
|
||||
break
|
||||
case 4401:
|
||||
message = msg
|
||||
break
|
||||
case 4402:
|
||||
message = JSON.parse(msg)
|
||||
break
|
||||
case 4403:
|
||||
message = msg
|
||||
break
|
||||
default:
|
||||
message = '连接异常,请稍后重试'
|
||||
}
|
||||
|
||||
ElNotification({
|
||||
title: '生成失败',
|
||||
message: h('i', { style: 'color: teal' }, message),
|
||||
type: 'error',
|
||||
duration: 6000
|
||||
})
|
||||
}
|
||||
|
||||
export function websocketSuccess() {
|
||||
ElNotification({
|
||||
title: '生成成功',
|
||||
message: h('div', [
|
||||
h('div', { style: 'font-weight: bold; color: teal;' }, '生成成功!'),
|
||||
h('br'),
|
||||
h('div', { style: 'color: orange; margin-top: 5px;' }, '内测状态,请及时下载生成的文件,云端储存与历史记录保留24小时!')
|
||||
]),
|
||||
type: 'success',
|
||||
duration: 6000
|
||||
})
|
||||
}
|
||||
|
||||
// 当前活跃的轮询定时器集合,用于页面卸载时清理
|
||||
const activePollIntervals = new Set()
|
||||
|
||||
export async function generate(data, generateData) {
|
||||
const useDisplay = useDisplayStore()
|
||||
let taskId = null
|
||||
let pollInterval = null
|
||||
|
||||
useDisplay.isSubGerenate = true
|
||||
|
||||
if (!data.modelId) {
|
||||
ElNotification({
|
||||
title: '生成失败',
|
||||
message: h('i', { style: 'color: teal' }, '未找到模型ID,请联系管理员配置'),
|
||||
type: 'error'
|
||||
})
|
||||
useDisplay.isSubGerenate = false
|
||||
return
|
||||
}
|
||||
|
||||
// 从登录态获取 sessionId
|
||||
const sessionId = useUserStore().userInfo.sessionId
|
||||
if (!sessionId) {
|
||||
ElNotification({
|
||||
title: '生成失败',
|
||||
message: h('i', { style: 'color: teal' }, '用户身份已过期,请重新登录'),
|
||||
type: 'error'
|
||||
})
|
||||
useDisplay.isSubGerenate = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const body = data.body
|
||||
|
||||
// 构造请求体
|
||||
const requestBody = {
|
||||
model_id: data.modelId,
|
||||
body,
|
||||
request: data.request,
|
||||
platform_code: getPlatformCode(data.type)
|
||||
}
|
||||
|
||||
// POST 创建任务
|
||||
const createResult = await requestCreateTask(requestBody, sessionId)
|
||||
|
||||
if (createResult.code !== 0) {
|
||||
ElNotification({
|
||||
title: '生成失败',
|
||||
message: h('i', { style: 'color: teal' }, createResult.message || '任务创建失败'),
|
||||
type: 'error'
|
||||
})
|
||||
useDisplay.isSubGerenate = false
|
||||
return
|
||||
}
|
||||
|
||||
taskId = createResult.data.task_id
|
||||
|
||||
// 在列表中插入"生成中"条目
|
||||
useDisplay.addGeneratingItem({
|
||||
taskId,
|
||||
type: data.type,
|
||||
generateData
|
||||
})
|
||||
setTimeout(() => {
|
||||
useDisplay.scrollToBottom()
|
||||
}, 100)
|
||||
|
||||
// 轮询任务状态
|
||||
const pollTask = async () => {
|
||||
try {
|
||||
const pollResult = await requestTaskStatus(taskId)
|
||||
|
||||
if (pollResult.code !== 0) return
|
||||
|
||||
const taskData = pollResult.data
|
||||
|
||||
if (taskData.status === 'completed') {
|
||||
clearInterval(pollInterval)
|
||||
activePollIntervals.delete(pollInterval)
|
||||
useDisplay.isSubGerenate = false
|
||||
|
||||
// 提取结果 URL
|
||||
const urls = taskData.outputs?.map((img) => img.url) || []
|
||||
if (urls.length > 0) {
|
||||
useDisplay.updateItemToSuccess(taskId, urls)
|
||||
websocketSuccess()
|
||||
} else {
|
||||
websocketError(4403, '未获取到生成结果')
|
||||
}
|
||||
} else if (taskData.status === 'failed') {
|
||||
clearInterval(pollInterval)
|
||||
activePollIntervals.delete(pollInterval)
|
||||
useDisplay.isSubGerenate = false
|
||||
websocketError(4403, taskData.vendor_error || '生成失败')
|
||||
}
|
||||
// queued / processing 状态继续轮询
|
||||
} catch (error) {
|
||||
console.error('轮询任务状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 每 20 秒轮询一次
|
||||
pollInterval = setInterval(pollTask, 20000)
|
||||
activePollIntervals.add(pollInterval)
|
||||
|
||||
// 5 秒后先做第一次轮询
|
||||
setTimeout(pollTask, 5000)
|
||||
} catch (error) {
|
||||
console.error('创建任务失败:', error)
|
||||
useDisplay.isSubGerenate = false
|
||||
ElNotification({
|
||||
title: '生成通知',
|
||||
message: h('i', { style: 'color: teal' }, '生成失败,请检查参数后重新提交任务'),
|
||||
type: 'error'
|
||||
})
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
activePollIntervals.delete(pollInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页面卸载时清理所有轮询
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
for (const interval of activePollIntervals) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
activePollIntervals.clear()
|
||||
})
|
||||
}
|
||||
@ -1,189 +0,0 @@
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { h, ref } from 'vue'
|
||||
import { useDisplayStore, useUserStore } from '@/stores'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import { createTask, getTask } from '@/utils/createTask'
|
||||
import { userError } from '@/utils/tokenError'
|
||||
|
||||
export function getChargeType(chargeType) {
|
||||
switch (chargeType) {
|
||||
case 'Painting':
|
||||
return 1
|
||||
case 'Video':
|
||||
return 4
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
export function websocketError(code, msg) {
|
||||
let message
|
||||
switch (code) {
|
||||
case 1006:
|
||||
message = '用户身份验证失败'
|
||||
userError()
|
||||
break
|
||||
case 4401: // 后端返回常规错误
|
||||
message = msg
|
||||
break
|
||||
case 4402: // 后端返回外部平台提交时的错误
|
||||
message = JSON.parse(msg)
|
||||
break
|
||||
case 4403: // 外部平台的任务结果的错误
|
||||
message = msg
|
||||
break
|
||||
default:
|
||||
message = '连接异常,请稍后重试'
|
||||
}
|
||||
|
||||
ElNotification({
|
||||
title: '生成失败',
|
||||
|
||||
message: h('i', { style: 'color: teal' }, message),
|
||||
type: 'error',
|
||||
duration: 6000 // 增加持续时间以适应更多信息
|
||||
})
|
||||
}
|
||||
|
||||
export function websocketSuccess() {
|
||||
// 合并两个通知为一个
|
||||
ElNotification({
|
||||
title: '生成成功',
|
||||
message: h('div', [
|
||||
h('div', { style: 'font-weight: bold; color: teal;' }, '生成成功!'),
|
||||
h('br'),
|
||||
h('div', { style: 'color: orange; margin-top: 5px;' }, '内测状态,请及时下载生成的文件,云端储存与历史记录保留24小时!')
|
||||
]),
|
||||
type: 'success',
|
||||
duration: 6000 // 增加持续时间以适应更多信息
|
||||
})
|
||||
}
|
||||
|
||||
export async function generate(data, generateData) {
|
||||
const progress_text = ref('')
|
||||
const message = ref('')
|
||||
const useDisplay = useDisplayStore()
|
||||
const token = getToken()
|
||||
const taskId = crypto.randomUUID()
|
||||
let currentTaskId = null
|
||||
|
||||
useDisplay.isSubGerenate = true
|
||||
|
||||
const result = await createTask(data, taskId, token)
|
||||
console.log(result)
|
||||
// const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjY0NDEwODAyMjk1OTgzNzIzMCwicm5TdHIiOiJiWkVwS2JLWFJyZmRIaFFHWXZKTkdzOGdGM0JSRmxQOCJ9.5eQ2GtVdrDntQDe2tnF8vl_DhTfd2uW-KNqzvl1imc0'
|
||||
const wsURL = `${import.meta.env.VITE_API_WORKFLOW_WS}/?token=Bearer ${token}`
|
||||
const socket = new WebSocket(wsURL)
|
||||
console.log('WebSocket连接已建立')
|
||||
|
||||
// 心跳机制相关变量
|
||||
let heartbeatInterval = null
|
||||
const heartbeatIntervalTime = 20000 // 30秒发送一次心跳
|
||||
|
||||
try {
|
||||
// 接收服务器消息
|
||||
socket.onmessage = async (event) => {
|
||||
// 处理pong响应
|
||||
if (event.data === 'pong') {
|
||||
console.log('收到心跳响应')
|
||||
return
|
||||
} else if (event.data === 'please give me taskId') {
|
||||
socket.send(`setTaskId:${taskId}`)
|
||||
progress_text.value = '信息提交中...'
|
||||
return
|
||||
} else if (event.data === 'OK! Please continue. ') {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'generate',
|
||||
data: result
|
||||
}))
|
||||
return
|
||||
} else if (event.data === '任务提交成功,正在排队中...') {
|
||||
progress_text.value = '视频生成中...'
|
||||
currentTaskId = taskId
|
||||
|
||||
useDisplay.addGeneratingItem({
|
||||
taskId: taskId,
|
||||
type: data.type,
|
||||
generateData: generateData
|
||||
})
|
||||
setTimeout(() => {
|
||||
useDisplay.scrollToBottom()
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
message.value = event.data
|
||||
}
|
||||
|
||||
// 处理链接错误
|
||||
socket.onerror = (error) => {
|
||||
console.error('WebSocket链接出错:', error)
|
||||
|
||||
// 清理心跳定时器
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
ElNotification({
|
||||
title: '生成通知',
|
||||
// eslint-disable-next-line no-undef
|
||||
message: h('i', { style: 'color: teal' }, '生成视频失败'),
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
// 处理链接关闭
|
||||
socket.onclose = async (event) => {
|
||||
console.log('WebSocket已关闭:', event)
|
||||
useDisplay.isSubGerenate = false
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
}
|
||||
const res = JSON.parse(message.value)
|
||||
if (event.code === 1006) {
|
||||
console.error('用户身份验证失败')
|
||||
userError()
|
||||
} else if (event.code === 1000 && event.reason === 'success') {
|
||||
console.log('收到服务器消息:', res)
|
||||
const result = await getTask(res)
|
||||
if(useUserStore().freeTimes) await useUserStore().fetchFreeTimes()
|
||||
if (result.type) {
|
||||
if (currentTaskId) {
|
||||
useDisplay.updateItemToSuccess(currentTaskId, result.urls)
|
||||
}
|
||||
|
||||
websocketSuccess()
|
||||
} else {
|
||||
websocketError(4403, result.message)
|
||||
}
|
||||
} else {
|
||||
websocketError(event.code, event.reason)
|
||||
}
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// 等待 WebSocket 连接打开
|
||||
socket.onopen = () => {
|
||||
console.log('WebSocket连接已建立')
|
||||
|
||||
// 启动心跳机制
|
||||
heartbeatInterval = setInterval(() => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send('ping')
|
||||
console.log('发送心跳包')
|
||||
}
|
||||
}, heartbeatIntervalTime)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error creating AI3D_file:', error)
|
||||
// eslint-disable-next-line no-undef
|
||||
ElNotification({
|
||||
title: '生成通知',
|
||||
// eslint-disable-next-line no-undef
|
||||
message: h('i', { style: 'color: teal' }, '生成失败,请检查参数后重新提交任务'),
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,189 +0,0 @@
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { h, ref } from 'vue'
|
||||
import { useDisplayStore, useUserStore } from '@/stores'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import { createTask, getTask } from '@/utils/createTask'
|
||||
import { userError } from '@/utils/tokenError'
|
||||
|
||||
export function getChargeType(chargeType) {
|
||||
switch (chargeType) {
|
||||
case 'Painting':
|
||||
return 1
|
||||
case 'Video':
|
||||
return 4
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
export function websocketError(code, msg) {
|
||||
let message
|
||||
switch (code) {
|
||||
case 1006:
|
||||
message = '用户身份验证失败'
|
||||
userError()
|
||||
break
|
||||
case 4401: // 后端返回常规错误
|
||||
message = msg
|
||||
break
|
||||
case 4402: // 后端返回外部平台提交时的错误
|
||||
message = JSON.parse(msg)
|
||||
break
|
||||
case 4403: // 外部平台的任务结果的错误
|
||||
message = msg
|
||||
break
|
||||
default:
|
||||
message = '连接异常,请稍后重试'
|
||||
}
|
||||
|
||||
ElNotification({
|
||||
title: '生成失败',
|
||||
|
||||
message: h('i', { style: 'color: teal' }, message),
|
||||
type: 'error',
|
||||
duration: 6000 // 增加持续时间以适应更多信息
|
||||
})
|
||||
}
|
||||
|
||||
export function websocketSuccess() {
|
||||
// 合并两个通知为一个
|
||||
ElNotification({
|
||||
title: '生成成功',
|
||||
message: h('div', [
|
||||
h('div', { style: 'font-weight: bold; color: teal;' }, '生成成功!'),
|
||||
h('br'),
|
||||
h('div', { style: 'color: orange; margin-top: 5px;' }, '内测状态,请及时下载生成的文件,云端储存与历史记录保留24小时!')
|
||||
]),
|
||||
type: 'success',
|
||||
duration: 6000 // 增加持续时间以适应更多信息
|
||||
})
|
||||
}
|
||||
|
||||
export async function generate(data, generateData) {
|
||||
const progress_text = ref('')
|
||||
const message = ref('')
|
||||
const useDisplay = useDisplayStore()
|
||||
const token = getToken()
|
||||
const taskId = crypto.randomUUID()
|
||||
let currentTaskId = null
|
||||
|
||||
useDisplay.isSubGerenate = true
|
||||
|
||||
const result = await createTask(data, taskId, token)
|
||||
console.log(result)
|
||||
// const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjY0NDEwODAyMjk1OTgzNzIzMCwicm5TdHIiOiJiWkVwS2JLWFJyZmRIaFFHWXZKTkdzOGdGM0JSRmxQOCJ9.5eQ2GtVdrDntQDe2tnF8vl_DhTfd2uW-KNqzvl1imc0'
|
||||
const wsURL = `${import.meta.env.VITE_API_WORKFLOW_WS}/?token=Bearer ${token}`
|
||||
const socket = new WebSocket(wsURL)
|
||||
console.log('WebSocket连接已建立')
|
||||
|
||||
// 心跳机制相关变量
|
||||
let heartbeatInterval = null
|
||||
const heartbeatIntervalTime = 20000 // 30秒发送一次心跳
|
||||
|
||||
try {
|
||||
// 接收服务器消息
|
||||
socket.onmessage = async (event) => {
|
||||
// 处理pong响应
|
||||
if (event.data === 'pong') {
|
||||
console.log('收到心跳响应')
|
||||
return
|
||||
} else if (event.data === 'please give me taskId') {
|
||||
socket.send(`setTaskId:${taskId}`)
|
||||
progress_text.value = '信息提交中...'
|
||||
return
|
||||
} else if (event.data === 'OK! Please continue. ') {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'generate',
|
||||
data: result
|
||||
}))
|
||||
return
|
||||
} else if (event.data === '任务提交成功,正在排队中...') {
|
||||
progress_text.value = '视频生成中...'
|
||||
currentTaskId = taskId
|
||||
|
||||
useDisplay.addGeneratingItem({
|
||||
taskId: taskId,
|
||||
type: data.type,
|
||||
generateData: generateData
|
||||
})
|
||||
setTimeout(() => {
|
||||
useDisplay.scrollToBottom()
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
message.value = event.data
|
||||
}
|
||||
|
||||
// 处理链接错误
|
||||
socket.onerror = (error) => {
|
||||
console.error('WebSocket链接出错:', error)
|
||||
|
||||
// 清理心跳定时器
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
ElNotification({
|
||||
title: '生成通知',
|
||||
// eslint-disable-next-line no-undef
|
||||
message: h('i', { style: 'color: teal' }, '生成视频失败'),
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
// 处理链接关闭
|
||||
socket.onclose = async (event) => {
|
||||
console.log('WebSocket已关闭:', event)
|
||||
useDisplay.isSubGerenate = false
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
}
|
||||
const res = JSON.parse(message.value)
|
||||
if (event.code === 1006) {
|
||||
console.error('用户身份验证失败')
|
||||
userError()
|
||||
} else if (event.code === 1000 && event.reason === 'success') {
|
||||
console.log('收到服务器消息:', res)
|
||||
const result = await getTask(res)
|
||||
if(useUserStore().freeTimes) await useUserStore().fetchFreeTimes()
|
||||
if (result.type) {
|
||||
if (currentTaskId) {
|
||||
useDisplay.updateItemToSuccess(currentTaskId, result.urls)
|
||||
}
|
||||
|
||||
websocketSuccess()
|
||||
} else {
|
||||
websocketError(4403, result.message)
|
||||
}
|
||||
} else {
|
||||
websocketError(event.code, event.reason)
|
||||
}
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// 等待 WebSocket 连接打开
|
||||
socket.onopen = () => {
|
||||
console.log('WebSocket连接已建立')
|
||||
|
||||
// 启动心跳机制
|
||||
heartbeatInterval = setInterval(() => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send('ping')
|
||||
console.log('发送心跳包')
|
||||
}
|
||||
}, heartbeatIntervalTime)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error creating AI3D_file:', error)
|
||||
// eslint-disable-next-line no-undef
|
||||
ElNotification({
|
||||
title: '生成通知',
|
||||
// eslint-disable-next-line no-undef
|
||||
message: h('i', { style: 'color: teal' }, '生成失败,请检查参数后重新提交任务'),
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,23 +1,22 @@
|
||||
<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 class="detailed-data">{{ secondTagText }}</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 class="detailed-data">{{ secondTagText }}</div>
|
||||
</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" />
|
||||
|
||||
@ -58,14 +57,14 @@
|
||||
<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>
|
||||
|
||||
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
content="画笔"
|
||||
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: {
|
||||
@ -134,22 +146,22 @@ const checkTextOverflow = () => {
|
||||
const lineHeight = 22.5
|
||||
const padding = 8
|
||||
const twoLineHeight = lineHeight * 2 + padding
|
||||
|
||||
|
||||
promptRef.value.style.maxHeight = 'none'
|
||||
promptRef.value.style.overflow = 'hidden'
|
||||
|
||||
|
||||
const actualHeight = promptRef.value.scrollHeight
|
||||
const lineCount = Math.ceil((actualHeight - padding) / lineHeight)
|
||||
|
||||
|
||||
if (!isHovering.value) {
|
||||
promptRef.value.style.maxHeight = `${twoLineHeight}px`
|
||||
promptRef.value.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
|
||||
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`
|
||||
@ -163,8 +175,8 @@ watch(isHovering, (newVal) => {
|
||||
if (promptRef.value) {
|
||||
const lineHeight = 22.5
|
||||
const padding = 8
|
||||
const twoLineHeight = lineHeight * 2 + padding
|
||||
|
||||
const twoLineHeight = lineHeight * 2 + padding
|
||||
|
||||
if (newVal) {
|
||||
promptRef.value.style.maxHeight = 'none'
|
||||
promptRef.value.style.overflow = 'visible'
|
||||
@ -194,11 +206,28 @@ const isCollected = (url) => {
|
||||
|
||||
const generateStatusText = computed(() => {
|
||||
if (props.item.status === 'generate') {
|
||||
if (props.item.type === 'Music') return '音乐生成中...'
|
||||
return '正在生成中...'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// 根据平台类型动态生成第二个标签文本
|
||||
const secondTagText = computed(() => {
|
||||
const gd = props.item.generateData
|
||||
if (props.item.type === 'Music') {
|
||||
if (gd.pureMusic === true) return '纯音乐'
|
||||
if (gd.pureMusic === false) return '有歌词'
|
||||
return ''
|
||||
}
|
||||
// Painting/Video:尝试从 modelParams 中提取比例信息
|
||||
const params = gd.modelParams || {}
|
||||
const key = Object.keys(params).find((k) =>
|
||||
['aspectRatio', 'ratio', 'proportion'].includes(k)
|
||||
)
|
||||
return key ? params[key] : ''
|
||||
})
|
||||
|
||||
const AIbrush = (file, index) => {
|
||||
emit('open-canvas', {
|
||||
mainImage: { url: file, index: index + 1 },
|
||||
@ -208,7 +237,7 @@ const AIbrush = (file, index) => {
|
||||
}
|
||||
|
||||
const reEdit = () => {
|
||||
if(props.item.generateData?.modelType === 'edit'){
|
||||
if (props.item.generateData?.modelType === 'edit') {
|
||||
ElMessage.error('画笔生成的任务不能重新编辑')
|
||||
return
|
||||
}
|
||||
@ -217,7 +246,7 @@ const reEdit = () => {
|
||||
}
|
||||
|
||||
const againGenerate = () => {
|
||||
if(props.item.generateData?.modelType === 'edit'){
|
||||
if (props.item.generateData?.modelType === 'edit') {
|
||||
ElMessage.error('画笔生成的任务不能再次生成')
|
||||
return
|
||||
}
|
||||
@ -276,7 +305,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 +321,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) {
|
||||
@ -342,7 +371,7 @@ const copyPrompt = async () => {
|
||||
width: 102%;
|
||||
z-index: 5;
|
||||
width: auto;
|
||||
|
||||
|
||||
&.expanded{
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
@ -381,12 +410,12 @@ const copyPrompt = async () => {
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
|
||||
|
||||
&.internal{
|
||||
display: inline-flex;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
|
||||
&.external{
|
||||
position: absolute;
|
||||
right: -16px;
|
||||
@ -502,12 +531,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 +563,10 @@ const copyPrompt = async () => {
|
||||
}
|
||||
|
||||
.left-top,.bottom-brush{
|
||||
display: none;
|
||||
display: flex;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
</div>
|
||||
<span class="line"></span>
|
||||
<div class="btn">
|
||||
<Select v-model="selectedFavorite" :options="favoriteOptions" width="auto" >
|
||||
<Select v-model="selectedFavorite" :options="favoriteOptions" width="auto">
|
||||
<template #prefix>
|
||||
<i-ep-Star />
|
||||
</template>
|
||||
@ -43,8 +43,8 @@
|
||||
</div>
|
||||
|
||||
<VirtualScroller
|
||||
ref="scrollerRef"
|
||||
v-if="props.if"
|
||||
ref="scrollerRef"
|
||||
:items="list"
|
||||
key-field="id"
|
||||
:estimated-height="300"
|
||||
@ -66,16 +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 { getGenerateHistoryList } from '@/apis/display'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getChargeType } from '@/utils/websocket'
|
||||
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: {
|
||||
@ -104,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' },
|
||||
@ -137,42 +137,61 @@ 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
|
||||
}
|
||||
|
||||
const fetchHistory = async (isLoadMore = false) => {
|
||||
if (isLoading.value || (!isLoadMore && !hasMoreData.value)) return
|
||||
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
|
||||
try {
|
||||
const pageToFetch = isLoadMore ? currentPage.value + 1 : 1
|
||||
|
||||
const result = await getGenerateHistoryList({
|
||||
userId: userStore.userInfo.id,
|
||||
chargeType: chargeType.value,
|
||||
page: pageToFetch,
|
||||
size: 10,
|
||||
sort: 'createTime,desc'
|
||||
|
||||
const result = await requestTaskHistory({
|
||||
user_id: userStore.userInfo.id,
|
||||
platform_code: getPlatformCode(props.type),
|
||||
status: 'completed',
|
||||
page: pageToFetch,
|
||||
pageSize: 10
|
||||
})
|
||||
|
||||
const dataList = result.data?.list || result.data || []
|
||||
|
||||
|
||||
if (dataList.length === 0) {
|
||||
hasMoreData.value = false
|
||||
if (!isLoadMore) {
|
||||
@ -192,9 +211,9 @@ const fetchHistory = async (isLoadMore = false) => {
|
||||
} else {
|
||||
useDisplay.initHistoryList(adaptedList)
|
||||
currentPage.value = 1
|
||||
|
||||
|
||||
await nextTick()
|
||||
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setTimeout(() => {
|
||||
if (scrollerRef.value && typeof scrollerRef.value.scrollToBottom === 'function') {
|
||||
@ -202,7 +221,7 @@ const fetchHistory = async (isLoadMore = false) => {
|
||||
}
|
||||
}, 100 * i)
|
||||
}
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
if (scrollerRef.value && typeof scrollerRef.value.scrollToBottom === 'function') {
|
||||
scrollerRef.value.scrollToBottom()
|
||||
@ -214,15 +233,15 @@ const fetchHistory = async (isLoadMore = false) => {
|
||||
}, 300)
|
||||
}, 600)
|
||||
}
|
||||
|
||||
|
||||
hasMoreData.value = dataList.length === 10
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取历史失败:', error)
|
||||
ElMessage({
|
||||
message: '获取历史失败',
|
||||
type: 'warning'
|
||||
})
|
||||
isInitializing.value = false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@ -230,7 +249,7 @@ const fetchHistory = async (isLoadMore = false) => {
|
||||
|
||||
const handleScroll = (scrollInfo) => {
|
||||
if (isInitializing.value) return
|
||||
|
||||
|
||||
if (!scrollInfo) return
|
||||
const { isAtPageTop, isAtPageBottom, distanceToPageTop, distanceToPageBottom } = scrollInfo
|
||||
|
||||
@ -241,11 +260,13 @@ const handleScroll = (scrollInfo) => {
|
||||
isLoadingMoreLocked.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
|
||||
if (isAtPageBottom) {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
} else if (distanceToPageTop >= 350) {
|
||||
} else if (distanceToPageBottom >= 350) {
|
||||
useDisplay.Sender_variant = 'default'
|
||||
} else {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
}
|
||||
}
|
||||
|
||||
@ -282,7 +303,7 @@ const handleDeleteSuccess = (id) => {
|
||||
onMounted(() => {
|
||||
if (!props.loading) return
|
||||
refreshing.value = true
|
||||
|
||||
|
||||
nextTick(() => {
|
||||
useDisplay.scrollerRef = scrollerRef.value
|
||||
useDisplay.resetPagination()
|
||||
@ -358,5 +379,4 @@ onBeforeUnmount(() => {
|
||||
background-color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import display from './display/index.vue'
|
||||
import { useDisplayStore } from '@/stores'
|
||||
import display from './display/index.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const useDisplay = useDisplayStore()
|
||||
|
||||
@ -4,14 +4,14 @@
|
||||
<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>
|
||||
|
||||
|
||||
<h1 class="title">账号未登录</h1>
|
||||
<p class="description">请先登录以访问完整功能</p>
|
||||
|
||||
|
||||
<div class="action-buttons">
|
||||
<button class="btn-primary" @click="handleLogin">
|
||||
立即登录
|
||||
@ -20,7 +20,7 @@
|
||||
注册账号
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="features">
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">✨</div>
|
||||
@ -36,7 +36,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="background-decoration">
|
||||
<div class="circle circle-1"></div>
|
||||
<div class="circle circle-2"></div>
|
||||
@ -311,15 +311,15 @@ const handleRegister = () => {
|
||||
.login-content {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
.features {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
|
||||