feat: add internal skg infinite canvas
2
.gitignore
vendored
@@ -32,3 +32,5 @@ output/
|
|||||||
# web
|
# web
|
||||||
web/.next/
|
web/.next/
|
||||||
web/out/
|
web/out/
|
||||||
|
web/public/canvas/
|
||||||
|
.pnpm-store/
|
||||||
|
|||||||
@@ -84,6 +84,11 @@
|
|||||||
"type" : "backend",
|
"type" : "backend",
|
||||||
"url" : "https:\/\/marketing.skg.com\/api"
|
"url" : "https:\/\/marketing.skg.com\/api"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label" : "production-canvas",
|
||||||
|
"type" : "app",
|
||||||
|
"url" : "https:\/\/marketing.skg.com\/canvas\/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label" : "agent-cut-preview",
|
"label" : "agent-cut-preview",
|
||||||
"type" : "app",
|
"type" : "app",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ WORKDIR /app
|
|||||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||||
|
|
||||||
COPY web/package.json web/pnpm-lock.yaml ./
|
COPY web/package.json web/pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
COPY web/canvas-app/package.json web/canvas-app/pnpm-lock.yaml ./canvas-app/
|
||||||
|
RUN pnpm install --frozen-lockfile && cd canvas-app && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY web ./
|
COPY web ./
|
||||||
|
|
||||||
|
|||||||
6
RULES.md
@@ -4,6 +4,7 @@
|
|||||||
- 后台启动(不弹 Terminal):`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291,日志写入 `.logs/`)
|
- 后台启动(不弹 Terminal):`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291,日志写入 `.logs/`)
|
||||||
- 后台停止:`./scripts/stop-dev-background.sh`
|
- 后台停止:`./scripts/stop-dev-background.sh`
|
||||||
- 前端 dev:`cd web && npm run dev`(Next.js 16,端口 4290)
|
- 前端 dev:`cd web && npm run dev`(Next.js 16,端口 4290)
|
||||||
|
- 无限画布 dev:`cd web && npm run dev:canvas`(Vue / Vite,端口 4292;生产构建会输出到 `/canvas/`)
|
||||||
- 后端 dev:`cd api && uvicorn main:app --host 127.0.0.1 --port 4291`(FastAPI,端口 4291,重任务用)
|
- 后端 dev:`cd api && uvicorn main:app --host 127.0.0.1 --port 4291`(FastAPI,端口 4291,重任务用)
|
||||||
- 注意:后端不要带 `--reload` 跑长下载 / 抽帧 / 音频任务;reload 会等待后台任务结束,导致 4291 端口占用但新请求卡住。
|
- 注意:后端不要带 `--reload` 跑长下载 / 抽帧 / 音频任务;reload 会等待后台任务结束,导致 4291 端口占用但新请求卡住。
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
||||||
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
||||||
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
||||||
- 当前产品方向(2026-05-25 单对话框版):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容生成入口,服务约 6 名公司成员同时使用。首页默认只保留一个中央对话框,不再显示侧栏、灵感区、任务列表或大结果面板;用户先选择四种生成方式之一:文生视频、文生图、首帧生视频、首尾帧生视频,然后手写提示词并点击生成。首帧 / 首尾帧模式只露必要图片上传位,图片模式显示尺寸选择,视频模式显示画幅和真实可用时长选择。后端 `/health` 向前端返回可选图片 / 视频模型、图片尺寸、视频画幅和视频时长,首页允许用户选择图片模型(自动、GPT Image 2、Gemini 图片兜底)和视频模型(Seedance、Kling、Veo 3 等别名;实际可用模型以环境变量映射为准)。当前 Doubao / Seedance 生产链路单条视频最长按 15 秒暴露,不在 UI 显示 30 秒;如后续要 30 秒,需要改成多段生成后合成。用户登录后仍只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离;结果生成后从对话框下方进入 `/detail/?job=<id>` 沉淀参考图、生成图、视频候选和提示词。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。
|
- 当前产品方向(2026-05-25 单对话框 + 无限画布版):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容生成入口,服务公司内部成员同时使用。首页默认只保留一个中央对话框,不再显示侧栏、灵感区、任务列表或大结果面板;用户先选择四种生成方式之一:文生视频、文生图、首帧生视频、首尾帧生视频,然后手写提示词并点击生成。首帧 / 首尾帧模式只露必要图片上传位,图片模式显示尺寸选择,视频模式显示画幅和真实可用时长选择。后端 `/health` 向前端返回可选图片 / 视频模型、图片尺寸、视频画幅和视频时长,首页允许用户选择图片模型(自动、GPT Image 2、Gemini 图片兜底)和视频模型(Seedance、Kling、Veo 3 等别名;实际可用模型以环境变量映射为准)。当前 Doubao / Seedance 生产链路单条视频最长按 15 秒暴露,不在 UI 显示 30 秒;如后续要 30 秒,需要改成多段生成后合成。用户登录后仍只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离;结果生成后从对话框下方进入 `/detail/?job=<id>` 沉淀参考图、生成图、视频候选和提示词。新增 `/canvas/` 作为个人无限画布入口,基于 huobao-canvas 交互逻辑改造为 SKG 内部版,界面去除原可见品牌/API 设置,生成调用本项目后端 `/api`,每个浏览器的画布项目先保存在本地 localStorage,图片/视频资产仍按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。
|
||||||
|
|
||||||
## 部署事实
|
## 部署事实
|
||||||
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
- 最近部署验证(2026-05-20):`f1c710e` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层中间栏先清空为待重构占位,不再接收拖拽或触发 subject-agent / subject-assets;右侧主体元素输出逻辑保持不变。
|
- 最近部署验证(2026-05-20):`f1c710e` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层中间栏先清空为待重构占位,不再接收拖拽或触发 subject-agent / subject-assets;右侧主体元素输出逻辑保持不变。
|
||||||
- 最近部署验证(2026-05-20):`7e763cf` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层改为参考帧分析 + 对话生成提示词 + 弹窗确认后再生成主体套图;右侧主体元素输出逻辑保持不变。部署时发现服务器 `WEB_AUTH_*` 环境变量缺失导致 `/auth/check` 503,已从 `/root/skg-marketing-studio-login.txt` 和新 session secret 恢复服务器 `deploy/.env.production` 后重启验证通过;后续同步生产代码必须继续排除服务器真实 `deploy/.env.production`。
|
- 最近部署验证(2026-05-20):`7e763cf` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层改为参考帧分析 + 对话生成提示词 + 弹窗确认后再生成主体套图;右侧主体元素输出逻辑保持不变。部署时发现服务器 `WEB_AUTH_*` 环境变量缺失导致 `/auth/check` 503,已从 `/root/skg-marketing-studio-login.txt` 和新 session secret 恢复服务器 `deploy/.env.production` 后重启验证通过;后续同步生产代码必须继续排除服务器真实 `deploy/.env.production`。
|
||||||
- 主站 / 前端:`https://marketing.skg.com`
|
- 主站 / 前端:`https://marketing.skg.com`
|
||||||
|
- 无限画布:`https://marketing.skg.com/canvas/`
|
||||||
- API / 后端:`https://marketing.skg.com/api`
|
- API / 后端:`https://marketing.skg.com/api`
|
||||||
- 代码仓库 / Gitea:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
- 代码仓库 / Gitea:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
||||||
- 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由)
|
- 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由)
|
||||||
@@ -68,7 +70,7 @@
|
|||||||
- 生产部署唯一入口:`./scripts/deploy-prod-safe.sh`(先在服务器备份 `deploy/.env.production`、`data/jobs`、资源库和 `secrets`,再用受保护 rsync 同步代码,最后 Docker 重建并运行 `verify-prod-docker.sh`)
|
- 生产部署唯一入口:`./scripts/deploy-prod-safe.sh`(先在服务器备份 `deploy/.env.production`、`data/jobs`、资源库和 `secrets`,再用受保护 rsync 同步代码,最后 Docker 重建并运行 `verify-prod-docker.sh`)
|
||||||
- 生产容器重建命令:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`;只允许脚本内部或明确只重启容器时使用,不允许再用裸 `rsync --delete` 手动同步。
|
- 生产容器重建命令:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`;只允许脚本内部或明确只重启容器时使用,不允许再用裸 `rsync --delete` 手动同步。
|
||||||
- 独立预览容器重建命令:服务器 `/opt/skg-marketing-studio` 下执行 `docker compose -f docker-compose.standalone.yml --env-file deploy/.env.production up -d --build`;Web 暴露 `0.0.0.0:4290->80`,后端仅在 compose 内部网络暴露,`/api/` 由 Web 容器 Nginx 反代并复用应用内登录校验。
|
- 独立预览容器重建命令:服务器 `/opt/skg-marketing-studio` 下执行 `docker compose -f docker-compose.standalone.yml --env-file deploy/.env.production up -d --build`;Web 暴露 `0.0.0.0:4290->80`,后端仅在 compose 内部网络暴露,`/api/` 由 Web 容器 Nginx 反代并复用应用内登录校验。
|
||||||
- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出;`/login/`、`/_next/`、`/assets/`、`/skg-logo-black.svg`、`/oasis-source/` 等登录页必需静态资源公开访问;未登录访问工作台跳转 `/login/`,`/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`;Traefik 通过 `coolify` 外部网络接入 80/443
|
- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出;`/login/`、`/_next/`、`/assets/`、`/skg-logo-black.svg`、`/oasis-source/` 等登录页必需静态资源公开访问;未登录访问工作台跳转 `/login/`;`/canvas/` 是受同一登录保护的 Vue / Vite 无限画布静态应用,Nginx fallback 到 `/canvas/index.html`;`/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`;Traefik 通过 `coolify` 外部网络接入 80/443
|
||||||
- Web 验收必须以生产 Docker 形态为准:前端是 `next export` 静态产物 + Nginx,不是 `next dev` / `next start`。任何 Web 改动部署后必须运行 `./scripts/verify-prod-docker.sh`,确认 `/login/`、`/_next/`、`/api/health`、本地 API 地址泄漏和 API 镜像 `.env` 污染检查通过;不能只用本地 `npm run build` 作为上线依据。
|
- Web 验收必须以生产 Docker 形态为准:前端是 `next export` 静态产物 + Nginx,不是 `next dev` / `next start`。任何 Web 改动部署后必须运行 `./scripts/verify-prod-docker.sh`,确认 `/login/`、`/_next/`、`/api/health`、本地 API 地址泄漏和 API 镜像 `.env` 污染检查通过;不能只用本地 `npm run build` 作为上线依据。
|
||||||
- 当前音频解析:`https://ai.skg.com/azure/v1` 的 `gpt-4o-transcribe` 当前返回 `DeploymentNotFound`,且官方 Azure OpenAI transcription 路径探测也未返回可用部署;生产临时复制本地成功策略,直接使用容器内多语言 `faster-whisper` 真实转写,默认语种为 `auto`,支持中文、英文和其他多语言原文识别,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`,并保持 `ASR_LANGUAGE` 为空或 `auto`,除非明确只想强制单一语种。
|
- 当前音频解析:`https://ai.skg.com/azure/v1` 的 `gpt-4o-transcribe` 当前返回 `DeploymentNotFound`,且官方 Azure OpenAI transcription 路径探测也未返回可用部署;生产临时复制本地成功策略,直接使用容器内多语言 `faster-whisper` 真实转写,默认语种为 `auto`,支持中文、英文和其他多语言原文识别,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`,并保持 `ASR_LANGUAGE` 为空或 `auto`,除非明确只想强制单一语种。
|
||||||
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`;全局资源中心持久化在 `./data/asset_library`、`./data/prompt_library` 和 `./data/_trash`
|
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`;全局资源中心持久化在 `./data/asset_library`、`./data/prompt_library` 和 `./data/_trash`
|
||||||
|
|||||||
12
THIRD_PARTY_NOTICES.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Third Party Notices
|
||||||
|
|
||||||
|
## huobao-canvas
|
||||||
|
|
||||||
|
Portions of the internal SKG canvas module are adapted from `chatfire-AI/huobao-canvas`.
|
||||||
|
|
||||||
|
- Source: https://github.com/chatfire-AI/huobao-canvas
|
||||||
|
- License note: the upstream README declares MIT licensing and links to a `LICENSE` file, but the cloned snapshot used for this integration did not include that file.
|
||||||
|
- Local integration path: `web/canvas-app/`
|
||||||
|
- SKG changes: branding, visible product text, model options, auth behavior, and API calls were changed for SKG internal use.
|
||||||
|
|
||||||
|
This notice is kept in the repository for engineering traceability and is not shown in the product UI.
|
||||||
@@ -106,6 +106,17 @@ server {
|
|||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /canvas {
|
||||||
|
return 308 /canvas/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /canvas/ {
|
||||||
|
auth_request /__auth;
|
||||||
|
error_page 401 = @login_redirect;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri $uri/ /canvas/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
location = /skg-logo-black.svg {
|
location = /skg-logo-black.svg {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
|
|||||||
@@ -511,6 +511,14 @@ export default function Home() {
|
|||||||
<span>{activeMode.needsFirstFrame ? "图片作为参考帧" : "只根据文字生成"}</span>
|
<span>{activeMode.needsFirstFrame ? "图片作为参考帧" : "只根据文字生成"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href="/canvas/"
|
||||||
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-full border border-white/10 bg-white/6 px-4 text-sm font-semibold text-white/72 transition hover:border-cyan-200/24 hover:text-cyan-100"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
无限画布
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={runPrimary}
|
onClick={runPrimary}
|
||||||
@@ -521,6 +529,7 @@ export default function Home() {
|
|||||||
生成
|
生成
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|||||||
5
web/canvas-app/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
24
web/canvas-app/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
20
web/canvas-app/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# SKG 无限画布
|
||||||
|
|
||||||
|
这是 SKG 营销内容工作台的内部画布模块,部署在主站 `/canvas/` 路径下。
|
||||||
|
|
||||||
|
## 内部使用方式
|
||||||
|
|
||||||
|
- `/canvas/`:项目入口和本机项目列表。
|
||||||
|
- `/canvas/p/new`:直接进入一个新画布。
|
||||||
|
- 画布里的生图、生视频请求统一走主后端 `/api`,员工不需要填写模型密钥。
|
||||||
|
- 生成的图片和视频仍由主后端保存到当前登录用户可访问的任务数据里,画布状态保存在当前浏览器本地。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web/canvas-app
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
主站构建会自动执行 `web/package.json` 里的 `build:canvas`,把 Vite 输出同步到 `web/public/canvas/` 后再执行 Next 静态导出。
|
||||||
13
web/canvas-app/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/skg-logo-black.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SKG 无限画布</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
web/canvas-app/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "skg-internal-canvas",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
|
"@vue-flow/background": "^1.3.2",
|
||||||
|
"@vue-flow/controls": "^1.1.3",
|
||||||
|
"@vue-flow/core": "^1.48.1",
|
||||||
|
"@vue-flow/minimap": "^1.5.4",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"naive-ui": "^2.43.2",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.24",
|
||||||
|
"vue-router": "^4.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"vite": "^5.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
2039
web/canvas-app/pnpm-lock.yaml
generated
Normal file
6
web/canvas-app/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
17
web/canvas-app/public/skg-logo-black.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<svg id="组_464" data-name="组 464" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="126.523" height="20.579" viewBox="0 0 126.523 20.579">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip-path">
|
||||||
|
<rect id="矩形_97" data-name="矩形 97" width="126.523" height="20.579" fill="#252525"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g id="组_37" data-name="组 37" clip-path="url(#clip-path)">
|
||||||
|
<path id="路径_171" data-name="路径 171" d="M382.888,44.125a.471.471,0,0,1,.526.539.465.465,0,0,1-.526.526h-4.477v1.942h5.164a.526.526,0,1,1,0,1.052H378.52a14.282,14.282,0,0,0,2.279,2.171,23.357,23.357,0,0,0,3.141,2.05.6.6,0,0,1,.364.634.513.513,0,0,1-.58.58,2.425,2.425,0,0,1-.526-.162Q381.89,52.62,380.7,51.7a20.51,20.51,0,0,1-2.292-2.171v4.706q0,.539-.58.539a.471.471,0,0,1-.526-.539v-4.6a14.979,14.979,0,0,1-1.564,1.618,18.642,18.642,0,0,1-3.2,2.292,1.39,1.39,0,0,1-.35.121q-.58,0-.58-.688a.569.569,0,0,1,.418-.58,18.559,18.559,0,0,0,3.344-2.252,10.037,10.037,0,0,0,1.794-1.969H372.4a.526.526,0,1,1,0-1.052h4.908V45.191h-4.477a.465.465,0,0,1-.526-.526.471.471,0,0,1,.526-.539h4.477V42.979a.471.471,0,0,1,.526-.539q.579,0,.58.539v1.146Z" transform="translate(-299.773 -34.235)" fill="#252525"/>
|
||||||
|
<path id="路径_172" data-name="路径 172" d="M455.771,43.632q.448.014.475.529a.453.453,0,0,1-.475.475H451.16v3.445h5.181a.485.485,0,0,1,.489.529.46.46,0,0,1-.489.475h-4.68a17.982,17.982,0,0,0,1.967,2.008,17.664,17.664,0,0,0,2.713,1.763.788.788,0,0,1,.638.692q-.013.461-.529.475a.421.421,0,0,1-.258-.108,12.929,12.929,0,0,1-2.876-1.75A18.024,18.024,0,0,1,451.16,50.1v4.015a.528.528,0,0,1-.529.542.506.506,0,0,1-.529-.542V50.211a11.854,11.854,0,0,1-1.614,1.533,17.92,17.92,0,0,1-3.432,2.17.4.4,0,0,1-.2.108.623.623,0,0,1-.529-.38q0-.556.42-.678a20.3,20.3,0,0,0,3.188-2.129,12.461,12.461,0,0,0,1.777-1.75H444.96a.459.459,0,0,1-.488-.475.484.484,0,0,1,.488-.529H450.1V44.636h-4.463a.453.453,0,0,1-.475-.475q.027-.516.475-.529H450.1v-.841a.5.5,0,0,1,.529-.543.517.517,0,0,1,.529.543v.841Zm-8.587,1.221a.584.584,0,0,1,.421.217,10.848,10.848,0,0,1,1,1.953v.366q-.054.38-.529.434a.41.41,0,0,1-.366-.271,8.655,8.655,0,0,0-.909-1.858,1.136,1.136,0,0,1-.095-.366.463.463,0,0,1,.474-.475m7.311-.109q.353.028.366.529a1.033,1.033,0,0,1-.027.149,9.743,9.743,0,0,1-1.085,2.238.814.814,0,0,1-.474.163.524.524,0,0,1-.475-.529,1.127,1.127,0,0,1,.149-.326,9.623,9.623,0,0,0,.909-1.858.628.628,0,0,1,.637-.366" transform="translate(-358.424 -34.081)" fill="#252525"/>
|
||||||
|
<path id="路径_173" data-name="路径 173" d="M519.865,43.241q.524,0,.524.406a.854.854,0,0,1-.052.249,18.483,18.483,0,0,1-.616,2.5v8.4q0,.472-.511.472a.411.411,0,0,1-.459-.472V48.783q-.157.315-.314.59a.682.682,0,0,1-.406.157q-.511,0-.511-.458a.9.9,0,0,1,.1-.315,14.351,14.351,0,0,0,1-2.241,18.69,18.69,0,0,0,.786-3.027q.066-.249.459-.249m1.035.459h1.48q.564,0,.563.459a2.922,2.922,0,0,1-.328.982l-1.14,2.332a.708.708,0,0,0-.066.262h1.166q.524,0,.524.721a10.8,10.8,0,0,1-.17,1.572,11.214,11.214,0,0,1-.629,2.555c-.026.07-.052.136-.079.2a3.086,3.086,0,0,0,.943.721,4.25,4.25,0,0,0,1.756.432h4.625q.407,0,.406.511a.452.452,0,0,1-.511.511h-4.271a6.355,6.355,0,0,1-1.952-.327,4.037,4.037,0,0,1-1.467-.878,5.2,5.2,0,0,1-1.061,1.258.437.437,0,0,1-.773-.3.6.6,0,0,1,.209-.419,5.262,5.262,0,0,0,1.035-1.258,5.843,5.843,0,0,1-.629-1.48,11.654,11.654,0,0,1-.354-1.808q0-.511.459-.511a.415.415,0,0,1,.406.3,10.438,10.438,0,0,0,.616,2.332,9.233,9.233,0,0,0,.34-1.349,9.41,9.41,0,0,0,.171-1.6.22.22,0,0,0-.249-.249h-1.074q-.616,0-.616-.472a2,2,0,0,1,.223-.773l1.219-2.45a.717.717,0,0,0,.092-.3c0-.035-.07-.052-.21-.052H520.9a.459.459,0,1,1,0-.917m2.857.459h1.69V43.7a.405.405,0,0,1,.459-.459q.51,0,.511.459v.459h1.8q.917,0,.917.812v.93h.367a.458.458,0,1,1,0,.917h-.367v.917q0,.878-.917.878h-1.8v.97h2.109a.406.406,0,0,1,.459.459q0,.511-.459.511h-2.109v.878h2.516a.405.405,0,0,1,.458.459q0,.511-.458.511h-2.516v.97q0,.459-.511.459a.405.405,0,0,1-.459-.459V52.4H523.4q-.459,0-.459-.511a.406.406,0,0,1,.459-.459h2.044v-.878h-1.69q-.459,0-.459-.511a.406.406,0,0,1,.459-.459h1.69v-.97h-1.638q-.511,0-.511-.459,0-.419.511-.419h1.638v-.917h-2.306a.458.458,0,1,1,0-.917h2.306v-.878h-1.69q-.511,0-.511-.459,0-.406.511-.406m2.659.865V45.9h1.743V45.39q0-.3-.406-.367Zm0,1.795v.917h1.441q.3,0,.3-.354v-.564Z" transform="translate(-417.47 -34.881)" fill="#252525"/>
|
||||||
|
<path id="路径_174" data-name="路径 174" d="M601.82,43.86q.509,0,.509.47a.45.45,0,0,1-.509.509h-4.488a.547.547,0,0,1,.156.4V45.6h2.792q1.03,0,1.031.965v.718h.757a.457.457,0,0,1,0,.913h-.757v.77q0,.966-1.031.965h-2.61a4.034,4.034,0,0,0,.5.992,4.9,4.9,0,0,0,1.057,1.161,8.22,8.22,0,0,0,1.279.887,6.927,6.927,0,0,0,1.331.587,1.1,1.1,0,0,1,.639.352.586.586,0,0,1,.1.353.5.5,0,0,1-.561.561,3.513,3.513,0,0,1-.992-.339,9.027,9.027,0,0,1-1.552-.874,11.617,11.617,0,0,1-1.149-.939,6.1,6.1,0,0,1-.835-1.1v2.44q0,1.174-1.435,1.174a4.988,4.988,0,0,1-1.226-.209.507.507,0,0,1-.4-.509q.026-.483.457-.509a.892.892,0,0,1,.183.026,4.822,4.822,0,0,0,.887.17.449.449,0,0,0,.508-.509V49.927h-3.405q-.417,0-.417-.456t.417-.457h3.405v-.822h-3.77a.457.457,0,1,1,0-.913h3.77v-.77H593q-.4,0-.4-.457t.4-.457h3.457v-.352a.474.474,0,0,1,.157-.4h-4.279a.265.265,0,0,0-.3.3v3.875a20.713,20.713,0,0,1-.274,3.614,11.665,11.665,0,0,1-.548,1.944.543.543,0,0,1-.509.352q-.535-.026-.561-.509a25.775,25.775,0,0,0,.626-2.518A16.351,16.351,0,0,0,591,49.014V44.578q0-.717.77-.718h4.11a.483.483,0,0,1-.078-.248.45.45,0,0,1,.509-.509.9.9,0,0,1,.353.1q.143.117.274.235a3.914,3.914,0,0,1,.339.365.22.22,0,0,1,.013.052ZM595.7,51.454q.509,0,.509.561,0,.2-.457.574a13.053,13.053,0,0,1-1.344,1.018,10.525,10.525,0,0,1-1.279.77,1.305,1.305,0,0,1-.444.091.45.45,0,0,1-.509-.509q0-.274.352-.431a8.458,8.458,0,0,0,1.448-.835,11.143,11.143,0,0,0,1.409-1.083.452.452,0,0,1,.313-.156m-2.6-1.214a1,1,0,0,1,.352.091q.26.209.5.391.248.222.627.639a.813.813,0,0,1,.052.3q0,.4-.509.4a.639.639,0,0,1-.365-.1q-.34-.339-.574-.535-.2-.157-.444-.326a.577.577,0,0,1-.143-.352.45.45,0,0,1,.509-.509m4.384-3.731v.77h2.792v-.4q0-.3-.352-.365Zm0,1.683v.822h2.544c.165,0,.248-.1.248-.313v-.509Zm4.071,1.892a.4.4,0,0,1,.456.457,1.041,1.041,0,0,1-.2.561,8.481,8.481,0,0,1-1.435,1.018.663.663,0,0,1-.352.1q-.561,0-.561-.509a.662.662,0,0,1,.1-.352,8.327,8.327,0,0,0,1.579-1.07.463.463,0,0,1,.4-.209" transform="translate(-476.054 -34.771)" fill="#252525"/>
|
||||||
|
<path id="路径_175" data-name="路径 175" d="M3.625,6.084a2.037,2.037,0,0,1,.06-2.413c.986-1.363,2.875-1.626,4.4-1.613a9.6,9.6,0,0,1,2.885.4,4.809,4.809,0,0,1,1.848,1.147,7.853,7.853,0,0,0,3.371,2.012,9.119,9.119,0,0,0,4.164,2.059c.582.044.564-.29.381-.476-.729-.575-1.884-1.305-2.122-2.367.366-.547.593-2.216-1.376-3.292A10.944,10.944,0,0,0,12.912.374,24.56,24.56,0,0,0,9.1,0,13.054,13.054,0,0,0,3.212,1.243C.958,2.415.032,4.451.547,6.275c.828,2.931,4.67,3.774,7.412,4.359,2.162.461,5.015.942,6.828,2.19,1.126.775,1.782,1.71,1.475,2.706a2.527,2.527,0,0,1-1.627,1.48,15.021,15.021,0,0,1-7.277.352,15.757,15.757,0,0,1-5.4-1.831,1.215,1.215,0,0,0-1.626.222,1.78,1.78,0,0,0-.32,1.257,2.518,2.518,0,0,0,1.751,2.077,23.2,23.2,0,0,0,8.788,1.483,16.031,16.031,0,0,0,7.266-1.656c3.485-2.024,3.417-5.992-.093-8.02C15.108,9.383,11.18,8.558,8.326,8c-1.648-.319-3.746-.476-4.7-1.92" transform="translate(0 0)" fill="#252525" fill-rule="evenodd"/>
|
||||||
|
<path id="路径_176" data-name="路径 176" d="M143.656,11.318c6.6-3.007,10-4.054,11.119-4.5a1.976,1.976,0,0,0,1.146-2.545,1.014,1.014,0,0,0-1.253-.571,88.026,88.026,0,0,0-13.116,5.438c-1.588.782-2.06,2.329-2.06,4.216,0,1.964.489,3.383,2.046,4.2A83.552,83.552,0,0,0,154.687,23a1.006,1.006,0,0,0,1.222-.578,1.992,1.992,0,0,0-1.128-2.59c-1.2-.4-4.44-1.552-11.126-4.45-1.335-.579-2.068-.96-2.068-2.025s.722-1.424,2.069-2.037" transform="translate(-112.525 -2.945)" fill="#252525" fill-rule="evenodd"/>
|
||||||
|
<path id="路径_177" data-name="路径 177" d="M123.463,19.464c0,1.649,2.673,1.329,2.673,0V1.088c0-1.649-2.673-1.219-2.673,0Z" transform="translate(-99.595 -0.005)" fill="#252525" fill-rule="evenodd"/>
|
||||||
|
<path id="路径_178" data-name="路径 178" d="M249.654,8.73h-8.53a2.09,2.09,0,0,0-2.276,2.259.922.922,0,0,0,.9.886c.62,0,8.683.005,8.683,0-.363,3.1-3.24,5.7-8.28,5.7-5.692,0-9.631-3.56-9.631-7.3,0-3.634,3.793-7.253,9.627-7.253a10.64,10.64,0,0,1,8.034,3.255c1.031,1.159,1.751-1.744.823-2.686A12.433,12.433,0,0,0,240.147,0c-6.834,0-11.667,4.992-11.667,10.285,0,5.431,4.946,10.293,11.667,10.293a11.854,11.854,0,0,0,2.5-.28,9.954,9.954,0,0,0,8.048-10c0-.811-.254-1.572-1.04-1.572" transform="translate(-184.309 -0.002)" fill="#252525" fill-rule="evenodd"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.3 KiB |
57
web/canvas-app/src/App.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Root App component | 根组件
|
||||||
|
* Provides naive-ui config and router view
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { NConfigProvider, NMessageProvider, NDialogProvider, darkTheme } from 'naive-ui'
|
||||||
|
import { isDark } from './stores/theme'
|
||||||
|
|
||||||
|
// Naive UI theme based on dark mode | 基于深色模式的 Naive UI 主题
|
||||||
|
const theme = computed(() => isDark.value ? darkTheme : null)
|
||||||
|
|
||||||
|
// Global theme overrides | 全局主题覆盖
|
||||||
|
const themeOverrides = {
|
||||||
|
common: {
|
||||||
|
borderRadius: '12px',
|
||||||
|
borderRadiusSmall: '8px'
|
||||||
|
},
|
||||||
|
Dialog: {
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '24px'
|
||||||
|
},
|
||||||
|
Modal: {
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '24px'
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '24px'
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
borderRadiusMedium: '10px',
|
||||||
|
borderRadiusSmall: '8px',
|
||||||
|
borderRadiusLarge: '12px',
|
||||||
|
heightMedium: '36px',
|
||||||
|
paddingMedium: '0 16px'
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
borderRadius: '10px',
|
||||||
|
heightMedium: '36px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-config-provider :theme="theme" :theme-overrides="themeOverrides">
|
||||||
|
<n-message-provider>
|
||||||
|
<n-dialog-provider>
|
||||||
|
<router-view />
|
||||||
|
</n-dialog-provider>
|
||||||
|
</n-message-provider>
|
||||||
|
</n-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Global app styles handled in style.css */
|
||||||
|
</style>
|
||||||
35
web/canvas-app/src/api/chat.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Chat API | 对话 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request } from '@/utils'
|
||||||
|
|
||||||
|
// 对话补全
|
||||||
|
export const chatCompletions = (data) =>
|
||||||
|
request({
|
||||||
|
url: `/chat/completions`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
// 流式对话补全
|
||||||
|
export const streamChatCompletions = async function* (data, signal, options = {}) {
|
||||||
|
const text = data?.messages?.at?.(-1)?.content || data?.goal || ''
|
||||||
|
const response = await fetch('/api/creative/copy', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ goal: typeof text === 'string' ? text : JSON.stringify(text), seconds: 15 }),
|
||||||
|
signal
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(error?.detail || error?.message || '提示词助手请求失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
const variant = json.variants?.[0]
|
||||||
|
yield variant?.image_prompt_en || variant?.video_prompt_en || ''
|
||||||
|
}
|
||||||
17
web/canvas-app/src/api/image.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Image API | 图片生成 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request } from '@/utils'
|
||||||
|
|
||||||
|
// 生成图片
|
||||||
|
export const generateImage = (data, options = {}) => {
|
||||||
|
const { requestType = 'json', endpoint = '/images/generations' } = options
|
||||||
|
|
||||||
|
return request({
|
||||||
|
url: endpoint,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
headers: requestType === 'formdata' ? { 'Content-Type': 'multipart/form-data' } : {}
|
||||||
|
})
|
||||||
|
}
|
||||||
8
web/canvas-app/src/api/index.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* API Index | API 索引
|
||||||
|
* Simplified for open source version | 开源版简化版
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './image'
|
||||||
|
export * from './video'
|
||||||
|
export * from './chat'
|
||||||
34
web/canvas-app/src/api/model.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Model API | 模型 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request } from '@/utils'
|
||||||
|
|
||||||
|
// 分页查询模型列表
|
||||||
|
export const getModelPage = (params) =>
|
||||||
|
request({
|
||||||
|
url: `/model/page`,
|
||||||
|
method: 'get',
|
||||||
|
params: { enable: true, size: 1000, current: 1, ...params }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 根据类型获取模型列表
|
||||||
|
export const getModelsByType = async (type) => {
|
||||||
|
const rsp = await getModelPage({ type, enable: true, size: 1000, current: 1 })
|
||||||
|
return rsp?.data?.records || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据全称获取模型详情
|
||||||
|
export const getModelByFullName = (fullName) =>
|
||||||
|
request({
|
||||||
|
url: `/model/fullName`,
|
||||||
|
method: 'get',
|
||||||
|
params: { fullName }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取所有模型类型
|
||||||
|
export const getModelTypes = () =>
|
||||||
|
request({
|
||||||
|
url: `/model/types`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
45
web/canvas-app/src/api/video.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Video API | 视频生成 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request } from '@/utils'
|
||||||
|
|
||||||
|
// 创建视频任务
|
||||||
|
export const createVideoTask = (data, options = {}) => {
|
||||||
|
const { endpoint = '/videos', requestType = 'json' } = options
|
||||||
|
return request({
|
||||||
|
url: endpoint,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
headers: requestType === 'formdata'
|
||||||
|
? { 'Content-Type': 'multipart/form-data' }
|
||||||
|
: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询视频任务状态
|
||||||
|
export const getVideoTaskStatus = (taskId, options = {}) => {
|
||||||
|
const { endpoint = '/videos' } = options
|
||||||
|
return request({
|
||||||
|
url: `${endpoint}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 轮询视频任务直到完成
|
||||||
|
export const pollVideoTask = async (taskId, maxAttempts = 120, interval = 5000) => {
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
const result = await getVideoTaskStatus(taskId)
|
||||||
|
|
||||||
|
if (result.status === 'completed' || result.data) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'failed') {
|
||||||
|
throw new Error(result.error?.message || '视频生成失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, interval))
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('视频生成超时')
|
||||||
|
}
|
||||||
BIN
web/canvas-app/src/assets/loading.webp
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
web/canvas-app/src/assets/product01.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
web/canvas-app/src/assets/scene01.jpeg
Normal file
|
After Width: | Height: | Size: 898 KiB |
BIN
web/canvas-app/src/assets/shot01.jpeg
Normal file
|
After Width: | Height: | Size: 936 KiB |
BIN
web/canvas-app/src/assets/workflow01.jpeg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
web/canvas-app/src/assets/workflow02.jpeg
Normal file
|
After Width: | Height: | Size: 902 KiB |
44
web/canvas-app/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<!-- App Header | 应用头部 -->
|
||||||
|
<header class="flex items-center justify-between px-4 md:px-8 py-4 border-b border-[var(--border-color)]">
|
||||||
|
<!-- Left slot | 左侧插槽 -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<slot name="left">
|
||||||
|
<!-- Default: empty or logo -->
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right section | 右侧区域 -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- Center slot | 中间插槽 -->
|
||||||
|
<slot name="center"></slot>
|
||||||
|
|
||||||
|
<!-- Theme toggle | 主题切换 -->
|
||||||
|
<button
|
||||||
|
@click="toggleTheme"
|
||||||
|
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<n-icon :size="20">
|
||||||
|
<SunnyOutline v-if="isDark" />
|
||||||
|
<MoonOutline v-else />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Right slot | 右侧插槽 -->
|
||||||
|
<slot name="right"></slot>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* App Header component | 应用头部组件
|
||||||
|
* Reusable header with slots for customization
|
||||||
|
*/
|
||||||
|
import { NIcon } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
SunnyOutline,
|
||||||
|
MoonOutline
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
import { isDark, toggleTheme } from '../stores/theme'
|
||||||
|
</script>
|
||||||
120
web/canvas-app/src/components/DownloadModal.vue
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Download Modal | 下载弹窗 -->
|
||||||
|
<n-modal v-model:show="visible" preset="card" title="素材下载" style="width: 600px; max-width: 90vw;">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Stats | 统计 -->
|
||||||
|
<div class="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
<span>图片: {{ imageAssets.length }} 张</span>
|
||||||
|
<span>视频: {{ videoAssets.length }} 个</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image assets | 图片素材 -->
|
||||||
|
<div v-if="imageAssets.length > 0">
|
||||||
|
<h4 class="text-sm font-medium mb-2">图片素材</h4>
|
||||||
|
<div class="grid grid-cols-4 gap-2 max-h-[200px] overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="(asset, idx) in imageAssets"
|
||||||
|
:key="idx"
|
||||||
|
class="relative aspect-square rounded-lg overflow-hidden bg-[var(--bg-tertiary)] cursor-pointer group"
|
||||||
|
@click="downloadAsset(asset)"
|
||||||
|
>
|
||||||
|
<img :src="asset.url" class="w-full h-full object-cover" />
|
||||||
|
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<n-icon :size="24" color="white"><DownloadOutline /></n-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video assets | 视频素材 -->
|
||||||
|
<div v-if="videoAssets.length > 0">
|
||||||
|
<h4 class="text-sm font-medium mb-2">视频素材</h4>
|
||||||
|
<div class="space-y-2 max-h-[200px] overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="(asset, idx) in videoAssets"
|
||||||
|
:key="idx"
|
||||||
|
class="flex items-center gap-3 p-2 rounded-lg bg-[var(--bg-tertiary)] hover:bg-[var(--bg-secondary)] cursor-pointer transition-colors"
|
||||||
|
@click="downloadAsset(asset)"
|
||||||
|
>
|
||||||
|
<div class="w-16 h-10 rounded bg-[var(--bg-primary)] flex items-center justify-center">
|
||||||
|
<n-icon :size="20"><VideocamOutline /></n-icon>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm truncate">{{ asset.label || '视频' }}</div>
|
||||||
|
<div class="text-xs text-[var(--text-secondary)]">{{ asset.duration ? asset.duration + 's' : '' }}</div>
|
||||||
|
</div>
|
||||||
|
<n-icon :size="20" class="text-[var(--text-secondary)]"><DownloadOutline /></n-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state | 空状态 -->
|
||||||
|
<div v-if="imageAssets.length === 0 && videoAssets.length === 0" class="text-center py-8 text-[var(--text-secondary)]">
|
||||||
|
暂无可下载的素材
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<n-button @click="visible = false">关闭</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Download Modal Component | 下载弹窗组件
|
||||||
|
* Display and download image/video assets from canvas nodes
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { NModal, NButton, NIcon } from 'naive-ui'
|
||||||
|
import { DownloadOutline, VideocamOutline } from '@vicons/ionicons5'
|
||||||
|
import { nodes } from '../stores/canvas'
|
||||||
|
|
||||||
|
// Props | 属性
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emits | 事件
|
||||||
|
const emit = defineEmits(['update:show'])
|
||||||
|
|
||||||
|
// Visible state with v-model support | 支持 v-model 的显示状态
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (val) => emit('update:show', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get downloadable image assets | 获取可下载的图片素材
|
||||||
|
const imageAssets = computed(() => {
|
||||||
|
return nodes.value
|
||||||
|
.filter(n => n.type === 'image' && n.data?.url)
|
||||||
|
.map(n => ({
|
||||||
|
url: n.data.url,
|
||||||
|
label: n.data.label || '图片',
|
||||||
|
nodeId: n.id
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get downloadable video assets | 获取可下载的视频素材
|
||||||
|
const videoAssets = computed(() => {
|
||||||
|
return nodes.value
|
||||||
|
.filter(n => n.type === 'video' && n.data?.url)
|
||||||
|
.map(n => ({
|
||||||
|
url: n.data.url,
|
||||||
|
label: n.data.label || '视频',
|
||||||
|
duration: n.data.duration,
|
||||||
|
nodeId: n.id
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Download single asset | 下载单个素材
|
||||||
|
const downloadAsset = (asset) => {
|
||||||
|
window.open(asset.url, '_blank')
|
||||||
|
window.$message?.success('已在新标签页打开')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
350
web/canvas-app/src/components/MentionsPicker.vue
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
<template>
|
||||||
|
<n-popover
|
||||||
|
:show="isShow"
|
||||||
|
trigger="manual"
|
||||||
|
placement="bottom-start"
|
||||||
|
:x="position.x"
|
||||||
|
:y="position.y"
|
||||||
|
:style="{ padding: 0 }"
|
||||||
|
raw
|
||||||
|
:show-arrow="false"
|
||||||
|
@update:show="handleShowChange"
|
||||||
|
>
|
||||||
|
<div class="mentions-picker">
|
||||||
|
<div class="mentions-search" v-if="showSearch">
|
||||||
|
<n-input
|
||||||
|
v-model:value="searchQuery"
|
||||||
|
placeholder="搜索节点..."
|
||||||
|
size="small"
|
||||||
|
:autofocus="true"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mentions-list" v-if="filteredNodes.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="(node, index) in filteredNodes"
|
||||||
|
:key="node.id"
|
||||||
|
class="mentions-item"
|
||||||
|
:class="{ active: index === selectedIndex }"
|
||||||
|
@click="selectNode(node)"
|
||||||
|
@mouseenter="selectedIndex = index"
|
||||||
|
>
|
||||||
|
<!-- ImageNode 显示图片预览 -->
|
||||||
|
<div v-if="node.type === 'image'" class="mentions-item-image">
|
||||||
|
<img v-if="node.data?.url" :src="node.data.url" :alt="node.data.publicProps?.name" />
|
||||||
|
<div v-else class="mentions-item-image-placeholder">
|
||||||
|
<n-icon :size="20"><ImageOutline /></n-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 非 ImageNode 显示图标 -->
|
||||||
|
<div v-else class="mentions-item-icon">
|
||||||
|
<n-icon :component="getNodeIcon(node.type)" />
|
||||||
|
</div>
|
||||||
|
<div class="mentions-item-content">
|
||||||
|
<div class="mentions-item-label">
|
||||||
|
<!-- ImageNode 优先显示 publicProps.name -->
|
||||||
|
{{ node.type === 'image' ? (node.data?.publicProps?.name || node.data?.label || '未命名') : (node.data?.label || node.id) }}
|
||||||
|
</div>
|
||||||
|
<div class="mentions-item-id">{{ node.id }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mentions-empty" v-else>
|
||||||
|
<span>没有可引用的节点</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { NPopover, NInput, NIcon } from 'naive-ui'
|
||||||
|
import { ImageOutline } from '@vicons/ionicons5'
|
||||||
|
import { nodes } from '@/stores/canvas'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 可见性
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 位置
|
||||||
|
position: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ x: 0, y: 0 })
|
||||||
|
},
|
||||||
|
// 上下文类型:'text' | 'llmConfig'
|
||||||
|
context: {
|
||||||
|
type: String,
|
||||||
|
default: 'text'
|
||||||
|
},
|
||||||
|
// 是否显示搜索框
|
||||||
|
showSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
// 限制只显示已连接的节点 ID 列表(可选)
|
||||||
|
connectedNodeIds: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible', 'select'])
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const selectedIndex = ref(0)
|
||||||
|
const isShow = ref(false)
|
||||||
|
|
||||||
|
// Sync with prop | 与 prop 同步
|
||||||
|
watch(() => props.visible, (newVal) => {
|
||||||
|
isShow.value = newVal
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Handle show change | 处理显示变化
|
||||||
|
const handleShowChange = (val) => {
|
||||||
|
isShow.value = val
|
||||||
|
if (!val) {
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据上下文获取可引用的节点类型
|
||||||
|
const targetTypes = computed(() => {
|
||||||
|
if (props.context === 'llmConfig') {
|
||||||
|
return ['text']
|
||||||
|
}
|
||||||
|
return ['image']
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查节点是否公开(仅 ImageNode 需要检查 publicProps.name)
|
||||||
|
const isNodePublic = (node) => {
|
||||||
|
if (node.type === 'image') {
|
||||||
|
// ImageNode 需要有 publicProps.name 才算公开
|
||||||
|
return node.data?.publicProps?.name && node.data.publicProps.name !== ''
|
||||||
|
}
|
||||||
|
// 其他节点类型默认公开
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可引用的节点列表
|
||||||
|
const availableNodes = computed(() => {
|
||||||
|
return nodes.value.filter(node => {
|
||||||
|
// 先检查类型
|
||||||
|
if (!targetTypes.value.includes(node.type)) return false
|
||||||
|
// 再检查是否公开
|
||||||
|
if (!isNodePublic(node)) return false
|
||||||
|
// 如果指定了 connectedNodeIds,则只显示已连接的节点
|
||||||
|
if (props.connectedNodeIds.length > 0) {
|
||||||
|
return props.connectedNodeIds.includes(node.id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 过滤后的节点列表
|
||||||
|
const filteredNodes = computed(() => {
|
||||||
|
if (!searchQuery.value) {
|
||||||
|
return availableNodes.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
return availableNodes.value.filter(node => {
|
||||||
|
const label = node.data?.label?.toLowerCase() || ''
|
||||||
|
const name = node.data?.publicProps?.name?.toLowerCase() || ''
|
||||||
|
const id = node.id.toLowerCase()
|
||||||
|
return label.includes(query) || name.includes(query) || id.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听搜索变化,重置选中索引
|
||||||
|
watch(searchQuery, () => {
|
||||||
|
selectedIndex.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听可见性变化,重置搜索
|
||||||
|
watch(() => props.visible, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
searchQuery.value = ''
|
||||||
|
selectedIndex.value = 0
|
||||||
|
// 添加全局键盘事件监听
|
||||||
|
document.addEventListener('keydown', handleGlobalKeydown)
|
||||||
|
} else {
|
||||||
|
// 移除全局键盘事件监听
|
||||||
|
document.removeEventListener('keydown', handleGlobalKeydown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全局键盘事件处理(用于在选择器显示时处理 Enter/Escape)
|
||||||
|
function handleGlobalKeydown(event) {
|
||||||
|
if (!isShow.value) return
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
if (filteredNodes.value[selectedIndex.value]) {
|
||||||
|
selectNode(filteredNodes.value[selectedIndex.value])
|
||||||
|
}
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
isShow.value = false
|
||||||
|
emit('update:visible', false)
|
||||||
|
} else if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredNodes.value.length - 1)
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取节点图标
|
||||||
|
function getNodeIcon(type) {
|
||||||
|
const icons = {
|
||||||
|
image: '📷',
|
||||||
|
text: '📝',
|
||||||
|
llmConfig: '🤖',
|
||||||
|
imageConfig: '🎨',
|
||||||
|
video: '🎬',
|
||||||
|
videoConfig: '🎥'
|
||||||
|
}
|
||||||
|
return icons[type] || '📄'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择节点
|
||||||
|
function selectNode(node) {
|
||||||
|
// ImageNode 优先使用 publicProps.name,其他节点使用 label
|
||||||
|
const displayName = node.type === 'image'
|
||||||
|
? (node.data?.publicProps?.name || node.data?.label || node.id)
|
||||||
|
: (node.data?.label || node.id)
|
||||||
|
|
||||||
|
emit('select', {
|
||||||
|
nodeId: node.id,
|
||||||
|
label: displayName,
|
||||||
|
type: node.type
|
||||||
|
})
|
||||||
|
isShow.value = false
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 键盘导航
|
||||||
|
function handleKeydown(event) {
|
||||||
|
const { key } = event
|
||||||
|
|
||||||
|
if (key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredNodes.value.length - 1)
|
||||||
|
} else if (key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
|
||||||
|
} else if (key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
if (filteredNodes.value[selectedIndex.value]) {
|
||||||
|
selectNode(filteredNodes.value[selectedIndex.value])
|
||||||
|
}
|
||||||
|
} else if (key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
isShow.value = false
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mentions-picker {
|
||||||
|
width: 240px;
|
||||||
|
max-height: 300px;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-search {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-list {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item:hover,
|
||||||
|
.mentions-item.active {
|
||||||
|
background: var(--hover-bg, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
background: var(--bg-color, #f0f0f0);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item-image {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-right: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item-image-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-color, #f0f0f0);
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color, #333);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item-id {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-empty {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
312
web/canvas-app/src/components/WorkflowPanel.vue
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Workflow panel | 工作流浮动面板 -->
|
||||||
|
<Transition name="panel-slide">
|
||||||
|
<div v-if="visible" class="workflow-panel" v-click-outside="handleClickOutside">
|
||||||
|
<!-- Header | 头部 -->
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-tabs">
|
||||||
|
<span
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: activeTab === 'public' }"
|
||||||
|
@click="activeTab = 'public'"
|
||||||
|
>公共工作流</span>
|
||||||
|
<span
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: activeTab === 'my' }"
|
||||||
|
@click="activeTab = 'my'"
|
||||||
|
>我的工作流</span>
|
||||||
|
</div>
|
||||||
|
<button class="expand-btn" @click="visible = false">
|
||||||
|
<n-icon :size="16"><CloseOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content | 内容 -->
|
||||||
|
<div class="panel-content">
|
||||||
|
<!-- Public workflows | 公共工作流 -->
|
||||||
|
<div v-if="activeTab === 'public'" class="workflow-grid">
|
||||||
|
<div
|
||||||
|
v-for="workflow in publicWorkflows"
|
||||||
|
:key="workflow.id"
|
||||||
|
class="workflow-card"
|
||||||
|
@click="handleAddWorkflow(workflow)"
|
||||||
|
>
|
||||||
|
<div class="card-cover">
|
||||||
|
<img v-if="workflow.cover" :src="workflow.cover" :alt="workflow.name" class="cover-img" />
|
||||||
|
<n-icon v-else :size="36" class="cover-icon">
|
||||||
|
<component :is="getIcon(workflow.icon)" />
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
<div class="card-title">{{ workflow.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- My workflows | 我的工作流 -->
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<n-icon :size="36" class="text-gray-500">
|
||||||
|
<FolderOpenOutline />
|
||||||
|
</n-icon>
|
||||||
|
<p class="text-gray-500 text-sm mt-2">暂无自定义工作流</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Workflow Panel Component | 工作流面板组件
|
||||||
|
* 显示工作流模板列表,支持一键添加到画布
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { NIcon } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
CloseOutline,
|
||||||
|
GridOutline,
|
||||||
|
ImageOutline,
|
||||||
|
VideocamOutline,
|
||||||
|
FolderOpenOutline,
|
||||||
|
BookOutline,
|
||||||
|
PersonOutline,
|
||||||
|
CartOutline,
|
||||||
|
ChatbubbleOutline
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
import { WORKFLOW_TEMPLATES } from '../config/workflows'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:show', 'add-workflow'])
|
||||||
|
|
||||||
|
// Active tab | 当前标签
|
||||||
|
const activeTab = ref('public')
|
||||||
|
|
||||||
|
// Visible state | 显示状态
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (val) => emit('update:show', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Public workflows | 公共工作流
|
||||||
|
const publicWorkflows = computed(() => WORKFLOW_TEMPLATES)
|
||||||
|
|
||||||
|
// Icon mapping | 图标映射
|
||||||
|
const iconMap = {
|
||||||
|
GridOutline,
|
||||||
|
ImageOutline,
|
||||||
|
VideocamOutline,
|
||||||
|
BookOutline,
|
||||||
|
PersonOutline,
|
||||||
|
ShoppingOutline: CartOutline,
|
||||||
|
ChatbubbleOutline
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIcon = (iconName) => {
|
||||||
|
return iconMap[iconName] || GridOutline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle add workflow | 处理添加工作流
|
||||||
|
const handleAddWorkflow = (workflow) => {
|
||||||
|
// 直接添加工作流,节点内容由用户自己填写
|
||||||
|
emit('add-workflow', { workflow, options: {} })
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle click outside | 点击外部关闭
|
||||||
|
const handleClickOutside = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom directive | 自定义指令
|
||||||
|
const vClickOutside = {
|
||||||
|
mounted(el, binding) {
|
||||||
|
el._clickOutside = (e) => {
|
||||||
|
if (!el.contains(e.target)) {
|
||||||
|
binding.value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', el._clickOutside)
|
||||||
|
}, 0)
|
||||||
|
},
|
||||||
|
unmounted(el) {
|
||||||
|
document.removeEventListener('click', el._clickOutside)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Panel container | 面板容器 */
|
||||||
|
.workflow-panel {
|
||||||
|
position: fixed;
|
||||||
|
left: 72px;
|
||||||
|
top: 100px;
|
||||||
|
width: 520px;
|
||||||
|
max-height: 70vh;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .workflow-panel {
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header | 头部 */
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn:hover {
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content | 内容区 */
|
||||||
|
.panel-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workflow grid | 工作流网格 */
|
||||||
|
.workflow-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workflow card | 工作流卡片 */
|
||||||
|
.workflow-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-card:hover .card-cover {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-cover {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-icon {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state | 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition | 过渡动画 */
|
||||||
|
.panel-slide-enter-active,
|
||||||
|
.panel-slide-leave-active {
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-slide-enter-from,
|
||||||
|
.panel-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar | 滚动条 */
|
||||||
|
.panel-content::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
150
web/canvas-app/src/components/edges/ImageOrderEdge.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Custom edge with image order selector | 带图片顺序选择器的自定义边 -->
|
||||||
|
<BaseEdge :path="path" :style="edgeStyle" />
|
||||||
|
|
||||||
|
<!-- Edge label with order selector | 带顺序选择器的边标签 -->
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
|
pointerEvents: 'all'
|
||||||
|
}"
|
||||||
|
class="nodrag nopan"
|
||||||
|
>
|
||||||
|
<n-dropdown
|
||||||
|
:options="orderOptions"
|
||||||
|
@select="handleOrderSelect"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-6 h-6 text-xs font-bold rounded-full bg-blue-500 text-white border-2 border-white shadow-md hover:scale-110 transition-transform"
|
||||||
|
>
|
||||||
|
{{ currentOrder }}
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { BaseEdge, EdgeLabelRenderer, getBezierPath, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NDropdown } from 'naive-ui'
|
||||||
|
import { edges, nodes } from '../../stores/canvas'
|
||||||
|
|
||||||
|
// Get VueFlow instance | 获取 VueFlow 实例
|
||||||
|
const { updateEdgeData } = useVueFlow()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
source: String,
|
||||||
|
target: String,
|
||||||
|
sourceX: Number,
|
||||||
|
sourceY: Number,
|
||||||
|
targetX: Number,
|
||||||
|
targetY: Number,
|
||||||
|
sourcePosition: String,
|
||||||
|
targetPosition: String,
|
||||||
|
data: Object,
|
||||||
|
markerEnd: String,
|
||||||
|
style: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Order labels | 顺序标签
|
||||||
|
const orderLabels = [
|
||||||
|
{ label: '① 第一张', key: 1 },
|
||||||
|
{ label: '② 第二张', key: 2 },
|
||||||
|
{ label: '③ 第三张', key: 3 },
|
||||||
|
{ label: '④ 第四张', key: 4 },
|
||||||
|
{ label: '⑤ 第五张', key: 5 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Dynamic order options based on connected edges count + @ mentioned images | 基于连接边数量和@提及图片的动态顺序选项
|
||||||
|
const orderOptions = computed(() => {
|
||||||
|
// Get all imageOrder edges connected to the same target | 获取连接到同一目标的图片边
|
||||||
|
const sameTargetImageEdges = edges.value.filter(edge =>
|
||||||
|
edge.target === props.target &&
|
||||||
|
edge.type === 'imageOrder'
|
||||||
|
)
|
||||||
|
const edgeCount = sameTargetImageEdges.length || 1
|
||||||
|
|
||||||
|
// Get @ mentioned image count from connected TextNodes | 获取已连接 TextNode 中 @ 提及的图片数量
|
||||||
|
let mentionedImageCount = 0
|
||||||
|
const connectedTextEdges = edges.value.filter(e => e.target === props.target)
|
||||||
|
for (const edge of connectedTextEdges) {
|
||||||
|
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||||||
|
if (sourceNode?.type === 'text') {
|
||||||
|
const content = sourceNode.data?.content || ''
|
||||||
|
// Count @ mentions of image nodes | 统计图片节点的 @ 提及
|
||||||
|
const mentionRegex = /@\[([^\]|]+)(?:\|([^\]]+))?\]/g
|
||||||
|
let match
|
||||||
|
while ((match = mentionRegex.exec(content)) !== null) {
|
||||||
|
const mentionedNode = nodes.value.find(n => n.id === match[1])
|
||||||
|
if (mentionedNode?.type === 'image') {
|
||||||
|
mentionedImageCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimum order is mentionedImageCount + 1 | 最小顺序是 @ 提及图片数量 + 1
|
||||||
|
const minOrder = mentionedImageCount + 1
|
||||||
|
// Total count = edge count + mentioned image count | 总数量 = 边数量 + @ 提及图片数量
|
||||||
|
const totalCount = edgeCount + mentionedImageCount
|
||||||
|
const maxOrder = Math.min(totalCount, 5)
|
||||||
|
|
||||||
|
// Return options from minOrder to maxOrder | 返回从 minOrder 到 maxOrder 的选项
|
||||||
|
return orderLabels.filter(label => label.key >= minOrder && label.key <= maxOrder)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Current order from edge data | 从边数据获取当前顺序
|
||||||
|
const currentOrder = computed(() => props.data?.imageOrder || 1)
|
||||||
|
|
||||||
|
// Calculate bezier path | 计算贝塞尔路径
|
||||||
|
const path = computed(() => {
|
||||||
|
const [edgePath] = getBezierPath({
|
||||||
|
sourceX: props.sourceX,
|
||||||
|
sourceY: props.sourceY,
|
||||||
|
targetX: props.targetX,
|
||||||
|
targetY: props.targetY,
|
||||||
|
sourcePosition: props.sourcePosition,
|
||||||
|
targetPosition: props.targetPosition
|
||||||
|
})
|
||||||
|
return edgePath
|
||||||
|
})
|
||||||
|
|
||||||
|
// Label position (center of edge) | 标签位置(边的中心)
|
||||||
|
const labelX = computed(() => (props.sourceX + props.targetX) / 2)
|
||||||
|
const labelY = computed(() => (props.sourceY + props.targetY) / 2)
|
||||||
|
|
||||||
|
// Edge style | 边样式
|
||||||
|
const edgeStyle = computed(() => ({
|
||||||
|
stroke: '#3b82f6',
|
||||||
|
strokeWidth: 2,
|
||||||
|
...props.style
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Handle order selection | 处理顺序选择
|
||||||
|
const handleOrderSelect = (newOrder) => {
|
||||||
|
// Get all image edges connected to the same target | 获取连接到同一目标的所有图片边
|
||||||
|
const sameTargetImageEdges = edges.value.filter(edge =>
|
||||||
|
edge.target === props.target &&
|
||||||
|
edge.type === 'imageOrder'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Find edge currently using this order | 查找当前使用此顺序的边
|
||||||
|
const edgeWithSameOrder = sameTargetImageEdges.find(edge =>
|
||||||
|
edge.id !== props.id &&
|
||||||
|
edge.data?.imageOrder === newOrder
|
||||||
|
)
|
||||||
|
|
||||||
|
// If another edge has this order, swap with current | 如果另一条边有此顺序,则交换
|
||||||
|
if (edgeWithSameOrder) {
|
||||||
|
updateEdgeData(edgeWithSameOrder.id, { imageOrder: currentOrder.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current edge order | 更新当前边顺序
|
||||||
|
updateEdgeData(props.id, { imageOrder: newOrder })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
117
web/canvas-app/src/components/edges/ImageRoleEdge.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Custom edge with image role selector | 带图片角色选择器的自定义边 -->
|
||||||
|
<BaseEdge :path="path" :style="edgeStyle" />
|
||||||
|
|
||||||
|
<!-- Edge label with role dropdown | 带角色下拉的边标签 -->
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
|
pointerEvents: 'all'
|
||||||
|
}"
|
||||||
|
class="nodrag nopan"
|
||||||
|
>
|
||||||
|
<n-dropdown
|
||||||
|
:options="imageRoleOptions"
|
||||||
|
@select="handleRoleSelect"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 shadow-sm hover:shadow transition-shadow"
|
||||||
|
>
|
||||||
|
{{ currentRoleLabel }}
|
||||||
|
<n-icon :size="10"><ChevronDownOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { BaseEdge, EdgeLabelRenderer, getBezierPath, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NDropdown, NIcon } from 'naive-ui'
|
||||||
|
import { ChevronDownOutline } from '@vicons/ionicons5'
|
||||||
|
import { edges } from '../../stores/canvas'
|
||||||
|
|
||||||
|
// Get VueFlow instance | 获取 VueFlow 实例
|
||||||
|
const { updateEdgeData } = useVueFlow()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
source: String,
|
||||||
|
target: String,
|
||||||
|
sourceX: Number,
|
||||||
|
sourceY: Number,
|
||||||
|
targetX: Number,
|
||||||
|
targetY: Number,
|
||||||
|
sourcePosition: String,
|
||||||
|
targetPosition: String,
|
||||||
|
data: Object,
|
||||||
|
markerEnd: String,
|
||||||
|
style: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Image role options | 图片角色选项
|
||||||
|
const imageRoleOptions = [
|
||||||
|
{ label: '首帧', key: 'first_frame_image' },
|
||||||
|
{ label: '尾帧', key: 'last_frame_image' },
|
||||||
|
{ label: '参考图', key: 'input_reference' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Current role from edge data | 从边数据获取当前角色
|
||||||
|
const currentRole = computed(() => props.data?.imageRole || 'first_frame_image')
|
||||||
|
|
||||||
|
// Current role label | 当前角色标签
|
||||||
|
const currentRoleLabel = computed(() => {
|
||||||
|
const option = imageRoleOptions.find(o => o.key === currentRole.value)
|
||||||
|
return option?.label || '首帧'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate bezier path | 计算贝塞尔路径
|
||||||
|
const path = computed(() => {
|
||||||
|
const [edgePath] = getBezierPath({
|
||||||
|
sourceX: props.sourceX,
|
||||||
|
sourceY: props.sourceY,
|
||||||
|
targetX: props.targetX,
|
||||||
|
targetY: props.targetY,
|
||||||
|
sourcePosition: props.sourcePosition,
|
||||||
|
targetPosition: props.targetPosition
|
||||||
|
})
|
||||||
|
return edgePath
|
||||||
|
})
|
||||||
|
|
||||||
|
// Label position (center of edge) | 标签位置(边的中心)
|
||||||
|
const labelX = computed(() => (props.sourceX + props.targetX) / 2)
|
||||||
|
const labelY = computed(() => (props.sourceY + props.targetY) / 2)
|
||||||
|
|
||||||
|
// Edge style | 边样式
|
||||||
|
const edgeStyle = computed(() => ({
|
||||||
|
stroke: '#6366f1',
|
||||||
|
strokeWidth: 2,
|
||||||
|
...props.style
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Handle role selection | 处理角色选择
|
||||||
|
const handleRoleSelect = (role) => {
|
||||||
|
// If selecting first_frame or last_frame, ensure uniqueness | 如果选择首帧或尾帧,确保唯一性
|
||||||
|
if (role === 'first_frame_image' || role === 'last_frame_image') {
|
||||||
|
// Find other edges connected to the same target with the same role | 查找连接到同一目标且具有相同角色的其他边
|
||||||
|
const sameTargetEdges = edges.value.filter(edge =>
|
||||||
|
edge.target === props.target &&
|
||||||
|
edge.id !== props.id &&
|
||||||
|
edge.data?.imageRole === role
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auto-switch the other edge to the opposite role | 自动切换其他边到相反角色
|
||||||
|
sameTargetEdges.forEach(edge => {
|
||||||
|
const oppositeRole = role === 'first_frame_image' ? 'last_frame_image' : 'first_frame_image'
|
||||||
|
updateEdgeData(edge.id, { imageRole: oppositeRole })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current edge role | 更新当前边角色
|
||||||
|
updateEdgeData(props.id, { imageRole: role })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
123
web/canvas-app/src/components/edges/PromptOrderEdge.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Custom edge with prompt order selector | 带提示词顺序选择器的自定义边 -->
|
||||||
|
<BaseEdge :path="path" :style="edgeStyle" />
|
||||||
|
|
||||||
|
<!-- Edge label with order selector | 带顺序选择器的边标签 -->
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
|
pointerEvents: 'all'
|
||||||
|
}"
|
||||||
|
class="nodrag nopan"
|
||||||
|
>
|
||||||
|
<n-dropdown
|
||||||
|
:options="orderOptions"
|
||||||
|
@select="handleOrderSelect"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-6 h-6 text-xs font-bold rounded-full bg-[var(--accent-color)] text-white border-2 border-white shadow-md hover:scale-110 transition-transform"
|
||||||
|
>
|
||||||
|
{{ currentOrder }}
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { BaseEdge, EdgeLabelRenderer, getBezierPath, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NDropdown } from 'naive-ui'
|
||||||
|
import { edges } from '../../stores/canvas'
|
||||||
|
|
||||||
|
// Get VueFlow instance | 获取 VueFlow 实例
|
||||||
|
const { updateEdgeData } = useVueFlow()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
source: String,
|
||||||
|
target: String,
|
||||||
|
sourceX: Number,
|
||||||
|
sourceY: Number,
|
||||||
|
targetX: Number,
|
||||||
|
targetY: Number,
|
||||||
|
sourcePosition: String,
|
||||||
|
targetPosition: String,
|
||||||
|
data: Object,
|
||||||
|
markerEnd: String,
|
||||||
|
style: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Order labels | 顺序标签
|
||||||
|
const orderLabels = [
|
||||||
|
{ label: '① 第一个', key: 1 },
|
||||||
|
{ label: '② 第二个', key: 2 },
|
||||||
|
{ label: '③ 第三个', key: 3 },
|
||||||
|
{ label: '④ 第四个', key: 4 },
|
||||||
|
{ label: '⑤ 第五个', key: 5 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Dynamic order options based on connected edges count | 基于连接边数量的动态顺序选项
|
||||||
|
const orderOptions = computed(() => {
|
||||||
|
// Get all promptOrder edges connected to the same target | 获取连接到同一目标的所有文本边
|
||||||
|
const sameTargetTextEdges = edges.value.filter(edge =>
|
||||||
|
edge.target === props.target &&
|
||||||
|
edge.type === 'promptOrder'
|
||||||
|
)
|
||||||
|
const count = sameTargetTextEdges.length || 1
|
||||||
|
return orderLabels.slice(0, count)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Current order from edge data | 从边数据获取当前顺序
|
||||||
|
const currentOrder = computed(() => props.data?.promptOrder || 1)
|
||||||
|
|
||||||
|
// Calculate bezier path | 计算贝塞尔路径
|
||||||
|
const path = computed(() => {
|
||||||
|
const [edgePath] = getBezierPath({
|
||||||
|
sourceX: props.sourceX,
|
||||||
|
sourceY: props.sourceY,
|
||||||
|
targetX: props.targetX,
|
||||||
|
targetY: props.targetY,
|
||||||
|
sourcePosition: props.sourcePosition,
|
||||||
|
targetPosition: props.targetPosition
|
||||||
|
})
|
||||||
|
return edgePath
|
||||||
|
})
|
||||||
|
|
||||||
|
// Label position (center of edge) | 标签位置(边的中心)
|
||||||
|
const labelX = computed(() => (props.sourceX + props.targetX) / 2)
|
||||||
|
const labelY = computed(() => (props.sourceY + props.targetY) / 2)
|
||||||
|
|
||||||
|
// Edge style | 边样式
|
||||||
|
const edgeStyle = computed(() => ({
|
||||||
|
stroke: '#10b981',
|
||||||
|
strokeWidth: 2,
|
||||||
|
...props.style
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Handle order selection | 处理顺序选择
|
||||||
|
const handleOrderSelect = (newOrder) => {
|
||||||
|
// Get all text edges connected to the same target | 获取连接到同一目标的所有文本边
|
||||||
|
const sameTargetTextEdges = edges.value.filter(edge =>
|
||||||
|
edge.target === props.target &&
|
||||||
|
edge.type === 'promptOrder'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Find edge currently using this order | 查找当前使用此顺序的边
|
||||||
|
const edgeWithSameOrder = sameTargetTextEdges.find(edge =>
|
||||||
|
edge.id !== props.id &&
|
||||||
|
edge.data?.promptOrder === newOrder
|
||||||
|
)
|
||||||
|
|
||||||
|
// If another edge has this order, swap with current | 如果另一条边有此顺序,则交换
|
||||||
|
if (edgeWithSameOrder) {
|
||||||
|
updateEdgeData(edgeWithSameOrder.id, { promptOrder: currentOrder.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current edge order | 更新当前边顺序
|
||||||
|
updateEdgeData(props.id, { promptOrder: newOrder })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
783
web/canvas-app/src/components/nodes/ImageConfigNode.vue
Normal file
@@ -0,0 +1,783 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Image config node wrapper | 文生图配置节点包裹层 -->
|
||||||
|
<div class="image-config-node-wrapper" @mouseenter="showHandleMenu = true" @mouseleave="showHandleMenu = false">
|
||||||
|
<!-- Image config node | 文生图配置节点 -->
|
||||||
|
<div
|
||||||
|
class="image-config-node bg-[var(--bg-secondary)] rounded-xl border min-w-[300px] transition-all duration-200"
|
||||||
|
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'">
|
||||||
|
<!-- Header | 头部 -->
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<span
|
||||||
|
v-if="!isEditingLabel"
|
||||||
|
@dblclick="startEditLabel"
|
||||||
|
class="text-sm font-medium text-[var(--text-secondary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
|
||||||
|
title="双击编辑名称"
|
||||||
|
>{{ data.label }}</span>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
ref="labelInputRef"
|
||||||
|
v-model="editingLabelValue"
|
||||||
|
@blur="finishEditLabel"
|
||||||
|
@keydown.enter="finishEditLabel"
|
||||||
|
@keydown.escape="cancelEditLabel"
|
||||||
|
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] px-1 rounded outline-none border border-blue-500"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<CopyOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<TrashOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config options | 配置选项 -->
|
||||||
|
<div class="p-3 space-y-3">
|
||||||
|
<!-- Model selector | 模型选择 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">模型</span>
|
||||||
|
<n-dropdown :options="modelOptions" @select="handleModelSelect">
|
||||||
|
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||||
|
{{ displayModelName }}
|
||||||
|
<n-icon :size="12"><ChevronDownOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quality selector | 画质选择 -->
|
||||||
|
<div v-if="hasQualityOptions" class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">画质</span>
|
||||||
|
<n-dropdown :options="qualityOptions" @select="handleQualitySelect">
|
||||||
|
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||||
|
{{ displayQuality }}
|
||||||
|
<n-icon :size="12"><ChevronForwardOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Size selector | 尺寸选择 -->
|
||||||
|
<div v-if="hasSizeOptions" class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">尺寸</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<n-dropdown :options="sizeOptions" @select="handleSizeSelect">
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||||
|
{{ displaySize }}
|
||||||
|
<n-icon :size="12">
|
||||||
|
<ChevronForwardOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model tips | 模型提示 -->
|
||||||
|
<div v-if="currentModelConfig?.tips" class="text-xs text-[var(--text-tertiary)] bg-[var(--bg-tertiary)] rounded px-2 py-1">
|
||||||
|
💡 {{ currentModelConfig.tips }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connected inputs indicator | 连接输入指示 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-xs text-[var(--text-secondary)] py-1 border-t border-[var(--border-color)]">
|
||||||
|
<span class="px-2 py-0.5 rounded-full"
|
||||||
|
:class="connectedPrompts.length > 0 ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||||||
|
提示词 {{ connectedPrompts.length > 0 ? `${connectedPrompts.length}个` : '○' }}
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-0.5 rounded-full"
|
||||||
|
:class="connectedRefImages.length > 0 ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||||||
|
参考图 {{ connectedRefImages.length > 0 ? `${connectedRefImages.length}张` : '○' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generate button | 生成按钮 -->
|
||||||
|
<div v-if="hasConnectedImageWithContent" class="flex gap-2">
|
||||||
|
<!-- Create new (primary) | 新建节点(主按钮) -->
|
||||||
|
<button @click="handleGenerate('new')" :disabled="loading || !isConfigured"
|
||||||
|
class="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<n-spin v-if="loading" :size="14" />
|
||||||
|
<template v-else>
|
||||||
|
<n-icon :size="14"><AddOutline /></n-icon>
|
||||||
|
新建生成
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
<!-- Replace existing (secondary) | 替换现有(次按钮) -->
|
||||||
|
<button @click="handleGenerate('replace')" :disabled="loading || !isConfigured"
|
||||||
|
class="flex-shrink-0 flex items-center justify-center gap-1 py-2 px-2.5 rounded-lg border border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-color)] hover:text-[var(--accent-color)] text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<n-spin v-if="loading" :size="14" />
|
||||||
|
<template v-else>
|
||||||
|
<n-icon :size="14"><RefreshOutline /></n-icon>
|
||||||
|
替换
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button v-else @click="handleGenerate('auto')" :disabled="loading || !isConfigured"
|
||||||
|
class="w-full flex items-center justify-center gap-2 py-2 px-4 rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<n-spin v-if="loading" :size="14" />
|
||||||
|
<template v-else>
|
||||||
|
<span
|
||||||
|
class="text-[var(--accent-color)] bg-white rounded-full w-4 h-4 flex items-center justify-center text-xs">◆</span>
|
||||||
|
立即生成
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Error message | 错误信息 -->
|
||||||
|
<div v-if="error" class="text-xs text-red-500 mt-2">
|
||||||
|
{{ error.message || '生成失败' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generated images preview | 生成图片预览 -->
|
||||||
|
<!-- <div v-if="generatedImages.length > 0" class="mt-3 space-y-2">
|
||||||
|
<div class="text-xs text-[var(--text-secondary)]">生成结果:</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 max-w-[240px]">
|
||||||
|
<div
|
||||||
|
v-for="(img, idx) in generatedImages"
|
||||||
|
:key="idx"
|
||||||
|
class="aspect-square rounded-lg overflow-hidden bg-[var(--bg-tertiary)] max-w-[110px]"
|
||||||
|
>
|
||||||
|
<img :src="img.url" class="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Handles | 连接点 -->
|
||||||
|
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
|
||||||
|
<NodeHandleMenu :nodeId="id" nodeType="imageConfig" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Image config node component | 文生图配置节点组件
|
||||||
|
* Configuration panel for text-to-image generation with API integration
|
||||||
|
*/
|
||||||
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||||
|
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NIcon, NDropdown, NSpin } from 'naive-ui'
|
||||||
|
import { ChevronDownOutline, ChevronForwardOutline, CopyOutline, TrashOutline, RefreshOutline, AddOutline, ImageOutline, CreateOutline } from '@vicons/ionicons5'
|
||||||
|
import { useImageGeneration } from '../../hooks'
|
||||||
|
import { updateNode, addNode, addEdge, nodes, edges, duplicateNode, removeNode } from '../../stores/canvas'
|
||||||
|
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||||||
|
import { useModelStore } from '../../stores/pinia'
|
||||||
|
import { getModelSizeOptions, getModelQualityOptions, getModelConfig, DEFAULT_IMAGE_MODEL } from '../../stores/models'
|
||||||
|
import { parseMentions } from '../../hooks/useNodeRef'
|
||||||
|
|
||||||
|
// 使用 Pinia store 获取模型选项(根据渠道过滤)
|
||||||
|
const modelStore = useModelStore()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Vue Flow instance | Vue Flow 实例
|
||||||
|
const { updateNodeInternals } = useVueFlow()
|
||||||
|
|
||||||
|
// API config state | API 配置状态
|
||||||
|
const isConfigured = computed(() => !!modelStore.currentApiKey)
|
||||||
|
|
||||||
|
// Image generation hook | 图片生成 hook
|
||||||
|
const { loading, error, images: generatedImages, generate } = useImageGeneration()
|
||||||
|
|
||||||
|
// Local state | 本地状态
|
||||||
|
const showHandleMenu = ref(false)
|
||||||
|
const localModel = ref(props.data?.model || DEFAULT_IMAGE_MODEL)
|
||||||
|
const localSize = ref(props.data?.size || '2048x2048')
|
||||||
|
const localQuality = ref(props.data?.quality || 'standard')
|
||||||
|
|
||||||
|
// Label editing state | Label 编辑状态
|
||||||
|
const isEditingLabel = ref(false)
|
||||||
|
const editingLabelValue = ref('')
|
||||||
|
const labelInputRef = ref(null)
|
||||||
|
|
||||||
|
// ImageConfig node menu operations | 图片配置节点菜单操作
|
||||||
|
const operations = [
|
||||||
|
// { type: 'imageConfig', label: '图生图', icon: ImageOutline, action: 'imageConfig_imageConfig' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Handle menu select | 处理菜单选择
|
||||||
|
const handleSelect = (item) => {
|
||||||
|
const action = item.action
|
||||||
|
|
||||||
|
if (action === 'imageConfig_imageConfig') {
|
||||||
|
// Image-to-image (create new image node for editing) | 图生图(创建新图片节点用于编辑)
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create new image node for editing
|
||||||
|
const imageNodeId = addNode('image', { x: nodeX + 400, y: nodeY }, {
|
||||||
|
label: '图片编辑'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect current config to new image node
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: imageNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => updateNodeInternals(imageNodeId), 50)
|
||||||
|
window.$message?.success('已创建图片编辑节点')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current model config | 获取当前模型配置
|
||||||
|
const currentModelConfig = computed(() => getModelConfig(localModel.value))
|
||||||
|
|
||||||
|
// Model options from Pinia store (filtered by provider) | 从 Pinia store 获取模型选项(根据渠道过滤)
|
||||||
|
const modelOptions = computed(() => modelStore.allImageModelOptions)
|
||||||
|
|
||||||
|
// Display model name | 显示模型名称
|
||||||
|
const displayModelName = computed(() => {
|
||||||
|
const model = modelOptions.value.find(m => m.key === localModel.value)
|
||||||
|
// 如果当前模型不在选项中,尝试从 allImageModels 找到
|
||||||
|
if (!model) {
|
||||||
|
const allModel = modelStore.allImageModels.find(m => m.key === localModel.value)
|
||||||
|
return allModel?.label || localModel.value || '选择模型'
|
||||||
|
}
|
||||||
|
return model?.label || localModel.value || '选择模型'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Quality options based on model | 基于模型的画质选项
|
||||||
|
const qualityOptions = computed(() => {
|
||||||
|
return getModelQualityOptions(localModel.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if model has quality options | 检查模型是否有画质选项
|
||||||
|
const hasQualityOptions = computed(() => {
|
||||||
|
return qualityOptions.value && qualityOptions.value.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Display quality | 显示画质
|
||||||
|
const displayQuality = computed(() => {
|
||||||
|
const option = qualityOptions.value.find(o => o.key === localQuality.value)
|
||||||
|
return option?.label || '标准画质'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Size options based on model and quality | 基于模型和画质的尺寸选项
|
||||||
|
const sizeOptions = computed(() => {
|
||||||
|
return getModelSizeOptions(localModel.value, localQuality.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if model has size options | 检查模型是否有尺寸选项
|
||||||
|
const hasSizeOptions = computed(() => {
|
||||||
|
const config = getModelConfig(localModel.value)
|
||||||
|
return config?.sizes && config.sizes.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Display size with label | 显示尺寸(带标签)
|
||||||
|
const displaySize = computed(() => {
|
||||||
|
const option = sizeOptions.value.find(o => o.key === localSize.value)
|
||||||
|
return option?.label || localSize.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize on mount | 挂载时初始化
|
||||||
|
onMounted(() => {
|
||||||
|
// 检查当前模型是否在可用模型列表中
|
||||||
|
const availableModels = modelStore.availableImageModels
|
||||||
|
const isModelAvailable = availableModels.some(m => m.key === localModel.value)
|
||||||
|
|
||||||
|
if (!localModel.value || !isModelAvailable) {
|
||||||
|
// 使用 store 中的默认模型或第一个可用模型
|
||||||
|
localModel.value = modelStore.selectedImageModel || availableModels[0]?.key || DEFAULT_IMAGE_MODEL
|
||||||
|
updateNode(props.id, { model: localModel.value })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 解析 textNode 内容中的 @ 引用,转换为简短引用(如 图 1)并收集图片
|
||||||
|
const resolveTextMentionsForImage = (textNode) => {
|
||||||
|
const content = textNode.data?.content || ''
|
||||||
|
const mentions = parseMentions(content)
|
||||||
|
|
||||||
|
if (mentions.length === 0) {
|
||||||
|
return { resolvedContent: content, refImages: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集引用的图片节点
|
||||||
|
const imageMentions = []
|
||||||
|
for (const mention of mentions) {
|
||||||
|
const referencedNode = nodes.value.find(n => n.id === mention.nodeId)
|
||||||
|
if (referencedNode?.type === 'image') {
|
||||||
|
const imageData = referencedNode.data?.base64 || referencedNode.data?.url
|
||||||
|
if (imageData) {
|
||||||
|
imageMentions.push({
|
||||||
|
order: mention.order,
|
||||||
|
nodeId: mention.nodeId,
|
||||||
|
imageData
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageMentions.length === 0) {
|
||||||
|
return { resolvedContent: content, refImages: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按出现顺序排序
|
||||||
|
imageMentions.sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
|
// 替换 @[nodeId] 为按顺序的 "图1"、"图2" 等
|
||||||
|
let resolvedContent = content
|
||||||
|
for (let i = 0; i < imageMentions.length; i++) {
|
||||||
|
const mention = imageMentions[i]
|
||||||
|
const placeholder = `@[${mention.nodeId}]`
|
||||||
|
// 按排序后的索引替换为 "图1"、"图2" 等
|
||||||
|
resolvedContent = resolvedContent.replace(placeholder, `图${i + 1}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回解析后的内容和图片数组(按引用顺序)
|
||||||
|
const refImages = imageMentions.map(m => m.imageData)
|
||||||
|
|
||||||
|
return { resolvedContent, refImages }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed connected prompts (sorted by order) | 计算连接的提示词(按顺序排列)
|
||||||
|
const connectedPrompts = computed(() => {
|
||||||
|
return getConnectedInputs().prompts
|
||||||
|
})
|
||||||
|
|
||||||
|
// Computed connected reference images | 计算连接的参考图
|
||||||
|
const connectedRefImages = computed(() => {
|
||||||
|
return getConnectedInputs().refImages
|
||||||
|
})
|
||||||
|
|
||||||
|
// 已连接的文本节点 ID 列表(用于 @ 提及时过滤)
|
||||||
|
const connectedTextNodeIds = computed(() => {
|
||||||
|
const incomingEdges = edges.value.filter(e => e.target === props.id)
|
||||||
|
const connectedIds = []
|
||||||
|
for (const edge of incomingEdges) {
|
||||||
|
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||||||
|
if (sourceNode?.type === 'text') {
|
||||||
|
connectedIds.push(sourceNode.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return connectedIds
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get connected nodes | 获取连接的节点
|
||||||
|
const getConnectedInputs = () => {
|
||||||
|
// 1. First check @ mentions | 首先检查 @ 引用
|
||||||
|
// Only check connected TextNodes | 只检查已连接的 TextNode
|
||||||
|
const textNodes = nodes.value.filter(n => n.type === 'text' && connectedTextNodeIds.value.includes(n.id))
|
||||||
|
const mentionsPrompts = []
|
||||||
|
const mentionsRefImages = []
|
||||||
|
|
||||||
|
for (const textNode of textNodes) {
|
||||||
|
const { resolvedContent, refImages: nodeRefImages } = resolveTextMentionsForImage(textNode)
|
||||||
|
|
||||||
|
// 如果有解析出图片引用
|
||||||
|
if (nodeRefImages.length > 0) {
|
||||||
|
// 添加解析后的提示词内容
|
||||||
|
mentionsPrompts.push({
|
||||||
|
order: mentionsPrompts.length,
|
||||||
|
content: resolvedContent,
|
||||||
|
nodeId: textNode.id
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加参考图
|
||||||
|
for (const imageData of nodeRefImages) {
|
||||||
|
mentionsRefImages.push({
|
||||||
|
order: mentionsRefImages.length,
|
||||||
|
imageData,
|
||||||
|
nodeId: textNode.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get edge-connected ImageNodes | 获取边连接的 ImageNode
|
||||||
|
const connectedEdges = edges.value.filter(e => e.target === props.id)
|
||||||
|
const edgeRefImages = [] // Array of { order, imageData, nodeId } | 参考图数组
|
||||||
|
|
||||||
|
for (const edge of connectedEdges) {
|
||||||
|
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||||||
|
if (!sourceNode) continue
|
||||||
|
|
||||||
|
if (sourceNode.type === 'image') {
|
||||||
|
// Prefer base64, fallback to url | 优先使用 base64,回退到 url
|
||||||
|
const imageData = sourceNode.data?.base64 || sourceNode.data?.url
|
||||||
|
if (imageData) {
|
||||||
|
// Get order from edge data, default to 1 | 从边数据获取顺序,默认为1
|
||||||
|
// Add offset of @ mentions count | 加上 @ 提及图片数量的偏移
|
||||||
|
const baseOrder = edge.data?.imageOrder || 1
|
||||||
|
const order = mentionsRefImages.length + baseOrder
|
||||||
|
edgeRefImages.push({ order, imageData, nodeId: sourceNode.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Merge and sort refImages | 合并并排序参考图
|
||||||
|
// Combine @ mentions refImages and edge-connected refImages | 合并 @ 提及和边连接的图片
|
||||||
|
const allRefImages = [...mentionsRefImages, ...edgeRefImages]
|
||||||
|
// Sort by order | 按顺序排序
|
||||||
|
allRefImages.sort((a, b) => a.order - b.order)
|
||||||
|
const sortedRefImages = allRefImages.map(r => r.imageData)
|
||||||
|
|
||||||
|
// 4. If there are @ mentions, use them | 如果有 @ 提及,使用它们
|
||||||
|
if (mentionsPrompts.length > 0) {
|
||||||
|
// Sort prompts by order | 按顺序排序提示词
|
||||||
|
mentionsPrompts.sort((a, b) => a.order - b.order)
|
||||||
|
const combinedPrompt = mentionsPrompts.map(p => p.content).join('\n\n')
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompt: combinedPrompt,
|
||||||
|
prompts: mentionsPrompts,
|
||||||
|
refImages: sortedRefImages,
|
||||||
|
refImagesWithOrder: allRefImages,
|
||||||
|
fromMentions: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fallback to edge connections | 降级到边的连接
|
||||||
|
// (only prompts, no @ mentions) (只有提示词,没有 @ 提及)
|
||||||
|
const prompts = [] // Array of { order, content } | 提示词数组
|
||||||
|
|
||||||
|
for (const edge of connectedEdges) {
|
||||||
|
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||||||
|
if (!sourceNode) continue
|
||||||
|
|
||||||
|
if (sourceNode.type === 'text') {
|
||||||
|
const content = sourceNode.data?.content || ''
|
||||||
|
if (content) {
|
||||||
|
// Get order from edge data, default to 1 | 从边数据获取顺序,默认为1
|
||||||
|
const order = edge.data?.promptOrder || 1
|
||||||
|
prompts.push({ order, content, nodeId: sourceNode.id })
|
||||||
|
}
|
||||||
|
} else if (sourceNode.type === 'llmConfig') {
|
||||||
|
// LLM node output as prompt | LLM 节点输出作为提示词
|
||||||
|
const content = sourceNode.data?.outputContent || ''
|
||||||
|
if (content) {
|
||||||
|
const order = edge.data?.promptOrder || 1
|
||||||
|
prompts.push({ order, content, nodeId: sourceNode.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Note: ImageNode handling moved to step 2 above | 注意:ImageNode 处理已移至步骤 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort prompts by order and concatenate | 按顺序排序并拼接
|
||||||
|
prompts.sort((a, b) => a.order - b.order)
|
||||||
|
const combinedPrompt = prompts.map(p => p.content).join('\n\n')
|
||||||
|
|
||||||
|
// Use edge-connected refImages (already sorted above) | 使用边连接的参考图(已在上面排序)
|
||||||
|
return { prompt: combinedPrompt, prompts, refImages: sortedRefImages, refImagesWithOrder: allRefImages, fromMentions: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle model selection | 处理模型选择
|
||||||
|
const handleModelSelect = (key) => {
|
||||||
|
localModel.value = key
|
||||||
|
const config = getModelConfig(key)
|
||||||
|
|
||||||
|
// 同步 Quality 到模型默认值
|
||||||
|
if (config?.defaultParams?.quality) {
|
||||||
|
localQuality.value = config.defaultParams.quality
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步 Size 到模型默认值
|
||||||
|
const newSizeOptions = getModelSizeOptions(key, localQuality.value)
|
||||||
|
let defaultSize = config?.defaultParams?.size
|
||||||
|
|
||||||
|
if (!defaultSize && newSizeOptions.length > 0) {
|
||||||
|
// 备用逻辑:查找 2048 或最接近的尺寸
|
||||||
|
defaultSize = newSizeOptions.find(o => o.key === '2048x2048')?.key
|
||||||
|
|| newSizeOptions.find(o => o.key.includes('1024'))?.key
|
||||||
|
|| newSizeOptions[0].key
|
||||||
|
}
|
||||||
|
|
||||||
|
localSize.value = defaultSize
|
||||||
|
|
||||||
|
// 更新节点数据
|
||||||
|
updateNode(props.id, {
|
||||||
|
model: key,
|
||||||
|
quality: localQuality.value,
|
||||||
|
size: defaultSize
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle quality selection | 处理画质选择
|
||||||
|
const handleQualitySelect = (quality) => {
|
||||||
|
localQuality.value = quality
|
||||||
|
// Update size to first option of new quality | 更新尺寸为新画质的第一个选项
|
||||||
|
const newSizeOptions = getModelSizeOptions(localModel.value, quality)
|
||||||
|
if (newSizeOptions.length > 0) {
|
||||||
|
const defaultSize = quality === '4k' ? newSizeOptions.find(o => o.key.includes('4096'))?.key || newSizeOptions[4]?.key : newSizeOptions[4]?.key
|
||||||
|
localSize.value = defaultSize || newSizeOptions[0].key
|
||||||
|
updateNode(props.id, { quality, size: localSize.value })
|
||||||
|
} else {
|
||||||
|
updateNode(props.id, { quality })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle size selection | 处理尺寸选择
|
||||||
|
const handleSizeSelect = (size) => {
|
||||||
|
localSize.value = size
|
||||||
|
updateNode(props.id, { size })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update size from manual input | 更新手动输入的尺寸
|
||||||
|
const updateSize = () => {
|
||||||
|
updateNode(props.id, { size: localSize.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created image node ID | 创建的图片节点 ID
|
||||||
|
const createdImageNodeId = ref(null)
|
||||||
|
|
||||||
|
// Find connected output image node | 查找已连接的输出图片节点
|
||||||
|
const findConnectedOutputImageNode = (onlyEmpty = true) => {
|
||||||
|
// Find edges where this node is the source | 查找以当前节点为源的边
|
||||||
|
const outputEdges = edges.value.filter(e => e.source === props.id)
|
||||||
|
|
||||||
|
for (const edge of outputEdges) {
|
||||||
|
const targetNode = nodes.value.find(n => n.id === edge.target)
|
||||||
|
if (targetNode?.type === 'image') {
|
||||||
|
if (onlyEmpty) {
|
||||||
|
// Check if target is an image node with empty or no url | 检查目标是否为空白图片节点
|
||||||
|
if (!targetNode.data?.url || targetNode.data?.url === '') {
|
||||||
|
return targetNode.id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Return any connected image node | 返回任意连接的图片节点
|
||||||
|
return targetNode.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's a connected image node with content | 检查是否有已连接且有内容的图片节点
|
||||||
|
const hasConnectedImageWithContent = computed(() => {
|
||||||
|
const outputEdges = edges.value.filter(e => e.source === props.id)
|
||||||
|
|
||||||
|
for (const edge of outputEdges) {
|
||||||
|
const targetNode = nodes.value.find(n => n.id === edge.target)
|
||||||
|
if (targetNode?.type === 'image' && targetNode.data?.url && targetNode.data.url !== '') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle generate action | 处理生成操作
|
||||||
|
// mode: 'auto' = 自动判断, 'replace' = 替换现有, 'new' = 新建节点
|
||||||
|
const handleGenerate = async (mode = 'auto') => {
|
||||||
|
const { prompt, prompts, refImages, refImagesWithOrder } = getConnectedInputs()
|
||||||
|
|
||||||
|
if (!prompt && refImages.length === 0) {
|
||||||
|
window.$message?.warning('请连接文本节点(提示词)或图片节点(参考图)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log prompt order for debugging | 记录提示词顺序用于调试
|
||||||
|
if (prompts.length > 1) {
|
||||||
|
console.log('[ImageConfigNode] 拼接提示词顺序:', prompts.map(p => `${p.order}: ${p.content.substring(0, 20)}...`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log image order for debugging | 记录图片顺序用于调试
|
||||||
|
if (refImagesWithOrder && refImagesWithOrder.length > 1) {
|
||||||
|
console.log('[ImageConfigNode] 参考图顺序:', refImagesWithOrder.map(r => `${r.order}: ${r.nodeId}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConfigured.value) {
|
||||||
|
window.$message?.warning('登录状态异常,请重新进入工作台')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageNodeId = null
|
||||||
|
|
||||||
|
if (mode === 'replace') {
|
||||||
|
// Replace mode: find any connected image node | 替换模式:查找任意连接的图片节点
|
||||||
|
imageNodeId = findConnectedOutputImageNode(false)
|
||||||
|
if (imageNodeId) {
|
||||||
|
updateNode(imageNodeId, { loading: true, url: '' })
|
||||||
|
}
|
||||||
|
} else if (mode === 'new') {
|
||||||
|
// New mode: always create new node | 新建模式:始终创建新节点
|
||||||
|
imageNodeId = null
|
||||||
|
} else {
|
||||||
|
// Auto mode: check for empty connected node first | 自动模式:先检查空白连接节点
|
||||||
|
imageNodeId = findConnectedOutputImageNode(true)
|
||||||
|
if (imageNodeId) {
|
||||||
|
updateNode(imageNodeId, { loading: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageNodeId) {
|
||||||
|
// Get current node position | 获取当前节点位置
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Calculate Y offset if creating new node alongside existing | 如果是新建节点,计算Y偏移
|
||||||
|
let yOffset = 0
|
||||||
|
if (mode === 'new') {
|
||||||
|
const outputEdges = edges.value.filter(e => e.source === props.id)
|
||||||
|
yOffset = outputEdges.length * 280 // Stack below existing outputs | 在现有输出下方堆叠
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create image node with loading state | 创建带加载状态的图片节点
|
||||||
|
imageNodeId = addNode('image', { x: nodeX + 400, y: nodeY + yOffset }, {
|
||||||
|
url: '',
|
||||||
|
loading: true,
|
||||||
|
label: '图像生成结果'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-connect imageConfig → image | 自动连接 生图配置 → 图片
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: imageNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createdImageNodeId.value = imageNodeId
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(imageNodeId)
|
||||||
|
}, 50)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build request params | 构建请求参数
|
||||||
|
const params = {
|
||||||
|
model: localModel.value,
|
||||||
|
prompt: prompt,
|
||||||
|
size: localSize.value,
|
||||||
|
quality: localQuality.value,
|
||||||
|
n: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add reference image if provided | 如果有参考图则添加
|
||||||
|
if (refImages.length > 0) {
|
||||||
|
params.image = refImages
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await generate(params)
|
||||||
|
|
||||||
|
// Update image node with generated URL | 更新图片节点 URL
|
||||||
|
if (result && result.length > 0) {
|
||||||
|
updateNode(imageNodeId, {
|
||||||
|
url: result[0].url,
|
||||||
|
loading: false,
|
||||||
|
label: '文生图',
|
||||||
|
model: localModel.value,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark this config node as executed | 标记配置节点已执行
|
||||||
|
updateNode(props.id, { executed: true, outputNodeId: imageNodeId })
|
||||||
|
}
|
||||||
|
window.$message?.success('图片生成成功')
|
||||||
|
} catch (err) {
|
||||||
|
// Update node to show error | 更新节点显示错误
|
||||||
|
updateNode(imageNodeId, {
|
||||||
|
loading: false,
|
||||||
|
error: err.message || '生成失败',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
window.$message?.error(err.message || '图片生成失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicate | 处理复制
|
||||||
|
const handleDuplicate = () => {
|
||||||
|
const newNodeId = duplicateNode(props.id)
|
||||||
|
window.$message?.success('节点已复制')
|
||||||
|
if (newNodeId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(newNodeId)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start editing label | 开始编辑 label
|
||||||
|
const startEditLabel = () => {
|
||||||
|
editingLabelValue.value = props.data?.label || ''
|
||||||
|
isEditingLabel.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
labelInputRef.value?.focus()
|
||||||
|
labelInputRef.value?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish editing label | 完成编辑 label
|
||||||
|
const finishEditLabel = () => {
|
||||||
|
const newLabel = editingLabelValue.value.trim()
|
||||||
|
if (newLabel && newLabel !== props.data?.label) {
|
||||||
|
updateNode(props.id, { label: newLabel })
|
||||||
|
}
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel editing label | 取消编辑 label
|
||||||
|
const cancelEditLabel = () => {
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delete | 处理删除
|
||||||
|
const handleDelete = () => {
|
||||||
|
removeNode(props.id)
|
||||||
|
window.$message?.success('节点已删除')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听模型变化,同步 Quality 和 Size
|
||||||
|
watch(() => props.data?.model, (newModel) => {
|
||||||
|
if (newModel && newModel !== localModel.value) {
|
||||||
|
localModel.value = newModel
|
||||||
|
const config = getModelConfig(newModel)
|
||||||
|
|
||||||
|
// 同步 Quality
|
||||||
|
if (config?.defaultParams?.quality) {
|
||||||
|
localQuality.value = config.defaultParams.quality
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步 Size
|
||||||
|
if (config?.defaultParams?.size) {
|
||||||
|
localSize.value = config.defaultParams.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 修复 Vue Flow visibility: hidden 问题
|
||||||
|
watch(() => props.data, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
updateNodeInternals(props.id)
|
||||||
|
})
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Watch for auto-execute flag | 监听自动执行标志
|
||||||
|
watch(
|
||||||
|
() => props.data?.autoExecute,
|
||||||
|
(shouldExecute) => {
|
||||||
|
if (shouldExecute && !loading.value) {
|
||||||
|
// Clear the flag first to prevent re-triggering | 先清除标志防止重复触发
|
||||||
|
updateNode(props.id, { autoExecute: false })
|
||||||
|
// Delay to ensure node connections are established | 延迟确保节点连接已建立
|
||||||
|
setTimeout(() => {
|
||||||
|
handleGenerate()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.image-config-node-wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-config-node {
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
992
web/canvas-app/src/components/nodes/ImageNode.vue
Normal file
@@ -0,0 +1,992 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Image node wrapper for hover area | 图片节点包裹层,扩展悬浮区域 -->
|
||||||
|
<div class="image-node-wrapper" @mouseenter="showActions = true; showHandleMenu = true" @mouseleave="showActions = false; showHandleMenu = false">
|
||||||
|
<!-- Image node | 图片节点 -->
|
||||||
|
<div
|
||||||
|
class="image-node bg-[var(--bg-secondary)] rounded-xl border min-w-[200px] max-w-[280px] relative transition-all duration-200"
|
||||||
|
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'">
|
||||||
|
<!-- Header | 头部 -->
|
||||||
|
<div class="px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
v-if="!isEditingLabel"
|
||||||
|
@dblclick="startEditLabel"
|
||||||
|
class="text-sm font-medium text-[var(--text-primary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
|
||||||
|
title="双击编辑名称"
|
||||||
|
>{{ data.label || '图像生成结果' }}</span>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
ref="labelInputRef"
|
||||||
|
v-model="editingLabelValue"
|
||||||
|
@blur="finishEditLabel"
|
||||||
|
@keydown.enter="finishEditLabel"
|
||||||
|
@keydown.escape="cancelEditLabel"
|
||||||
|
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-primary)] px-1 rounded outline-none border border-blue-500"
|
||||||
|
/>
|
||||||
|
<!-- Public switch | 公开开关 -->
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<button
|
||||||
|
class="flex items-center"
|
||||||
|
title="设置公开(可被 @ 引用)"
|
||||||
|
>
|
||||||
|
<n-switch
|
||||||
|
:value="isPublic"
|
||||||
|
@update:value="handleTogglePublic"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
{{ isPublic ? '已公开: ' + (data.label || '图片') : '点击公开(可被 @ 引用)' }}
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<!-- Replace button | 替换按钮 -->
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<button @click="showReplaceModal = true" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<SwapHorizontalOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
替换图片
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip v-if="data.url" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<button @click="handlePreview" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<EyeOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
预览
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip v-if="data.url" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<button @click="handleDownload" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<DownloadOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
下载
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<CopyOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
复制节点
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<TrashOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
删除节点
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Model name | 模型名称 -->
|
||||||
|
<div v-if="data.model" class="mt-1 text-xs text-[var(--text-secondary)] truncate">
|
||||||
|
{{ data.model }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image preview area | 图片预览区域 -->
|
||||||
|
<div class="p-3">
|
||||||
|
<!-- Loading state | 加载状态 -->
|
||||||
|
<div v-if="data.loading"
|
||||||
|
class="aspect-square rounded-xl bg-gradient-to-br from-cyan-400 via-blue-300 to-amber-200 flex flex-col items-center justify-center gap-3 relative overflow-hidden">
|
||||||
|
<!-- Animated gradient overlay | 动画渐变遮罩 -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-br from-cyan-500/20 via-blue-400/20 to-amber-300/20 animate-pulse">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading image | 加载图片 -->
|
||||||
|
<div class="relative z-10">
|
||||||
|
<img src="../../assets/loading.webp" alt="Loading" class="w-14 h-12" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm text-white font-medium relative z-10">创作中</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state | 错误状态 -->
|
||||||
|
<div v-else-if="data.error"
|
||||||
|
class="aspect-square rounded-xl bg-red-50 dark:bg-red-900/20 flex flex-col items-center justify-center gap-2 border border-red-200 dark:border-red-800">
|
||||||
|
<n-icon :size="32" class="text-red-500">
|
||||||
|
<CloseCircleOutline />
|
||||||
|
</n-icon>
|
||||||
|
<span class="text-sm text-red-600 dark:text-red-400 text-center px-2">{{ data.error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image display | 图片显示 -->
|
||||||
|
<div
|
||||||
|
v-else-if="data.url"
|
||||||
|
class="rounded-xl overflow-hidden relative"
|
||||||
|
ref="imageContainerRef"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="data.url"
|
||||||
|
:alt="data.label"
|
||||||
|
class="w-full h-auto object-cover"
|
||||||
|
:class="{ 'pointer-events-none': isInpaintMode }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Inpaint canvas with events | 涂抹画布(带事件) -->
|
||||||
|
<canvas
|
||||||
|
v-if="isInpaintMode"
|
||||||
|
ref="canvasRef"
|
||||||
|
class="absolute inset-0 w-full h-full cursor-none z-10"
|
||||||
|
@mousedown.stop.prevent="onCanvasPaint"
|
||||||
|
@mousemove.stop="onCanvasMove"
|
||||||
|
@mouseup.stop="onPaintEnd"
|
||||||
|
@mouseleave="onPaintEnd"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Brush cursor | 画笔光标 -->
|
||||||
|
<div
|
||||||
|
v-show="brushCursor.visible && isInpaintMode"
|
||||||
|
class="absolute pointer-events-none border-2 border-purple-500 rounded-full bg-purple-400/30 transition-none"
|
||||||
|
:style="{
|
||||||
|
width: brushSize * 2 + 'px',
|
||||||
|
height: brushSize * 2 + 'px',
|
||||||
|
left: brushCursor.x - brushSize + 'px',
|
||||||
|
top: brushCursor.y - brushSize + 'px'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Inpaint toolbar | 涂抹工具栏 -->
|
||||||
|
<div
|
||||||
|
v-show="isInpaintMode"
|
||||||
|
class="absolute top-1.5 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-2 py-1 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm rounded-full shadow-md border border-gray-200/80 dark:border-gray-700 z-[9999]"
|
||||||
|
@mousedown.stop
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<!-- Mode indicator | 模式指示 -->
|
||||||
|
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 pr-1.5 border-r border-gray-200 dark:border-gray-600">
|
||||||
|
<n-icon :size="12"><BrushOutline /></n-icon>
|
||||||
|
<span>擦除</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Size slider | 大小滑块 -->
|
||||||
|
<div class="flex items-center gap-1 w-16">
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full bg-purple-400"></div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
v-model="brushSize"
|
||||||
|
min="10"
|
||||||
|
max="80"
|
||||||
|
class="w-full h-0.5 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-purple"
|
||||||
|
/>
|
||||||
|
<div class="w-2.5 h-2.5 rounded-full bg-purple-400"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset button | 重置按钮 -->
|
||||||
|
<button
|
||||||
|
@click="clearMask"
|
||||||
|
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||||
|
title="清除"
|
||||||
|
>
|
||||||
|
<n-icon :size="12" class="text-gray-400"><RefreshOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Apply button | 应用按钮 -->
|
||||||
|
<button
|
||||||
|
@click="applyInpaint"
|
||||||
|
class="px-2 py-0.5 bg-purple-500 hover:bg-purple-600 text-white text-xs rounded transition-colors"
|
||||||
|
>
|
||||||
|
应用
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL Loading state | URL 加载状态 -->
|
||||||
|
<div v-else-if="urlLoading"
|
||||||
|
class="aspect-square rounded-xl bg-gradient-to-br from-cyan-400 via-blue-300 to-amber-200 flex flex-col items-center justify-center gap-3 relative overflow-hidden">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-br from-cyan-500/20 via-blue-400/20 to-amber-300/20 animate-pulse"></div>
|
||||||
|
<div class="relative z-10">
|
||||||
|
<img src="../../assets/loading.webp" alt="Loading" class="w-14 h-12" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-white font-medium relative z-10">加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload placeholder | 上传占位 -->
|
||||||
|
<div v-else class="rounded-xl bg-[var(--bg-tertiary)] border-2 border-dashed border-[var(--border-color)] p-3">
|
||||||
|
<!-- Upload area | 上传区域 -->
|
||||||
|
<div class="aspect-video flex flex-col items-center justify-center gap-2 relative cursor-pointer hover:bg-[var(--bg-secondary)] rounded-lg transition-colors">
|
||||||
|
<n-icon :size="32" class="text-[var(--text-secondary)]">
|
||||||
|
<ImageOutline />
|
||||||
|
</n-icon>
|
||||||
|
<span class="text-sm text-[var(--text-secondary)] text-center">拖放图片或点击上传</span>
|
||||||
|
<input type="file" accept="image/*" class="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
@change="handleFileUpload" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider | 分割线 -->
|
||||||
|
<div class="flex items-center gap-2 my-3">
|
||||||
|
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">或</span>
|
||||||
|
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL input | URL 输入 -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="urlInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="输入图片地址..."
|
||||||
|
class="flex-1 px-2 py-1 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg outline-none focus:border-[var(--accent-color)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]"
|
||||||
|
@keydown.enter="handleUrlSubmit"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="handleUrlSubmit"
|
||||||
|
:disabled="!urlInput.trim()"
|
||||||
|
class="px-3 py-2 text-xs bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||||
|
>
|
||||||
|
预览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Handles | 连接点 -->
|
||||||
|
<NodeHandleMenu :nodeId="id" nodeType="image" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
|
||||||
|
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image preview dialog | 图片预览弹窗 -->
|
||||||
|
<n-image-preview
|
||||||
|
v-model:show="showRef"
|
||||||
|
:src="props.data?.url"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Replace image modal | 替换图片弹窗 -->
|
||||||
|
<n-modal v-model:show="showReplaceModal" preset="card" title="替换图片" class="w-[400px]" :mask-closable="true">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Upload area | 上传区域 -->
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed border-[var(--border-color)] rounded-xl p-4 cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
@click="replaceFileInputRef?.click()"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<n-icon :size="32" class="text-[var(--text-secondary)]">
|
||||||
|
<ImageOutline />
|
||||||
|
</n-icon>
|
||||||
|
<span class="text-sm text-[var(--text-secondary)]">点击上传图片</span>
|
||||||
|
<input
|
||||||
|
ref="replaceFileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleReplaceFileUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider | 分割线 -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">或</span>
|
||||||
|
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL input | URL 输入 -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="replaceUrlInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="输入图片地址..."
|
||||||
|
class="flex-1 px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg outline-none focus:border-[var(--accent-color)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]"
|
||||||
|
@keydown.enter="handleReplaceUrlSubmit"
|
||||||
|
/>
|
||||||
|
<n-button type="primary" size="small" :disabled="!replaceUrlInput.trim()" @click="handleReplaceUrlSubmit">
|
||||||
|
确认
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Image node component | 图片节点组件
|
||||||
|
* Displays and manages image content with loading state
|
||||||
|
*/
|
||||||
|
import { ref, nextTick, computed } from 'vue'
|
||||||
|
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NIcon, NTooltip, NSwitch, NImagePreview, NModal, NButton } from 'naive-ui'
|
||||||
|
import { TrashOutline, ExpandOutline, ImageOutline, CloseCircleOutline, CopyOutline, VideocamOutline, DownloadOutline, EyeOutline, BrushOutline, RefreshOutline, ColorWandOutline, SwapHorizontalOutline } from '@vicons/ionicons5'
|
||||||
|
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
|
||||||
|
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Vue Flow instance | Vue Flow 实例
|
||||||
|
const { updateNodeInternals } = useVueFlow()
|
||||||
|
|
||||||
|
// Hover state | 悬浮状态
|
||||||
|
const showActions = ref(true)
|
||||||
|
const showHandleMenu = ref(false)
|
||||||
|
|
||||||
|
// Label editing state | Label 编辑状态
|
||||||
|
const isEditingLabel = ref(false)
|
||||||
|
const editingLabelValue = ref('')
|
||||||
|
const labelInputRef = ref(null)
|
||||||
|
|
||||||
|
// URL input state | URL 输入状态
|
||||||
|
const urlInput = ref('')
|
||||||
|
const urlLoading = ref(false)
|
||||||
|
|
||||||
|
// Replace modal state | 替换弹窗状态
|
||||||
|
const showReplaceModal = ref(false)
|
||||||
|
const replaceUrlInput = ref('')
|
||||||
|
const replaceFileInputRef = ref(null)
|
||||||
|
|
||||||
|
// Inpainting state | 涂抹重绘状态
|
||||||
|
const isInpaintMode = ref(false)
|
||||||
|
const brushSize = ref(40)
|
||||||
|
const isDrawing = ref(false)
|
||||||
|
const canvasRef = ref(null)
|
||||||
|
const imageContainerRef = ref(null)
|
||||||
|
const interactionLayerRef = ref(null)
|
||||||
|
const brushCursor = ref({ x: 0, y: 0, visible: false })
|
||||||
|
const maskData = ref(null)
|
||||||
|
|
||||||
|
|
||||||
|
// Computed public props status | 计算是否公开
|
||||||
|
const isPublic = computed(() => {
|
||||||
|
return props.data?.publicProps?.name != null && props.data?.publicProps?.name !== ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle toggle public | 处理切换公开状态
|
||||||
|
const handleTogglePublic = (value) => {
|
||||||
|
if (value) {
|
||||||
|
// 公开:使用节点名称
|
||||||
|
const name = props.data?.label || '图片'
|
||||||
|
updateNode(props.id, {
|
||||||
|
publicProps: { name }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 取消公开
|
||||||
|
updateNode(props.id, {
|
||||||
|
publicProps: {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image node menu operations | 图片节点菜单操作
|
||||||
|
const operations = [
|
||||||
|
{ type: 'imageConfig', label: '图生图', icon: ImageOutline, action: 'image_imageConfig' },
|
||||||
|
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline, action: 'image_videoConfig' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Handle menu select | 处理菜单选择
|
||||||
|
const handleSelect = (item) => {
|
||||||
|
const action = item.action
|
||||||
|
|
||||||
|
if (action === 'image_imageConfig') {
|
||||||
|
// Image-to-image workflow | 图生图工作流
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
const sourceUrl = currentNode?.data?.url
|
||||||
|
|
||||||
|
if (!sourceUrl) {
|
||||||
|
window.$message?.warning('当前图片节点没有图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create text node for prompt
|
||||||
|
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
|
||||||
|
content: '',
|
||||||
|
label: '提示词'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create imageConfig node
|
||||||
|
const configNodeId = addNode('imageConfig', { x: nodeX + 900, y: nodeY }, {
|
||||||
|
model: 'doubao-seedream-4-5-251128',
|
||||||
|
size: '2048x2048',
|
||||||
|
label: '生图配置'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect edges
|
||||||
|
addEdge({ source: props.id, target: configNodeId, sourceHandle: 'right', targetHandle: 'left' })
|
||||||
|
addEdge({ source: textNodeId, target: configNodeId, sourceHandle: 'right', targetHandle: 'left' })
|
||||||
|
|
||||||
|
setTimeout(() => updateNodeInternals([textNodeId, configNodeId]), 50)
|
||||||
|
window.$message?.success('已创建图生图工作流')
|
||||||
|
} else if (action === 'image_videoConfig') {
|
||||||
|
// Video generation workflow | 视频生成工作流
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create text node for prompt
|
||||||
|
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
|
||||||
|
content: '',
|
||||||
|
label: '提示词'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create videoConfig node
|
||||||
|
const configNodeId = addNode('videoConfig', { x: nodeX + 600, y: nodeY }, {
|
||||||
|
label: '视频生成'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect image to videoConfig
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left',
|
||||||
|
type: 'imageRole',
|
||||||
|
data: { imageRole: 'first_frame_image' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect text to videoConfig
|
||||||
|
addEdge({
|
||||||
|
source: textNodeId,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => updateNodeInternals([textNodeId, configNodeId]), 50)
|
||||||
|
window.$message?.success('已创建视频生成工作流')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle inpaint mode | 切换涂抹模式
|
||||||
|
const toggleInpaintMode = () => {
|
||||||
|
isInpaintMode.value = !isInpaintMode.value
|
||||||
|
if (isInpaintMode.value) {
|
||||||
|
nextTick(() => initCanvas())
|
||||||
|
} else {
|
||||||
|
clearMask()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize canvas | 初始化画布
|
||||||
|
const initCanvas = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
// Set canvas internal size to match its CSS rendered size | 设置画布内部尺寸匹配 CSS 渲染尺寸
|
||||||
|
// clientWidth/clientHeight give the CSS box size
|
||||||
|
canvas.width = canvas.clientWidth
|
||||||
|
canvas.height = canvas.clientHeight
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure canvas size matches display | 确保画布尺寸匹配显示
|
||||||
|
const syncCanvasSize = () => {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
if (canvas.width !== canvas.clientWidth || canvas.height !== canvas.clientHeight) {
|
||||||
|
canvas.width = canvas.clientWidth
|
||||||
|
canvas.height = canvas.clientHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas paint handlers | 画布绘制处理器
|
||||||
|
const onCanvasPaint = (e) => {
|
||||||
|
syncCanvasSize()
|
||||||
|
isDrawing.value = true
|
||||||
|
paintAt(e.offsetX, e.offsetY)
|
||||||
|
brushCursor.value = { x: e.offsetX, y: e.offsetY, visible: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCanvasMove = (e) => {
|
||||||
|
brushCursor.value = { x: e.offsetX, y: e.offsetY, visible: true }
|
||||||
|
if (isDrawing.value) {
|
||||||
|
paintAt(e.offsetX, e.offsetY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPaintEnd = () => {
|
||||||
|
isDrawing.value = false
|
||||||
|
brushCursor.value.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paint at coordinates | 在坐标绘制
|
||||||
|
const paintAt = (x, y) => {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(x, y, brushSize.value, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = 'rgba(139, 92, 246, 0.5)'
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide brush cursor | 隐藏画笔光标
|
||||||
|
const hideBrushCursor = () => {
|
||||||
|
brushCursor.value.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear mask | 清除蒙版
|
||||||
|
const clearMask = () => {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
maskData.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply inpaint and create workflow | 应用重绘并创建工作流
|
||||||
|
const applyInpaint = () => {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas || canvas.width === 0 || canvas.height === 0) {
|
||||||
|
window.$message?.error('画布未初始化')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the original image and resize mask to match | 获取原图并调整蒙版大小匹配
|
||||||
|
const container = imageContainerRef.value
|
||||||
|
const img = container?.querySelector('img')
|
||||||
|
if (!img) {
|
||||||
|
window.$message?.error('未找到图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mask at original image resolution | 创建原图分辨率的蒙版
|
||||||
|
const maskCanvas = document.createElement('canvas')
|
||||||
|
const imgWidth = img.naturalWidth || img.width
|
||||||
|
const imgHeight = img.naturalHeight || img.height
|
||||||
|
maskCanvas.width = imgWidth
|
||||||
|
maskCanvas.height = imgHeight
|
||||||
|
const maskCtx = maskCanvas.getContext('2d')
|
||||||
|
|
||||||
|
// Fill black background | 填充黑色背景
|
||||||
|
maskCtx.fillStyle = '#000000'
|
||||||
|
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height)
|
||||||
|
|
||||||
|
// Scale factor from display to original | 从显示尺寸到原图的缩放因子
|
||||||
|
const scaleX = imgWidth / canvas.width
|
||||||
|
const scaleY = imgHeight / canvas.height
|
||||||
|
|
||||||
|
// Get painted areas and scale to original resolution | 获取绑制区域并缩放到原图分辨率
|
||||||
|
const originalData = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Draw scaled white areas on mask | 在蒙版上绘制缩放后的白色区域
|
||||||
|
maskCtx.fillStyle = '#FFFFFF'
|
||||||
|
for (let y = 0; y < canvas.height; y++) {
|
||||||
|
for (let x = 0; x < canvas.width; x++) {
|
||||||
|
const i = (y * canvas.width + x) * 4
|
||||||
|
if (originalData.data[i + 3] > 0) {
|
||||||
|
// Scale and draw | 缩放并绘制
|
||||||
|
maskCtx.fillRect(
|
||||||
|
Math.floor(x * scaleX),
|
||||||
|
Math.floor(y * scaleY),
|
||||||
|
Math.ceil(scaleX),
|
||||||
|
Math.ceil(scaleY)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to base64 (remove data URL prefix for API) | 转换为 base64(移除前缀用于 API)
|
||||||
|
const dataUrl = maskCanvas.toDataURL('image/png')
|
||||||
|
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '')
|
||||||
|
maskData.value = base64Data
|
||||||
|
|
||||||
|
// Create inpaint workflow | 创建重绘工作流
|
||||||
|
createInpaintWorkflow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inpaint workflow | 创建重绘工作流
|
||||||
|
const createInpaintWorkflow = () => {
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create text node for prompt | 创建文本节点用于提示词
|
||||||
|
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
|
||||||
|
content: '请输入重绘提示词...',
|
||||||
|
label: '重绘提示词'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create imageConfig node for inpainting | 创建图生图配置节点
|
||||||
|
const configNodeId = addNode('imageConfig', { x: nodeX + 600, y: nodeY }, {
|
||||||
|
model: 'doubao-seedream-4-5-251128',
|
||||||
|
size: '2048x2048',
|
||||||
|
label: '局部重绘',
|
||||||
|
inpaintMode: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update current node with mask data | 更新当前节点的蒙版数据
|
||||||
|
updateNode(props.id, {
|
||||||
|
maskData: maskData.value,
|
||||||
|
hasInpaintMask: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect image node to config node | 连接图片节点到配置节点
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect text node to config node | 连接文本节点到配置节点
|
||||||
|
addEdge({
|
||||||
|
source: textNodeId,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Exit inpaint mode | 退出涂抹模式
|
||||||
|
isInpaintMode.value = false
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate | 强制重新计算
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals([textNodeId, configNodeId])
|
||||||
|
}, 50)
|
||||||
|
|
||||||
|
window.$message?.success('已创建局部重绘工作流')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert file to base64 | 将文件转换为 base64
|
||||||
|
const fileToBase64 = (file) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result)
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file upload | 处理文件上传
|
||||||
|
const handleFileUpload = async (event) => {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (file) {
|
||||||
|
try {
|
||||||
|
// Convert to base64 | 转换为 base64
|
||||||
|
const base64 = await fileToBase64(file)
|
||||||
|
// Store both display URL and base64 | 同时存储显示 URL 和 base64
|
||||||
|
updateNode(props.id, {
|
||||||
|
url: base64, // Use base64 as display URL | 使用 base64 作为显示 URL
|
||||||
|
base64: base64, // Store base64 for API calls | 存储 base64 用于 API 调用
|
||||||
|
fileName: file.name,
|
||||||
|
fileType: file.type,
|
||||||
|
label: '参考图',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('File upload error:', err)
|
||||||
|
window.$message?.error('图片上传失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle URL submit | 处理 URL 提交
|
||||||
|
const handleUrlSubmit = () => {
|
||||||
|
const url = urlInput.value.trim()
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
// Validate URL format | 验证 URL 格式
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
window.$message?.warning('请输入有效的图片地址 (http:// 或 https://)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state | 显示加载状态
|
||||||
|
urlLoading.value = true
|
||||||
|
|
||||||
|
// Preload image to check validity | 预加载图片检查有效性
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
// Update node with URL | 更新节点 URL
|
||||||
|
updateNode(props.id, {
|
||||||
|
url: url,
|
||||||
|
label: '网络图片',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
urlInput.value = ''
|
||||||
|
urlLoading.value = false
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
window.$message?.error('图片加载失败,请检查地址是否正确')
|
||||||
|
urlLoading.value = false
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Handle replace file upload | 处理替换文件上传
|
||||||
|
const handleReplaceFileUpload = async (event) => {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (file) {
|
||||||
|
try {
|
||||||
|
const base64 = await fileToBase64(file)
|
||||||
|
updateNode(props.id, {
|
||||||
|
url: base64,
|
||||||
|
base64: base64,
|
||||||
|
fileName: file.name,
|
||||||
|
fileType: file.type,
|
||||||
|
label: '参考图',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
showReplaceModal.value = false
|
||||||
|
replaceUrlInput.value = ''
|
||||||
|
window.$message?.success('图片已替换')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('File upload error:', err)
|
||||||
|
window.$message?.error('图片上传失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle replace URL submit | 处理替换 URL 提交
|
||||||
|
const handleReplaceUrlSubmit = () => {
|
||||||
|
const url = replaceUrlInput.value.trim()
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
window.$message?.warning('请输入有效的图片地址 (http:// 或 https://)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
updateNode(props.id, {
|
||||||
|
url: url,
|
||||||
|
label: '网络图片',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
showReplaceModal.value = false
|
||||||
|
replaceUrlInput.value = ''
|
||||||
|
window.$message?.success('图片已替换')
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
window.$message?.error('图片加载失败,请检查地址是否正确')
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start editing label | 开始编辑 label
|
||||||
|
const startEditLabel = () => {
|
||||||
|
editingLabelValue.value = props.data?.label || '图像生成结果'
|
||||||
|
isEditingLabel.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
labelInputRef.value?.focus()
|
||||||
|
labelInputRef.value?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish editing label | 完成编辑 label
|
||||||
|
const finishEditLabel = () => {
|
||||||
|
const newLabel = editingLabelValue.value.trim()
|
||||||
|
if (newLabel && newLabel !== props.data?.label) {
|
||||||
|
updateNode(props.id, { label: newLabel })
|
||||||
|
}
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel editing label | 取消编辑 label
|
||||||
|
const cancelEditLabel = () => {
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delete | 处理删除
|
||||||
|
const handleDelete = () => {
|
||||||
|
removeNode(props.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicate | 处理复制
|
||||||
|
const handleDuplicate = () => {
|
||||||
|
const newId = duplicateNode(props.id)
|
||||||
|
if (newId) {
|
||||||
|
// Clear selection and select the new node | 清除选中并选中新节点
|
||||||
|
updateNode(props.id, { selected: false })
|
||||||
|
updateNode(newId, { selected: true })
|
||||||
|
window.$message?.success('节点已复制')
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(newId)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle image generation | 处理图片生图(图生图)
|
||||||
|
const handleImageGen = () => {
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create text node for prompt | 创建文本节点用于提示词
|
||||||
|
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
|
||||||
|
content: '',
|
||||||
|
label: '提示词'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create ImageNode for editing | 创建图片编辑节点
|
||||||
|
const imageNodeId = addNode('image', { x: nodeX + 600, y: nodeY }, {
|
||||||
|
url: props.data.url, // Pass the current image as input
|
||||||
|
label: '图生图',
|
||||||
|
refImage: props.data.url // Mark as reference image
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create imageConfig node for generation | 创建生图配置节点
|
||||||
|
const configNodeId = addNode('imageConfig', { x: nodeX + 900, y: nodeY }, {
|
||||||
|
model: 'doubao-seedream-4-5-251128',
|
||||||
|
size: '2048x2048',
|
||||||
|
label: '生图配置'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect image node to new image node | 连接当前图片节点到新图片节点
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: imageNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect new image node to config node | 连接新图片节点到配置节点
|
||||||
|
addEdge({
|
||||||
|
source: imageNodeId,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect text node to config node | 连接文本节点到配置节点
|
||||||
|
addEdge({
|
||||||
|
source: textNodeId,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals([textNodeId, imageNodeId, configNodeId])
|
||||||
|
}, 50)
|
||||||
|
|
||||||
|
window.$message?.success('已创建图生图工作流')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview state | 预览状态
|
||||||
|
const showRef = ref(false)
|
||||||
|
|
||||||
|
// Handle preview | 处理预览
|
||||||
|
const handlePreview = () => {
|
||||||
|
if (props.data.url) {
|
||||||
|
showRef.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle download | 处理下载
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (props.data.url) {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = props.data.url
|
||||||
|
link.download = props.data.fileName || `image_${Date.now()}.png`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.$message?.success('图片下载中...')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle video generation | 处理视频生成
|
||||||
|
const handleVideoGen = () => {
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create text node for prompt | 创建文本节点用于提示词
|
||||||
|
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
|
||||||
|
content: '',
|
||||||
|
label: '提示词'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create videoConfig node | 创建视频配置节点
|
||||||
|
const configNodeId = addNode('videoConfig', { x: nodeX + 600, y: nodeY }, {
|
||||||
|
label: '视频生成'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect image node to config node with role | 连接图片节点到配置节点并设置角色
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left',
|
||||||
|
type: 'imageRole',
|
||||||
|
data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect text node to config node | 连接文本节点到配置节点
|
||||||
|
addEdge({
|
||||||
|
source: textNodeId,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals([textNodeId, configNodeId])
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.image-node-wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding-right: 50px;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-node {
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slider styling | 滑块样式 */
|
||||||
|
.slider-purple::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #8b5cf6;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-purple::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #8b5cf6;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inpaint mode cursor | 涂抹模式光标 */
|
||||||
|
.cursor-none {
|
||||||
|
cursor: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1216
web/canvas-app/src/components/nodes/LLMConfigNode.vue
Normal file
232
web/canvas-app/src/components/nodes/NodeHandleMenu.vue
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Right handle with expandable menu | 右侧连接点带展开菜单 -->
|
||||||
|
<div class="handle-menu-anchor">
|
||||||
|
<!-- Vue Flow handle for edge connections - visible and draggable | 可见且可拖拽的 Vue Flow 连接点 -->
|
||||||
|
<Handle type="source" :position="Position.Right" id="right" style="width: 12px; height: 12px;" />
|
||||||
|
|
||||||
|
<!-- Hover zone with + icon | 带 + 图标的悬浮区域 -->
|
||||||
|
<div v-if="true && showHandleHoverZone" class="handle-hover-zone"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
|
@mouseleave="handleMouseLeave">
|
||||||
|
<n-icon :size="14" class="add-icon">
|
||||||
|
<AddOutline />
|
||||||
|
</n-icon>
|
||||||
|
<transition name="menu-fade">
|
||||||
|
<div v-if="showMenu" class="handle-menu"
|
||||||
|
@mouseenter="handleMenuMouseEnter"
|
||||||
|
@mouseleave="handleMenuMouseLeave"
|
||||||
|
@mousedown.stop>
|
||||||
|
<button v-for="item in menuItems" :key="item.type" @click.stop="handleCreate(item)" class="menu-item group">
|
||||||
|
<n-icon :size="14" class="text-gray-500 group-hover:text-white">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
</n-icon>
|
||||||
|
<span class="menu-label">{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { Handle, Position } from '@vue-flow/core'
|
||||||
|
import { NIcon } from 'naive-ui'
|
||||||
|
import { AddOutline } from '@vicons/ionicons5'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
nodeId: { type: String, required: true },
|
||||||
|
nodeType: { type: String, required: true },
|
||||||
|
visible: { type: Boolean },
|
||||||
|
dotColor: { type: String, default: 'var(--accent-color)' },
|
||||||
|
operations: { type: Array, default: null } // 传空数组则不显示 handle-hover-zone
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emit select event to parent component | 向父组件发送选择事件
|
||||||
|
const emit = defineEmits(['select'])
|
||||||
|
|
||||||
|
const showMenu = ref(false)
|
||||||
|
let hideTimeout = null
|
||||||
|
|
||||||
|
// Handle mouse enter with delay cancellation
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (hideTimeout) {
|
||||||
|
clearTimeout(hideTimeout)
|
||||||
|
hideTimeout = null
|
||||||
|
}
|
||||||
|
showMenu.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle mouse leave with delay
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
hideTimeout = setTimeout(() => {
|
||||||
|
showMenu.value = false
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle menu mouse enter - cancel hide timeout
|
||||||
|
const handleMenuMouseEnter = () => {
|
||||||
|
if (hideTimeout) {
|
||||||
|
clearTimeout(hideTimeout)
|
||||||
|
hideTimeout = null
|
||||||
|
}
|
||||||
|
showMenu.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle menu mouse leave with delay
|
||||||
|
const handleMenuMouseLeave = () => {
|
||||||
|
hideTimeout = setTimeout(() => {
|
||||||
|
showMenu.value = false
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu items from operations prop | 从 operations prop 获取菜单项
|
||||||
|
const menuItems = computed(() => {
|
||||||
|
return props.operations || []
|
||||||
|
})
|
||||||
|
|
||||||
|
// Whether to show handle-hover-zone | 是否显示 handle-hover-zone
|
||||||
|
const showHandleHoverZone = computed(() => {
|
||||||
|
return props.operations && props.operations.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emit select event to parent component | 向父组件发送选择事件
|
||||||
|
const handleCreate = (item) => {
|
||||||
|
emit('select', item)
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Anchor sits at the right edge center of the parent node | 锚点在父节点右边缘中心 */
|
||||||
|
.handle-menu-anchor {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(50%, -50%);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover zone - hidden by default, show on anchor hover | 默认隐藏,锚点 hover 时显示 */
|
||||||
|
.handle-hover-zone {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: -30px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary, #2a2a3e);
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
opacity: 1;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show hover zone when anchor is hovered | 锚点 hover 时显示悬浮区域 */
|
||||||
|
.handle-menu-anchor:hover .handle-hover-zone {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-hover-zone:hover {
|
||||||
|
background: var(--accent-color, #8b5cf6);
|
||||||
|
border-color: var(--accent-color, #8b5cf6);
|
||||||
|
transform: translate(-50%, -50%) scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add icon | 添加图标 */
|
||||||
|
.add-icon {
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-hover-zone:hover .add-icon {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visible dot | 可见圆点 */
|
||||||
|
.handle-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.6);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-dot.is-active {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
box-shadow: 0 0 8px rgba(139, 92, 246, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu floats to the right of the dot | 菜单浮在圆点右侧 */
|
||||||
|
.handle-menu {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 8px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--bg-secondary, #1e1e2e);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background: var(--accent-color, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu divider | 菜单分隔线 */
|
||||||
|
.menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color, #333);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation | 动画 */
|
||||||
|
.menu-fade-enter-active,
|
||||||
|
.menu-fade-leave-active {
|
||||||
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-fade-enter-from,
|
||||||
|
.menu-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-4px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
856
web/canvas-app/src/components/nodes/TextNode.vue
Normal file
@@ -0,0 +1,856 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Text node wrapper | 文本节点包裹层 -->
|
||||||
|
<div class="text-node-wrapper" @mouseenter="showHandleMenu = true" @mouseleave="showHandleMenu = false">
|
||||||
|
<!-- Text node | 文本节点 -->
|
||||||
|
<div
|
||||||
|
class="text-node bg-[var(--bg-secondary)] rounded-xl border min-w-[280px] max-w-[350px] relative transition-all duration-200"
|
||||||
|
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'">
|
||||||
|
<!-- Header | 头部 -->
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<span
|
||||||
|
v-if="!isEditingLabel"
|
||||||
|
@dblclick="startEditLabel"
|
||||||
|
class="text-sm font-medium text-[var(--text-secondary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
|
||||||
|
title="双击编辑名称"
|
||||||
|
>{{ data.label }}</span>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
ref="labelInputRef"
|
||||||
|
v-model="editingLabelValue"
|
||||||
|
@blur="finishEditLabel"
|
||||||
|
@keydown.enter="finishEditLabel"
|
||||||
|
@keydown.escape="cancelEditLabel"
|
||||||
|
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] px-1 rounded outline-none border border-blue-500"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<CopyOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<TrashOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
<!-- <button class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="展开">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<ExpandOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content | 内容 -->
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="textarea-wrapper" ref="textareaWrapper">
|
||||||
|
<!-- 可编辑的文本区域(支持 @ 引用图片显示)参考 MaterialInput -->
|
||||||
|
<div
|
||||||
|
ref="editorRef"
|
||||||
|
class="editor-content"
|
||||||
|
contenteditable="true"
|
||||||
|
@input="handleInput"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
@paste="handlePaste"
|
||||||
|
@blur="updateContent"
|
||||||
|
@wheel.stop
|
||||||
|
@mousedown.stop
|
||||||
|
:data-placeholder="placeholder"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<!-- Polish button | 润色按钮 -->
|
||||||
|
<button
|
||||||
|
@click="handlePolish"
|
||||||
|
:disabled="isPolishing || !plainText.trim()"
|
||||||
|
class="mt-2 px-3 py-1.5 text-xs rounded-lg bg-[var(--bg-tertiary)] hover:bg-[var(--accent-color)] hover:text-white border border-[var(--border-color)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<n-spin v-if="isPolishing" :size="12" />
|
||||||
|
<span v-else>✨</span>
|
||||||
|
AI 润色
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Handles | 连接点 -->
|
||||||
|
<NodeHandleMenu :nodeId="id" nodeType="text" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
|
||||||
|
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mentions picker | @ 选择器 -->
|
||||||
|
<MentionsPicker
|
||||||
|
v-model:visible="showMentionsPicker"
|
||||||
|
:position="mentionsPosition"
|
||||||
|
context="text"
|
||||||
|
@select="handleMentionSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Text node component | 文本节点组件
|
||||||
|
* Allows user to input and edit text content
|
||||||
|
*/
|
||||||
|
import { ref, watch, nextTick, computed, onMounted } from 'vue'
|
||||||
|
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NIcon, NSpin } from 'naive-ui'
|
||||||
|
import { TrashOutline, ExpandOutline, CopyOutline, ImageOutline, VideocamOutline, ChatbubbleOutline, CreateOutline } from '@vicons/ionicons5'
|
||||||
|
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
|
||||||
|
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||||||
|
import MentionsPicker from '../MentionsPicker.vue'
|
||||||
|
import { useChat } from '../../hooks'
|
||||||
|
import { useModelStore } from '../../stores/pinia'
|
||||||
|
import { parseMentions } from '../../hooks/useNodeRef'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Vue Flow instance | Vue Flow 实例
|
||||||
|
const { updateNodeInternals } = useVueFlow()
|
||||||
|
|
||||||
|
// API config state | API 配置状态
|
||||||
|
const modelStore = useModelStore()
|
||||||
|
const isApiConfigured = computed(() => !!modelStore.currentApiKey)
|
||||||
|
|
||||||
|
// Chat hook for polish | 润色用的 Chat hook
|
||||||
|
const { send: sendChat } = useChat({
|
||||||
|
systemPrompt: '你是一个专业的AI绘画提示词专家。将用户输入的内容美化成高质量的生图提示词,包含风格、光线、構图、细节等要素。直接返回提示词,不要其他解释。',
|
||||||
|
model: 'gpt-4o-mini'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Local content state | 本地内容状态
|
||||||
|
const showHandleMenu = ref(false)
|
||||||
|
const content = ref(props.data?.content || '')
|
||||||
|
const placeholder = '请输入文本内容,输入 @ 可引用图片节点...'
|
||||||
|
|
||||||
|
// Label editing state | Label 编辑状态
|
||||||
|
const isEditingLabel = ref(false)
|
||||||
|
const editingLabelValue = ref('')
|
||||||
|
const labelInputRef = ref(null)
|
||||||
|
|
||||||
|
// Polish loading state | 润色加载状态
|
||||||
|
const isPolishing = ref(false)
|
||||||
|
|
||||||
|
// Mentions picker state | @ 选择器状态
|
||||||
|
const showMentionsPicker = ref(false)
|
||||||
|
const mentionsPosition = ref({ x: 0, y: 0 })
|
||||||
|
const editorRef = ref(null)
|
||||||
|
const textareaWrapper = ref(null)
|
||||||
|
const mentionSearchStart = ref(-1) // @ 触发搜索的起始位置
|
||||||
|
const lastContent = ref('') // 上一次的内容,用于检测变化
|
||||||
|
|
||||||
|
// ============ 参考 MaterialInput 的逻辑 ============
|
||||||
|
|
||||||
|
// 从 contenteditable 中提取纯文本(将 chip 转为 @label)
|
||||||
|
const getEditableText = () => {
|
||||||
|
const el = editorRef.value
|
||||||
|
if (!el) return ''
|
||||||
|
let text = ''
|
||||||
|
const walk = (node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
text += node.textContent
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
if (node.classList?.contains('mention-chip')) {
|
||||||
|
text += `@[${node.dataset.nodeId}]`
|
||||||
|
} else if (node.tagName === 'BR') {
|
||||||
|
text += '\n'
|
||||||
|
} else {
|
||||||
|
node.childNodes.forEach(walk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.childNodes.forEach(walk)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 DOM 光标位置计算纯文本中的位置(考虑 mention-chip 的转换)
|
||||||
|
const getTextPositionBeforeCursor = (editor, range) => {
|
||||||
|
const container = editor
|
||||||
|
let textLength = 0
|
||||||
|
let found = false
|
||||||
|
|
||||||
|
const walk = (node) => {
|
||||||
|
if (found) return
|
||||||
|
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const nodeLength = node.textContent.length
|
||||||
|
if (range.startContainer === node) {
|
||||||
|
textLength += range.startOffset
|
||||||
|
found = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
textLength += nodeLength
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
if (node.classList?.contains('mention-chip')) {
|
||||||
|
// mention-chip 在纯文本中算作 @[nodeId]
|
||||||
|
const replacement = `@[${node.dataset.nodeId || ''}]`
|
||||||
|
if (range.startContainer === node || isNodeInside(node, range.startContainer)) {
|
||||||
|
// 光标在 mention-chip 内部
|
||||||
|
found = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
textLength += replacement.length
|
||||||
|
} else if (node.tagName === 'BR') {
|
||||||
|
textLength += 1
|
||||||
|
} else {
|
||||||
|
for (const child of node.childNodes) {
|
||||||
|
walk(child)
|
||||||
|
if (found) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(container)
|
||||||
|
return textLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查节点是否在父节点内部
|
||||||
|
const isNodeInside = (parent, child) => {
|
||||||
|
let node = child
|
||||||
|
while (node) {
|
||||||
|
if (node === parent) return true
|
||||||
|
node = node.parentNode
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 mention chip 元素
|
||||||
|
const createMentionChip = (node) => {
|
||||||
|
const chip = document.createElement('span')
|
||||||
|
chip.className = 'mention-chip'
|
||||||
|
chip.contentEditable = 'false'
|
||||||
|
chip.dataset.nodeId = node.id
|
||||||
|
chip.dataset.label = node.data?.publicProps?.name || node.data?.label || '图片'
|
||||||
|
|
||||||
|
if (node.data?.url) {
|
||||||
|
const img = document.createElement('img')
|
||||||
|
img.src = node.data.url
|
||||||
|
img.className = 'mention-chip-thumb'
|
||||||
|
chip.appendChild(img)
|
||||||
|
} else {
|
||||||
|
const iconWrap = document.createElement('span')
|
||||||
|
iconWrap.className = 'mention-chip-icon'
|
||||||
|
iconWrap.textContent = '📷'
|
||||||
|
chip.appendChild(iconWrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = document.createElement('span')
|
||||||
|
label.className = 'mention-chip-label'
|
||||||
|
label.textContent = chip.dataset.label
|
||||||
|
chip.appendChild(label)
|
||||||
|
|
||||||
|
return chip
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 contenteditable 中插入 mention chip(替换 @searchText)
|
||||||
|
const insertMentionChipDOM = (node) => {
|
||||||
|
const el = editorRef.value
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
// 遍历文本节点,找到最后一个 @
|
||||||
|
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||||
|
let lastAtNode = null
|
||||||
|
let lastAtOffset = -1
|
||||||
|
|
||||||
|
while (walker.nextNode()) {
|
||||||
|
const idx = walker.currentNode.textContent.lastIndexOf('@')
|
||||||
|
if (idx !== -1) {
|
||||||
|
lastAtNode = walker.currentNode
|
||||||
|
lastAtOffset = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastAtNode || lastAtOffset === -1) return
|
||||||
|
|
||||||
|
const chip = createMentionChip(node)
|
||||||
|
const spaceNode = document.createTextNode('\u00A0')
|
||||||
|
const beforeText = lastAtNode.textContent.substring(0, lastAtOffset)
|
||||||
|
|
||||||
|
if (beforeText) {
|
||||||
|
lastAtNode.textContent = beforeText
|
||||||
|
lastAtNode.parentNode.insertBefore(chip, lastAtNode.nextSibling)
|
||||||
|
lastAtNode.parentNode.insertBefore(spaceNode, chip.nextSibling)
|
||||||
|
} else {
|
||||||
|
const parent = lastAtNode.parentNode
|
||||||
|
parent.insertBefore(chip, lastAtNode)
|
||||||
|
parent.insertBefore(spaceNode, chip.nextSibling)
|
||||||
|
parent.removeChild(lastAtNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 光标移到空格之后
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStartAfter(spaceNode)
|
||||||
|
range.collapse(true)
|
||||||
|
const sel = window.getSelection()
|
||||||
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(range)
|
||||||
|
|
||||||
|
// 同步文本
|
||||||
|
isInternalUpdate = true
|
||||||
|
content.value = getEditableText()
|
||||||
|
lastContent.value = content.value
|
||||||
|
nextTick(() => { isInternalUpdate = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 contenteditable 内容(纯文本)
|
||||||
|
const setEditableContent = (text) => {
|
||||||
|
if (!editorRef.value) return
|
||||||
|
editorRef.value.innerHTML = ''
|
||||||
|
if (text) {
|
||||||
|
editorRef.value.textContent = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描 contenteditable 文本节点,将 @label 或 @[nodeId] 自动转为 chip
|
||||||
|
const convertTextMentionsToChips = () => {
|
||||||
|
const el = editorRef.value
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
// 获取所有可引用的图片节点(需要公开的)
|
||||||
|
const imageNodes = nodes.value.filter(n => n.type === 'image' && n.data?.publicProps?.name)
|
||||||
|
if (imageNodes.length === 0) return
|
||||||
|
|
||||||
|
// 快速检查:无 @ 直接跳过
|
||||||
|
if (!el.textContent.includes('@')) return
|
||||||
|
|
||||||
|
// 优先匹配 @[nodeId] 格式
|
||||||
|
const nodeIdPattern = /@\[([^\]|]+)(?:\|([^\]]+))?\]/g
|
||||||
|
|
||||||
|
// 收集需要替换的文本节点(跳过 chip 内部)
|
||||||
|
const targets = []
|
||||||
|
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||||
|
while (walker.nextNode()) {
|
||||||
|
const node = walker.currentNode
|
||||||
|
if (node.parentElement?.closest('.mention-chip')) continue
|
||||||
|
nodeIdPattern.lastIndex = 0
|
||||||
|
if (nodeIdPattern.test(node.textContent)) {
|
||||||
|
targets.push(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targets.length === 0) return
|
||||||
|
|
||||||
|
// 替换文本节点为 chip + 文本片段
|
||||||
|
targets.forEach(textNode => {
|
||||||
|
const text = textNode.textContent
|
||||||
|
nodeIdPattern.lastIndex = 0
|
||||||
|
const fragment = document.createDocumentFragment()
|
||||||
|
let lastIdx = 0
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = nodeIdPattern.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIdx) {
|
||||||
|
fragment.appendChild(document.createTextNode(text.slice(lastIdx, match.index)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过 nodeId 查找节点
|
||||||
|
const nodeId = match[1]
|
||||||
|
const node = imageNodes.find(n => n.id === nodeId)
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
fragment.appendChild(createMentionChip(node))
|
||||||
|
fragment.appendChild(document.createTextNode('\u00A0'))
|
||||||
|
} else {
|
||||||
|
fragment.appendChild(document.createTextNode(match[0]))
|
||||||
|
}
|
||||||
|
lastIdx = nodeIdPattern.lastIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIdx < text.length) {
|
||||||
|
fragment.appendChild(document.createTextNode(text.slice(lastIdx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
textNode.parentNode.replaceChild(fragment, textNode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防抖版本(用于输入事件,避免频繁 DOM 操作)
|
||||||
|
let _convertTimer = null
|
||||||
|
const debouncedConvertMentions = () => {
|
||||||
|
if (_convertTimer) clearTimeout(_convertTimer)
|
||||||
|
_convertTimer = setTimeout(convertTextMentionsToChips, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聚焦 contenteditable 并将光标移到末尾
|
||||||
|
const focusEditableEnd = () => {
|
||||||
|
const el = editorRef.value
|
||||||
|
if (!el) return
|
||||||
|
el.focus()
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(el)
|
||||||
|
range.collapse(false)
|
||||||
|
const sel = window.getSelection()
|
||||||
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(range)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle paste - 参考 MaterialInput,纯文本粘贴
|
||||||
|
const handlePaste = (e) => {
|
||||||
|
// 纯文本粘贴(防止粘入富文本)
|
||||||
|
e.preventDefault()
|
||||||
|
const text = e.clipboardData?.getData('text/plain') || ''
|
||||||
|
document.execCommand('insertText', false, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内部更新标志
|
||||||
|
let isInternalUpdate = false
|
||||||
|
|
||||||
|
// @ 提及预览列表(已移除,改为在 editor 中直接显示)
|
||||||
|
|
||||||
|
// 获取纯文本(用于 AI 润色)
|
||||||
|
const plainText = computed(() => {
|
||||||
|
return content.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 将 @[nodeId] 转换为带图片的 HTML
|
||||||
|
const editorHtml = computed(() => {
|
||||||
|
let html = content.value
|
||||||
|
// 转义 HTML 特殊字符
|
||||||
|
html = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
|
||||||
|
// 替换 @[nodeId] 为图片
|
||||||
|
html = html.replace(/@\[([^\]|]+)(?:\|([^\]]+))?\]/g, (match, nodeId) => {
|
||||||
|
const node = nodes.value.find(n => n.id === nodeId)
|
||||||
|
if (node?.type === 'image' && node.data?.url) {
|
||||||
|
const displayName = node.data?.publicProps?.name || node.data?.label || '图片'
|
||||||
|
return `<span class="mention-inline" data-node-id="${nodeId}"><img src="${node.data.url}" alt="${displayName}" />${displayName}</span>`
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
|
||||||
|
// 换行符转换为 <br>
|
||||||
|
html = html.replace(/\n/g, '<br>')
|
||||||
|
|
||||||
|
return html
|
||||||
|
})
|
||||||
|
|
||||||
|
// Text node menu operations | 文本节点菜单操作
|
||||||
|
const operations = [
|
||||||
|
{ type: 'imageConfig', label: '生图', icon: ImageOutline },
|
||||||
|
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline },
|
||||||
|
{ type: 'llmConfig', label: 'LLM', icon: ChatbubbleOutline }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Handle menu select | 处理菜单选择
|
||||||
|
const handleSelect = (item) => {
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
const defaultData = {
|
||||||
|
imageConfig: { model: 'doubao-seedream-4-5-251128', size: '2048x2048', label: '文生图' },
|
||||||
|
videoConfig: { label: '视频生成' },
|
||||||
|
llmConfig: { label: 'LLM文本生成' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const newId = addNode(item.type, { x: nodeX + 400, y: nodeY }, defaultData[item.type] || {})
|
||||||
|
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: newId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => updateNodeInternals(newId), 50)
|
||||||
|
window.$message?.success(`已创建${item.label}节点`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle input for @ trigger | 处理 @ 触发输入(参考 MaterialInput)
|
||||||
|
const handleInput = (e) => {
|
||||||
|
const editor = e.target
|
||||||
|
isInternalUpdate = true
|
||||||
|
content.value = getEditableText()
|
||||||
|
lastContent.value = content.value
|
||||||
|
nextTick(() => { isInternalUpdate = false })
|
||||||
|
|
||||||
|
// 触发文本到 chip 的转换
|
||||||
|
debouncedConvertMentions()
|
||||||
|
|
||||||
|
// 获取光标位置
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection.rangeCount) return
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
// 使用辅助函数计算纯文本中的光标位置
|
||||||
|
const cursorPos = getTextPositionBeforeCursor(editor, range)
|
||||||
|
const fullText = getEditableText()
|
||||||
|
const textBeforeCursor = fullText.slice(0, cursorPos)
|
||||||
|
|
||||||
|
// Check if cursor is after @ character | 检查光标是否在 @ 字符后
|
||||||
|
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||||
|
|
||||||
|
if (lastAtIndex !== -1) {
|
||||||
|
// Check if there's a space after @ (meaning user finished typing mention) | 检查 @ 后面是否有空格(用户已完成输入)
|
||||||
|
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1)
|
||||||
|
|
||||||
|
// Check if there's a complete @[...] mention | 检查是否有完整的 @[...] 配对
|
||||||
|
const bracketMatch = textAfterAt.match(/\[([^\]]*)\]/)
|
||||||
|
const hasCompleteMention = bracketMatch !== null
|
||||||
|
|
||||||
|
// Show picker only if: @ exists, no space after @, and not part of a complete @[...] mention
|
||||||
|
if (!textAfterAt.includes(' ') && !hasCompleteMention) {
|
||||||
|
// Calculate position | 计算位置
|
||||||
|
showMentionsPicker.value = true
|
||||||
|
mentionSearchStart.value = lastAtIndex
|
||||||
|
|
||||||
|
// Get editor position relative to viewport | 获取 editor 相对于视口的位置
|
||||||
|
const rect = editor.getBoundingClientRect()
|
||||||
|
mentionsPosition.value = {
|
||||||
|
x: rect.left + 10,
|
||||||
|
y: rect.bottom + 5
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide picker if conditions not met | 如果条件不满足,隐藏选择器
|
||||||
|
showMentionsPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle keydown for mentions and Shift+Enter | 处理 @ 选择器和 Shift+Enter 换行
|
||||||
|
const handleKeydown = (e) => {
|
||||||
|
// 处理 @ 选择器
|
||||||
|
if (showMentionsPicker.value) {
|
||||||
|
// 回车键选中当前高亮的项
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
// 触发 MentionsPicker 的选择事件,需要通过自定义事件来处理
|
||||||
|
// 由于无法直接访问 MentionsPicker 的内部状态,这里暂时不做处理
|
||||||
|
// 让事件继续传播到 MentionsPicker
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
showMentionsPicker.value = false
|
||||||
|
// Remove the incomplete @ | 移除不完整的 @
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection.rangeCount) return
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
const editor = editorRef.value
|
||||||
|
const cursorPos = range.startOffset
|
||||||
|
const textBeforeCursor = content.value.slice(0, cursorPos)
|
||||||
|
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||||
|
|
||||||
|
if (lastAtIndex !== -1) {
|
||||||
|
content.value = textBeforeCursor.slice(0, lastAtIndex) + content.value.slice(cursorPos)
|
||||||
|
lastContent.value = content.value
|
||||||
|
// Update editor content | 更新 editor 内容
|
||||||
|
nextTick(() => {
|
||||||
|
editor.innerHTML = editorHtml.value
|
||||||
|
// Set cursor position | 设置光标位置
|
||||||
|
const newRange = document.createRange()
|
||||||
|
newRange.setStart(editor.firstChild || editor, lastAtIndex)
|
||||||
|
newRange.collapse(true)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(newRange)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规范化 Shift+Enter 插入换行
|
||||||
|
if (e.key === 'Enter' && e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
document.execCommand('insertLineBreak')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle mention selection | 处理 @ 引用选择(参考 MaterialInput)
|
||||||
|
const handleMentionSelect = ({ nodeId }) => {
|
||||||
|
// 找到对应的图片节点
|
||||||
|
const node = nodes.value.find(n => n.id === nodeId)
|
||||||
|
if (!node) {
|
||||||
|
showMentionsPicker.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入 mention chip 到 DOM
|
||||||
|
insertMentionChipDOM(node)
|
||||||
|
|
||||||
|
// 更新 store
|
||||||
|
updateContent()
|
||||||
|
showMentionsPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for external data changes | 监听外部数据变化
|
||||||
|
watch(() => props.data?.content, (newVal) => {
|
||||||
|
if (newVal !== content.value) {
|
||||||
|
content.value = newVal || ''
|
||||||
|
lastContent.value = content.value
|
||||||
|
// Sync to editor | 同步到 editor
|
||||||
|
setEditableContent(content.value)
|
||||||
|
// 立即将文本中的 @label 转为 chip
|
||||||
|
nextTick(() => convertTextMentionsToChips())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch content changes and sync to editor | 监听内容变化并同步到编辑器
|
||||||
|
watch(content, (newVal) => {
|
||||||
|
if (isInternalUpdate) return
|
||||||
|
setEditableContent(newVal)
|
||||||
|
// 立即将文本中的 @label 转为 chip
|
||||||
|
nextTick(() => convertTextMentionsToChips())
|
||||||
|
lastContent.value = newVal
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize editor content | 初始化 editor 内容
|
||||||
|
onMounted(() => {
|
||||||
|
if (editorRef.value) {
|
||||||
|
if (props.data?.content) {
|
||||||
|
content.value = props.data.content
|
||||||
|
}
|
||||||
|
lastContent.value = content.value
|
||||||
|
// 使用 setEditableContent + convertTextMentionsToChips 确保正确创建 mention-chip
|
||||||
|
setEditableContent(content.value)
|
||||||
|
nextTick(() => convertTextMentionsToChips())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update content in store | 更新存储中的内容
|
||||||
|
const updateContent = () => {
|
||||||
|
updateNode(props.id, { content: content.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle AI polish | 处理 AI 润色
|
||||||
|
const handlePolish = async () => {
|
||||||
|
const input = content.value.trim()
|
||||||
|
if (!input) return
|
||||||
|
|
||||||
|
// Check API configuration | 检查 API 配置
|
||||||
|
if (!isApiConfigured.value) {
|
||||||
|
window.$message?.warning('登录状态异常,请重新进入工作台')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isPolishing.value = true
|
||||||
|
const originalContent = content.value
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call chat API to polish the prompt | 调用 AI 润色提示词
|
||||||
|
const result = await sendChat(input, true)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
content.value = result
|
||||||
|
updateNode(props.id, { content: result })
|
||||||
|
window.$message?.success('提示词已润色')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
content.value = originalContent
|
||||||
|
window.$message?.error(err.message || '润色失败')
|
||||||
|
} finally {
|
||||||
|
isPolishing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start editing label | 开始编辑 label
|
||||||
|
const startEditLabel = () => {
|
||||||
|
editingLabelValue.value = props.data?.label || ''
|
||||||
|
isEditingLabel.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
labelInputRef.value?.focus()
|
||||||
|
labelInputRef.value?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish editing label | 完成编辑 label
|
||||||
|
const finishEditLabel = () => {
|
||||||
|
const newLabel = editingLabelValue.value.trim()
|
||||||
|
if (newLabel && newLabel !== props.data?.label) {
|
||||||
|
updateNode(props.id, { label: newLabel })
|
||||||
|
}
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel editing label | 取消编辑 label
|
||||||
|
const cancelEditLabel = () => {
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delete | 处理删除
|
||||||
|
const handleDelete = () => {
|
||||||
|
removeNode(props.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicate | 处理复制
|
||||||
|
const handleDuplicate = () => {
|
||||||
|
const newNodeId = duplicateNode(props.id)
|
||||||
|
window.$message?.success('节点已复制')
|
||||||
|
if (newNodeId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(newNodeId)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle image generation | 处理图片生成
|
||||||
|
const handleImageGen = () => {
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create imageConfig node | 创建text生图配置节点
|
||||||
|
const configNodeId = addNode('imageConfig', { x: nodeX + 400, y: nodeY }, {
|
||||||
|
model: 'doubao-seedream-4-5-251128',
|
||||||
|
size: '2048x2048',
|
||||||
|
label: '文生图'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto connect | 自动连接
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(configNodeId)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle video generation | 处理视频生成
|
||||||
|
const handleVideoGen = () => {
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create videoConfig node | 创建视频配置节点
|
||||||
|
const configNodeId = addNode('videoConfig', { x: nodeX + 400, y: nodeY }, {
|
||||||
|
label: '视频生成'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto connect | 自动连接
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(configNodeId)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-node-wrapper {
|
||||||
|
padding-right: 50px;
|
||||||
|
padding-top: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-node {
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea wrapper - 参考 MaterialInput input-with-mention */
|
||||||
|
.textarea-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor styles | 编辑器样式 - 参考 MaterialInput */
|
||||||
|
.editor-content {
|
||||||
|
min-height: 60px;
|
||||||
|
max-height: 120px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
outline: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content:focus {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content:empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
|
||||||
|
/* Inline mention in editor | editor 中内联提及 */
|
||||||
|
.editor-content :deep(.mention-inline) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content :deep(.mention-inline img) {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 2px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mentions preview | @ 提及预览 */
|
||||||
|
.mentions-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mention chip - 参考 MaterialInput 样式 */
|
||||||
|
.mention-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px 2px 2px;
|
||||||
|
margin: 0 2px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-chip img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-placeholder {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-name {
|
||||||
|
max-width: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
539
web/canvas-app/src/components/nodes/VideoConfigNode.vue
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Video config node wrapper | 视频配置节点包裹层 -->
|
||||||
|
<div class="video-config-node-wrapper relative" @mouseenter="showHandleMenu = true" @mouseleave="showHandleMenu = false">
|
||||||
|
<!-- Video config node | 视频配置节点 -->
|
||||||
|
<div class="video-config-node bg-[var(--bg-secondary)] rounded-xl border min-w-[300px] transition-all duration-200"
|
||||||
|
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'">
|
||||||
|
<!-- Header | 头部 -->
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<span
|
||||||
|
v-if="!isEditingLabel"
|
||||||
|
@dblclick="startEditLabel"
|
||||||
|
class="text-sm font-medium text-[var(--text-secondary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
|
||||||
|
title="双击编辑名称"
|
||||||
|
>{{ data.label || '视频生成' }}</span>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
ref="labelInputRef"
|
||||||
|
v-model="editingLabelValue"
|
||||||
|
@blur="finishEditLabel"
|
||||||
|
@keydown.enter="finishEditLabel"
|
||||||
|
@keydown.escape="cancelEditLabel"
|
||||||
|
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] px-1 rounded outline-none border border-blue-500"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<CopyOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<TrashOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config options | 配置选项 -->
|
||||||
|
<div class="p-3 space-y-3">
|
||||||
|
<!-- Model selector | 模型选择 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">模型</span>
|
||||||
|
<n-dropdown :options="modelOptions" @select="handleModelSelect">
|
||||||
|
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||||
|
{{ displayModelName }}
|
||||||
|
<n-icon :size="12"><ChevronDownOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aspect ratio selector | 宽高比选择 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">比例</span>
|
||||||
|
<n-dropdown :options="ratioOptions" @select="handleRatioSelect">
|
||||||
|
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||||
|
{{ localRatio }}
|
||||||
|
<n-icon :size="12">
|
||||||
|
<ChevronForwardOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duration selector | 时长选择 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">时长</span>
|
||||||
|
<n-dropdown :options="durationOptions" @select="handleDurationSelect">
|
||||||
|
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||||
|
{{ localDuration }}s
|
||||||
|
<n-icon :size="12">
|
||||||
|
<ChevronForwardOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connected inputs indicator | 连接输入指示 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-xs text-[var(--text-secondary)] py-1 border-t border-[var(--border-color)]">
|
||||||
|
<span class="px-2 py-0.5 rounded-full"
|
||||||
|
:class="connectedPrompt ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||||||
|
提示词 {{ connectedPrompt ? '✓' : '○' }}
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-0.5 rounded-full"
|
||||||
|
:class="imagesByRole.firstFrame ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||||||
|
首帧 {{ imagesByRole.firstFrame ? '✓' : '○' }}
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-0.5 rounded-full"
|
||||||
|
:class="imagesByRole.lastFrame ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||||||
|
尾帧 {{ imagesByRole.lastFrame ? '✓' : '○' }}
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-0.5 rounded-full"
|
||||||
|
:class="imagesByRole.referenceImages.length > 0 ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||||||
|
参考图 {{ imagesByRole.referenceImages.length > 0 ? `✓ ${imagesByRole.referenceImages.length}` : '○' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar | 进度条 -->
|
||||||
|
<!-- <div v-if="status === 'polling'" class="space-y-1">
|
||||||
|
<div class="flex justify-between text-xs text-[var(--text-secondary)]">
|
||||||
|
<span>生成中...</span>
|
||||||
|
<span>{{ progress.percentage }}%</span>
|
||||||
|
</div>
|
||||||
|
<n-progress type="line" :percentage="progress.percentage" :show-indicator="false" :height="4" />
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<!-- Generate button | 生成按钮 -->
|
||||||
|
<button @click="handleGenerate" :disabled="isGenerating || !isConfigured"
|
||||||
|
class="w-full flex items-center justify-center gap-2 py-2 px-4 rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<n-spin v-if="isGenerating" :size="14" />
|
||||||
|
<template v-else>
|
||||||
|
<n-icon :size="16">
|
||||||
|
<VideocamOutline />
|
||||||
|
</n-icon>
|
||||||
|
生成视频
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Error message | 错误信息 -->
|
||||||
|
<div v-if="error" class="text-xs text-red-500 mt-2">
|
||||||
|
{{ error.message || '生成失败' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generated video preview | 生成视频预览 -->
|
||||||
|
<!-- <div v-if="generatedVideo?.url" class="mt-3 space-y-2">
|
||||||
|
<div class="text-xs text-[var(--text-secondary)]">生成结果:</div>
|
||||||
|
<div class="aspect-video rounded-lg overflow-hidden bg-black">
|
||||||
|
<video :src="generatedVideo.url" controls class="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Handles | 连接点 -->
|
||||||
|
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
|
||||||
|
<NodeHandleMenu :nodeId="id" nodeType="videoConfig" :visible="showHandleMenu" :operations="[]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Video config node component | 视频配置节点组件
|
||||||
|
* Configuration panel for video generation with API integration
|
||||||
|
*/
|
||||||
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||||
|
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NIcon, NDropdown, NSpin } from 'naive-ui'
|
||||||
|
import { ChevronForwardOutline, ChevronDownOutline, TrashOutline, VideocamOutline, CopyOutline, CreateOutline } from '@vicons/ionicons5'
|
||||||
|
import { useVideoGeneration } from '../../hooks'
|
||||||
|
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes, edges } from '../../stores/canvas'
|
||||||
|
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||||||
|
import { useModelStore } from '../../stores/pinia'
|
||||||
|
import { getModelRatioOptions, getModelDurationOptions, getModelConfig, DEFAULT_VIDEO_MODEL } from '../../stores/models'
|
||||||
|
|
||||||
|
// 使用 Pinia store 获取模型选项(根据渠道过滤)
|
||||||
|
const modelStore = useModelStore()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Vue Flow instance | Vue Flow 实例
|
||||||
|
const { updateNodeInternals } = useVueFlow()
|
||||||
|
|
||||||
|
// API config state | API 配置状态
|
||||||
|
const isConfigured = computed(() => !!modelStore.currentApiKey)
|
||||||
|
|
||||||
|
// Video generation hook | 视频生成 hook
|
||||||
|
const { loading, error, status, video: generatedVideo, progress, createVideoTaskOnly } = useVideoGeneration()
|
||||||
|
|
||||||
|
// Local state | 本地状态
|
||||||
|
const showHandleMenu = ref(false)
|
||||||
|
const isGenerating = ref(false) // 任务创建中状态
|
||||||
|
const localModel = ref(props.data?.model || DEFAULT_VIDEO_MODEL)
|
||||||
|
const localRatio = ref(props.data?.ratio || '16:9')
|
||||||
|
const localDuration = ref(props.data?.dur || 5)
|
||||||
|
|
||||||
|
// Label editing state | Label 编辑状态
|
||||||
|
const isEditingLabel = ref(false)
|
||||||
|
const editingLabelValue = ref('')
|
||||||
|
const labelInputRef = ref(null)
|
||||||
|
|
||||||
|
// Get connected images with roles | 获取连接的图片及其角色
|
||||||
|
const connectedImages = computed(() => {
|
||||||
|
const connectedEdges = edges.value.filter(e => e.target === props.id)
|
||||||
|
const images = []
|
||||||
|
|
||||||
|
for (const edge of connectedEdges) {
|
||||||
|
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||||||
|
if (sourceNode?.type === 'image' && sourceNode.data?.url) {
|
||||||
|
images.push({
|
||||||
|
nodeId: sourceNode.id,
|
||||||
|
edgeId: edge.id,
|
||||||
|
url: sourceNode.data.url,
|
||||||
|
base64: sourceNode.data.base64,
|
||||||
|
role: edge.data?.imageRole || 'first_frame_image' // Default to first frame | 默认首帧
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return images
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get images by role | 按角色获取图片
|
||||||
|
const imagesByRole = computed(() => {
|
||||||
|
const firstFrame = connectedImages.value.find(img => img.role === 'first_frame_image')
|
||||||
|
const lastFrame = connectedImages.value.find(img => img.role === 'last_frame_image')
|
||||||
|
const referenceImages = connectedImages.value.filter(img => img.role === 'input_reference')
|
||||||
|
|
||||||
|
return {
|
||||||
|
firstFrame,
|
||||||
|
lastFrame,
|
||||||
|
referenceImages
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get current model config | 获取当前模型配置
|
||||||
|
const currentModelConfig = computed(() => getModelConfig(localModel.value))
|
||||||
|
|
||||||
|
// Model options from Pinia store (filtered by provider) | 从 Pinia store 获取模型选项(根据渠道过滤)
|
||||||
|
const modelOptions = computed(() => modelStore.allVideoModelOptions)
|
||||||
|
|
||||||
|
// Display model name | 显示模型名称
|
||||||
|
const displayModelName = computed(() => {
|
||||||
|
const model = modelOptions.value.find(m => m.key === localModel.value)
|
||||||
|
// 如果当前模型不在选项中,尝试从 allVideoModels 找到
|
||||||
|
if (!model) {
|
||||||
|
const allModel = modelStore.allVideoModels.find(m => m.key === localModel.value)
|
||||||
|
return allModel?.label || localModel.value || '选择模型'
|
||||||
|
}
|
||||||
|
return model?.label || localModel.value || '选择模型'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ratio options based on model | 基于模型的比例选项
|
||||||
|
const ratioOptions = computed(() => {
|
||||||
|
return getModelRatioOptions(localModel.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Duration options based on model | 基于模型的时长选项
|
||||||
|
const durationOptions = computed(() => {
|
||||||
|
return getModelDurationOptions(localModel.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle model selection | 处理模型选择
|
||||||
|
const handleModelSelect = (key) => {
|
||||||
|
localModel.value = key
|
||||||
|
// Update ratio and duration to model's default | 更新为模型默认比例和时长
|
||||||
|
const config = getModelConfig(key)
|
||||||
|
const updates = { model: key }
|
||||||
|
if (config?.defaultParams?.ratio) {
|
||||||
|
localRatio.value = config.defaultParams.ratio
|
||||||
|
updates.ratio = config.defaultParams.ratio
|
||||||
|
}
|
||||||
|
if (config?.defaultParams?.duration) {
|
||||||
|
localDuration.value = config.defaultParams.duration
|
||||||
|
updates.dur = config.defaultParams.duration
|
||||||
|
}
|
||||||
|
updateNode(props.id, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicate | 处理复制
|
||||||
|
const handleDuplicate = () => {
|
||||||
|
const newNodeId = duplicateNode(props.id)
|
||||||
|
window.$message?.success('节点已复制')
|
||||||
|
if (newNodeId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(newNodeId)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ratio selection | 处理比例选择
|
||||||
|
const handleRatioSelect = (key) => {
|
||||||
|
localRatio.value = key
|
||||||
|
updateNode(props.id, { ratio: key })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duration selection | 处理时长选择
|
||||||
|
const handleDurationSelect = (key) => {
|
||||||
|
localDuration.value = key
|
||||||
|
updateNode(props.id, { dur: key })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get connected inputs by role | 根据角色获取连接的输入
|
||||||
|
const getConnectedInputs = () => {
|
||||||
|
const connectedEdges = edges.value.filter(e => e.target === props.id)
|
||||||
|
|
||||||
|
let prompt = ''
|
||||||
|
let first_frame_image = ''
|
||||||
|
let last_frame_image = ''
|
||||||
|
const images = [] // input_reference images | 参考图
|
||||||
|
|
||||||
|
for (const edge of connectedEdges) {
|
||||||
|
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||||||
|
if (!sourceNode) continue
|
||||||
|
|
||||||
|
if (sourceNode.type === 'text') {
|
||||||
|
prompt = sourceNode.data?.content || ''
|
||||||
|
} else if (sourceNode.type === 'llmConfig') {
|
||||||
|
// LLM node output as prompt | LLM 节点输出作为提示词
|
||||||
|
const content = sourceNode.data?.outputContent || ''
|
||||||
|
if (content) prompt = content
|
||||||
|
} else if (sourceNode.type === 'image' && sourceNode.data?.url) {
|
||||||
|
const imageData = sourceNode.data.base64 || sourceNode.data.url
|
||||||
|
const role = edge.data?.imageRole || 'first_frame_image'
|
||||||
|
|
||||||
|
if (role === 'first_frame_image') {
|
||||||
|
first_frame_image = imageData
|
||||||
|
} else if (role === 'last_frame_image') {
|
||||||
|
last_frame_image = imageData
|
||||||
|
} else if (role === 'input_reference') {
|
||||||
|
images.push(imageData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { prompt, first_frame_image, last_frame_image, images }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed connected prompt | 计算连接的提示词
|
||||||
|
const connectedPrompt = computed(() => {
|
||||||
|
return getConnectedInputs().prompt
|
||||||
|
})
|
||||||
|
|
||||||
|
// Created video node ID | 创建的视频节点 ID
|
||||||
|
const createdVideoNodeId = ref(null)
|
||||||
|
|
||||||
|
// Handle generate action | 处理生成操作
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
// 设置生成中状态
|
||||||
|
isGenerating.value = true
|
||||||
|
|
||||||
|
const { prompt, first_frame_image, last_frame_image, images } = getConnectedInputs()
|
||||||
|
|
||||||
|
const hasInput = prompt || first_frame_image || last_frame_image || images.length > 0
|
||||||
|
if (!hasInput) {
|
||||||
|
window.$message?.warning('请先连接文本节点或图片节点')
|
||||||
|
isGenerating.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConfigured.value) {
|
||||||
|
window.$message?.warning('登录状态异常,请重新进入工作台')
|
||||||
|
isGenerating.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current node position | 获取当前节点位置
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create video node with loading state | 创建带加载状态的视频节点
|
||||||
|
const videoNodeId = addNode('video', { x: nodeX + 350, y: nodeY }, {
|
||||||
|
url: '',
|
||||||
|
loading: true,
|
||||||
|
label: '视频生成中...'
|
||||||
|
})
|
||||||
|
createdVideoNodeId.value = videoNodeId
|
||||||
|
|
||||||
|
// Auto-connect videoConfig → video | 自动连接 视频配置 → 视频
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: videoNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(videoNodeId)
|
||||||
|
}, 50)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build request params (raw form data) | 构建请求参数(原始表单数据)
|
||||||
|
// These will be transformed by inputTransform | 这些会被 inputTransform 转换
|
||||||
|
const params = {
|
||||||
|
model: localModel.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add prompt if provided | 如果有提示词则添加
|
||||||
|
if (prompt) {
|
||||||
|
params.prompt = prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add first frame image | 添加首帧图片
|
||||||
|
if (first_frame_image) {
|
||||||
|
params.first_frame_image = first_frame_image
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add last frame image | 添加尾帧图片
|
||||||
|
if (last_frame_image) {
|
||||||
|
params.last_frame_image = last_frame_image
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add reference images (input_reference) | 添加参考图
|
||||||
|
if (images.length > 0) {
|
||||||
|
params.images = images
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ratio/size | 添加比例参数
|
||||||
|
if (localRatio.value) {
|
||||||
|
params.ratio = localRatio.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add duration | 添加时长
|
||||||
|
if (localDuration.value) {
|
||||||
|
params.dur = localDuration.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只创建任务,获取 taskId,不在这里轮询
|
||||||
|
const { taskId: newTaskId, url } = await createVideoTaskOnly(params)
|
||||||
|
|
||||||
|
// 如果有直接 URL,更新视频节点
|
||||||
|
if (url) {
|
||||||
|
updateNode(videoNodeId, {
|
||||||
|
url: url,
|
||||||
|
loading: false,
|
||||||
|
label: '视频生成',
|
||||||
|
model: localModel.value,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
window.$message?.success('视频生成成功')
|
||||||
|
// Mark this config node as executed | 标记配置节点已执行
|
||||||
|
updateNode(props.id, { executed: true, outputNodeId: videoNodeId })
|
||||||
|
} else if (newTaskId) {
|
||||||
|
// 需要轮询,传递 taskId 给 VideoNode
|
||||||
|
updateNode(videoNodeId, {
|
||||||
|
taskId: newTaskId,
|
||||||
|
loading: true,
|
||||||
|
label: '视频生成中...',
|
||||||
|
model: localModel.value,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
window.$message?.success('视频任务已创建')
|
||||||
|
// Mark this config node as executed | 标记配置节点已执行
|
||||||
|
updateNode(props.id, { executed: true, outputNodeId: videoNodeId })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Update node to show error | 更新节点显示错误
|
||||||
|
updateNode(videoNodeId, {
|
||||||
|
loading: false,
|
||||||
|
error: err.message || '生成失败',
|
||||||
|
label: '生成失败',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
window.$message?.error(err.message || '视频生成失败')
|
||||||
|
} finally {
|
||||||
|
isGenerating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start editing label | 开始编辑 label
|
||||||
|
const startEditLabel = () => {
|
||||||
|
editingLabelValue.value = props.data?.label || '视频生成'
|
||||||
|
isEditingLabel.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
labelInputRef.value?.focus()
|
||||||
|
labelInputRef.value?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish editing label | 完成编辑 label
|
||||||
|
const finishEditLabel = () => {
|
||||||
|
const newLabel = editingLabelValue.value.trim()
|
||||||
|
if (newLabel && newLabel !== props.data?.label) {
|
||||||
|
updateNode(props.id, { label: newLabel })
|
||||||
|
}
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel editing label | 取消编辑 label
|
||||||
|
const cancelEditLabel = () => {
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delete | 处理删除
|
||||||
|
const handleDelete = () => {
|
||||||
|
removeNode(props.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on mount | 挂载时初始化
|
||||||
|
onMounted(() => {
|
||||||
|
// 检查当前模型是否在可用模型列表中
|
||||||
|
const availableModels = modelStore.availableVideoModels
|
||||||
|
const isModelAvailable = availableModels.some(m => m.key === localModel.value)
|
||||||
|
|
||||||
|
if (!localModel.value || !isModelAvailable) {
|
||||||
|
// 使用 store 中的默认模型或第一个可用模型
|
||||||
|
localModel.value = modelStore.selectedVideoModel || availableModels[0]?.key || DEFAULT_VIDEO_MODEL
|
||||||
|
updateNode(props.id, { model: localModel.value })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for model changes from props | 监听 props 中模型变化
|
||||||
|
watch(() => props.data?.model, (newModel) => {
|
||||||
|
if (newModel && newModel !== localModel.value) {
|
||||||
|
localModel.value = newModel
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 修复 Vue Flow visibility: hidden 问题
|
||||||
|
// 当节点数据变化时,强制更新内部状态
|
||||||
|
watch(() => props.data, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
updateNodeInternals(props.id)
|
||||||
|
})
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Watch for auto-execute flag | 监听自动执行标志
|
||||||
|
watch(
|
||||||
|
() => props.data?.autoExecute,
|
||||||
|
(shouldExecute) => {
|
||||||
|
if (shouldExecute && !loading.value) {
|
||||||
|
// Clear the flag first to prevent re-triggering | 先清除标志防止重复触发
|
||||||
|
updateNode(props.id, { autoExecute: false })
|
||||||
|
// Delay to ensure node connections are established | 延迟确保节点连接已建立
|
||||||
|
setTimeout(() => {
|
||||||
|
handleGenerate()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-config-node-wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-config-node {
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
341
web/canvas-app/src/components/nodes/VideoNode.vue
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Video node wrapper | 视频节点包裹层 -->
|
||||||
|
<div class="video-node-wrapper relative" @mouseenter="showActions = true; showHandleMenu = true" @mouseleave="showActions = false; showHandleMenu = false">
|
||||||
|
<!-- Video node | 视频节点 -->
|
||||||
|
<div
|
||||||
|
class="video-node bg-[var(--bg-secondary)] rounded-xl border w-[400px] relative transition-all duration-200"
|
||||||
|
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'"
|
||||||
|
|
||||||
|
>
|
||||||
|
<!-- Header | 头部 -->
|
||||||
|
<div class="px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
v-if="!isEditingLabel"
|
||||||
|
@dblclick="startEditLabel"
|
||||||
|
class="text-sm font-medium text-[var(--text-secondary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
|
||||||
|
title="双击编辑名称"
|
||||||
|
>{{ data.label }}</span>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
ref="labelInputRef"
|
||||||
|
v-model="editingLabelValue"
|
||||||
|
@blur="finishEditLabel"
|
||||||
|
@keydown.enter="finishEditLabel"
|
||||||
|
@keydown.escape="cancelEditLabel"
|
||||||
|
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] px-1 rounded outline-none border border-blue-500"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<CopyOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<TrashOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Model name | 模型名称 -->
|
||||||
|
<div v-if="data.model" class="mt-1 text-xs text-[var(--text-secondary)] truncate">
|
||||||
|
{{ data.model }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video preview area | 视频预览区域 -->
|
||||||
|
<div class="p-3">
|
||||||
|
<!-- Loading state | 加载状态 -->
|
||||||
|
<div
|
||||||
|
v-if="(data.taskId && !data.url) || (data.loading && !data.taskId)"
|
||||||
|
class="aspect-video rounded-lg bg-gradient-to-br from-cyan-400 via-blue-300 to-amber-200 flex flex-col items-center justify-center gap-3 relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Animated gradient overlay | 动画渐变遮罩 -->
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-br from-cyan-500/20 via-blue-400/20 to-amber-300/20 animate-pulse"></div>
|
||||||
|
|
||||||
|
<!-- Loading image | 加载图片 -->
|
||||||
|
<div class="relative z-10">
|
||||||
|
<img
|
||||||
|
src="../../assets/loading.webp"
|
||||||
|
alt="Loading"
|
||||||
|
class="w-14 h-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm text-white font-medium relative z-10">{{ data.taskId ? '创作中,预计等待 1 分钟' : '任务创建中...' }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Error state | 错误状态 -->
|
||||||
|
<div
|
||||||
|
v-else-if="data.error"
|
||||||
|
class="aspect-video rounded-lg bg-red-50 dark:bg-red-900/20 flex flex-col items-center justify-center gap-2 border border-red-200 dark:border-red-800"
|
||||||
|
>
|
||||||
|
<n-icon :size="32" class="text-red-500"><CloseCircleOutline /></n-icon>
|
||||||
|
<span class="text-sm text-red-500">{{ data.error }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Video preview | 视频预览 -->
|
||||||
|
<div
|
||||||
|
v-else-if="data.url"
|
||||||
|
class="aspect-video rounded-lg overflow-hidden bg-black"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
:src="data.url"
|
||||||
|
controls
|
||||||
|
class="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Empty state | 空状态 -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="aspect-video rounded-lg bg-[var(--bg-tertiary)] flex flex-col items-center justify-center gap-2 border-2 border-dashed border-[var(--border-color)] relative"
|
||||||
|
>
|
||||||
|
<n-icon :size="32" class="text-[var(--text-secondary)]"><VideocamOutline /></n-icon>
|
||||||
|
<span class="text-sm text-[var(--text-secondary)]">拖放视频或点击上传</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="video/*"
|
||||||
|
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
@change="handleFileUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duration info | 时长信息 -->
|
||||||
|
<div v-if="data.duration" class="mt-2 text-xs text-[var(--text-secondary)]">
|
||||||
|
时长: {{ formatDuration(data.duration) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Handles | 连接点 -->
|
||||||
|
<NodeHandleMenu :nodeId="id" nodeType="video" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
|
||||||
|
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right side - Action buttons | 右侧 - 操作按钮 -->
|
||||||
|
<div
|
||||||
|
v-show="showActions && data.url"
|
||||||
|
class="absolute right-10 top-20 -translate-y-1/2 translate-x-full flex flex-col gap-2 z-[1000]"
|
||||||
|
>
|
||||||
|
<!-- Preview button | 预览按钮 -->
|
||||||
|
<button
|
||||||
|
@click="handlePreview"
|
||||||
|
class="action-btn group p-2 bg-white rounded-lg transition-all border border-gray-200 flex items-center gap-0 hover:gap-1.5 w-max"
|
||||||
|
>
|
||||||
|
<n-icon :size="16" class="text-gray-600"><EyeOutline /></n-icon>
|
||||||
|
<span class="text-xs text-gray-600 max-w-0 overflow-hidden group-hover:max-w-[80px] transition-all duration-200 whitespace-nowrap">预览</span>
|
||||||
|
</button>
|
||||||
|
<!-- Download button | 下载按钮 -->
|
||||||
|
<button
|
||||||
|
@click="handleDownload"
|
||||||
|
class="action-btn group p-2 bg-white rounded-lg transition-all border border-gray-200 flex items-center gap-0 hover:gap-1.5 w-max"
|
||||||
|
>
|
||||||
|
<n-icon :size="16" class="text-gray-600"><DownloadOutline /></n-icon>
|
||||||
|
<span class="text-xs text-gray-600 max-w-0 overflow-hidden group-hover:max-w-[80px] transition-all duration-200 whitespace-nowrap">下载</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Video node component | 视频节点组件
|
||||||
|
* Displays and manages video content
|
||||||
|
*/
|
||||||
|
import { ref, nextTick, watch, onMounted } from 'vue'
|
||||||
|
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NIcon, NSpin } from 'naive-ui'
|
||||||
|
import { TrashOutline, ExpandOutline, VideocamOutline, CopyOutline, CloseCircleOutline, DownloadOutline, EyeOutline, CreateOutline } from '@vicons/ionicons5'
|
||||||
|
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
|
||||||
|
import { useVideoGeneration } from '../../hooks/useApi'
|
||||||
|
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Vue Flow instance
|
||||||
|
const { updateNodeInternals } = useVueFlow()
|
||||||
|
|
||||||
|
// Get pollVideoTask from useVideoGeneration | 从 useVideoGeneration 获取轮询函数
|
||||||
|
const { pollVideoTask } = useVideoGeneration()
|
||||||
|
|
||||||
|
// Hover state | 悬浮状态
|
||||||
|
const showActions = ref(false)
|
||||||
|
const showHandleMenu = ref(false)
|
||||||
|
|
||||||
|
// Label editing state | Label 编辑状态
|
||||||
|
const isEditingLabel = ref(false)
|
||||||
|
const editingLabelValue = ref('')
|
||||||
|
const labelInputRef = ref(null)
|
||||||
|
|
||||||
|
// Video node menu operations | 视频节点菜单操作
|
||||||
|
const operations = [
|
||||||
|
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Polling state | 轮询状态
|
||||||
|
const isPolling = ref(false)
|
||||||
|
|
||||||
|
// Watch for taskId changes and start polling | 监听 taskId 变化并开始轮询
|
||||||
|
watch(() => props.data?.taskId, (taskId) => {
|
||||||
|
if (taskId && !props.data?.url && !isPolling.value) {
|
||||||
|
startPolling(taskId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 页面刷新后恢复轮询 | Resume polling after page refresh
|
||||||
|
onMounted(() => {
|
||||||
|
const { taskId, url } = props.data || {}
|
||||||
|
if (taskId && !url && !isPolling.value) {
|
||||||
|
startPolling(taskId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start polling for video result | 开始轮询获取视频结果
|
||||||
|
const startPolling = async (taskId) => {
|
||||||
|
if (isPolling.value) return
|
||||||
|
|
||||||
|
isPolling.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pollVideoTask(taskId, (attempt, percentage) => {
|
||||||
|
// 更新进度
|
||||||
|
updateNode(props.id, {
|
||||||
|
progress: percentage,
|
||||||
|
attempt
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// 轮询成功,更新视频节点
|
||||||
|
updateNode(props.id, {
|
||||||
|
url: result.url,
|
||||||
|
loading: false,
|
||||||
|
progress: 100,
|
||||||
|
label: '视频生成',
|
||||||
|
taskId: null // 清除 taskId
|
||||||
|
})
|
||||||
|
window.$message?.success('视频生成成功')
|
||||||
|
} catch (err) {
|
||||||
|
// 轮询失败
|
||||||
|
updateNode(props.id, {
|
||||||
|
loading: false,
|
||||||
|
error: err.message || '生成失败',
|
||||||
|
label: '生成失败',
|
||||||
|
taskId: null // 清除 taskId
|
||||||
|
})
|
||||||
|
window.$message?.error(err.message || '视频生成失败')
|
||||||
|
} finally {
|
||||||
|
isPolling.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle menu select | 处理菜单选择
|
||||||
|
const handleSelect = (item) => {
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
const newId = addNode('videoConfig', { x: nodeX + 400, y: nodeY }, { label: '视频生成' })
|
||||||
|
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: newId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(newId)
|
||||||
|
}, 50)
|
||||||
|
window.$message?.success(`已创建视频生成节点`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file upload | 处理文件上传
|
||||||
|
const handleFileUpload = (event) => {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (file) {
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
updateNode(props.id, {
|
||||||
|
url,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format duration | 格式化时长
|
||||||
|
const formatDuration = (seconds) => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start editing label | 开始编辑 label
|
||||||
|
const startEditLabel = () => {
|
||||||
|
editingLabelValue.value = props.data?.label || ''
|
||||||
|
isEditingLabel.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
labelInputRef.value?.focus()
|
||||||
|
labelInputRef.value?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish editing label | 完成编辑 label
|
||||||
|
const finishEditLabel = () => {
|
||||||
|
const newLabel = editingLabelValue.value.trim()
|
||||||
|
if (newLabel && newLabel !== props.data?.label) {
|
||||||
|
updateNode(props.id, { label: newLabel })
|
||||||
|
}
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel editing label | 取消编辑 label
|
||||||
|
const cancelEditLabel = () => {
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delete | 处理删除
|
||||||
|
const handleDelete = () => {
|
||||||
|
removeNode(props.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle preview | 处理预览
|
||||||
|
const handlePreview = () => {
|
||||||
|
if (props.data.url) {
|
||||||
|
window.open(props.data.url, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle download | 处理下载
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (props.data.url) {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = props.data.url
|
||||||
|
link.download = props.data.fileName || `video_${Date.now()}.mp4`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.$message?.success('视频下载中...')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicate | 处理复制
|
||||||
|
const handleDuplicate = () => {
|
||||||
|
const newId = duplicateNode(props.id)
|
||||||
|
if (newId) {
|
||||||
|
// Clear selection and select the new node | 清除选中并选中新节点
|
||||||
|
updateNode(props.id, { selected: false })
|
||||||
|
updateNode(newId, { selected: true })
|
||||||
|
window.$message?.success('节点已复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-node-wrapper {
|
||||||
|
padding-right: 50px;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-node {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
118
web/canvas-app/src/config/models.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* SKG model and size configuration.
|
||||||
|
* These values mirror the backend /health capabilities and keep the canvas UI simple.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SKG_IMAGE_SIZE_OPTIONS = [
|
||||||
|
{ label: '自动', key: 'auto' },
|
||||||
|
{ label: '竖图 2:3', key: '1024x1536' },
|
||||||
|
{ label: '方图 1:1', key: '1024x1024' },
|
||||||
|
{ label: '横图 3:2', key: '1536x1024' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SKG_IMAGE_QUALITY_OPTIONS = [
|
||||||
|
{ label: '标准', key: 'standard' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SKG_VIDEO_SIZE_OPTIONS = [
|
||||||
|
{ label: '竖屏 9:16', key: '720x1280' },
|
||||||
|
{ label: '横屏 16:9', key: '1280x720' },
|
||||||
|
{ label: '方形 1:1', key: '1024x1024' },
|
||||||
|
{ label: '竖屏 3:4', key: '960x1280' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const VIDEO_RATIO_LIST = SKG_VIDEO_SIZE_OPTIONS
|
||||||
|
|
||||||
|
export const SEEDANCE_RESOLUTION_OPTIONS = [
|
||||||
|
{ label: '720p', key: '720p' },
|
||||||
|
{ label: '1080p', key: '1080p' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const IMAGE_MODELS = [
|
||||||
|
{
|
||||||
|
label: '自动',
|
||||||
|
key: 'auto',
|
||||||
|
provider: ['skg'],
|
||||||
|
sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key),
|
||||||
|
qualities: SKG_IMAGE_QUALITY_OPTIONS,
|
||||||
|
defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'GPT Image 2',
|
||||||
|
key: 'gpt-image-2',
|
||||||
|
provider: ['skg'],
|
||||||
|
sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key),
|
||||||
|
qualities: SKG_IMAGE_QUALITY_OPTIONS,
|
||||||
|
defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Gemini 图片备用',
|
||||||
|
key: 'gemini-3-pro-image-preview',
|
||||||
|
provider: ['skg'],
|
||||||
|
sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key),
|
||||||
|
qualities: SKG_IMAGE_QUALITY_OPTIONS,
|
||||||
|
defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const VIDEO_MODELS = [
|
||||||
|
{
|
||||||
|
label: 'Seedance',
|
||||||
|
key: 'seedance',
|
||||||
|
provider: ['skg'],
|
||||||
|
type: 't2v+i2v',
|
||||||
|
ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key),
|
||||||
|
durs: [5, 8, 10, 12, 15].map(s => ({ label: `${s} 秒`, key: s })),
|
||||||
|
resolutions: ['720p', '1080p'],
|
||||||
|
defaultResolution: '1080p',
|
||||||
|
defaultParams: { ratio: '720x1280', duration: 10, resolution: '1080p' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Kling',
|
||||||
|
key: 'kling',
|
||||||
|
provider: ['skg'],
|
||||||
|
type: 't2v+i2v',
|
||||||
|
ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key),
|
||||||
|
durs: [4, 8, 12].map(s => ({ label: `${s} 秒`, key: s })),
|
||||||
|
resolutions: ['720p'],
|
||||||
|
defaultResolution: '720p',
|
||||||
|
defaultParams: { ratio: '720x1280', duration: 8, resolution: '720p' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Veo 3',
|
||||||
|
key: 'veo3',
|
||||||
|
provider: ['skg'],
|
||||||
|
type: 't2v+i2v',
|
||||||
|
ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key),
|
||||||
|
durs: [4, 8, 12].map(s => ({ label: `${s} 秒`, key: s })),
|
||||||
|
resolutions: ['720p'],
|
||||||
|
defaultResolution: '720p',
|
||||||
|
defaultParams: { ratio: '720x1280', duration: 8, resolution: '720p' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CHAT_MODELS = [
|
||||||
|
{ label: 'SKG 提示词助手', key: 'skg-copy', provider: ['skg'] }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const IMAGE_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS
|
||||||
|
export const IMAGE_QUALITY_OPTIONS = SKG_IMAGE_QUALITY_OPTIONS
|
||||||
|
export const IMAGE_STYLE_OPTIONS = [{ label: '商业营销', key: 'commercial' }]
|
||||||
|
export const VIDEO_RATIO_OPTIONS = SKG_VIDEO_SIZE_OPTIONS
|
||||||
|
export const VIDEO_DURATION_OPTIONS = [5, 8, 10, 12, 15].map(s => ({ label: `${s} 秒`, key: s }))
|
||||||
|
|
||||||
|
export const SEEDREAM_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS
|
||||||
|
export const SEEDREAM_4K_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS
|
||||||
|
export const SEEDREAM_QUALITY_OPTIONS = SKG_IMAGE_QUALITY_OPTIONS
|
||||||
|
|
||||||
|
export const DEFAULT_IMAGE_MODEL = 'auto'
|
||||||
|
export const DEFAULT_VIDEO_MODEL = 'seedance'
|
||||||
|
export const DEFAULT_CHAT_MODEL = 'skg-copy'
|
||||||
|
export const DEFAULT_IMAGE_SIZE = '1024x1536'
|
||||||
|
export const DEFAULT_VIDEO_RATIO = '720x1280'
|
||||||
|
export const DEFAULT_VIDEO_DURATION = 10
|
||||||
|
|
||||||
|
export const getModelByName = (key) => {
|
||||||
|
const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
|
||||||
|
return allModels.find(m => m.key === key)
|
||||||
|
}
|
||||||
40
web/canvas-app/src/config/providers.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* SKG internal provider config.
|
||||||
|
* The browser never receives upstream model keys; all generation goes through /api.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PROVIDERS = {
|
||||||
|
skg: {
|
||||||
|
label: 'SKG 内部模型',
|
||||||
|
defaultBaseUrl: '/api',
|
||||||
|
endpoints: {
|
||||||
|
chat: '/creative/copy',
|
||||||
|
image: '/jobs/{jobId}/frames/{idx}/generate',
|
||||||
|
video: '/jobs/{jobId}/frames/{idx}/storyboard/video',
|
||||||
|
videoQuery: '/jobs/{jobId}'
|
||||||
|
},
|
||||||
|
requestAdapter: {
|
||||||
|
chat: (params) => params,
|
||||||
|
image: (params) => params,
|
||||||
|
video: (params) => params
|
||||||
|
},
|
||||||
|
responseAdapter: {
|
||||||
|
chat: (response) => response,
|
||||||
|
image: (response) => response,
|
||||||
|
video: (response) => response
|
||||||
|
}
|
||||||
|
},
|
||||||
|
default: 'skg'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getProviderList = () => (
|
||||||
|
Object.entries(PROVIDERS)
|
||||||
|
.filter(([key]) => key !== 'default')
|
||||||
|
.map(([key, value]) => ({ key, label: value.label }))
|
||||||
|
)
|
||||||
|
|
||||||
|
export const getDefaultProvider = () => PROVIDERS.default || 'skg'
|
||||||
|
|
||||||
|
export const getProviderConfig = (provider) => PROVIDERS[provider] || PROVIDERS.skg
|
||||||
|
|
||||||
|
export const getDefaultBaseUrl = (provider) => getProviderConfig(provider).defaultBaseUrl
|
||||||
90
web/canvas-app/src/config/workflows.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* SKG internal workflow templates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const makeId = (prefix) => `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`
|
||||||
|
|
||||||
|
export const MULTI_ANGLE_PROMPTS = {
|
||||||
|
front: {
|
||||||
|
label: '正面',
|
||||||
|
english: 'Front View',
|
||||||
|
prompt: (subject) => `生成 SKG 营销图正面视角,主体清晰,产品佩戴或摆放关系准确,干净高级商业光线。\n主体参考: ${subject || '按上一张参考图保持一致'}`
|
||||||
|
},
|
||||||
|
side: {
|
||||||
|
label: '侧面',
|
||||||
|
english: 'Side View',
|
||||||
|
prompt: (subject) => `生成 SKG 营销图侧面视角,保持主体和产品一致,肩颈或产品轮廓清楚,真实生活场景。\n主体参考: ${subject || '按上一张参考图保持一致'}`
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
label: '细节',
|
||||||
|
english: 'Detail View',
|
||||||
|
prompt: (subject) => `生成 SKG 产品细节视角,强调材质、佩戴方式、使用触点和高级感,无文字水印。\n主体参考: ${subject || '按上一张参考图保持一致'}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WORKFLOW_TEMPLATES = [
|
||||||
|
{
|
||||||
|
id: 'skg-text-image-video',
|
||||||
|
name: '图文转视频',
|
||||||
|
description: '提示词 → 营销图 → 视频候选',
|
||||||
|
icon: 'VideocamOutline',
|
||||||
|
category: 'skg',
|
||||||
|
cover: '',
|
||||||
|
createNodes: (startPosition) => {
|
||||||
|
const textId = makeId('text')
|
||||||
|
const imageConfigId = makeId('image_config')
|
||||||
|
const imageId = makeId('image')
|
||||||
|
const videoConfigId = makeId('video_config')
|
||||||
|
const videoId = makeId('video')
|
||||||
|
const nodes = [
|
||||||
|
{
|
||||||
|
id: textId,
|
||||||
|
type: 'text',
|
||||||
|
position: { x: startPosition.x, y: startPosition.y },
|
||||||
|
data: {
|
||||||
|
label: '提示词',
|
||||||
|
content: '竖屏 SKG 短视频广告,真实办公室午休场景,人物佩戴 SKG 颈部按摩仪放松,产品形状清晰稳定,镜头缓慢推进,高级干净光线'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: imageConfigId,
|
||||||
|
type: 'imageConfig',
|
||||||
|
position: { x: startPosition.x + 380, y: startPosition.y },
|
||||||
|
data: { label: '文生图', model: 'auto', size: '1024x1536' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: imageId,
|
||||||
|
type: 'image',
|
||||||
|
position: { x: startPosition.x + 760, y: startPosition.y },
|
||||||
|
data: { label: '营销图结果', url: '' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: videoConfigId,
|
||||||
|
type: 'videoConfig',
|
||||||
|
position: { x: startPosition.x + 1140, y: startPosition.y },
|
||||||
|
data: { label: '图生视频', model: 'seedance', ratio: '720x1280', dur: 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: videoId,
|
||||||
|
type: 'video',
|
||||||
|
position: { x: startPosition.x + 1520, y: startPosition.y },
|
||||||
|
data: { label: '视频结果', url: '' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const edges = [
|
||||||
|
{ id: makeId('edge'), source: textId, target: imageConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'promptOrder', data: { promptOrder: 1 } },
|
||||||
|
{ id: makeId('edge'), source: imageConfigId, target: imageId, sourceHandle: 'right', targetHandle: 'left' },
|
||||||
|
{ id: makeId('edge'), source: textId, target: videoConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'promptOrder', data: { promptOrder: 1 } },
|
||||||
|
{ id: makeId('edge'), source: imageId, target: videoConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'imageRole', data: { imageRole: 'first_frame_image' } },
|
||||||
|
{ id: makeId('edge'), source: videoConfigId, target: videoId, sourceHandle: 'right', targetHandle: 'left' }
|
||||||
|
]
|
||||||
|
return { nodes, edges }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const getWorkflowById = (id) => WORKFLOW_TEMPLATES.find(w => w.id === id)
|
||||||
|
|
||||||
|
export const getWorkflowsByCategory = (category) => WORKFLOW_TEMPLATES.filter(w => w.category === category)
|
||||||
|
|
||||||
|
export default WORKFLOW_TEMPLATES
|
||||||
25
web/canvas-app/src/hooks/index.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Hooks Entry | Hooks 入口
|
||||||
|
* Exports all hooks for easy import
|
||||||
|
*/
|
||||||
|
|
||||||
|
// API Configuration Hook | API 配置 Hook
|
||||||
|
export { useApiConfig } from './useApiConfig'
|
||||||
|
|
||||||
|
// Model Configuration Hook | 模型配置 Hook
|
||||||
|
export { useModelConfig } from './useModelConfig'
|
||||||
|
|
||||||
|
// Provider Hook | 渠道管理 Hook
|
||||||
|
export { useProvider } from './useProvider'
|
||||||
|
|
||||||
|
// API Operation Hooks | API 操作 Hooks
|
||||||
|
export {
|
||||||
|
useApiState,
|
||||||
|
useChat,
|
||||||
|
useImageGeneration,
|
||||||
|
useVideoGeneration,
|
||||||
|
useApi
|
||||||
|
} from './useApi'
|
||||||
|
|
||||||
|
// Workflow Orchestrator Hook | 工作流编排 Hook
|
||||||
|
export { useWorkflowOrchestrator } from './useWorkflowOrchestrator'
|
||||||
299
web/canvas-app/src/hooks/useApi.js
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import { ref, reactive, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
|
||||||
|
|
||||||
|
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
|
||||||
|
|
||||||
|
const toAssetUrl = (path) => {
|
||||||
|
if (!path) return ''
|
||||||
|
if (/^(https?:|blob:|data:)/i.test(path)) return path
|
||||||
|
return apiUrl(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseApiError = async (response, fallback) => {
|
||||||
|
const text = await response.text().catch(() => '')
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
return parsed?.detail || parsed?.error || fallback
|
||||||
|
} catch {
|
||||||
|
return text || fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestJson = async (path, init = {}) => {
|
||||||
|
const response = await fetch(apiUrl(path), {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
|
...(init.headers || {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await parseApiError(response, `${path} ${response.status}`))
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrlToFile = (dataUrl, filename = 'reference.jpg') => {
|
||||||
|
const [meta, payload] = dataUrl.split(',')
|
||||||
|
const mime = /data:([^;]+)/.exec(meta)?.[1] || 'image/jpeg'
|
||||||
|
const binary = atob(payload || '')
|
||||||
|
const bytes = new Uint8Array(binary.length)
|
||||||
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
||||||
|
return new File([bytes], filename, { type: mime })
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageSourceToFile = async (source, filename = 'reference.jpg') => {
|
||||||
|
if (!source) return null
|
||||||
|
if (source instanceof File) return source
|
||||||
|
if (typeof source !== 'string') return null
|
||||||
|
if (source.startsWith('data:')) return dataUrlToFile(source, filename)
|
||||||
|
const url = source.startsWith('/jobs/') ? apiUrl(source) : source
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) throw new Error(`读取参考图失败 ${response.status}`)
|
||||||
|
const blob = await response.blob()
|
||||||
|
return new File([blob], filename, { type: blob.type || 'image/jpeg' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCreativeImageJob = async (file = null) => {
|
||||||
|
if (file) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return requestJson('/creative/jobs/image', { method: 'POST', body: form })
|
||||||
|
}
|
||||||
|
return requestJson('/creative/jobs/image', { method: 'POST', body: JSON.stringify({}) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadReferenceFrame = async (jobId, file) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return requestJson(`/jobs/${jobId}/frames/upload`, { method: 'POST', body: form })
|
||||||
|
}
|
||||||
|
|
||||||
|
const newestGeneratedImage = (job, frameIdx = 0) => {
|
||||||
|
const frame = (job.frames || []).find(item => item.index === frameIdx) || job.frames?.[0]
|
||||||
|
return [...(frame?.generated_images || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const newestGeneratedVideo = (job) => (
|
||||||
|
[...(job.generated_videos || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
const normalizeVideoSize = (value) => {
|
||||||
|
const raw = String(value || '').trim().toLowerCase()
|
||||||
|
const map = {
|
||||||
|
'9:16': '720x1280',
|
||||||
|
'9x16': '720x1280',
|
||||||
|
'vertical': '720x1280',
|
||||||
|
'portrait': '720x1280',
|
||||||
|
'16:9': '1280x720',
|
||||||
|
'16x9': '1280x720',
|
||||||
|
'horizontal': '1280x720',
|
||||||
|
'landscape': '1280x720',
|
||||||
|
'1:1': '1024x1024',
|
||||||
|
'1x1': '1024x1024',
|
||||||
|
'3:4': '960x1280',
|
||||||
|
'3x4': '960x1280'
|
||||||
|
}
|
||||||
|
if (/^\d+x\d+$/.test(raw)) return raw
|
||||||
|
return map[raw] || '720x1280'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useApiState = () => {
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const status = ref('idle')
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
loading.value = false
|
||||||
|
error.value = null
|
||||||
|
status.value = 'idle'
|
||||||
|
}
|
||||||
|
const setLoading = (isLoading) => {
|
||||||
|
loading.value = isLoading
|
||||||
|
status.value = isLoading ? 'running' : status.value
|
||||||
|
}
|
||||||
|
const setError = (err) => {
|
||||||
|
error.value = err
|
||||||
|
status.value = 'error'
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
const setSuccess = () => {
|
||||||
|
status.value = 'success'
|
||||||
|
loading.value = false
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loading, error, status, reset, setLoading, setError, setSuccess }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChat = () => {
|
||||||
|
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
|
||||||
|
const messages = ref([])
|
||||||
|
const currentResponse = ref('')
|
||||||
|
let stopped = false
|
||||||
|
|
||||||
|
const send = async (content) => {
|
||||||
|
setLoading(true)
|
||||||
|
stopped = false
|
||||||
|
try {
|
||||||
|
const response = await requestJson('/creative/copy', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ goal: content, seconds: 15 })
|
||||||
|
})
|
||||||
|
const variant = response.variants?.[0]
|
||||||
|
const result = variant?.image_prompt_en || variant?.video_prompt_en || content
|
||||||
|
if (!stopped) {
|
||||||
|
currentResponse.value = result
|
||||||
|
messages.value.push({ role: 'user', content })
|
||||||
|
messages.value.push({ role: 'assistant', content: result })
|
||||||
|
}
|
||||||
|
setSuccess()
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
setError(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
stopped = true
|
||||||
|
}
|
||||||
|
const clear = () => {
|
||||||
|
messages.value = []
|
||||||
|
currentResponse.value = ''
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
onUnmounted(() => stop())
|
||||||
|
return { loading, error, status, messages, currentResponse, send, stop, clear, reset }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useImageGeneration = () => {
|
||||||
|
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
|
||||||
|
const images = ref([])
|
||||||
|
const currentImage = ref(null)
|
||||||
|
|
||||||
|
const generate = async (params) => {
|
||||||
|
setLoading(true)
|
||||||
|
images.value = []
|
||||||
|
currentImage.value = null
|
||||||
|
try {
|
||||||
|
const refs = Array.isArray(params.image) ? params.image : (params.image ? [params.image] : [])
|
||||||
|
const firstRef = refs[0] ? await imageSourceToFile(refs[0], 'image-reference.jpg') : null
|
||||||
|
const job = await createCreativeImageJob(firstRef)
|
||||||
|
const updated = await requestJson(`/jobs/${job.id}/frames/0/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: params.prompt || '',
|
||||||
|
model: params.model || 'auto',
|
||||||
|
size: params.size || '1024x1536',
|
||||||
|
mode: firstRef ? 'edit' : 'text'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const generated = newestGeneratedImage(updated, 0)
|
||||||
|
if (!generated?.url) throw new Error('图片生成完成但未返回地址')
|
||||||
|
const result = [{ ...generated, url: toAssetUrl(generated.url), jobId: updated.id, frameIdx: 0 }]
|
||||||
|
images.value = result
|
||||||
|
currentImage.value = result[0]
|
||||||
|
setSuccess()
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
setError(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loading, error, status, images, currentImage, generate, reset }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVideoGeneration = () => {
|
||||||
|
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
|
||||||
|
const video = ref(null)
|
||||||
|
const taskId = ref(null)
|
||||||
|
const progress = reactive({ attempt: 0, maxAttempts: 180, percentage: 0 })
|
||||||
|
|
||||||
|
const createVideoTaskOnly = async (params) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const firstFile = params.first_frame_image ? await imageSourceToFile(params.first_frame_image, 'first-frame.jpg') : null
|
||||||
|
let job = await createCreativeImageJob(firstFile)
|
||||||
|
let lastFrameIdx = null
|
||||||
|
if (params.last_frame_image) {
|
||||||
|
const lastFile = await imageSourceToFile(params.last_frame_image, 'last-frame.jpg')
|
||||||
|
if (lastFile) {
|
||||||
|
job = await uploadReferenceFrame(job.id, lastFile)
|
||||||
|
lastFrameIdx = Math.max(...(job.frames || []).map(frame => frame.index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updated = await requestJson(`/jobs/${job.id}/frames/0/storyboard/video`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: params.prompt || '',
|
||||||
|
duration: Number(params.dur || params.duration || params.seconds || 10),
|
||||||
|
count: 1,
|
||||||
|
first_image: firstFile ? { kind: 'keyframe', frame_idx: 0 } : null,
|
||||||
|
last_image: lastFrameIdx !== null ? { kind: 'keyframe', frame_idx: lastFrameIdx } : null,
|
||||||
|
model: params.model || 'seedance',
|
||||||
|
size: normalizeVideoSize(params.ratio || params.size)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const created = newestGeneratedVideo(updated)
|
||||||
|
if (!created?.id) throw new Error('视频任务已提交但未返回任务编号')
|
||||||
|
const id = `skg:${updated.id}:${created.id}`
|
||||||
|
taskId.value = id
|
||||||
|
status.value = 'polling'
|
||||||
|
setSuccess()
|
||||||
|
return { taskId: id }
|
||||||
|
} catch (err) {
|
||||||
|
setError(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollVideoTask = async (pollTaskId, onProgress = () => {}) => {
|
||||||
|
const match = /^skg:([^:]+):([^:]+)$/.exec(String(pollTaskId || ''))
|
||||||
|
if (!match) throw new Error('未知视频任务类型')
|
||||||
|
const [, jobId, videoId] = match
|
||||||
|
const maxAttempts = 180
|
||||||
|
const interval = 5000
|
||||||
|
|
||||||
|
for (let i = 0; i < maxAttempts; i += 1) {
|
||||||
|
const job = await requestJson(`/jobs/${jobId}`, { method: 'GET' })
|
||||||
|
const item = (job.generated_videos || []).find(v => v.id === videoId)
|
||||||
|
if (!item) throw new Error('视频任务不存在')
|
||||||
|
const percentage = item.progress || Math.min(Math.round((i / maxAttempts) * 100), 98)
|
||||||
|
onProgress(i + 1, percentage)
|
||||||
|
progress.attempt = i + 1
|
||||||
|
progress.percentage = percentage
|
||||||
|
if (item.status === 'completed') {
|
||||||
|
const result = { ...item, url: toAssetUrl(item.url || `/jobs/${jobId}/storyboard-videos/${videoId}.mp4`) }
|
||||||
|
video.value = result
|
||||||
|
setSuccess()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if (item.status === 'failed') {
|
||||||
|
throw new Error(item.error || '视频生成失败')
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, interval))
|
||||||
|
}
|
||||||
|
throw new Error('视频生成超时')
|
||||||
|
}
|
||||||
|
|
||||||
|
const generate = async (params) => {
|
||||||
|
const { taskId: newTaskId, url } = await createVideoTaskOnly(params)
|
||||||
|
if (url) {
|
||||||
|
video.value = { url }
|
||||||
|
return video.value
|
||||||
|
}
|
||||||
|
return pollVideoTask(newTaskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loading, error, status, video, taskId, progress, generate, reset, createVideoTaskOnly, pollVideoTask }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useApi = () => {
|
||||||
|
const chat = useChat()
|
||||||
|
const image = useImageGeneration()
|
||||||
|
const videoGen = useVideoGeneration()
|
||||||
|
return { config: {}, chat, image, video: videoGen }
|
||||||
|
}
|
||||||
26
web/canvas-app/src/hooks/useApiConfig.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal auth/session config.
|
||||||
|
* Upstream model credentials stay on the server and are not configured in this UI.
|
||||||
|
*/
|
||||||
|
export const useApiConfig = () => {
|
||||||
|
const apiKey = ref('internal-session')
|
||||||
|
const baseUrl = ref('/api')
|
||||||
|
const isConfigured = computed(() => true)
|
||||||
|
|
||||||
|
const setApiKey = () => {}
|
||||||
|
const setBaseUrl = () => {}
|
||||||
|
const configure = () => {}
|
||||||
|
const clear = () => {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
isConfigured,
|
||||||
|
setApiKey,
|
||||||
|
setBaseUrl,
|
||||||
|
configure,
|
||||||
|
clear
|
||||||
|
}
|
||||||
|
}
|
||||||
433
web/canvas-app/src/hooks/useModelConfig.js
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
/**
|
||||||
|
* Model Config Hook | 模型配置 Hook
|
||||||
|
* Manages model configuration with local storage persistence
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { STORAGE_KEYS } from '@/utils'
|
||||||
|
import { useProvider } from './useProvider'
|
||||||
|
import {
|
||||||
|
CHAT_MODELS,
|
||||||
|
IMAGE_MODELS,
|
||||||
|
VIDEO_MODELS,
|
||||||
|
DEFAULT_CHAT_MODEL,
|
||||||
|
DEFAULT_IMAGE_MODEL,
|
||||||
|
DEFAULT_VIDEO_MODEL
|
||||||
|
} from '@/config/models'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查模型是否支持指定渠道
|
||||||
|
* @param {Object} model - 模型配置
|
||||||
|
* @param {string} provider - 渠道名称
|
||||||
|
* @returns {boolean} 是否支持
|
||||||
|
*/
|
||||||
|
const isModelSupported = (model, provider) => {
|
||||||
|
// 如果没有 provider 字段,默认支持所有渠道
|
||||||
|
if (!model.provider) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 如果有 provider 字段,检查是否包含指定渠道
|
||||||
|
return model.provider.includes(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored JSON value from localStorage
|
||||||
|
*/
|
||||||
|
const getStoredJson = (key, defaultValue = []) => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(key)
|
||||||
|
return stored ? JSON.parse(stored) : defaultValue
|
||||||
|
} catch {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set stored JSON value to localStorage
|
||||||
|
*/
|
||||||
|
const setStoredJson = (key, value) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value))
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored string value from localStorage
|
||||||
|
*/
|
||||||
|
const getStored = (key, defaultValue = '') => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key) || defaultValue
|
||||||
|
} catch {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set stored string value to localStorage
|
||||||
|
*/
|
||||||
|
const setStored = (key, value) => {
|
||||||
|
try {
|
||||||
|
if (value) {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared reactive state (singleton pattern)
|
||||||
|
const customChatModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, []))
|
||||||
|
const customImageModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, []))
|
||||||
|
const customVideoModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, []))
|
||||||
|
|
||||||
|
// 按渠道存储的自定义模型 | 结构: { 'skg': [{key, label}] }
|
||||||
|
const customChatModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER || 'custom-chat-models-by-provider', {}))
|
||||||
|
const customImageModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER || 'custom-image-models-by-provider', {}))
|
||||||
|
const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER || 'custom-video-models-by-provider', {}))
|
||||||
|
|
||||||
|
const selectedChatModel = ref(getStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, DEFAULT_CHAT_MODEL))
|
||||||
|
const selectedImageModel = ref(getStored(STORAGE_KEYS.SELECTED_IMAGE_MODEL, DEFAULT_IMAGE_MODEL))
|
||||||
|
const selectedVideoModel = ref(getStored(STORAGE_KEYS.SELECTED_VIDEO_MODEL, DEFAULT_VIDEO_MODEL))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model Configuration Hook
|
||||||
|
*/
|
||||||
|
export const useModelConfig = () => {
|
||||||
|
// Get current provider | 获取当前渠道
|
||||||
|
const { currentProvider } = useProvider()
|
||||||
|
|
||||||
|
// Combined models (built-in + custom, including provider-specific custom models)
|
||||||
|
const allChatModels = computed(() => [
|
||||||
|
...CHAT_MODELS.map(m => ({ ...m, isCustom: false })),
|
||||||
|
...customChatModels.value.map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true
|
||||||
|
})),
|
||||||
|
// 添加当前渠道的自定义模型
|
||||||
|
...(customChatModelsByProvider.value[currentProvider.value] || []).map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
provider: [currentProvider.value]
|
||||||
|
}))
|
||||||
|
])
|
||||||
|
|
||||||
|
const allImageModels = computed(() => [
|
||||||
|
...IMAGE_MODELS.map(m => ({ ...m, isCustom: false })),
|
||||||
|
...customImageModels.value.map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
sizes: [],
|
||||||
|
defaultParams: { quality: 'standard', style: 'vivid' }
|
||||||
|
})),
|
||||||
|
// 添加当前渠道的自定义模型
|
||||||
|
...(customImageModelsByProvider.value[currentProvider.value] || []).map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
sizes: [],
|
||||||
|
defaultParams: { quality: 'standard', style: 'vivid' },
|
||||||
|
provider: [currentProvider.value]
|
||||||
|
}))
|
||||||
|
])
|
||||||
|
|
||||||
|
const allVideoModels = computed(() => [
|
||||||
|
...VIDEO_MODELS.map(m => ({ ...m, isCustom: false })),
|
||||||
|
...customVideoModels.value.map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
ratios: ['16x9', '9:16', '1:1'],
|
||||||
|
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||||
|
defaultParams: { ratio: '16:9', duration: 5 }
|
||||||
|
})),
|
||||||
|
// 添加当前渠道的自定义模型
|
||||||
|
...(customVideoModelsByProvider.value[currentProvider.value] || []).map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
ratios: ['16x9', '9:16', '1:1'],
|
||||||
|
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||||
|
defaultParams: { ratio: '16:9', duration: 5 },
|
||||||
|
provider: [currentProvider.value]
|
||||||
|
}))
|
||||||
|
])
|
||||||
|
|
||||||
|
// Available models filtered by provider | 根据渠道过滤的可用模型
|
||||||
|
const availableChatModels = computed(() =>
|
||||||
|
allChatModels.value.filter(m => isModelSupported(m, currentProvider.value))
|
||||||
|
)
|
||||||
|
|
||||||
|
const availableImageModels = computed(() =>
|
||||||
|
allImageModels.value.filter(m => isModelSupported(m, currentProvider.value))
|
||||||
|
)
|
||||||
|
|
||||||
|
const availableVideoModels = computed(() =>
|
||||||
|
allVideoModels.value.filter(m => isModelSupported(m, currentProvider.value))
|
||||||
|
)
|
||||||
|
|
||||||
|
// All models (including models from all providers, not filtered) | 所有模型(不按渠道过滤)
|
||||||
|
const allAvailableChatModels = computed(() => allChatModels.value)
|
||||||
|
const allAvailableImageModels = computed(() => allImageModels.value)
|
||||||
|
const allAvailableVideoModels = computed(() => allVideoModels.value)
|
||||||
|
|
||||||
|
// 获取指定渠道的模型(包括内置 + 该渠道自定义)
|
||||||
|
const getModelsByProvider = (provider) => {
|
||||||
|
const chat = [
|
||||||
|
...CHAT_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })),
|
||||||
|
...(customChatModelsByProvider.value[provider] || []).map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
provider: [provider]
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
const image = [
|
||||||
|
...IMAGE_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })),
|
||||||
|
...(customImageModelsByProvider.value[provider] || []).map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
sizes: [],
|
||||||
|
defaultParams: { quality: 'standard', style: 'vivid' },
|
||||||
|
provider: [provider]
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
const video = [
|
||||||
|
...VIDEO_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })),
|
||||||
|
...(customVideoModelsByProvider.value[provider] || []).map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
ratios: ['16x9', '9:16', '1:1'],
|
||||||
|
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||||
|
defaultParams: { ratio: '16:9', duration: 5 },
|
||||||
|
provider: [provider]
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
return { chat, image, video }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch and persist changes
|
||||||
|
watch(customChatModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, val), { deep: true })
|
||||||
|
watch(customImageModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, val), { deep: true })
|
||||||
|
watch(customVideoModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, val), { deep: true })
|
||||||
|
|
||||||
|
// Watch and persist by provider changes
|
||||||
|
watch(customChatModelsByProvider, (val) => {
|
||||||
|
const key = STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER || 'custom-chat-models-by-provider'
|
||||||
|
setStoredJson(key, val)
|
||||||
|
}, { deep: true })
|
||||||
|
watch(customImageModelsByProvider, (val) => {
|
||||||
|
const key = STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER || 'custom-image-models-by-provider'
|
||||||
|
setStoredJson(key, val)
|
||||||
|
}, { deep: true })
|
||||||
|
watch(customVideoModelsByProvider, (val) => {
|
||||||
|
const key = STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER || 'custom-video-models-by-provider'
|
||||||
|
setStoredJson(key, val)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(selectedChatModel, (val) => setStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, val))
|
||||||
|
watch(selectedImageModel, (val) => setStored(STORAGE_KEYS.SELECTED_IMAGE_MODEL, val))
|
||||||
|
watch(selectedVideoModel, (val) => setStored(STORAGE_KEYS.SELECTED_VIDEO_MODEL, val))
|
||||||
|
|
||||||
|
// Add custom model
|
||||||
|
const addCustomChatModel = (modelKey, label = '') => {
|
||||||
|
if (!modelKey || customChatModels.value.some(m => m.key === modelKey)) return false
|
||||||
|
customChatModels.value.push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomImageModel = (modelKey, label = '') => {
|
||||||
|
if (!modelKey || customImageModels.value.some(m => m.key === modelKey)) return false
|
||||||
|
customImageModels.value.push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomVideoModel = (modelKey, label = '') => {
|
||||||
|
if (!modelKey || customVideoModels.value.some(m => m.key === modelKey)) return false
|
||||||
|
customVideoModels.value.push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove custom model
|
||||||
|
const removeCustomChatModel = (modelKey) => {
|
||||||
|
const idx = customChatModels.value.findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customChatModels.value.splice(idx, 1)
|
||||||
|
if (selectedChatModel.value === modelKey) {
|
||||||
|
selectedChatModel.value = DEFAULT_CHAT_MODEL
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomImageModel = (modelKey) => {
|
||||||
|
const idx = customImageModels.value.findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customImageModels.value.splice(idx, 1)
|
||||||
|
if (selectedImageModel.value === modelKey) {
|
||||||
|
selectedImageModel.value = DEFAULT_IMAGE_MODEL
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomVideoModel = (modelKey) => {
|
||||||
|
const idx = customVideoModels.value.findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customVideoModels.value.splice(idx, 1)
|
||||||
|
if (selectedVideoModel.value === modelKey) {
|
||||||
|
selectedVideoModel.value = DEFAULT_VIDEO_MODEL
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按渠道添加自定义模型
|
||||||
|
const addCustomChatModelByProvider = (modelKey, provider, label = '') => {
|
||||||
|
if (!modelKey) return false
|
||||||
|
if (!customChatModelsByProvider.value[provider]) {
|
||||||
|
customChatModelsByProvider.value[provider] = []
|
||||||
|
}
|
||||||
|
if (customChatModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
|
||||||
|
customChatModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomImageModelByProvider = (modelKey, provider, label = '') => {
|
||||||
|
if (!modelKey) return false
|
||||||
|
if (!customImageModelsByProvider.value[provider]) {
|
||||||
|
customImageModelsByProvider.value[provider] = []
|
||||||
|
}
|
||||||
|
if (customImageModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
|
||||||
|
customImageModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomVideoModelByProvider = (modelKey, provider, label = '') => {
|
||||||
|
if (!modelKey) return false
|
||||||
|
if (!customVideoModelsByProvider.value[provider]) {
|
||||||
|
customVideoModelsByProvider.value[provider] = []
|
||||||
|
}
|
||||||
|
if (customVideoModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
|
||||||
|
customVideoModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按渠道删除自定义模型
|
||||||
|
const removeCustomChatModelByProvider = (modelKey, provider) => {
|
||||||
|
if (!customChatModelsByProvider.value[provider]) return false
|
||||||
|
const idx = customChatModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customChatModelsByProvider.value[provider].splice(idx, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomImageModelByProvider = (modelKey, provider) => {
|
||||||
|
if (!customImageModelsByProvider.value[provider]) return false
|
||||||
|
const idx = customImageModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customImageModelsByProvider.value[provider].splice(idx, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomVideoModelByProvider = (modelKey, provider) => {
|
||||||
|
if (!customVideoModelsByProvider.value[provider]) return false
|
||||||
|
const idx = customVideoModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customVideoModelsByProvider.value[provider].splice(idx, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get model by key
|
||||||
|
const getChatModel = (key) => allChatModels.value.find(m => m.key === key)
|
||||||
|
const getImageModel = (key) => allImageModels.value.find(m => m.key === key)
|
||||||
|
const getVideoModel = (key) => allVideoModels.value.find(m => m.key === key)
|
||||||
|
|
||||||
|
// Clear all custom models
|
||||||
|
const clearCustomModels = () => {
|
||||||
|
customChatModels.value = []
|
||||||
|
customImageModels.value = []
|
||||||
|
customVideoModels.value = []
|
||||||
|
selectedChatModel.value = DEFAULT_CHAT_MODEL
|
||||||
|
selectedImageModel.value = DEFAULT_IMAGE_MODEL
|
||||||
|
selectedVideoModel.value = DEFAULT_VIDEO_MODEL
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// All models (built-in + custom)
|
||||||
|
allChatModels,
|
||||||
|
allImageModels,
|
||||||
|
allVideoModels,
|
||||||
|
|
||||||
|
// Available models filtered by provider | 根据渠道过滤的可用模型
|
||||||
|
availableChatModels,
|
||||||
|
availableImageModels,
|
||||||
|
availableVideoModels,
|
||||||
|
|
||||||
|
// All models (including models from all providers, not filtered) | 所有模型(不按渠道过滤)
|
||||||
|
allAvailableChatModels,
|
||||||
|
allAvailableImageModels,
|
||||||
|
allAvailableVideoModels,
|
||||||
|
|
||||||
|
// Custom models only
|
||||||
|
customChatModels,
|
||||||
|
customImageModels,
|
||||||
|
customVideoModels,
|
||||||
|
|
||||||
|
// Selected models
|
||||||
|
selectedChatModel,
|
||||||
|
selectedImageModel,
|
||||||
|
selectedVideoModel,
|
||||||
|
|
||||||
|
// Add methods
|
||||||
|
addCustomChatModel,
|
||||||
|
addCustomImageModel,
|
||||||
|
addCustomVideoModel,
|
||||||
|
|
||||||
|
// Remove methods
|
||||||
|
removeCustomChatModel,
|
||||||
|
removeCustomImageModel,
|
||||||
|
removeCustomVideoModel,
|
||||||
|
|
||||||
|
// Get model
|
||||||
|
getChatModel,
|
||||||
|
getImageModel,
|
||||||
|
getVideoModel,
|
||||||
|
|
||||||
|
// Get models by provider
|
||||||
|
getModelsByProvider,
|
||||||
|
|
||||||
|
// Custom models by provider
|
||||||
|
customChatModelsByProvider,
|
||||||
|
customImageModelsByProvider,
|
||||||
|
customVideoModelsByProvider,
|
||||||
|
|
||||||
|
// Add/Remove by provider methods
|
||||||
|
addCustomChatModelByProvider,
|
||||||
|
addCustomImageModelByProvider,
|
||||||
|
addCustomVideoModelByProvider,
|
||||||
|
removeCustomChatModelByProvider,
|
||||||
|
removeCustomImageModelByProvider,
|
||||||
|
removeCustomVideoModelByProvider,
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
clearCustomModels
|
||||||
|
}
|
||||||
|
}
|
||||||
103
web/canvas-app/src/hooks/useNodeRef.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 节点引用解析 Hook
|
||||||
|
* 用于解析文本中的 @[nodeId] 引用格式
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析文本中的 @ 引用
|
||||||
|
* @param {string} text - 待解析的文本
|
||||||
|
* @returns {Array<{nodeId: string, name?: string, order: number}>} 解析出的引用列表
|
||||||
|
*/
|
||||||
|
export function parseMentions(text) {
|
||||||
|
if (!text) return []
|
||||||
|
|
||||||
|
const mentions = []
|
||||||
|
// 匹配 @[nodeId] 或 @[nodeId|name] 格式
|
||||||
|
const regex = /@\[([^\]|]+)(?:\|([^\]]+))?\]/g
|
||||||
|
let match
|
||||||
|
let order = 0
|
||||||
|
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
mentions.push({
|
||||||
|
nodeId: match[1],
|
||||||
|
name: match[2] || null,
|
||||||
|
order: order++
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return mentions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文本是否包含对指定节点的 @ 引用
|
||||||
|
* @param {string} text - 待检查的文本
|
||||||
|
* @param {string} nodeId - 节点ID
|
||||||
|
* @returns {boolean} 是否包含引用
|
||||||
|
*/
|
||||||
|
export function hasMention(text, nodeId) {
|
||||||
|
const mentions = parseMentions(text)
|
||||||
|
return mentions.some(m => m.nodeId === nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文本中提取对指定节点的引用
|
||||||
|
* @param {string} text - 待解析的文本
|
||||||
|
* @param {string} nodeId - 节点ID
|
||||||
|
* @returns {Array<{nodeId: string, name?: string, order: number}>} 匹配的引用
|
||||||
|
*/
|
||||||
|
export function getMentionsToNode(text, nodeId) {
|
||||||
|
const mentions = parseMentions(text)
|
||||||
|
return mentions.filter(m => m.nodeId === nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理文本中的 @ 引用标记,保留引用名称(如果有)
|
||||||
|
* @param {string} text - 待清理的文本
|
||||||
|
* @param {string} placeholder - 替换引用的占位符,默认空字符串
|
||||||
|
* @returns {string} 清理后的文本
|
||||||
|
*/
|
||||||
|
export function cleanMentions(text, placeholder = '') {
|
||||||
|
if (!text) return ''
|
||||||
|
return text.replace(/@\[([^\]|]+)(?:\|([^\]]+))?\]/g, (_, nodeId, name) => {
|
||||||
|
return name || placeholder
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在文本中插入 @ 引用
|
||||||
|
* @param {string} text - 原文本
|
||||||
|
* @param {string} nodeId - 节点ID
|
||||||
|
* @param {string} name - 显示名称(可选)
|
||||||
|
* @param {number} position - 插入位置(默认末尾)
|
||||||
|
* @returns {string} 插入引用后的文本
|
||||||
|
*/
|
||||||
|
export function insertMention(text, nodeId, name = null, position = -1) {
|
||||||
|
const mention = name ? `@[${nodeId}|${name}]` : `@[${nodeId}]`
|
||||||
|
|
||||||
|
if (position < 0 || position >= text.length) {
|
||||||
|
return text + mention
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.slice(0, position) + mention + text.slice(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文本中移除指定节点的 @ 引用
|
||||||
|
* @param {string} text - 原文本
|
||||||
|
* @param {string} nodeId - 节点ID
|
||||||
|
* @returns {string} 移除引用后的文本
|
||||||
|
*/
|
||||||
|
export function removeMention(text, nodeId) {
|
||||||
|
if (!text) return ''
|
||||||
|
return text.replace(new RegExp(`@\\[${nodeId}(?:\\|[^\\]]+)?\\]`, 'g'), '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文本中所有 @ 引用的节点ID列表(去重)
|
||||||
|
* @param {string} text - 待解析的文本
|
||||||
|
* @returns {string[]} 节点ID列表
|
||||||
|
*/
|
||||||
|
export function getMentionedNodeIds(text) {
|
||||||
|
const mentions = parseMentions(text)
|
||||||
|
return [...new Set(mentions.map(m => m.nodeId))]
|
||||||
|
}
|
||||||
124
web/canvas-app/src/hooks/useProvider.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Provider Hook | 渠道管理 Hook
|
||||||
|
* 管理当前选中的 API 渠道,提供请求/响应适配功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { PROVIDERS, getProviderList, getDefaultProvider, getProviderConfig } from '@/config/providers'
|
||||||
|
|
||||||
|
// 存储键名
|
||||||
|
const STORAGE_KEY = 'api-provider'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored value from localStorage | 从 localStorage 获取存储值
|
||||||
|
*/
|
||||||
|
const getStored = (key, defaultValue = '') => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key) || defaultValue
|
||||||
|
} catch {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set stored value to localStorage | 设置存储值到 localStorage
|
||||||
|
*/
|
||||||
|
const setStored = (key, value) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove stored value from localStorage | 从 localStorage 移除存储值
|
||||||
|
*/
|
||||||
|
const removeStored = (key) => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取存储的渠道
|
||||||
|
*/
|
||||||
|
const getStoredProvider = () => {
|
||||||
|
return getStored(STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider Hook | 渠道管理 Hook
|
||||||
|
*/
|
||||||
|
export const useProvider = () => {
|
||||||
|
// 当前选中的渠道
|
||||||
|
const currentProvider = ref(getStoredProvider() || getDefaultProvider())
|
||||||
|
|
||||||
|
// 渠道列表
|
||||||
|
const providerList = getProviderList()
|
||||||
|
|
||||||
|
// 当前渠道配置
|
||||||
|
const providerConfig = computed(() => getProviderConfig(currentProvider.value))
|
||||||
|
|
||||||
|
// 当前渠道标签
|
||||||
|
const providerLabel = computed(() => providerConfig.value.label || currentProvider.value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前渠道
|
||||||
|
*/
|
||||||
|
const setProvider = (provider) => {
|
||||||
|
if (PROVIDERS[provider]) {
|
||||||
|
currentProvider.value = provider
|
||||||
|
setStored(STORAGE_KEY, provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除渠道配置
|
||||||
|
*/
|
||||||
|
const clearProvider = () => {
|
||||||
|
currentProvider.value = getDefaultProvider()
|
||||||
|
removeStored(STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 适配请求参数
|
||||||
|
* @param {string} type - 请求类型:'chat' | 'image' | 'video'
|
||||||
|
* @param {Object} params - 原始请求参数
|
||||||
|
*/
|
||||||
|
const adaptRequest = (type, params) => {
|
||||||
|
const config = providerConfig.value
|
||||||
|
if (config.requestAdapter && config.requestAdapter[type]) {
|
||||||
|
return config.requestAdapter[type](params)
|
||||||
|
}
|
||||||
|
// 如果没有适配器,返回原始参数
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 适配响应数据
|
||||||
|
* @param {string} type - 响应类型:'chat' | 'image' | 'video'
|
||||||
|
* @param {Object} response - 原始响应数据
|
||||||
|
*/
|
||||||
|
const adaptResponse = (type, response) => {
|
||||||
|
const config = providerConfig.value
|
||||||
|
if (config.responseAdapter && config.responseAdapter[type]) {
|
||||||
|
return config.responseAdapter[type](response)
|
||||||
|
}
|
||||||
|
// 如果没有适配器,返回原始响应
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentProvider,
|
||||||
|
providerList,
|
||||||
|
providerConfig,
|
||||||
|
providerLabel,
|
||||||
|
setProvider,
|
||||||
|
clearProvider,
|
||||||
|
adaptRequest,
|
||||||
|
adaptResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
86
web/canvas-app/src/hooks/useWorkflowOrchestrator.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { addNode, addEdge, updateNode } from '@/stores/canvas'
|
||||||
|
|
||||||
|
const WORKFLOW_TYPES = {
|
||||||
|
TEXT_TO_IMAGE: 'text_to_image',
|
||||||
|
TEXT_TO_IMAGE_TO_VIDEO: 'text_to_image_to_video',
|
||||||
|
STORYBOARD: 'storyboard'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWorkflowOrchestrator = () => {
|
||||||
|
const isAnalyzing = ref(false)
|
||||||
|
const isExecuting = ref(false)
|
||||||
|
const currentStep = ref(0)
|
||||||
|
const totalSteps = ref(0)
|
||||||
|
const executionLog = ref([])
|
||||||
|
|
||||||
|
const addLog = (type, message) => {
|
||||||
|
executionLog.value.push({ type, message, timestamp: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyzeIntent = async (userInput) => {
|
||||||
|
const text = String(userInput || '')
|
||||||
|
const wantsVideo = /视频|动起来|镜头|运动|video|motion/i.test(text)
|
||||||
|
return {
|
||||||
|
workflow_type: wantsVideo ? WORKFLOW_TYPES.TEXT_TO_IMAGE_TO_VIDEO : WORKFLOW_TYPES.TEXT_TO_IMAGE,
|
||||||
|
description: wantsVideo ? 'SKG 文生视频' : 'SKG 文生图',
|
||||||
|
image_prompt: text,
|
||||||
|
video_prompt: text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeWorkflow = async (params, position = { x: 100, y: 100 }) => {
|
||||||
|
isExecuting.value = true
|
||||||
|
currentStep.value = 1
|
||||||
|
totalSteps.value = params.workflow_type === WORKFLOW_TYPES.TEXT_TO_IMAGE_TO_VIDEO ? 3 : 2
|
||||||
|
try {
|
||||||
|
const textId = addNode('text', position, {
|
||||||
|
label: '提示词',
|
||||||
|
content: params.image_prompt || params.video_prompt || ''
|
||||||
|
})
|
||||||
|
if (params.workflow_type === WORKFLOW_TYPES.TEXT_TO_IMAGE_TO_VIDEO) {
|
||||||
|
const videoConfigId = addNode('videoConfig', { x: position.x + 400, y: position.y }, {
|
||||||
|
label: '文生视频',
|
||||||
|
autoExecute: true
|
||||||
|
})
|
||||||
|
addEdge({ source: textId, target: videoConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'promptOrder', data: { promptOrder: 1 } })
|
||||||
|
updateNode(videoConfigId, { autoExecute: true })
|
||||||
|
} else {
|
||||||
|
const imageConfigId = addNode('imageConfig', { x: position.x + 400, y: position.y }, {
|
||||||
|
label: '文生图',
|
||||||
|
autoExecute: true
|
||||||
|
})
|
||||||
|
addEdge({ source: textId, target: imageConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'promptOrder', data: { promptOrder: 1 } })
|
||||||
|
updateNode(imageConfigId, { autoExecute: true })
|
||||||
|
}
|
||||||
|
addLog('success', 'SKG 工作流已启动')
|
||||||
|
} finally {
|
||||||
|
isExecuting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTextToImageWorkflow = (imagePrompt, position) => executeWorkflow({
|
||||||
|
workflow_type: WORKFLOW_TYPES.TEXT_TO_IMAGE,
|
||||||
|
image_prompt: imagePrompt
|
||||||
|
}, position)
|
||||||
|
|
||||||
|
const createMultiAngleStoryboard = (characterDescription, position) => executeWorkflow({
|
||||||
|
workflow_type: WORKFLOW_TYPES.STORYBOARD,
|
||||||
|
image_prompt: `SKG 多角度营销分镜,主体要求:${characterDescription || '保持主体和产品一致'}`
|
||||||
|
}, position)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAnalyzing,
|
||||||
|
isExecuting,
|
||||||
|
currentStep,
|
||||||
|
totalSteps,
|
||||||
|
executionLog,
|
||||||
|
analyzeIntent,
|
||||||
|
executeWorkflow,
|
||||||
|
createTextToImageWorkflow,
|
||||||
|
createMultiAngleStoryboard,
|
||||||
|
WORKFLOW_TYPES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useWorkflowOrchestrator
|
||||||
15
web/canvas-app/src/main.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Main entry point | 主入口
|
||||||
|
*/
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
24
web/canvas-app/src/router/index.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Router configuration | 路由配置
|
||||||
|
*/
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: () => import('../views/Home.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/p/:id?',
|
||||||
|
name: 'Canvas',
|
||||||
|
component: () => import('../views/Canvas.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory('/canvas/'),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
10
web/canvas-app/src/stores/api.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* API Store | API 状态存储
|
||||||
|
* Pure global state - internal session config lives in hooks/useApiConfig.js
|
||||||
|
* 纯全局状态 - 内部会话配置位于 hooks/useApiConfig.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export from hook for backward compatibility | 为向后兼容重新导出
|
||||||
|
export { useApiConfig } from '../hooks/useApiConfig'
|
||||||
|
|
||||||
|
// For components that need direct access to config state | 用于需要直接访问配置状态的组件
|
||||||
559
web/canvas-app/src/stores/canvas.js
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
/**
|
||||||
|
* Canvas store | 画布状态管理
|
||||||
|
* Manages nodes, edges and canvas state
|
||||||
|
*/
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { updateProjectCanvas, getProjectCanvas } from './projects'
|
||||||
|
import { IMAGE_MODELS, VIDEO_MODELS, CHAT_MODELS, DEFAULT_IMAGE_MODEL, DEFAULT_VIDEO_MODEL, DEFAULT_CHAT_MODEL } from '../config/models'
|
||||||
|
|
||||||
|
// Node ID counter | 节点ID计数器
|
||||||
|
let nodeId = 0
|
||||||
|
const getNodeId = () => `node_${nodeId++}`
|
||||||
|
|
||||||
|
// Current project ID | 当前项目ID
|
||||||
|
export const currentProjectId = ref(null)
|
||||||
|
|
||||||
|
// Nodes and edges | 节点和边
|
||||||
|
export const nodes = ref([])
|
||||||
|
export const edges = ref([])
|
||||||
|
|
||||||
|
// Viewport state | 视口状态
|
||||||
|
export const canvasViewport = ref({ x: 100, y: 50, zoom: 0.8 })
|
||||||
|
|
||||||
|
// Selected node | 选中的节点
|
||||||
|
export const selectedNode = ref(null)
|
||||||
|
|
||||||
|
// Auto-save flag | 自动保存标志
|
||||||
|
let autoSaveEnabled = false
|
||||||
|
let saveTimeout = null
|
||||||
|
|
||||||
|
// History for undo/redo | 撤销/重做历史
|
||||||
|
const history = ref([])
|
||||||
|
const historyIndex = ref(-1)
|
||||||
|
const MAX_HISTORY = 50
|
||||||
|
let isRestoring = false
|
||||||
|
|
||||||
|
// Position change threshold for history | 位置变化阈值
|
||||||
|
const POSITION_THRESHOLD = 10
|
||||||
|
|
||||||
|
// Batch operation tracking | 批量操作跟踪
|
||||||
|
let isBatchOperation = false
|
||||||
|
let batchStartState = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save current state to history | 保存当前状态到历史
|
||||||
|
* @param {boolean} force - Force save even if batch operation | 强制保存,即使在批量操作中
|
||||||
|
*/
|
||||||
|
const saveToHistory = (force = false) => {
|
||||||
|
if (isRestoring) return
|
||||||
|
|
||||||
|
// If in batch operation and not forced, don't save | 如果在批量操作中且未强制保存,则不保存
|
||||||
|
if (isBatchOperation && !force) return
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
nodes: JSON.parse(JSON.stringify(nodes.value)),
|
||||||
|
edges: JSON.parse(JSON.stringify(edges.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove future history if we're not at the end | 如果不在末尾,删除未来历史
|
||||||
|
if (historyIndex.value < history.value.length - 1) {
|
||||||
|
history.value = history.value.slice(0, historyIndex.value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new state | 添加新状态
|
||||||
|
history.value.push(state)
|
||||||
|
|
||||||
|
// Limit history size | 限制历史大小
|
||||||
|
if (history.value.length > MAX_HISTORY) {
|
||||||
|
history.value.shift()
|
||||||
|
} else {
|
||||||
|
historyIndex.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start batch operation | 开始批量操作
|
||||||
|
* Records the starting state for batch operations
|
||||||
|
*/
|
||||||
|
export const startBatchOperation = () => {
|
||||||
|
isBatchOperation = true
|
||||||
|
batchStartState = {
|
||||||
|
nodes: JSON.parse(JSON.stringify(nodes.value)),
|
||||||
|
edges: JSON.parse(JSON.stringify(edges.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End batch operation and save to history | 结束批量操作并保存到历史
|
||||||
|
* Compares with start state to decide if save is needed
|
||||||
|
*/
|
||||||
|
export const endBatchOperation = () => {
|
||||||
|
if (!isBatchOperation || !batchStartState) {
|
||||||
|
isBatchOperation = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are significant changes | 检查是否有显著变化
|
||||||
|
const hasSignificantChanges = checkSignificantChanges(batchStartState, {
|
||||||
|
nodes: nodes.value,
|
||||||
|
edges: edges.value
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasSignificantChanges) {
|
||||||
|
saveToHistory(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
isBatchOperation = false
|
||||||
|
batchStartState = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if changes are significant enough to save | 检查变化是否足够显著需要保存
|
||||||
|
* @param {object} oldState - Previous state | 之前的状态
|
||||||
|
* @param {object} newState - New state | 新状态
|
||||||
|
* @returns {boolean} - Whether changes should be saved | 是否应该保存变化
|
||||||
|
*/
|
||||||
|
const checkSignificantChanges = (oldState, newState) => {
|
||||||
|
const oldNodes = oldState.nodes || []
|
||||||
|
const newNodes = newState.nodes || []
|
||||||
|
|
||||||
|
// Check for added or removed nodes | 检查添加或删除的节点
|
||||||
|
if (oldNodes.length !== newNodes.length) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for new nodes (by comparing IDs) | 检查新节点
|
||||||
|
const oldNodeIds = new Set(oldNodes.map(n => n.id))
|
||||||
|
const newNodeIds = new Set(newNodes.map(n => n.id))
|
||||||
|
|
||||||
|
// Nodes added | 添加的节点
|
||||||
|
for (const id of newNodeIds) {
|
||||||
|
if (!oldNodeIds.has(id)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nodes removed | 删除的节点
|
||||||
|
for (const id of oldNodeIds) {
|
||||||
|
if (!newNodeIds.has(id)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check position changes for existing nodes | 检查现有节点的位置变化
|
||||||
|
for (const newNode of newNodes) {
|
||||||
|
const oldNode = oldNodes.find(n => n.id === newNode.id)
|
||||||
|
if (oldNode) {
|
||||||
|
const dx = Math.abs(newNode.position.x - oldNode.position.x)
|
||||||
|
const dy = Math.abs(newNode.position.y - oldNode.position.y)
|
||||||
|
|
||||||
|
// If any node moved beyond threshold, save | 如果任何节点移动超过阈值,则保存
|
||||||
|
if (dx > POSITION_THRESHOLD || dy > POSITION_THRESHOLD) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for edge changes | 检查边的变化
|
||||||
|
const oldEdges = oldState.edges || []
|
||||||
|
const newEdges = newState.edges || []
|
||||||
|
|
||||||
|
if (oldEdges.length !== newEdges.length) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a new node | 添加新节点
|
||||||
|
export const addNode = (type, position = { x: 100, y: 100 }, data = {}) => {
|
||||||
|
const id = getNodeId()
|
||||||
|
const now = Date.now()
|
||||||
|
const newNode = {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
position,
|
||||||
|
data: {
|
||||||
|
...getDefaultNodeData(type),
|
||||||
|
...data,
|
||||||
|
createdAt: data.createdAt || now,
|
||||||
|
updatedAt: data.updatedAt || now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes.value = [...nodes.value, newNode]
|
||||||
|
saveToHistory() // Save after adding node | 添加节点后保存
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple nodes in batch | 批量添加多个节点
|
||||||
|
* Uses batch operation to group all node additions into one history entry
|
||||||
|
* @param {Array} nodeSpecs - Array of node specs [{ type, position, data }, ...]
|
||||||
|
* @param {boolean} autoBatch - Whether to auto-manage batch operation (default: true)
|
||||||
|
* @returns {Array} - Array of created node IDs | 创建的节点ID数组
|
||||||
|
*/
|
||||||
|
export const addNodes = (nodeSpecs, autoBatch = true) => {
|
||||||
|
if (!nodeSpecs || nodeSpecs.length === 0) return []
|
||||||
|
|
||||||
|
// Start batch operation if auto | 如果自动管理则开始批量操作
|
||||||
|
if (autoBatch) {
|
||||||
|
startBatchOperation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = []
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
nodeSpecs.forEach(spec => {
|
||||||
|
const { type, position = { x: 100, y: 100 }, data = {} } = spec
|
||||||
|
const id = getNodeId()
|
||||||
|
const newNode = {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
position,
|
||||||
|
data: {
|
||||||
|
...getDefaultNodeData(type),
|
||||||
|
...data,
|
||||||
|
createdAt: data.createdAt || now,
|
||||||
|
updatedAt: data.updatedAt || now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes.value = [...nodes.value, newNode]
|
||||||
|
ids.push(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// End batch operation if auto | 如果自动管理则结束批量操作并保存到历史
|
||||||
|
if (autoBatch) {
|
||||||
|
endBatchOperation()
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get default data for node type | 获取节点类型的默认数据
|
||||||
|
const getDefaultNodeData = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'text':
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
label: '文本输入',
|
||||||
|
publicProps: {} // 公共属性(可被 @ 引用)
|
||||||
|
}
|
||||||
|
case 'imageConfig': {
|
||||||
|
const imageModel = IMAGE_MODELS.find(m => m.key === DEFAULT_IMAGE_MODEL) || IMAGE_MODELS[0]
|
||||||
|
return {
|
||||||
|
prompt: '',
|
||||||
|
model: DEFAULT_IMAGE_MODEL,
|
||||||
|
size: imageModel?.defaultParams?.size || '1x1',
|
||||||
|
quality: imageModel?.defaultParams?.quality || 'standard',
|
||||||
|
label: '文生图'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'videoConfig': {
|
||||||
|
const videoModel = VIDEO_MODELS.find(m => m.key === DEFAULT_VIDEO_MODEL) || VIDEO_MODELS[0]
|
||||||
|
return {
|
||||||
|
prompt: '',
|
||||||
|
ratio: videoModel?.defaultParams?.ratio || '16:9',
|
||||||
|
duration: videoModel?.defaultParams?.duration || 5,
|
||||||
|
model: DEFAULT_VIDEO_MODEL,
|
||||||
|
label: '图生视频'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'video':
|
||||||
|
return {
|
||||||
|
url: '',
|
||||||
|
duration: 0,
|
||||||
|
label: '视频节点'
|
||||||
|
}
|
||||||
|
case 'image':
|
||||||
|
return {
|
||||||
|
url: '',
|
||||||
|
label: '图片节点',
|
||||||
|
publicProps: { name: '图片' } // 公共属性(可被 @ 引用)
|
||||||
|
}
|
||||||
|
case 'llmConfig':
|
||||||
|
return {
|
||||||
|
systemPrompt: '',
|
||||||
|
model: DEFAULT_CHAT_MODEL,
|
||||||
|
outputFormat: 'text',
|
||||||
|
outputContent: '',
|
||||||
|
label: 'LLM文本生成',
|
||||||
|
publicProps: {} // 公共属性(可被 @ 引用)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update node data | 更新节点数据
|
||||||
|
export const updateNode = (id, data) => {
|
||||||
|
nodes.value = nodes.value.map(node =>
|
||||||
|
node.id === id ? { ...node, data: { ...node.data, ...data } } : node
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove node | 删除节点
|
||||||
|
export const removeNode = (id) => {
|
||||||
|
nodes.value = nodes.value.filter(node => node.id !== id)
|
||||||
|
edges.value = edges.value.filter(edge => edge.source !== id && edge.target !== id)
|
||||||
|
saveToHistory() // Save after removing node | 删除节点后保存
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate node | 复制节点
|
||||||
|
export const duplicateNode = (id) => {
|
||||||
|
const sourceNode = nodes.value.find(node => node.id === id)
|
||||||
|
if (!sourceNode) return null
|
||||||
|
|
||||||
|
const newId = getNodeId()
|
||||||
|
|
||||||
|
// Calculate max z-index | 计算最大层级
|
||||||
|
const maxZIndex = Math.max(0, ...nodes.value.map(n => n.zIndex || 0))
|
||||||
|
|
||||||
|
const newNode = {
|
||||||
|
id: newId,
|
||||||
|
type: sourceNode.type,
|
||||||
|
position: {
|
||||||
|
x: sourceNode.position.x + 50,
|
||||||
|
y: sourceNode.position.y + 50
|
||||||
|
},
|
||||||
|
data: { ...sourceNode.data },
|
||||||
|
zIndex: maxZIndex + 1
|
||||||
|
}
|
||||||
|
nodes.value = [...nodes.value, newNode]
|
||||||
|
saveToHistory() // Save after duplicating node | 复制节点后保存
|
||||||
|
return newId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add edge | 添加边
|
||||||
|
export const addEdge = (params) => {
|
||||||
|
const newEdge = {
|
||||||
|
id: `edge_${params.source}_${params.target}`,
|
||||||
|
...params
|
||||||
|
}
|
||||||
|
edges.value = [...edges.value, newEdge]
|
||||||
|
saveToHistory() // Save after adding edge | 添加连线后保存
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple edges in batch | 批量添加多条边
|
||||||
|
* Uses batch operation to group all edge additions into one history entry
|
||||||
|
* @param {Array} edgeSpecs - Array of edge specs [{ source, target, sourceHandle, targetHandle, type, data }, ...]
|
||||||
|
* @param {boolean} autoBatch - Whether to auto-manage batch operation (default: true)
|
||||||
|
* @returns {Array} - Array of created edge IDs | 创建的边ID数组
|
||||||
|
*/
|
||||||
|
export const addEdges = (edgeSpecs, autoBatch = true) => {
|
||||||
|
if (!edgeSpecs || edgeSpecs.length === 0) return []
|
||||||
|
|
||||||
|
// Start batch operation if auto | 如果自动管理则开始批量操作
|
||||||
|
if (autoBatch) {
|
||||||
|
startBatchOperation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = []
|
||||||
|
|
||||||
|
edgeSpecs.forEach(params => {
|
||||||
|
const newEdge = {
|
||||||
|
id: `edge_${params.source}_${params.target}`,
|
||||||
|
...params
|
||||||
|
}
|
||||||
|
edges.value = [...edges.value, newEdge]
|
||||||
|
ids.push(newEdge.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// End batch operation if auto | 如果自动管理则结束批量操作并保存到历史
|
||||||
|
if (autoBatch) {
|
||||||
|
endBatchOperation()
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update edge data | 更新边数据
|
||||||
|
export const updateEdge = (id, data) => {
|
||||||
|
edges.value = edges.value.map(edge =>
|
||||||
|
edge.id === id ? { ...edge, data: { ...edge.data, ...data } } : edge
|
||||||
|
)
|
||||||
|
saveToHistory() // Save after updating edge | 更新连线后保存
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove edge | 删除边
|
||||||
|
export const removeEdge = (id) => {
|
||||||
|
edges.value = edges.value.filter(edge => edge.id !== id)
|
||||||
|
saveToHistory() // Save after removing edge | 删除连线后保存
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear canvas | 清空画布
|
||||||
|
export const clearCanvas = () => {
|
||||||
|
nodes.value = []
|
||||||
|
edges.value = []
|
||||||
|
nodeId = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize with sample data | 使用示例数据初始化
|
||||||
|
export const initSampleData = () => {
|
||||||
|
clearCanvas()
|
||||||
|
|
||||||
|
// Add text node | 添加文本节点
|
||||||
|
addNode('text', { x: 150, y: 150 }, {
|
||||||
|
content: '一只金毛寻回犬在草地上奔跑,摇着尾巴,脸上带着快乐的表情。它的毛发在阳光下闪耀,眼神充满了对自由的渴望,全身散发着阳光、友善的气息。',
|
||||||
|
label: '文本输入'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add image config node | 添加文生图配置节点
|
||||||
|
addNode('imageConfig', { x: 450, y: 150 }, {
|
||||||
|
prompt: '',
|
||||||
|
model: 'doubao-seedream-4-5-251128',
|
||||||
|
ratio: '16:9 | 4张 | 高清',
|
||||||
|
label: '文生图'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add edge between nodes | 添加节点之间的边
|
||||||
|
addEdge({
|
||||||
|
source: 'node_0',
|
||||||
|
target: 'node_1',
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load project data | 加载项目数据
|
||||||
|
* @param {string} projectId - Project ID | 项目ID
|
||||||
|
*/
|
||||||
|
export const loadProject = (projectId) => {
|
||||||
|
autoSaveEnabled = false
|
||||||
|
isRestoring = true
|
||||||
|
currentProjectId.value = projectId
|
||||||
|
|
||||||
|
const canvasData = getProjectCanvas(projectId)
|
||||||
|
|
||||||
|
if (canvasData) {
|
||||||
|
// Restore nodes | 恢复节点
|
||||||
|
nodes.value = canvasData.nodes || []
|
||||||
|
edges.value = canvasData.edges || []
|
||||||
|
canvasViewport.value = canvasData.viewport || { x: 100, y: 50, zoom: 0.8 }
|
||||||
|
|
||||||
|
// Update node ID counter | 更新节点ID计数器
|
||||||
|
const maxId = nodes.value.reduce((max, node) => {
|
||||||
|
const match = node.id.match(/node_(\d+)/)
|
||||||
|
if (match) {
|
||||||
|
return Math.max(max, parseInt(match[1], 10))
|
||||||
|
}
|
||||||
|
return max
|
||||||
|
}, -1)
|
||||||
|
nodeId = maxId + 1
|
||||||
|
} else {
|
||||||
|
// Empty project | 空项目
|
||||||
|
clearCanvas()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize history with current state | 用当前状态初始化历史
|
||||||
|
history.value = [{
|
||||||
|
nodes: JSON.parse(JSON.stringify(nodes.value)),
|
||||||
|
edges: JSON.parse(JSON.stringify(edges.value))
|
||||||
|
}]
|
||||||
|
historyIndex.value = 0
|
||||||
|
|
||||||
|
// Enable auto-save after loading | 加载后启用自动保存
|
||||||
|
setTimeout(() => {
|
||||||
|
autoSaveEnabled = true
|
||||||
|
isRestoring = false
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save current project | 保存当前项目
|
||||||
|
*/
|
||||||
|
export const saveProject = () => {
|
||||||
|
if (!currentProjectId.value) return
|
||||||
|
updateProjectCanvas(currentProjectId.value, {
|
||||||
|
nodes: nodes.value,
|
||||||
|
edges: edges.value,
|
||||||
|
viewport: canvasViewport.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced auto-save | 防抖动自动保存
|
||||||
|
*/
|
||||||
|
const debouncedSave = () => {
|
||||||
|
if (!autoSaveEnabled || !currentProjectId.value) return
|
||||||
|
|
||||||
|
if (saveTimeout) {
|
||||||
|
clearTimeout(saveTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTimeout = setTimeout(() => {
|
||||||
|
saveProject()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update viewport and save | 更新视口并保存
|
||||||
|
*/
|
||||||
|
export const updateViewport = (viewport) => {
|
||||||
|
canvasViewport.value = viewport
|
||||||
|
debouncedSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo last action | 撤销上一步操作
|
||||||
|
*/
|
||||||
|
export const undo = () => {
|
||||||
|
if (historyIndex.value <= 0) {
|
||||||
|
window.$message?.info('没有可撤销的操作')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
historyIndex.value--
|
||||||
|
restoreState(history.value[historyIndex.value])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redo last undone action | 重做上一步撤销的操作
|
||||||
|
*/
|
||||||
|
export const redo = () => {
|
||||||
|
if (historyIndex.value >= history.value.length - 1) {
|
||||||
|
window.$message?.info('没有可重做的操作')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
historyIndex.value++
|
||||||
|
restoreState(history.value[historyIndex.value])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore state from history | 从历史恢复状态
|
||||||
|
*/
|
||||||
|
const restoreState = (state) => {
|
||||||
|
isRestoring = true
|
||||||
|
nodes.value = JSON.parse(JSON.stringify(state.nodes))
|
||||||
|
edges.value = JSON.parse(JSON.stringify(state.edges))
|
||||||
|
setTimeout(() => {
|
||||||
|
isRestoring = false
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if can undo | 检查是否可以撤销
|
||||||
|
*/
|
||||||
|
export const canUndo = () => historyIndex.value > 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if can redo | 检查是否可以重做
|
||||||
|
*/
|
||||||
|
export const canRedo = () => historyIndex.value < history.value.length - 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually save current state to history | 手动保存当前状态到历史
|
||||||
|
* Used for edge deletions and other operations not covered by automatic saves
|
||||||
|
*/
|
||||||
|
export const manualSaveHistory = () => {
|
||||||
|
saveToHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for changes and auto-save (only save to project, not history) | 监听变化并自动保存(仅保存项目,不保存历史)
|
||||||
|
watch([nodes, edges], () => {
|
||||||
|
debouncedSave()
|
||||||
|
}, { deep: true })
|
||||||
213
web/canvas-app/src/stores/models.js
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Model Store | 模型状态管理
|
||||||
|
* Built-in models + custom models from local storage | 开源版内置模型 + 本地存储自定义模型
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import {
|
||||||
|
IMAGE_MODELS,
|
||||||
|
VIDEO_MODELS,
|
||||||
|
CHAT_MODELS,
|
||||||
|
SEEDREAM_SIZE_OPTIONS,
|
||||||
|
SEEDREAM_4K_SIZE_OPTIONS,
|
||||||
|
SEEDREAM_QUALITY_OPTIONS,
|
||||||
|
SEEDANCE_RESOLUTION_OPTIONS,
|
||||||
|
VIDEO_RATIO_LIST,
|
||||||
|
VIDEO_RATIO_OPTIONS,
|
||||||
|
VIDEO_DURATION_OPTIONS,
|
||||||
|
DEFAULT_IMAGE_MODEL,
|
||||||
|
DEFAULT_VIDEO_MODEL,
|
||||||
|
DEFAULT_CHAT_MODEL,
|
||||||
|
DEFAULT_IMAGE_SIZE,
|
||||||
|
DEFAULT_VIDEO_RATIO,
|
||||||
|
DEFAULT_VIDEO_DURATION
|
||||||
|
} from '@/config/models'
|
||||||
|
import { useModelConfig } from '@/hooks/useModelConfig'
|
||||||
|
|
||||||
|
// Loading state (always false for built-in models) | 加载状态
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
// Get model config hook | 获取模型配置 hook
|
||||||
|
const getModelConfigHook = () => {
|
||||||
|
try {
|
||||||
|
return useModelConfig()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize models (no-op for built-in) | 初始化模型
|
||||||
|
*/
|
||||||
|
export const loadAllModels = async () => {
|
||||||
|
const modelConfig = getModelConfigHook()
|
||||||
|
if (modelConfig) {
|
||||||
|
return [...modelConfig.allImageModels.value, ...modelConfig.allVideoModels.value, ...modelConfig.allChatModels.value]
|
||||||
|
}
|
||||||
|
return [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get model config by name | 根据名称获取模型配置
|
||||||
|
*/
|
||||||
|
export const getModelConfig = (modelKey) => {
|
||||||
|
const modelConfig = getModelConfigHook()
|
||||||
|
if (modelConfig) {
|
||||||
|
return modelConfig.getImageModel(modelKey) ||
|
||||||
|
modelConfig.getVideoModel(modelKey) ||
|
||||||
|
modelConfig.getChatModel(modelKey)
|
||||||
|
}
|
||||||
|
const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
|
||||||
|
return allModels.find(m => m.key === modelKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get size options for image model | 获取图片模型尺寸选项
|
||||||
|
* Returns options based on model's sizes array and quality
|
||||||
|
*/
|
||||||
|
export const getModelSizeOptions = (modelKey, quality = 'standard') => {
|
||||||
|
const model = IMAGE_MODELS.find(m => m.key === modelKey)
|
||||||
|
|
||||||
|
// If model has getSizesByQuality function, use it | 如果模型有 getSizesByQuality 函数,使用它
|
||||||
|
if (model?.getSizesByQuality) {
|
||||||
|
return model.getSizesByQuality(quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model?.sizes) return SEEDREAM_SIZE_OPTIONS
|
||||||
|
|
||||||
|
// Convert sizes array to dropdown options | 转换 sizes 数组为下拉选项
|
||||||
|
const sizeOptions = quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS
|
||||||
|
return model.sizes.map(size => {
|
||||||
|
const option = sizeOptions.find(o => o.key === size)
|
||||||
|
return option || { label: size, key: size }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get quality options for image model | 获取图片模型画质选项
|
||||||
|
*/
|
||||||
|
export const getModelQualityOptions = (modelKey) => {
|
||||||
|
const model = IMAGE_MODELS.find(m => m.key === modelKey)
|
||||||
|
return model?.qualities || []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ratio options for video model | 获取视频模型比例选项
|
||||||
|
* Returns options based on model's ratios array
|
||||||
|
*/
|
||||||
|
export const getModelRatioOptions = (modelKey) => {
|
||||||
|
const model = VIDEO_MODELS.find(m => m.key === modelKey)
|
||||||
|
if (!model?.ratios) return VIDEO_RATIO_OPTIONS
|
||||||
|
|
||||||
|
// Convert ratios array to dropdown options | 转换 ratios 数组为下拉选项
|
||||||
|
return model.ratios.map(ratio => {
|
||||||
|
const option = VIDEO_RATIO_LIST.find(o => o.key === ratio)
|
||||||
|
return option || { label: ratio, key: ratio }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get duration options for video model | 获取视频模型时长选项
|
||||||
|
* Returns options based on model's durs array
|
||||||
|
*/
|
||||||
|
export const getModelDurationOptions = (modelKey) => {
|
||||||
|
const model = VIDEO_MODELS.find(m => m.key === modelKey)
|
||||||
|
if (!model?.durs) return VIDEO_DURATION_OPTIONS
|
||||||
|
|
||||||
|
// durs is already in { label, key } format | durs 已经是 { label, key } 格式
|
||||||
|
return model.durs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get resolution options for video model | 获取视频模型分辨率选项
|
||||||
|
* Returns options based on model's resolutions array
|
||||||
|
*/
|
||||||
|
export const getModelResolutionOptions = (modelKey) => {
|
||||||
|
const model = VIDEO_MODELS.find(m => m.key === modelKey)
|
||||||
|
if (!model?.resolutions) return SEEDANCE_RESOLUTION_OPTIONS
|
||||||
|
|
||||||
|
return model.resolutions.map(res => {
|
||||||
|
const option = SEEDANCE_RESOLUTION_OPTIONS.find(o => o.key === res)
|
||||||
|
return option || { label: res, key: res }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropdown options (built-in + custom) | 下拉选项(内置 + 自定义)- 根据渠道过滤
|
||||||
|
export const imageModelOptions = computed(() => {
|
||||||
|
const modelConfig = getModelConfigHook()
|
||||||
|
return modelConfig ? modelConfig.availableImageModels.value : IMAGE_MODELS
|
||||||
|
})
|
||||||
|
|
||||||
|
export const videoModelOptions = computed(() => {
|
||||||
|
const modelConfig = getModelConfigHook()
|
||||||
|
return modelConfig ? modelConfig.availableVideoModels.value : VIDEO_MODELS
|
||||||
|
})
|
||||||
|
|
||||||
|
export const chatModelOptions = computed(() => {
|
||||||
|
const modelConfig = getModelConfigHook()
|
||||||
|
return modelConfig ? modelConfig.availableChatModels.value : CHAT_MODELS
|
||||||
|
})
|
||||||
|
|
||||||
|
// All model options (not filtered by provider) | 所有模型选项(不按渠道过滤)
|
||||||
|
export const allImageModelOptions = computed(() => {
|
||||||
|
const modelConfig = getModelConfigHook()
|
||||||
|
return modelConfig ? modelConfig.allAvailableImageModels.value : IMAGE_MODELS
|
||||||
|
})
|
||||||
|
|
||||||
|
export const allVideoModelOptions = computed(() => {
|
||||||
|
const modelConfig = getModelConfigHook()
|
||||||
|
return modelConfig ? modelConfig.allAvailableVideoModels.value : VIDEO_MODELS
|
||||||
|
})
|
||||||
|
|
||||||
|
export const allChatModelOptions = computed(() => {
|
||||||
|
const modelConfig = getModelConfigHook()
|
||||||
|
return modelConfig ? modelConfig.allAvailableChatModels.value : CHAT_MODELS
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simple select options (for n-select) | 简单选择选项
|
||||||
|
export const imageModelSelectOptions = computed(() =>
|
||||||
|
imageModelOptions.value.map(m => ({ label: m.label, value: m.key }))
|
||||||
|
)
|
||||||
|
|
||||||
|
export const videoModelSelectOptions = computed(() =>
|
||||||
|
videoModelOptions.value.map(m => ({ label: m.label, value: m.key }))
|
||||||
|
)
|
||||||
|
|
||||||
|
export const chatModelSelectOptions = computed(() =>
|
||||||
|
chatModelOptions.value.map(m => ({ label: m.label, value: m.key }))
|
||||||
|
)
|
||||||
|
|
||||||
|
// All select options (not filtered by provider) | 所有选择选项(不按渠道过滤)
|
||||||
|
export const allImageModelSelectOptions = computed(() =>
|
||||||
|
allImageModelOptions.value.map(m => ({ label: m.label, value: m.key }))
|
||||||
|
)
|
||||||
|
|
||||||
|
export const allVideoModelSelectOptions = computed(() =>
|
||||||
|
allVideoModelOptions.value.map(m => ({ label: m.label, value: m.key }))
|
||||||
|
)
|
||||||
|
|
||||||
|
export const allChatModelSelectOptions = computed(() =>
|
||||||
|
allChatModelOptions.value.map(m => ({ label: m.label, value: m.key }))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Export model arrays (reactive with custom models) | 导出模型数组(响应式,包含自定义模型)
|
||||||
|
export const imageModels = computed(() => imageModelOptions.value)
|
||||||
|
export const videoModels = computed(() => videoModelOptions.value)
|
||||||
|
export const chatModels = computed(() => chatModelOptions.value)
|
||||||
|
|
||||||
|
// Export defaults | 导出默认值
|
||||||
|
export {
|
||||||
|
DEFAULT_IMAGE_MODEL,
|
||||||
|
DEFAULT_VIDEO_MODEL,
|
||||||
|
DEFAULT_CHAT_MODEL,
|
||||||
|
DEFAULT_IMAGE_SIZE,
|
||||||
|
DEFAULT_VIDEO_RATIO,
|
||||||
|
DEFAULT_VIDEO_DURATION
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export options | 导出选项
|
||||||
|
export { SEEDREAM_SIZE_OPTIONS, SEEDREAM_4K_SIZE_OPTIONS, SEEDREAM_QUALITY_OPTIONS, SEEDANCE_RESOLUTION_OPTIONS, VIDEO_RATIO_OPTIONS, VIDEO_DURATION_OPTIONS }
|
||||||
|
|
||||||
|
// Export state | 导出状态
|
||||||
|
export { loading, error }
|
||||||
6
web/canvas-app/src/stores/pinia/index.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Pinia Stores | Pinia 状态管理
|
||||||
|
* 统一导出所有 Pinia stores
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useModelStore } from './models'
|
||||||
613
web/canvas-app/src/stores/pinia/models.js
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
/**
|
||||||
|
* Pinia Store: Model Config | 模型配置 Store
|
||||||
|
* 管理模型配置、渠道切换和模型选择
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
CHAT_MODELS,
|
||||||
|
IMAGE_MODELS,
|
||||||
|
VIDEO_MODELS,
|
||||||
|
DEFAULT_CHAT_MODEL,
|
||||||
|
DEFAULT_IMAGE_MODEL,
|
||||||
|
DEFAULT_VIDEO_MODEL
|
||||||
|
} from '@/config/models'
|
||||||
|
import { PROVIDERS, getProviderList, getDefaultProvider, getProviderConfig, getDefaultBaseUrl } from '@/config/providers'
|
||||||
|
|
||||||
|
// 存储键名
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
PROVIDER: 'api-provider',
|
||||||
|
CUSTOM_CHAT_MODELS: 'custom-chat-models',
|
||||||
|
CUSTOM_IMAGE_MODELS: 'custom-image-models',
|
||||||
|
CUSTOM_VIDEO_MODELS: 'custom-video-models',
|
||||||
|
SELECTED_CHAT_MODEL: 'selected-chat-model',
|
||||||
|
SELECTED_IMAGE_MODEL: 'selected-image-model',
|
||||||
|
SELECTED_VIDEO_MODEL: 'selected-video-model',
|
||||||
|
CUSTOM_CHAT_MODELS_BY_PROVIDER: 'custom-chat-models-by-provider',
|
||||||
|
CUSTOM_IMAGE_MODELS_BY_PROVIDER: 'custom-image-models-by-provider',
|
||||||
|
CUSTOM_VIDEO_MODELS_BY_PROVIDER: 'custom-video-models-by-provider',
|
||||||
|
API_KEYS_BY_PROVIDER: 'api-keys-by-provider',
|
||||||
|
BASE_URLS_BY_PROVIDER: 'base-urls-by-provider'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored value from localStorage
|
||||||
|
*/
|
||||||
|
const getStored = (key, defaultValue = '') => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key) || defaultValue
|
||||||
|
} catch {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set stored value to localStorage
|
||||||
|
*/
|
||||||
|
const setStored = (key, value) => {
|
||||||
|
try {
|
||||||
|
if (value) {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored JSON value from localStorage
|
||||||
|
*/
|
||||||
|
const getStoredJson = (key, defaultValue = []) => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(key)
|
||||||
|
return stored ? JSON.parse(stored) : defaultValue
|
||||||
|
} catch {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set stored JSON value to localStorage
|
||||||
|
*/
|
||||||
|
const setStoredJson = (key, value) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查模型是否支持指定渠道
|
||||||
|
*/
|
||||||
|
const isModelSupported = (model, provider) => {
|
||||||
|
if (!model.provider) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return model.provider.includes(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useModelStore = defineStore('model', () => {
|
||||||
|
// ============ Provider 状态 | Provider State ============
|
||||||
|
|
||||||
|
// 当前选中的渠道
|
||||||
|
const currentProvider = ref(getStored(STORAGE_KEYS.PROVIDER) || getDefaultProvider())
|
||||||
|
|
||||||
|
// 渠道列表
|
||||||
|
const providerList = computed(() => getProviderList())
|
||||||
|
|
||||||
|
// 当前渠道配置
|
||||||
|
const providerConfig = computed(() => getProviderConfig(currentProvider.value))
|
||||||
|
|
||||||
|
// 当前渠道标签
|
||||||
|
const providerLabel = computed(() => providerConfig.value.label || currentProvider.value)
|
||||||
|
|
||||||
|
// 设置当前渠道
|
||||||
|
const setProvider = (provider) => {
|
||||||
|
if (PROVIDERS[provider]) {
|
||||||
|
currentProvider.value = provider
|
||||||
|
setStored(STORAGE_KEYS.PROVIDER, provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除渠道配置
|
||||||
|
const clearProvider = () => {
|
||||||
|
currentProvider.value = getDefaultProvider()
|
||||||
|
removeStored(STORAGE_KEYS.PROVIDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 适配请求参数
|
||||||
|
const adaptRequest = (type, params) => {
|
||||||
|
const config = providerConfig.value
|
||||||
|
if (config.requestAdapter && config.requestAdapter[type]) {
|
||||||
|
return config.requestAdapter[type](params)
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
// 适配响应数据
|
||||||
|
const adaptResponse = (type, response) => {
|
||||||
|
const config = providerConfig.value
|
||||||
|
if (config.responseAdapter && config.responseAdapter[type]) {
|
||||||
|
return config.responseAdapter[type](response)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Custom Models 状态 | Custom Models State ============
|
||||||
|
|
||||||
|
// 全局自定义模型(不区分渠道)
|
||||||
|
const customChatModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, []))
|
||||||
|
const customImageModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, []))
|
||||||
|
const customVideoModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, []))
|
||||||
|
|
||||||
|
// 按渠道存储的自定义模型 | 结构: { 'skg': [{key, label}] }
|
||||||
|
const customChatModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER, {}))
|
||||||
|
const customImageModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER, {}))
|
||||||
|
const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER, {}))
|
||||||
|
|
||||||
|
// 选中的模型
|
||||||
|
const selectedChatModel = ref(getStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, DEFAULT_CHAT_MODEL))
|
||||||
|
const selectedImageModel = ref(getStored(STORAGE_KEYS.SELECTED_IMAGE_MODEL, DEFAULT_IMAGE_MODEL))
|
||||||
|
const selectedVideoModel = ref(getStored(STORAGE_KEYS.SELECTED_VIDEO_MODEL, DEFAULT_VIDEO_MODEL))
|
||||||
|
|
||||||
|
// 按渠道存储的 API 配置
|
||||||
|
const apiKeysByProvider = ref(getStoredJson(STORAGE_KEYS.API_KEYS_BY_PROVIDER, {}))
|
||||||
|
const baseUrlsByProvider = ref(getStoredJson(STORAGE_KEYS.BASE_URLS_BY_PROVIDER, {}))
|
||||||
|
|
||||||
|
// 内部模式由服务端会话鉴权,不在浏览器暴露上游模型密钥。
|
||||||
|
const currentApiKey = computed(() => 'internal-session')
|
||||||
|
const currentBaseUrl = computed(() => baseUrlsByProvider.value[currentProvider.value] || getDefaultBaseUrl(currentProvider.value))
|
||||||
|
|
||||||
|
// 设置指定渠道凭据(兼容旧本地状态)
|
||||||
|
const setApiKeyByProvider = (provider, apiKey) => {
|
||||||
|
apiKeysByProvider.value[provider] = apiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置指定渠道的 Base URL
|
||||||
|
const setBaseUrlByProvider = (provider, baseUrl) => {
|
||||||
|
baseUrlsByProvider.value[provider] = baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除指定渠道的 API 配置
|
||||||
|
const clearApiConfigByProvider = (provider) => {
|
||||||
|
delete apiKeysByProvider.value[provider]
|
||||||
|
delete baseUrlsByProvider.value[provider]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Computed: All Models (built-in + custom + by provider) ============
|
||||||
|
|
||||||
|
const allChatModels = computed(() => [
|
||||||
|
...CHAT_MODELS.map(m => ({ ...m, isCustom: false })),
|
||||||
|
...customChatModels.value.map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true
|
||||||
|
})),
|
||||||
|
// 添加当前渠道的自定义模型
|
||||||
|
...(customChatModelsByProvider.value[currentProvider.value] || []).map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
provider: [currentProvider.value]
|
||||||
|
}))
|
||||||
|
])
|
||||||
|
|
||||||
|
const allImageModels = computed(() => [
|
||||||
|
...IMAGE_MODELS.map(m => ({ ...m, isCustom: false })),
|
||||||
|
...customImageModels.value.map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
sizes: [],
|
||||||
|
defaultParams: { quality: 'standard', style: 'vivid' }
|
||||||
|
})),
|
||||||
|
// 添加当前渠道的自定义模型
|
||||||
|
...(customImageModelsByProvider.value[currentProvider.value] || []).map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
sizes: [],
|
||||||
|
defaultParams: { quality: 'standard', style: 'vivid' },
|
||||||
|
provider: [currentProvider.value]
|
||||||
|
}))
|
||||||
|
])
|
||||||
|
|
||||||
|
const allVideoModels = computed(() => [
|
||||||
|
...VIDEO_MODELS.map(m => ({ ...m, isCustom: false })),
|
||||||
|
...customVideoModels.value.map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
ratios: ['16x9', '9:16', '1:1'],
|
||||||
|
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||||
|
defaultParams: { ratio: '16:9', duration: 5 }
|
||||||
|
})),
|
||||||
|
// 添加当前渠道的自定义模型
|
||||||
|
...(customVideoModelsByProvider.value[currentProvider.value] || []).map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
ratios: ['16x9', '9:16', '1:1'],
|
||||||
|
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||||
|
defaultParams: { ratio: '16:9', duration: 5 },
|
||||||
|
provider: [currentProvider.value]
|
||||||
|
}))
|
||||||
|
])
|
||||||
|
|
||||||
|
// ============ Computed: Available Models (filtered by provider) ============
|
||||||
|
|
||||||
|
// 按渠道过滤的可用模型
|
||||||
|
const availableChatModels = computed(() =>
|
||||||
|
allChatModels.value.filter(m => isModelSupported(m, currentProvider.value))
|
||||||
|
)
|
||||||
|
|
||||||
|
const availableImageModels = computed(() =>
|
||||||
|
allImageModels.value.filter(m => isModelSupported(m, currentProvider.value))
|
||||||
|
)
|
||||||
|
|
||||||
|
const availableVideoModels = computed(() =>
|
||||||
|
allVideoModels.value.filter(m => isModelSupported(m, currentProvider.value))
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============ Computed: Model Options for UI (all models, not filtered by provider) ============
|
||||||
|
|
||||||
|
// 返回适合 n-dropdown 使用的选项格式(全部模型,不按渠道过滤)
|
||||||
|
const allImageModelOptions = computed(() =>
|
||||||
|
allImageModels.value.map(m => ({
|
||||||
|
label: m.label,
|
||||||
|
key: m.key
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const allVideoModelOptions = computed(() =>
|
||||||
|
allVideoModels.value.map(m => ({
|
||||||
|
label: m.label,
|
||||||
|
key: m.key
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const allChatModelOptions = computed(() =>
|
||||||
|
allChatModels.value.map(m => ({
|
||||||
|
label: m.label,
|
||||||
|
key: m.key
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============ Computed: Model Options for UI (filtered by provider - deprecated, use all* instead) ============
|
||||||
|
|
||||||
|
// 返回适合 n-dropdown 使用的选项格式
|
||||||
|
const imageModelOptions = computed(() =>
|
||||||
|
availableImageModels.value.map(m => ({
|
||||||
|
label: m.label,
|
||||||
|
key: m.key
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const videoModelOptions = computed(() =>
|
||||||
|
availableVideoModels.value.map(m => ({
|
||||||
|
label: m.label,
|
||||||
|
key: m.key
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const chatModelOptions = computed(() =>
|
||||||
|
availableChatModels.value.map(m => ({
|
||||||
|
label: m.label,
|
||||||
|
key: m.key
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============ Methods: Add/Remove Custom Models ============
|
||||||
|
|
||||||
|
const addCustomChatModel = (modelKey, label = '') => {
|
||||||
|
if (!modelKey || customChatModels.value.some(m => m.key === modelKey)) return false
|
||||||
|
customChatModels.value.push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomImageModel = (modelKey, label = '') => {
|
||||||
|
if (!modelKey || customImageModels.value.some(m => m.key === modelKey)) return false
|
||||||
|
customImageModels.value.push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomVideoModel = (modelKey, label = '') => {
|
||||||
|
if (!modelKey || customVideoModels.value.some(m => m.key === modelKey)) return false
|
||||||
|
customVideoModels.value.push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomChatModel = (modelKey) => {
|
||||||
|
const idx = customChatModels.value.findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customChatModels.value.splice(idx, 1)
|
||||||
|
if (selectedChatModel.value === modelKey) {
|
||||||
|
selectedChatModel.value = DEFAULT_CHAT_MODEL
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomImageModel = (modelKey) => {
|
||||||
|
const idx = customImageModels.value.findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customImageModels.value.splice(idx, 1)
|
||||||
|
if (selectedImageModel.value === modelKey) {
|
||||||
|
selectedImageModel.value = DEFAULT_IMAGE_MODEL
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomVideoModel = (modelKey) => {
|
||||||
|
const idx = customVideoModels.value.findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customVideoModels.value.splice(idx, 1)
|
||||||
|
if (selectedVideoModel.value === modelKey) {
|
||||||
|
selectedVideoModel.value = DEFAULT_VIDEO_MODEL
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Methods: Get Model Config ============
|
||||||
|
|
||||||
|
const getChatModel = (key) => allChatModels.value.find(m => m.key === key)
|
||||||
|
const getImageModel = (key) => allImageModels.value.find(m => m.key === key)
|
||||||
|
const getVideoModel = (key) => allVideoModels.value.find(m => m.key === key)
|
||||||
|
|
||||||
|
// ============ Methods: Get API Endpoints ============
|
||||||
|
|
||||||
|
// 获取图片端点
|
||||||
|
const getImageEndpoint = () => {
|
||||||
|
const endpoint = providerConfig.value.endpoints?.image || '/images/generations'
|
||||||
|
return `${currentBaseUrl.value}${endpoint}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取视频生成端点
|
||||||
|
const getVideoEndpoint = () => {
|
||||||
|
const endpoint = providerConfig.value.endpoints?.video || '/videos'
|
||||||
|
return `${currentBaseUrl.value}${endpoint}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取视频任务查询端点
|
||||||
|
const getVideoTaskEndpoint = () => {
|
||||||
|
const config = providerConfig.value
|
||||||
|
// 优先使用 videoQuery 端点,支持 {taskId} 占位符替换
|
||||||
|
let endpoint = config.endpoints?.videoQuery || config.endpoints?.video || '/videos'
|
||||||
|
return `${currentBaseUrl.value}${endpoint}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取聊天端点(支持参考图片)
|
||||||
|
const getChatEndpoint = () => {
|
||||||
|
const endpoint = providerConfig.value?.endpoints?.chat || '/chat/completions'
|
||||||
|
return `${currentBaseUrl.value}${endpoint}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Methods: Get Models By Provider ============
|
||||||
|
|
||||||
|
const getModelsByProvider = (provider) => {
|
||||||
|
const chat = [
|
||||||
|
...CHAT_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })),
|
||||||
|
...(customChatModelsByProvider.value[provider] || []).map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
provider: [provider]
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
const image = [
|
||||||
|
...IMAGE_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })),
|
||||||
|
...(customImageModelsByProvider.value[provider] || []).map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
sizes: [],
|
||||||
|
defaultParams: { quality: 'standard', style: 'vivid' },
|
||||||
|
provider: [provider]
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
const video = [
|
||||||
|
...VIDEO_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })),
|
||||||
|
...(customVideoModelsByProvider.value[provider] || []).map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
ratios: ['16x9', '9:16', '1:1'],
|
||||||
|
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||||
|
defaultParams: { ratio: '16:9', duration: 5 },
|
||||||
|
provider: [provider]
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
return { chat, image, video }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Methods: Add/Remove Custom Models By Provider ============
|
||||||
|
|
||||||
|
const addCustomChatModelByProvider = (modelKey, provider, label = '') => {
|
||||||
|
if (!modelKey) return false
|
||||||
|
if (!customChatModelsByProvider.value[provider]) {
|
||||||
|
customChatModelsByProvider.value[provider] = []
|
||||||
|
}
|
||||||
|
if (customChatModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
|
||||||
|
customChatModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomImageModelByProvider = (modelKey, provider, label = '') => {
|
||||||
|
if (!modelKey) return false
|
||||||
|
if (!customImageModelsByProvider.value[provider]) {
|
||||||
|
customImageModelsByProvider.value[provider] = []
|
||||||
|
}
|
||||||
|
if (customImageModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
|
||||||
|
customImageModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomVideoModelByProvider = (modelKey, provider, label = '') => {
|
||||||
|
if (!modelKey) return false
|
||||||
|
if (!customVideoModelsByProvider.value[provider]) {
|
||||||
|
customVideoModelsByProvider.value[provider] = []
|
||||||
|
}
|
||||||
|
if (customVideoModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
|
||||||
|
customVideoModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomChatModelByProvider = (modelKey, provider) => {
|
||||||
|
if (!customChatModelsByProvider.value[provider]) return false
|
||||||
|
const idx = customChatModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customChatModelsByProvider.value[provider].splice(idx, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomImageModelByProvider = (modelKey, provider) => {
|
||||||
|
if (!customImageModelsByProvider.value[provider]) return false
|
||||||
|
const idx = customImageModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customImageModelsByProvider.value[provider].splice(idx, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomVideoModelByProvider = (modelKey, provider) => {
|
||||||
|
if (!customVideoModelsByProvider.value[provider]) return false
|
||||||
|
const idx = customVideoModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customVideoModelsByProvider.value[provider].splice(idx, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除所有自定义模型
|
||||||
|
const clearCustomModels = () => {
|
||||||
|
customChatModels.value = []
|
||||||
|
customImageModels.value = []
|
||||||
|
customVideoModels.value = []
|
||||||
|
selectedChatModel.value = DEFAULT_CHAT_MODEL
|
||||||
|
selectedImageModel.value = DEFAULT_IMAGE_MODEL
|
||||||
|
selectedVideoModel.value = DEFAULT_VIDEO_MODEL
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Watch & Persist ============
|
||||||
|
|
||||||
|
// 监听并持久化自定义模型
|
||||||
|
watch(customChatModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, val), { deep: true })
|
||||||
|
watch(customImageModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, val), { deep: true })
|
||||||
|
watch(customVideoModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, val), { deep: true })
|
||||||
|
|
||||||
|
// 监听并持久化按渠道的自定义模型
|
||||||
|
watch(customChatModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER, val), { deep: true })
|
||||||
|
watch(customImageModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER, val), { deep: true })
|
||||||
|
watch(customVideoModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER, val), { deep: true })
|
||||||
|
|
||||||
|
// 监听并持久化选中的模型
|
||||||
|
watch(selectedChatModel, (val) => setStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, val))
|
||||||
|
watch(selectedImageModel, (val) => setStored(STORAGE_KEYS.SELECTED_IMAGE_MODEL, val))
|
||||||
|
watch(selectedVideoModel, (val) => setStored(STORAGE_KEYS.SELECTED_VIDEO_MODEL, val))
|
||||||
|
|
||||||
|
// 监听并持久化 API 配置
|
||||||
|
watch(apiKeysByProvider, (val) => setStoredJson(STORAGE_KEYS.API_KEYS_BY_PROVIDER, val), { deep: true })
|
||||||
|
watch(baseUrlsByProvider, (val) => setStoredJson(STORAGE_KEYS.BASE_URLS_BY_PROVIDER, val), { deep: true })
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Provider
|
||||||
|
currentProvider,
|
||||||
|
providerList,
|
||||||
|
providerConfig,
|
||||||
|
providerLabel,
|
||||||
|
setProvider,
|
||||||
|
clearProvider,
|
||||||
|
adaptRequest,
|
||||||
|
adaptResponse,
|
||||||
|
|
||||||
|
// All models (built-in + custom)
|
||||||
|
allChatModels,
|
||||||
|
allImageModels,
|
||||||
|
allVideoModels,
|
||||||
|
|
||||||
|
// Available models filtered by provider
|
||||||
|
availableChatModels,
|
||||||
|
availableImageModels,
|
||||||
|
availableVideoModels,
|
||||||
|
|
||||||
|
// Model options for UI (dropdown format)
|
||||||
|
imageModelOptions,
|
||||||
|
videoModelOptions,
|
||||||
|
chatModelOptions,
|
||||||
|
|
||||||
|
// All model options (not filtered by provider)
|
||||||
|
allImageModelOptions,
|
||||||
|
allVideoModelOptions,
|
||||||
|
allChatModelOptions,
|
||||||
|
|
||||||
|
// Selected models
|
||||||
|
selectedChatModel,
|
||||||
|
selectedImageModel,
|
||||||
|
selectedVideoModel,
|
||||||
|
|
||||||
|
// Custom models
|
||||||
|
customChatModels,
|
||||||
|
customImageModels,
|
||||||
|
customVideoModels,
|
||||||
|
|
||||||
|
// Custom models by provider
|
||||||
|
customChatModelsByProvider,
|
||||||
|
customImageModelsByProvider,
|
||||||
|
customVideoModelsByProvider,
|
||||||
|
|
||||||
|
// Add/Remove methods
|
||||||
|
addCustomChatModel,
|
||||||
|
addCustomImageModel,
|
||||||
|
addCustomVideoModel,
|
||||||
|
removeCustomChatModel,
|
||||||
|
removeCustomImageModel,
|
||||||
|
removeCustomVideoModel,
|
||||||
|
|
||||||
|
// Add/Remove by provider methods
|
||||||
|
addCustomChatModelByProvider,
|
||||||
|
addCustomImageModelByProvider,
|
||||||
|
addCustomVideoModelByProvider,
|
||||||
|
removeCustomChatModelByProvider,
|
||||||
|
removeCustomImageModelByProvider,
|
||||||
|
removeCustomVideoModelByProvider,
|
||||||
|
|
||||||
|
// Get model
|
||||||
|
getChatModel,
|
||||||
|
getImageModel,
|
||||||
|
getVideoModel,
|
||||||
|
|
||||||
|
// Get API endpoints
|
||||||
|
getImageEndpoint,
|
||||||
|
getVideoEndpoint,
|
||||||
|
getVideoTaskEndpoint,
|
||||||
|
getChatEndpoint,
|
||||||
|
|
||||||
|
// Get models by provider
|
||||||
|
getModelsByProvider,
|
||||||
|
|
||||||
|
// Clear all custom models
|
||||||
|
clearCustomModels,
|
||||||
|
|
||||||
|
// API Config by provider
|
||||||
|
currentApiKey,
|
||||||
|
currentBaseUrl,
|
||||||
|
apiKeysByProvider,
|
||||||
|
baseUrlsByProvider,
|
||||||
|
setApiKeyByProvider,
|
||||||
|
setBaseUrlByProvider,
|
||||||
|
clearApiConfigByProvider
|
||||||
|
}
|
||||||
|
})
|
||||||
379
web/canvas-app/src/stores/projects.js
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
/**
|
||||||
|
* Projects store | 项目状态管理
|
||||||
|
* Manages projects with localStorage persistence
|
||||||
|
*/
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
|
||||||
|
// Storage key | 存储键
|
||||||
|
const STORAGE_KEY = 'ai-canvas-projects'
|
||||||
|
|
||||||
|
// Generate unique ID | 生成唯一ID
|
||||||
|
const generateId = () => `project_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
|
||||||
|
// Projects list | 项目列表
|
||||||
|
export const projects = ref([])
|
||||||
|
|
||||||
|
// Current project ID | 当前项目ID
|
||||||
|
export const currentProjectId = ref(null)
|
||||||
|
|
||||||
|
// Current project | 当前项目
|
||||||
|
export const currentProject = computed(() => {
|
||||||
|
return projects.value.find(p => p.id === currentProjectId.value) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load projects from localStorage | 从 localStorage 加载项目
|
||||||
|
*/
|
||||||
|
export const loadProjects = () => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
// Convert date strings back to Date objects | 将日期字符串转换回 Date 对象
|
||||||
|
projects.value = parsed.map(p => ({
|
||||||
|
...p,
|
||||||
|
createdAt: new Date(p.createdAt),
|
||||||
|
updatedAt: new Date(p.updatedAt)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load projects:', err)
|
||||||
|
projects.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean node data for storage | 清理节点数据用于存储
|
||||||
|
* Removes base64 data URLs to reduce storage size | 移除 base64 数据减小存储大小
|
||||||
|
*/
|
||||||
|
const cleanNodeForStorage = (node) => {
|
||||||
|
if (!node.data) return node
|
||||||
|
|
||||||
|
const cleanedData = { ...node.data }
|
||||||
|
|
||||||
|
// Remove base64 data | 移除 base64 数据
|
||||||
|
if (cleanedData.base64) {
|
||||||
|
delete cleanedData.base64
|
||||||
|
}
|
||||||
|
|
||||||
|
// If url is a base64 data URL, keep it only if it's from external source | 如果 url 是 base64,只有外部来源才保留
|
||||||
|
if (cleanedData.url?.startsWith?.('data:')) {
|
||||||
|
// For uploaded images, we can't persist them in localStorage | 上传的图片无法持久化到 localStorage
|
||||||
|
delete cleanedData.url
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove mask data | 移除蒙版数据
|
||||||
|
if (cleanedData.maskData) {
|
||||||
|
delete cleanedData.maskData
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...node, data: cleanedData }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean project for storage | 清理项目用于存储
|
||||||
|
*/
|
||||||
|
const cleanProjectForStorage = (project) => {
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
canvasData: project.canvasData ? {
|
||||||
|
...project.canvasData,
|
||||||
|
nodes: project.canvasData.nodes?.map(cleanNodeForStorage) || []
|
||||||
|
} : project.canvasData,
|
||||||
|
// Remove base64 thumbnails | 移除 base64 缩略图
|
||||||
|
thumbnail: project.thumbnail?.startsWith?.('data:') ? '' : project.thumbnail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save projects to localStorage | 保存项目到 localStorage
|
||||||
|
* Handles QuotaExceededError by compressing data | 通过压缩数据处理配额超限错误
|
||||||
|
*/
|
||||||
|
export const saveProjects = () => {
|
||||||
|
// Always clean data before saving | 保存前始终清理数据
|
||||||
|
const cleanedProjects = projects.value.map(cleanProjectForStorage)
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(cleanedProjects))
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'QuotaExceededError') {
|
||||||
|
console.warn('localStorage quota exceeded, attempting aggressive cleanup...')
|
||||||
|
|
||||||
|
// Remove thumbnails and limit old projects | 移除缩略图并限制旧项目
|
||||||
|
const minimalProjects = cleanedProjects.map((project, index) => ({
|
||||||
|
...project,
|
||||||
|
thumbnail: '', // Remove all thumbnails | 移除所有缩略图
|
||||||
|
// Keep only essential canvas data for older projects | 旧项目只保留基本画布数据
|
||||||
|
canvasData: index > 10 ? { nodes: [], edges: [], viewport: project.canvasData?.viewport } : project.canvasData
|
||||||
|
}))
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(minimalProjects))
|
||||||
|
console.log('Saved with aggressive cleanup')
|
||||||
|
window.$message?.warning('存储空间不足,已自动清理部分数据')
|
||||||
|
} catch (retryErr) {
|
||||||
|
console.error('Still failed after aggressive cleanup:', retryErr)
|
||||||
|
// Last resort: only keep first 5 projects | 最后手段:只保留前5个项目
|
||||||
|
try {
|
||||||
|
const essentialProjects = minimalProjects.slice(0, 5)
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(essentialProjects))
|
||||||
|
projects.value = projects.value.slice(0, 5)
|
||||||
|
window.$message?.warning('存储空间严重不足,已保留最近 5 个项目')
|
||||||
|
} catch (finalErr) {
|
||||||
|
console.error('Cannot save even minimal data:', finalErr)
|
||||||
|
window.$message?.error('存储失败,请清理浏览器存储空间')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Failed to save projects:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new project | 创建新项目
|
||||||
|
* @param {string} name - Project name | 项目名称
|
||||||
|
* @returns {string} - New project ID | 新项目ID
|
||||||
|
*/
|
||||||
|
export const createProject = (name = '未命名项目') => {
|
||||||
|
const id = generateId()
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const newProject = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
thumbnail: '',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
// Canvas data | 画布数据
|
||||||
|
canvasData: {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
viewport: { x: 100, y: 50, zoom: 0.8 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
projects.value = [newProject, ...projects.value]
|
||||||
|
saveProjects()
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update project | 更新项目
|
||||||
|
* @param {string} id - Project ID | 项目ID
|
||||||
|
* @param {object} data - Update data | 更新数据
|
||||||
|
*/
|
||||||
|
export const updateProject = (id, data) => {
|
||||||
|
const index = projects.value.findIndex(p => p.id === id)
|
||||||
|
if (index === -1) return false
|
||||||
|
|
||||||
|
projects.value[index] = {
|
||||||
|
...projects.value[index],
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to top of list | 移动到列表顶部
|
||||||
|
const [updated] = projects.value.splice(index, 1)
|
||||||
|
projects.value = [updated, ...projects.value]
|
||||||
|
|
||||||
|
saveProjects()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update project canvas data | 更新项目画布数据
|
||||||
|
* @param {string} id - Project ID | 项目ID
|
||||||
|
* @param {object} canvasData - Canvas data (nodes, edges, viewport) | 画布数据
|
||||||
|
*/
|
||||||
|
export const updateProjectCanvas = (id, canvasData) => {
|
||||||
|
const project = projects.value.find(p => p.id === id)
|
||||||
|
if (!project) return false
|
||||||
|
|
||||||
|
project.canvasData = {
|
||||||
|
...project.canvasData,
|
||||||
|
...canvasData
|
||||||
|
}
|
||||||
|
project.updatedAt = new Date()
|
||||||
|
|
||||||
|
// Auto-update thumbnail from last edited image/video node | 自动从最后编辑的图片/视频节点更新缩略图
|
||||||
|
if (canvasData.nodes) {
|
||||||
|
const mediaNodes = canvasData.nodes
|
||||||
|
.filter(node => (node.type === 'image' || node.type === 'video') && node.data?.url)
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sort by last updated time | 按最后更新时间排序
|
||||||
|
const aTime = a.data?.updatedAt || a.data?.createdAt || 0
|
||||||
|
const bTime = b.data?.updatedAt || b.data?.createdAt || 0
|
||||||
|
return bTime - aTime
|
||||||
|
})
|
||||||
|
if (mediaNodes.length > 0) {
|
||||||
|
const latestNode = mediaNodes[0]
|
||||||
|
// Use thumbnail for video nodes, url for image nodes | 视频节点使用缩略图,图片节点使用 URL
|
||||||
|
if (latestNode.type === 'video') {
|
||||||
|
project.thumbnail = latestNode.data.thumbnail || latestNode.data.url
|
||||||
|
} else {
|
||||||
|
project.thumbnail = latestNode.data.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveProjects()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project canvas data | 获取项目画布数据
|
||||||
|
* @param {string} id - Project ID | 项目ID
|
||||||
|
* @returns {object|null} - Canvas data or null | 画布数据或空
|
||||||
|
*/
|
||||||
|
export const getProjectCanvas = (id) => {
|
||||||
|
const project = projects.value.find(p => p.id === id)
|
||||||
|
return project?.canvasData || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete project | 删除项目
|
||||||
|
* @param {string} id - Project ID | 项目ID
|
||||||
|
*/
|
||||||
|
export const deleteProject = (id) => {
|
||||||
|
projects.value = projects.value.filter(p => p.id !== id)
|
||||||
|
saveProjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate project | 复制项目
|
||||||
|
* @param {string} id - Source project ID | 源项目ID
|
||||||
|
* @returns {string|null} - New project ID or null | 新项目ID或空
|
||||||
|
*/
|
||||||
|
export const duplicateProject = (id) => {
|
||||||
|
const source = projects.value.find(p => p.id === id)
|
||||||
|
if (!source) return null
|
||||||
|
|
||||||
|
const newId = generateId()
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const newProject = {
|
||||||
|
...JSON.parse(JSON.stringify(source)), // Deep clone | 深拷贝
|
||||||
|
id: newId,
|
||||||
|
name: `${source.name} (副本)`,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
|
||||||
|
projects.value = [newProject, ...projects.value]
|
||||||
|
saveProjects()
|
||||||
|
|
||||||
|
return newId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename project | 重命名项目
|
||||||
|
* @param {string} id - Project ID | 项目ID
|
||||||
|
* @param {string} name - New name | 新名称
|
||||||
|
*/
|
||||||
|
export const renameProject = (id, name) => {
|
||||||
|
return updateProject(id, { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update project thumbnail | 更新项目缩略图
|
||||||
|
* @param {string} id - Project ID | 项目ID
|
||||||
|
* @param {string} thumbnail - Thumbnail URL (base64 or URL) | 缩略图URL
|
||||||
|
*/
|
||||||
|
export const updateProjectThumbnail = (id, thumbnail) => {
|
||||||
|
return updateProject(id, { thumbnail })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sorted projects | 获取排序后的项目列表
|
||||||
|
* @param {string} sortBy - Sort field (updatedAt, createdAt, name) | 排序字段
|
||||||
|
* @param {string} order - Sort order (asc, desc) | 排序顺序
|
||||||
|
*/
|
||||||
|
export const getSortedProjects = (sortBy = 'updatedAt', order = 'desc') => {
|
||||||
|
return computed(() => {
|
||||||
|
const sorted = [...projects.value]
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
let valueA = a[sortBy]
|
||||||
|
let valueB = b[sortBy]
|
||||||
|
|
||||||
|
if (valueA instanceof Date) {
|
||||||
|
valueA = valueA.getTime()
|
||||||
|
valueB = valueB.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof valueA === 'string') {
|
||||||
|
valueA = valueA.toLowerCase()
|
||||||
|
valueB = valueB.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order === 'asc') {
|
||||||
|
return valueA > valueB ? 1 : -1
|
||||||
|
} else {
|
||||||
|
return valueA < valueB ? 1 : -1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return sorted
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize projects store | 初始化项目存储
|
||||||
|
*/
|
||||||
|
export const initProjectsStore = () => {
|
||||||
|
loadProjects()
|
||||||
|
|
||||||
|
// Create sample project if empty | 如果为空则创建示例项目
|
||||||
|
if (projects.value.length === 0) {
|
||||||
|
const id = createProject('示例项目')
|
||||||
|
const project = projects.value.find(p => p.id === id)
|
||||||
|
if (project) {
|
||||||
|
project.canvasData = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'node_0',
|
||||||
|
type: 'text',
|
||||||
|
position: { x: 150, y: 150 },
|
||||||
|
data: {
|
||||||
|
content: '一只金毛寻回犬在草地上奔跑,摇着尾巴,脸上带着快乐的表情。它的毛发在阳光下闪耀,眼神充满了对自由的渴望,全身散发着阳光、友善的气息。',
|
||||||
|
label: '文本输入'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'node_1',
|
||||||
|
type: 'imageConfig',
|
||||||
|
position: { x: 500, y: 150 },
|
||||||
|
data: {
|
||||||
|
prompt: '',
|
||||||
|
model: 'doubao-seedream-4-5-251128',
|
||||||
|
size: '512x512',
|
||||||
|
label: '文生图'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: 'edge_node_0_node_1',
|
||||||
|
source: 'node_0',
|
||||||
|
target: 'node_1',
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
viewport: { x: 100, y: 50, zoom: 0.8 }
|
||||||
|
}
|
||||||
|
saveProjects()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for debugging | 导出用于调试
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__aiCanvasProjects = {
|
||||||
|
projects,
|
||||||
|
loadProjects,
|
||||||
|
saveProjects,
|
||||||
|
createProject,
|
||||||
|
deleteProject
|
||||||
|
}
|
||||||
|
}
|
||||||
25
web/canvas-app/src/stores/theme.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Theme store | 主题状态管理
|
||||||
|
* Handles dark/light mode switching
|
||||||
|
*/
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
// Get initial theme from localStorage or system preference | 从本地存储或系统偏好获取初始主题
|
||||||
|
const getInitialTheme = () => {
|
||||||
|
const stored = localStorage.getItem('theme')
|
||||||
|
if (stored) return stored === 'dark'
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isDark = ref(getInitialTheme())
|
||||||
|
|
||||||
|
// Watch and apply theme changes | 监听并应用主题变化
|
||||||
|
watch(isDark, (value) => {
|
||||||
|
document.documentElement.classList.toggle('dark', value)
|
||||||
|
localStorage.setItem('theme', value ? 'dark' : 'light')
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Toggle theme | 切换主题
|
||||||
|
export const toggleTheme = () => {
|
||||||
|
isDark.value = !isDark.value
|
||||||
|
}
|
||||||
89
web/canvas-app/src/style.css
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Base styles | 基础样式 */
|
||||||
|
:root {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode variables | 浅色模式变量 */
|
||||||
|
:root {
|
||||||
|
--bg-primary: #f3f6f8;
|
||||||
|
--bg-secondary: rgba(255, 255, 255, 0.82);
|
||||||
|
--bg-tertiary: rgba(229, 236, 240, 0.82);
|
||||||
|
--text-primary: #111827;
|
||||||
|
--text-secondary: #667085;
|
||||||
|
--text-tertiary: #98a2b3;
|
||||||
|
--border-color: rgba(17, 24, 39, 0.1);
|
||||||
|
--accent-color: #07a5a5;
|
||||||
|
--accent-hover: #078b8b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode variables | 深色模式变量 */
|
||||||
|
.dark {
|
||||||
|
--bg-primary: #0b1117;
|
||||||
|
--bg-secondary: rgba(20, 28, 37, 0.86);
|
||||||
|
--bg-tertiary: rgba(42, 54, 66, 0.86);
|
||||||
|
--text-primary: #f7fafc;
|
||||||
|
--text-secondary: #a6b0bb;
|
||||||
|
--text-tertiary: #77828e;
|
||||||
|
--border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
--accent-color: #2dd4bf;
|
||||||
|
--accent-hover: #14b8a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: background-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
header,
|
||||||
|
.glass-panel {
|
||||||
|
backdrop-filter: blur(22px);
|
||||||
|
-webkit-backdrop-filter: blur(22px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vue Flow styles override | Vue Flow 样式覆盖 */
|
||||||
|
.vue-flow {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-flow__node {
|
||||||
|
border-radius: 8px;
|
||||||
|
/* box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); */
|
||||||
|
}
|
||||||
|
.vue-flow__node.selected {
|
||||||
|
/* border: 1px solid green; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-flow__edge-path {
|
||||||
|
stroke: var(--border-color);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-flow__handle {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background-color: var(--accent-color) !important;
|
||||||
|
border: 2px solid var(--bg-secondary);
|
||||||
|
}
|
||||||
59
web/canvas-app/src/utils/constants.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Constants | 常量配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
// API Base URL | API 基础 URL
|
||||||
|
export const DEFAULT_API_BASE_URL = '/api'
|
||||||
|
|
||||||
|
// API Endpoints | API 端点
|
||||||
|
export const API_ENDPOINTS = {
|
||||||
|
// Model | 模型
|
||||||
|
MODEL_PAGE: '/model/page',
|
||||||
|
MODEL_FULL_NAME: '/model/fullName',
|
||||||
|
MODEL_TYPES: '/model/types',
|
||||||
|
|
||||||
|
// Image | 图片
|
||||||
|
IMAGE_GENERATIONS: '/images/generations',
|
||||||
|
|
||||||
|
// Video | 视频
|
||||||
|
VIDEO_GENERATIONS: '/videos',
|
||||||
|
VIDEO_TASK: '/videos',
|
||||||
|
|
||||||
|
// Chat | 对话
|
||||||
|
CHAT_COMPLETIONS: '/chat/completions'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Codes | 错误码
|
||||||
|
export const ERROR_CODES = {
|
||||||
|
INVALID_API_KEY: 'INVALID_API_KEY',
|
||||||
|
RATE_LIMIT: 'RATE_LIMIT',
|
||||||
|
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||||
|
TIMEOUT: 'TIMEOUT',
|
||||||
|
UNKNOWN: 'UNKNOWN'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video Poll Config | 视频轮询配置
|
||||||
|
export const VIDEO_POLL_CONFIG = {
|
||||||
|
MAX_ATTEMPTS: 120,
|
||||||
|
POLL_INTERVAL: 5000
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default Chat Config | 默认问答配置
|
||||||
|
export const DEFAULT_CHAT_CONFIG = {
|
||||||
|
supportImage: false,
|
||||||
|
supportFile: false,
|
||||||
|
supportWeb: false,
|
||||||
|
supportDeepThink: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local Storage Keys | 本地存储键
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
API_KEY: 'apiKey',
|
||||||
|
BASE_URL: 'apiBaseUrl',
|
||||||
|
CUSTOM_CHAT_MODELS: 'customChatModels',
|
||||||
|
CUSTOM_IMAGE_MODELS: 'customImageModels',
|
||||||
|
CUSTOM_VIDEO_MODELS: 'customVideoModels',
|
||||||
|
SELECTED_CHAT_MODEL: 'selectedChatModel',
|
||||||
|
SELECTED_IMAGE_MODEL: 'selectedImageModel',
|
||||||
|
SELECTED_VIDEO_MODEL: 'selectedVideoModel'
|
||||||
|
}
|
||||||
9
web/canvas-app/src/utils/index.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Utils Index | 工具函数索引
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './constants'
|
||||||
|
export * from './schema'
|
||||||
|
import request, { setBaseUrl, getBaseUrl } from './request'
|
||||||
|
|
||||||
|
export { request, setBaseUrl, getBaseUrl }
|
||||||
88
web/canvas-app/src/utils/request.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* HTTP Request Utility | HTTP 请求工具
|
||||||
|
* Axios-based request with interceptors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
// Base URL from environment or default
|
||||||
|
// Create axios instance | 创建 axios 实例
|
||||||
|
const instance = axios.create({
|
||||||
|
baseURL: "/api",
|
||||||
|
timeout: 30000000
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request interceptor | 请求拦截器
|
||||||
|
instance.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('Request error:', error)
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response interceptor | 响应拦截器
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(res) => {
|
||||||
|
const { data, code, message } = res.data || {}
|
||||||
|
|
||||||
|
// Handle stream response | 处理流响应
|
||||||
|
if (res.config.responseType === 'stream') {
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle blob response | 处理 blob 响应
|
||||||
|
if (res.data instanceof Blob) {
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success response | 成功响应
|
||||||
|
if (code === 200 || res.status === 200) {
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error response | 错误响应
|
||||||
|
window.$message?.error(message || 'Request failed')
|
||||||
|
return Promise.reject(res.data)
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
const { response } = error
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
const { status, data } = response
|
||||||
|
const message = data?.message || data?.error?.message || error.message
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
window.$message?.error('登录已过期,请重新进入工作台')
|
||||||
|
} else if (status === 429) {
|
||||||
|
window.$message?.error('请求过于频繁,请稍后再试')
|
||||||
|
} else {
|
||||||
|
window.$message?.error(message || '请求失败')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.$message?.error(error.message || '网络错误')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set API base URL | 设置 API 基础 URL
|
||||||
|
* @param {string} url - Base URL
|
||||||
|
*/
|
||||||
|
export const setBaseUrl = (url) => {
|
||||||
|
instance.defaults.baseURL = url
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current base URL | 获取当前基础 URL
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export const getBaseUrl = () => {
|
||||||
|
return instance.defaults.baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
export default instance
|
||||||
105
web/canvas-app/src/utils/schema.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* API Utils | API 工具函数
|
||||||
|
* Simplified for open source version | 开源版简化版
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get nested value from object | 获取嵌套对象的值
|
||||||
|
* @param {Object} obj - Source object
|
||||||
|
* @param {string} path - Path like "data.url" or "choices.0.message"
|
||||||
|
* @returns {*} Value at path
|
||||||
|
*/
|
||||||
|
export const getNestedValue = (obj, path) => {
|
||||||
|
if (!obj || !path) return obj
|
||||||
|
const paths = path.split('.')
|
||||||
|
let value = obj
|
||||||
|
for (const p of paths) {
|
||||||
|
value = value?.[p]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build request body with FormData support | 构建请求体,支持 FormData
|
||||||
|
* @param {Object} params - Request parameters
|
||||||
|
* @param {string} requestType - 'json' or 'formdata'
|
||||||
|
* @returns {Object|FormData} Request body
|
||||||
|
*/
|
||||||
|
export const buildRequestBody = (params, requestType = 'json') => {
|
||||||
|
if (requestType !== 'formdata') {
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
const fd = new FormData()
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item, idx) => {
|
||||||
|
if (item instanceof File) {
|
||||||
|
fd.append(`${key}[${idx}]`, item, item.name)
|
||||||
|
} else if (typeof item === 'object' && item !== null) {
|
||||||
|
fd.append(`${key}[${idx}]`, JSON.stringify(item))
|
||||||
|
} else {
|
||||||
|
fd.append(`${key}[${idx}]`, item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (value instanceof File) {
|
||||||
|
fd.append(key, value, value.name)
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
fd.append(key, JSON.stringify(value))
|
||||||
|
} else if (value !== undefined && value !== null && value !== '') {
|
||||||
|
fd.append(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fd
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse API result based on output schema | 根据输出 schema 解析 API 结果
|
||||||
|
* @param {Object} result - API response
|
||||||
|
* @param {Object} outputSchema - Output schema with displayField
|
||||||
|
* @param {string} resultType - Result type: 'image', 'video', 'chat'
|
||||||
|
* @returns {Array} Parsed results
|
||||||
|
*/
|
||||||
|
export const parseApiResult = (result, outputSchema, resultType = 'image') => {
|
||||||
|
if (!result) return []
|
||||||
|
|
||||||
|
// Default field based on result type
|
||||||
|
const defaultField = resultType === 'video' ? 'video_url' : (resultType === 'image' ? 'data' : null)
|
||||||
|
const displayField = outputSchema?.displayField || defaultField
|
||||||
|
|
||||||
|
// No displayField, try default parsing
|
||||||
|
if (!displayField) {
|
||||||
|
if (result?.data) {
|
||||||
|
return Array.isArray(result.data) ? result.data : [result.data]
|
||||||
|
}
|
||||||
|
return [result]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse displayField path
|
||||||
|
// Supports: "data", "data[].url", "choices[].message.content"
|
||||||
|
if (displayField.includes('[]')) {
|
||||||
|
// Array path like data[].url
|
||||||
|
const [arrayPath, ...rest] = displayField.split('[]')
|
||||||
|
const fieldPath = rest.join('[]').replace(/^\./, '') // Remove leading dot
|
||||||
|
|
||||||
|
// Get array
|
||||||
|
let data = arrayPath ? getNestedValue(result, arrayPath) : result
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
data = data ? [data] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract field from each element if fieldPath exists
|
||||||
|
if (fieldPath) {
|
||||||
|
return data.map(item => getNestedValue(item, fieldPath)).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
} else {
|
||||||
|
// Simple path like "data"
|
||||||
|
const data = getNestedValue(result, displayField)
|
||||||
|
return Array.isArray(data) ? data : (data ? [data] : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
929
web/canvas-app/src/views/Canvas.vue
Normal file
@@ -0,0 +1,929 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Canvas page | 画布页面 -->
|
||||||
|
<div class="h-screen w-screen flex flex-col bg-[var(--bg-primary)]">
|
||||||
|
<!-- Header | 顶部导航 -->
|
||||||
|
<AppHeader class="bg-[var(--bg-secondary)]">
|
||||||
|
<template #left>
|
||||||
|
<button
|
||||||
|
@click="goBack"
|
||||||
|
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<n-icon :size="20"><ChevronBackOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
<n-dropdown :options="projectOptions" @select="handleProjectAction">
|
||||||
|
<button class="flex items-center gap-1 hover:bg-[var(--bg-tertiary)] px-2 py-1 rounded-lg transition-colors">
|
||||||
|
<span class="font-medium">{{ projectName }}</span>
|
||||||
|
<n-icon :size="16"><ChevronDownOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</template>
|
||||||
|
<template #right>
|
||||||
|
<button
|
||||||
|
@click="showDownloadModal = true"
|
||||||
|
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
|
||||||
|
:class="{ 'text-[var(--accent-color)]': hasDownloadableAssets }"
|
||||||
|
title="批量下载素材"
|
||||||
|
>
|
||||||
|
<n-icon :size="20"><DownloadOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</AppHeader>
|
||||||
|
|
||||||
|
<!-- Main canvas area | 主画布区域 -->
|
||||||
|
<div class="flex-1 relative overflow-hidden">
|
||||||
|
<!-- Vue Flow canvas | Vue Flow 画布 -->
|
||||||
|
<VueFlow
|
||||||
|
:key="flowKey"
|
||||||
|
v-model:nodes="nodes"
|
||||||
|
v-model:edges="edges"
|
||||||
|
v-model:viewport="viewport"
|
||||||
|
:node-types="nodeTypes"
|
||||||
|
:edge-types="edgeTypes"
|
||||||
|
:default-viewport="canvasViewport"
|
||||||
|
:min-zoom="0.1"
|
||||||
|
:max-zoom="2"
|
||||||
|
:snap-to-grid="true"
|
||||||
|
:snap-grid="[20, 20]"
|
||||||
|
@connect="onConnect"
|
||||||
|
@node-click="onNodeClick"
|
||||||
|
@pane-click="onPaneClick"
|
||||||
|
@viewport-change="handleViewportChange"
|
||||||
|
@edges-change="onEdgesChange"
|
||||||
|
class="canvas-flow"
|
||||||
|
>
|
||||||
|
<Background v-if="showGrid" :gap="20" :size="1" />
|
||||||
|
<MiniMap
|
||||||
|
v-if="!isMobile"
|
||||||
|
position="bottom-right"
|
||||||
|
:pannable="true"
|
||||||
|
:zoomable="true"
|
||||||
|
/>
|
||||||
|
</VueFlow>
|
||||||
|
|
||||||
|
<!-- Left toolbar | 左侧工具栏 -->
|
||||||
|
<aside class="absolute left-4 top-1/2 -translate-y-1/2 flex flex-col gap-1 p-2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-lg z-10">
|
||||||
|
<button
|
||||||
|
@click="showNodeMenu = !showNodeMenu"
|
||||||
|
class="w-10 h-10 flex items-center justify-center rounded-xl bg-[var(--accent-color)] text-white hover:bg-[var(--accent-hover)] transition-colors"
|
||||||
|
title="添加节点"
|
||||||
|
>
|
||||||
|
<n-icon :size="20"><AddOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="showWorkflowPanel = true"
|
||||||
|
class="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
title="工作流模板"
|
||||||
|
>
|
||||||
|
<n-icon :size="20"><AppsOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
<div class="w-full h-px bg-[var(--border-color)] my-1"></div>
|
||||||
|
<button
|
||||||
|
v-for="tool in tools"
|
||||||
|
:key="tool.id"
|
||||||
|
@click="tool.action"
|
||||||
|
:disabled="tool.disabled && tool.disabled()"
|
||||||
|
class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
:title="tool.name"
|
||||||
|
>
|
||||||
|
<n-icon :size="20"><component :is="tool.icon" /></n-icon>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Node menu popup | 节点菜单弹窗 -->
|
||||||
|
<div
|
||||||
|
v-if="showNodeMenu"
|
||||||
|
class="absolute left-20 top-1/2 -translate-y-1/2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-lg p-2 z-20"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="nodeType in nodeTypeOptions"
|
||||||
|
:key="nodeType.type"
|
||||||
|
@click="addNewNode(nodeType.type)"
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors text-left"
|
||||||
|
>
|
||||||
|
<n-icon :size="20" :color="nodeType.color"><component :is="nodeType.icon" /></n-icon>
|
||||||
|
<span class="text-sm">{{ nodeType.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom controls | 底部控制 -->
|
||||||
|
<div class="absolute bottom-4 left-4 flex items-center gap-2 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)] p-1">
|
||||||
|
<!-- <button
|
||||||
|
@click="showGrid = !showGrid"
|
||||||
|
:class="showGrid ? 'bg-[var(--accent-color)] text-white' : 'hover:bg-[var(--bg-tertiary)]'"
|
||||||
|
class="p-2 rounded transition-colors"
|
||||||
|
title="切换网格"
|
||||||
|
>
|
||||||
|
<n-icon :size="16"><GridOutline /></n-icon>
|
||||||
|
</button> -->
|
||||||
|
<button
|
||||||
|
@click="fitView({ padding: 0.2 })"
|
||||||
|
class="p-2 hover:bg-[var(--bg-tertiary)] rounded transition-colors"
|
||||||
|
title="适应视图"
|
||||||
|
>
|
||||||
|
<n-icon :size="16"><LocateOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-1 px-2">
|
||||||
|
<button @click="zoomOut" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
|
||||||
|
<n-icon :size="14"><RemoveOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
<span class="text-xs min-w-[40px] text-center">{{ Math.round(viewport.zoom * 100) }}%</span>
|
||||||
|
<button @click="zoomIn" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
|
||||||
|
<n-icon :size="14"><AddOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom input panel (floating) | 底部输入面板(悬浮) -->
|
||||||
|
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4 z-20">
|
||||||
|
<!-- Processing indicator | 处理中指示器 -->
|
||||||
|
<div
|
||||||
|
v-if="isProcessing"
|
||||||
|
class="mb-3 p-3 bg-[var(--bg-primary)] rounded-xl border border-[var(--accent-color)] animate-pulse"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-[var(--accent-color)] mb-2">
|
||||||
|
<n-spin :size="14" />
|
||||||
|
<span>正在创建生成任务...</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentResponse" class="text-sm text-[var(--text-primary)] whitespace-pre-wrap">
|
||||||
|
{{ currentResponse }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-[var(--bg-primary)] rounded-xl border border-[var(--border-color)] p-3">
|
||||||
|
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-for="modeItem in creationModes"
|
||||||
|
:key="modeItem.id"
|
||||||
|
@click="setCreationMode(modeItem.id)"
|
||||||
|
class="px-3 py-1.5 text-xs rounded-lg border transition-colors"
|
||||||
|
:class="creationMode === modeItem.id ? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)]' : 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
|
||||||
|
>
|
||||||
|
{{ modeItem.label }}
|
||||||
|
</button>
|
||||||
|
<label
|
||||||
|
v-if="needsFirstFrame"
|
||||||
|
class="px-3 py-1.5 text-xs rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-secondary)] cursor-pointer hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
{{ firstFrameFile ? `首帧 · ${firstFrameFile.name}` : '上传首帧' }}
|
||||||
|
<input type="file" accept="image/*" class="hidden" @change="event => handleFrameFile('first', event)" />
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
v-if="needsLastFrame"
|
||||||
|
class="px-3 py-1.5 text-xs rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-secondary)] cursor-pointer hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
{{ lastFrameFile ? `尾帧 · ${lastFrameFile.name}` : '上传尾帧' }}
|
||||||
|
<input type="file" accept="image/*" class="hidden" @change="event => handleFrameFile('last', event)" />
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
v-if="firstFrameFile || lastFrameFile"
|
||||||
|
@click="clearFrameFiles"
|
||||||
|
class="px-2 py-1.5 text-xs rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]"
|
||||||
|
>
|
||||||
|
清空帧
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="chatInput"
|
||||||
|
:placeholder="inputPlaceholder"
|
||||||
|
:disabled="isProcessing"
|
||||||
|
class="w-full bg-transparent resize-none outline-none text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] min-h-[40px] max-h-[120px] disabled:opacity-50"
|
||||||
|
rows="1"
|
||||||
|
@keydown.enter.exact="handleEnterKey"
|
||||||
|
@keydown.enter.ctrl="sendMessage"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center justify-between mt-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span v-if="firstFramePreview" class="h-8 w-8 overflow-hidden rounded-md border border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
||||||
|
<img :src="firstFramePreview" alt="首帧" class="h-full w-full object-cover" />
|
||||||
|
</span>
|
||||||
|
<span v-if="lastFramePreview" class="h-8 w-8 overflow-hidden rounded-md border border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
||||||
|
<img :src="lastFramePreview" alt="尾帧" class="h-full w-full object-cover" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click="sendMessage"
|
||||||
|
:disabled="isProcessing || !canSubmit"
|
||||||
|
class="w-8 h-8 rounded-xl bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] flex items-center justify-center transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<n-spin v-if="isProcessing" :size="16" />
|
||||||
|
<n-icon v-else :size="20" color="white"><SendOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick suggestions | 快捷建议 -->
|
||||||
|
<div class="flex flex-wrap items-center justify-center gap-2 mt-2">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">推荐:</span>
|
||||||
|
<button
|
||||||
|
v-for="tag in suggestions"
|
||||||
|
:key="tag"
|
||||||
|
@click="chatInput = tag"
|
||||||
|
class="px-2 py-0.5 text-xs rounded-full bg-[var(--bg-secondary)]/80 border border-[var(--border-color)] hover:border-[var(--accent-color)] transition-colors"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
<button class="p-1 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||||||
|
<n-icon :size="14"><RefreshOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rename Modal | 重命名弹窗 -->
|
||||||
|
<n-modal v-model:show="showRenameModal" preset="dialog" title="重命名项目">
|
||||||
|
<n-input v-model:value="renameValue" placeholder="请输入项目名称" />
|
||||||
|
<template #action>
|
||||||
|
<n-button @click="showRenameModal = false">取消</n-button>
|
||||||
|
<n-button type="primary" @click="confirmRename">确定</n-button>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<!-- Delete Confirm Modal | 删除确认弹窗 -->
|
||||||
|
<n-modal v-model:show="showDeleteModal" preset="dialog" title="删除项目" type="warning">
|
||||||
|
<p>确定要删除项目「{{ projectName }}」吗?此操作不可恢复。</p>
|
||||||
|
<template #action>
|
||||||
|
<n-button @click="showDeleteModal = false">取消</n-button>
|
||||||
|
<n-button type="error" @click="confirmDelete">删除</n-button>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<!-- Download Modal | 下载弹窗 -->
|
||||||
|
<DownloadModal v-model:show="showDownloadModal" />
|
||||||
|
|
||||||
|
<!-- Workflow Panel | 工作流面板 -->
|
||||||
|
<WorkflowPanel v-model:show="showWorkflowPanel" @add-workflow="handleAddWorkflow" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Canvas view component | 画布视图组件
|
||||||
|
* Main infinite canvas with Vue Flow integration
|
||||||
|
*/
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick, markRaw } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { Background } from '@vue-flow/background'
|
||||||
|
import { MiniMap } from '@vue-flow/minimap'
|
||||||
|
import { NIcon, NDropdown, NSpin, NModal, NInput, NButton } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
ChevronBackOutline,
|
||||||
|
ChevronDownOutline,
|
||||||
|
AddOutline,
|
||||||
|
ImageOutline,
|
||||||
|
SendOutline,
|
||||||
|
RefreshOutline,
|
||||||
|
TextOutline,
|
||||||
|
VideocamOutline,
|
||||||
|
ColorPaletteOutline,
|
||||||
|
BookmarkOutline,
|
||||||
|
ArrowUndoOutline,
|
||||||
|
ArrowRedoOutline,
|
||||||
|
GridOutline,
|
||||||
|
LocateOutline,
|
||||||
|
RemoveOutline,
|
||||||
|
DownloadOutline,
|
||||||
|
AppsOutline,
|
||||||
|
ChatbubbleOutline
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
import { nodes, edges, addNode, addNodes, addEdge, addEdges, updateNode, initSampleData, loadProject, saveProject, clearCanvas, canvasViewport, updateViewport, undo, redo, canUndo, canRedo, manualSaveHistory, startBatchOperation, endBatchOperation } from '../stores/canvas'
|
||||||
|
import { loadAllModels } from '../stores/models'
|
||||||
|
import { useChat, useWorkflowOrchestrator } from '../hooks'
|
||||||
|
import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects'
|
||||||
|
|
||||||
|
import DownloadModal from '../components/DownloadModal.vue'
|
||||||
|
import WorkflowPanel from '../components/WorkflowPanel.vue'
|
||||||
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
|
||||||
|
// Initialize models on page load | 页面加载时初始化模型
|
||||||
|
onMounted(() => {
|
||||||
|
loadAllModels()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Chat templates | 问答模板
|
||||||
|
const CHAT_TEMPLATES = {
|
||||||
|
imagePrompt: {
|
||||||
|
name: '生图提示词',
|
||||||
|
systemPrompt: '你是一个专业的AI绘画提示词专家。将用户输入的内容美化成高质量的生图提示词,包含风格、光线、構图、细节等要素。直接返回提示词,不要其他解释。',
|
||||||
|
model: 'gpt-4o-mini'
|
||||||
|
},
|
||||||
|
videoPrompt: {
|
||||||
|
name: '视频提示词',
|
||||||
|
systemPrompt: '你是一个专业的AI视频提示词专家。将用户输入的内容美化成高质量的视频生成提示词,包含运动、场景、镜头等要素。直接返回提示词,不要其他解释。',
|
||||||
|
model: 'gpt-4o-mini'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current template | 当前模板
|
||||||
|
const currentTemplate = ref('imagePrompt')
|
||||||
|
|
||||||
|
// Chat hook with image prompt template | 问答 hook
|
||||||
|
const {
|
||||||
|
loading: chatLoading,
|
||||||
|
status: chatStatus,
|
||||||
|
currentResponse,
|
||||||
|
send: sendChat
|
||||||
|
} = useChat({
|
||||||
|
systemPrompt: CHAT_TEMPLATES.imagePrompt.systemPrompt,
|
||||||
|
model: CHAT_TEMPLATES.imagePrompt.model
|
||||||
|
})
|
||||||
|
|
||||||
|
// Workflow orchestrator hook | 工作流编排 hook
|
||||||
|
const {
|
||||||
|
isAnalyzing: workflowAnalyzing,
|
||||||
|
isExecuting: workflowExecuting,
|
||||||
|
currentStep: workflowStep,
|
||||||
|
totalSteps: workflowTotalSteps,
|
||||||
|
executionLog: workflowLog,
|
||||||
|
analyzeIntent,
|
||||||
|
executeWorkflow,
|
||||||
|
createTextToImageWorkflow,
|
||||||
|
createMultiAngleStoryboard,
|
||||||
|
WORKFLOW_TYPES
|
||||||
|
} = useWorkflowOrchestrator()
|
||||||
|
|
||||||
|
// Custom node components | 自定义节点组件
|
||||||
|
import TextNode from '../components/nodes/TextNode.vue'
|
||||||
|
import ImageConfigNode from '../components/nodes/ImageConfigNode.vue'
|
||||||
|
import VideoNode from '../components/nodes/VideoNode.vue'
|
||||||
|
import ImageNode from '../components/nodes/ImageNode.vue'
|
||||||
|
import VideoConfigNode from '../components/nodes/VideoConfigNode.vue'
|
||||||
|
import LLMConfigNode from '../components/nodes/LLMConfigNode.vue'
|
||||||
|
import ImageRoleEdge from '../components/edges/ImageRoleEdge.vue'
|
||||||
|
import PromptOrderEdge from '../components/edges/PromptOrderEdge.vue'
|
||||||
|
import ImageOrderEdge from '../components/edges/ImageOrderEdge.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// Vue Flow instance | Vue Flow 实例
|
||||||
|
const { viewport, zoomIn, zoomOut, fitView, updateNodeInternals } = useVueFlow()
|
||||||
|
|
||||||
|
// Register custom node types | 注册自定义节点类型
|
||||||
|
const nodeTypes = {
|
||||||
|
text: markRaw(TextNode),
|
||||||
|
imageConfig: markRaw(ImageConfigNode),
|
||||||
|
video: markRaw(VideoNode),
|
||||||
|
image: markRaw(ImageNode),
|
||||||
|
videoConfig: markRaw(VideoConfigNode),
|
||||||
|
llmConfig: markRaw(LLMConfigNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register custom edge types | 注册自定义边类型
|
||||||
|
const edgeTypes = {
|
||||||
|
imageRole: markRaw(ImageRoleEdge),
|
||||||
|
promptOrder: markRaw(PromptOrderEdge),
|
||||||
|
imageOrder: markRaw(ImageOrderEdge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI state | UI状态
|
||||||
|
const showNodeMenu = ref(false)
|
||||||
|
const chatInput = ref('')
|
||||||
|
const isMobile = ref(false)
|
||||||
|
const showGrid = ref(true)
|
||||||
|
const isProcessing = ref(false)
|
||||||
|
|
||||||
|
const creationModes = [
|
||||||
|
{ id: 'text-video', label: '文生视频' },
|
||||||
|
{ id: 'text-image', label: '文生图' },
|
||||||
|
{ id: 'first-frame-video', label: '首帧生视频' },
|
||||||
|
{ id: 'first-last-frame-video', label: '首尾帧生视频' }
|
||||||
|
]
|
||||||
|
const creationMode = ref('text-video')
|
||||||
|
const firstFrameFile = ref(null)
|
||||||
|
const lastFrameFile = ref(null)
|
||||||
|
const firstFramePreview = ref('')
|
||||||
|
const lastFramePreview = ref('')
|
||||||
|
const needsFirstFrame = computed(() => creationMode.value === 'first-frame-video' || creationMode.value === 'first-last-frame-video')
|
||||||
|
const needsLastFrame = computed(() => creationMode.value === 'first-last-frame-video')
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
if (!chatInput.value.trim()) return false
|
||||||
|
if (needsFirstFrame.value && !firstFrameFile.value) return false
|
||||||
|
if (needsLastFrame.value && !lastFrameFile.value) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Flow key for forcing re-render on project switch | 项目切换时强制重新渲染的 key
|
||||||
|
const flowKey = ref(Date.now())
|
||||||
|
|
||||||
|
// Modal state | 弹窗状态
|
||||||
|
const showRenameModal = ref(false)
|
||||||
|
const showDeleteModal = ref(false)
|
||||||
|
const showDownloadModal = ref(false)
|
||||||
|
const showWorkflowPanel = ref(false)
|
||||||
|
const renameValue = ref('')
|
||||||
|
|
||||||
|
// Check if has downloadable assets | 检查是否有可下载素材
|
||||||
|
const hasDownloadableAssets = computed(() => {
|
||||||
|
return nodes.value.some(n =>
|
||||||
|
(n.type === 'image' || n.type === 'video') && n.data?.url
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// Project info | 项目信息
|
||||||
|
const projectName = computed(() => {
|
||||||
|
const project = projects.value.find(p => p.id === route.params.id)
|
||||||
|
return project?.name || '未命名项目'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Project dropdown options | 项目下拉选项
|
||||||
|
const projectOptions = [
|
||||||
|
{ label: '重命名', key: 'rename' },
|
||||||
|
{ label: '复制', key: 'duplicate' },
|
||||||
|
{ label: '删除', key: 'delete' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Toolbar tools | 工具栏工具
|
||||||
|
const tools = [
|
||||||
|
{ id: 'text', name: '文本', icon: TextOutline, action: () => addNewNode('text') },
|
||||||
|
{ id: 'image', name: '图片', icon: ImageOutline, action: () => addNewNode('image') },
|
||||||
|
{ id: 'imageConfig', name: '文生图', icon: ColorPaletteOutline, action: () => addNewNode('imageConfig') },
|
||||||
|
{ id: 'videoConfig', name: '视频生成', icon: VideocamOutline, action: () => addNewNode('videoConfig') },
|
||||||
|
{ id: 'undo', name: '撤销', icon: ArrowUndoOutline, action: () => undo(), disabled: () => !canUndo() },
|
||||||
|
{ id: 'redo', name: '重做', icon: ArrowRedoOutline, action: () => redo(), disabled: () => !canRedo() }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Node type options for menu | 节点类型菜单选项
|
||||||
|
const nodeTypeOptions = [
|
||||||
|
{ type: 'text', name: '文本节点', icon: TextOutline, color: '#3b82f6' },
|
||||||
|
{ type: 'llmConfig', name: 'LLM文本生成', icon: ChatbubbleOutline, color: '#a855f7' },
|
||||||
|
{ type: 'imageConfig', name: '文生图配置', icon: ColorPaletteOutline, color: '#22c55e' },
|
||||||
|
{ type: 'videoConfig', name: '视频生成配置', icon: VideocamOutline, color: '#f59e0b' },
|
||||||
|
{ type: 'image', name: '图片节点', icon: ImageOutline, color: '#8b5cf6' },
|
||||||
|
{ type: 'video', name: '视频节点', icon: VideocamOutline, color: '#ef4444' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Input placeholder | 输入占位符
|
||||||
|
const inputPlaceholder = computed(() => {
|
||||||
|
if (creationMode.value === 'text-image') return '写清楚画面、主体、构图、光线、比例和 SKG 产品露出方式'
|
||||||
|
if (creationMode.value === 'first-frame-video') return '上传首帧后,写人物动作、镜头运动、产品细节保持和视频节奏'
|
||||||
|
if (creationMode.value === 'first-last-frame-video') return '上传首帧和尾帧后,写中间如何过渡、动作节奏和产品细节保持'
|
||||||
|
return '写清楚画面、动作、镜头、产品出现方式、视频比例和时长'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Quick suggestions | 快捷建议
|
||||||
|
const suggestions = [
|
||||||
|
'15 秒竖屏,办公室午休,人物戴上 SKG 颈部按摩仪放松,镜头缓慢推进',
|
||||||
|
'详情页横图,SKG 产品在干净桌面上,晨光,质感高级',
|
||||||
|
'首帧人物低头办公,过渡到佩戴 SKG 后肩颈放松,产品形状保持稳定',
|
||||||
|
'竖屏广告,真实生活场景,产品清晰可见,无文字水印'
|
||||||
|
]
|
||||||
|
|
||||||
|
const setCreationMode = (mode) => {
|
||||||
|
creationMode.value = mode
|
||||||
|
if (mode === 'text-video' || mode === 'text-image') {
|
||||||
|
clearFrameFiles()
|
||||||
|
} else if (mode === 'first-frame-video') {
|
||||||
|
lastFrameFile.value = null
|
||||||
|
lastFramePreview.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileToDataUrl = (file) => new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(String(reader.result || ''))
|
||||||
|
reader.onerror = () => reject(reader.error || new Error('读取图片失败'))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleFrameFile = (slot, event) => {
|
||||||
|
const file = event?.target?.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
if (slot === 'first') {
|
||||||
|
if (firstFramePreview.value) URL.revokeObjectURL(firstFramePreview.value)
|
||||||
|
firstFrameFile.value = file
|
||||||
|
firstFramePreview.value = url
|
||||||
|
} else {
|
||||||
|
if (lastFramePreview.value) URL.revokeObjectURL(lastFramePreview.value)
|
||||||
|
lastFrameFile.value = file
|
||||||
|
lastFramePreview.value = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFrameFiles = () => {
|
||||||
|
if (firstFramePreview.value) URL.revokeObjectURL(firstFramePreview.value)
|
||||||
|
if (lastFramePreview.value) URL.revokeObjectURL(lastFramePreview.value)
|
||||||
|
firstFrameFile.value = null
|
||||||
|
lastFrameFile.value = null
|
||||||
|
firstFramePreview.value = ''
|
||||||
|
lastFramePreview.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new node | 添加新节点
|
||||||
|
const addNewNode = async (type) => {
|
||||||
|
// Calculate viewport center position | 计算视口中心位置
|
||||||
|
const viewportCenterX = -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom
|
||||||
|
const viewportCenterY = -viewport.value.y / viewport.value.zoom + (window.innerHeight / 2) / viewport.value.zoom
|
||||||
|
|
||||||
|
// Add node at viewport center | 在视口中心添加节点
|
||||||
|
const nodeId = addNode(type, { x: viewportCenterX - 100, y: viewportCenterY - 100 })
|
||||||
|
|
||||||
|
// Set highest z-index | 设置最高层级
|
||||||
|
const maxZIndex = Math.max(0, ...nodes.value.map(n => n.zIndex || 0))
|
||||||
|
updateNode(nodeId, { zIndex: maxZIndex + 1 })
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(nodeId)
|
||||||
|
}, 50)
|
||||||
|
|
||||||
|
showNodeMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle add workflow from panel | 处理从面板添加工作流
|
||||||
|
const handleAddWorkflow = ({ workflow, options }) => {
|
||||||
|
// Calculate viewport center position | 计算视口中心位置
|
||||||
|
const viewportCenterX = -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom
|
||||||
|
const viewportCenterY = -viewport.value.y / viewport.value.zoom + (window.innerHeight / 2) / viewport.value.zoom
|
||||||
|
|
||||||
|
// Create nodes from workflow template | 从工作流模板创建节点
|
||||||
|
const startPosition = { x: viewportCenterX - 300, y: viewportCenterY - 200 }
|
||||||
|
const { nodes: newNodes, edges: newEdges } = workflow.createNodes(startPosition, options)
|
||||||
|
|
||||||
|
// Start batch operation manually | 手动开始批量操作
|
||||||
|
startBatchOperation()
|
||||||
|
|
||||||
|
// Add nodes to canvas in batch | 批量将节点添加到画布
|
||||||
|
const nodeSpecs = newNodes.map(node => ({
|
||||||
|
type: node.type,
|
||||||
|
position: node.position,
|
||||||
|
data: node.data
|
||||||
|
}))
|
||||||
|
const nodeIds = addNodes(nodeSpecs, false)
|
||||||
|
|
||||||
|
// Map old node IDs to new IDs | 映射旧节点ID到新ID
|
||||||
|
const idMap = {}
|
||||||
|
newNodes.forEach((node, index) => {
|
||||||
|
idMap[node.id] = nodeIds[index]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add edges to canvas in batch | 批量将边添加到画布
|
||||||
|
const edgeSpecs = newEdges.map(edge => ({
|
||||||
|
source: idMap[edge.source] || edge.source,
|
||||||
|
target: idMap[edge.target] || edge.target,
|
||||||
|
sourceHandle: edge.sourceHandle || 'right',
|
||||||
|
targetHandle: edge.targetHandle || 'left',
|
||||||
|
type: edge.type,
|
||||||
|
data: edge.data
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Add edges (autoBatch=false to use manual batch) | 添加边(autoBatch=false 以使用手动批量)
|
||||||
|
addEdges(edgeSpecs, false)
|
||||||
|
|
||||||
|
// End batch operation and save to history | 结束批量操作并保存到历史
|
||||||
|
endBatchOperation()
|
||||||
|
|
||||||
|
// Delay node internals update | 延迟节点内部更新
|
||||||
|
setTimeout(() => {
|
||||||
|
// Update node internals | 更新节点内部
|
||||||
|
nodeIds.forEach(nodeId => {
|
||||||
|
updateNodeInternals(nodeId)
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
window.$message?.success(`已添加工作流: ${workflow.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle connection | 处理连接
|
||||||
|
const onConnect = (params) => {
|
||||||
|
// Check connection types | 检查连接类型
|
||||||
|
const sourceNode = nodes.value.find(n => n.id === params.source)
|
||||||
|
const targetNode = nodes.value.find(n => n.id === params.target)
|
||||||
|
|
||||||
|
if (sourceNode?.type === 'image' && targetNode?.type === 'videoConfig') {
|
||||||
|
// Use imageRole edge type | 使用图片角色边类型
|
||||||
|
addEdge({
|
||||||
|
...params,
|
||||||
|
type: 'imageRole',
|
||||||
|
data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
|
||||||
|
})
|
||||||
|
} else if (sourceNode?.type === 'text' && targetNode?.type === 'imageConfig') {
|
||||||
|
// Use promptOrder edge type | 使用提示词顺序边类型
|
||||||
|
// Calculate next order number | 计算下一个顺序号
|
||||||
|
const existingTextEdges = edges.value.filter(e =>
|
||||||
|
e.target === params.target && e.type === 'promptOrder'
|
||||||
|
)
|
||||||
|
const nextOrder = existingTextEdges.length + 1
|
||||||
|
|
||||||
|
addEdge({
|
||||||
|
...params,
|
||||||
|
type: 'promptOrder',
|
||||||
|
data: { promptOrder: nextOrder }
|
||||||
|
})
|
||||||
|
} else if (sourceNode?.type === 'image' && targetNode?.type === 'imageConfig') {
|
||||||
|
// Use imageOrder edge type | 使用图片顺序边类型
|
||||||
|
// Calculate next order number | 计算下一个顺序号
|
||||||
|
const existingImageEdges = edges.value.filter(e =>
|
||||||
|
e.target === params.target && e.type === 'imageOrder'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get @ mentioned image count from connected TextNodes | 获取已连接 TextNode 中 @ 提及的图片数量
|
||||||
|
let mentionedImageCount = 0
|
||||||
|
const connectedTextEdges = edges.value.filter(e => e.target === params.target)
|
||||||
|
for (const edge of connectedTextEdges) {
|
||||||
|
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||||||
|
if (sourceNode?.type === 'text') {
|
||||||
|
const content = sourceNode.data?.content || ''
|
||||||
|
// Count @ mentions of image nodes | 统计图片节点的 @ 提及
|
||||||
|
const mentionRegex = /@\[([^\]|]+)(?:\|([^\]]+))?\]/g
|
||||||
|
let match
|
||||||
|
while ((match = mentionRegex.exec(content)) !== null) {
|
||||||
|
const mentionedNode = nodes.value.find(n => n.id === match[1])
|
||||||
|
if (mentionedNode?.type === 'image') {
|
||||||
|
mentionedImageCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next order = existing edges + mentioned image count + 1 | 下一个序号 = 现有边数 + @提及图片数 + 1
|
||||||
|
const nextOrder = existingImageEdges.length + mentionedImageCount + 1
|
||||||
|
|
||||||
|
addEdge({
|
||||||
|
...params,
|
||||||
|
type: 'imageOrder',
|
||||||
|
data: { imageOrder: nextOrder }
|
||||||
|
})
|
||||||
|
} else if (sourceNode?.type === 'llmConfig' && targetNode?.type === 'imageConfig') {
|
||||||
|
// LLM output as prompt for image generation | LLM 输出作为图片生成提示词
|
||||||
|
const existingTextEdges = edges.value.filter(e =>
|
||||||
|
e.target === params.target && e.type === 'promptOrder'
|
||||||
|
)
|
||||||
|
const nextOrder = existingTextEdges.length + 1
|
||||||
|
|
||||||
|
addEdge({
|
||||||
|
...params,
|
||||||
|
type: 'promptOrder',
|
||||||
|
data: { promptOrder: nextOrder }
|
||||||
|
})
|
||||||
|
} else if (sourceNode?.type === 'llmConfig' && targetNode?.type === 'videoConfig') {
|
||||||
|
// LLM output as prompt for video generation | LLM 输出作为视频生成提示词
|
||||||
|
addEdge({
|
||||||
|
...params,
|
||||||
|
type: 'promptOrder',
|
||||||
|
data: { promptOrder: 1 }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
addEdge(params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onNodeClick = (event) => {
|
||||||
|
// nodes.value.forEach(node => {
|
||||||
|
// updateNode(node.id, { selected: false })
|
||||||
|
// })
|
||||||
|
|
||||||
|
// // Select clicked node | 选中的节点
|
||||||
|
// const clickedNode = nodes.value.find(n => n.id === event.node.id)
|
||||||
|
// if (clickedNode) {
|
||||||
|
// updateNode(event.node.id, { selected: true })
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle viewport change | 处理视口变化
|
||||||
|
const handleViewportChange = (newViewport) => {
|
||||||
|
updateViewport(newViewport)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle edges change | 处理边变化
|
||||||
|
const onEdgesChange = (changes) => {
|
||||||
|
// Check if any edge is being removed | 检查是否有边被删除
|
||||||
|
const hasRemoval = changes.some(change => change.type === 'remove')
|
||||||
|
|
||||||
|
if (hasRemoval) {
|
||||||
|
// Trigger history save after edge removal | 边删除后触发历史保存
|
||||||
|
nextTick(() => {
|
||||||
|
manualSaveHistory()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle pane click | 处理画布点击
|
||||||
|
const onPaneClick = () => {
|
||||||
|
showNodeMenu.value = false
|
||||||
|
// Clear all selections | 清除所有选中
|
||||||
|
// nodes.value = nodes.value.map(node => ({
|
||||||
|
// ...node,
|
||||||
|
// selected: false
|
||||||
|
// }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle project action | 处理项目操作
|
||||||
|
const handleProjectAction = (key) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'rename':
|
||||||
|
renameValue.value = projectName.value
|
||||||
|
showRenameModal.value = true
|
||||||
|
break
|
||||||
|
case 'duplicate':
|
||||||
|
// TODO: Implement duplicate
|
||||||
|
window.$message?.info('复制功能开发中')
|
||||||
|
break
|
||||||
|
case 'delete':
|
||||||
|
showDeleteModal.value = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm rename | 确认重命名
|
||||||
|
const confirmRename = () => {
|
||||||
|
const projectId = route.params.id
|
||||||
|
if (renameValue.value.trim()) {
|
||||||
|
renameProject(projectId, renameValue.value.trim())
|
||||||
|
window.$message?.success('已重命名')
|
||||||
|
}
|
||||||
|
showRenameModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm delete | 确认删除
|
||||||
|
const confirmDelete = () => {
|
||||||
|
const projectId = route.params.id
|
||||||
|
// deleteProject(projectId) // TODO: import deleteProject
|
||||||
|
showDeleteModal.value = false
|
||||||
|
window.$message?.success('项目已删除')
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Enter key | 处理回车键
|
||||||
|
const handleEnterKey = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
sendMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message | 发送消息
|
||||||
|
const sendMessage = async () => {
|
||||||
|
const input = chatInput.value.trim()
|
||||||
|
if (!input || !canSubmit.value) return
|
||||||
|
|
||||||
|
isProcessing.value = true
|
||||||
|
const content = chatInput.value
|
||||||
|
chatInput.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Calculate position to avoid overlap | 计算位置避免重叠
|
||||||
|
let maxY = 0
|
||||||
|
if (nodes.value.length > 0) {
|
||||||
|
maxY = Math.max(...nodes.value.map(n => n.position.y))
|
||||||
|
}
|
||||||
|
const baseX = 100
|
||||||
|
const baseY = maxY + 200
|
||||||
|
|
||||||
|
const textNodeId = addNode('text', { x: baseX, y: baseY }, {
|
||||||
|
content,
|
||||||
|
label: '提示词'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (creationMode.value === 'text-image') {
|
||||||
|
const imageConfigNodeId = addNode('imageConfig', { x: baseX + 400, y: baseY }, {
|
||||||
|
label: '文生图',
|
||||||
|
autoExecute: true
|
||||||
|
})
|
||||||
|
addEdge({
|
||||||
|
source: textNodeId,
|
||||||
|
target: imageConfigNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let videoX = baseX + 400
|
||||||
|
let promptY = baseY
|
||||||
|
const imageNodeIds = []
|
||||||
|
|
||||||
|
if (needsFirstFrame.value && firstFrameFile.value) {
|
||||||
|
const dataUrl = await fileToDataUrl(firstFrameFile.value)
|
||||||
|
const firstId = addNode('image', { x: baseX, y: baseY + 160 }, {
|
||||||
|
url: dataUrl,
|
||||||
|
base64: dataUrl,
|
||||||
|
label: '首帧'
|
||||||
|
})
|
||||||
|
imageNodeIds.push({ id: firstId, role: 'first_frame_image' })
|
||||||
|
promptY = baseY - 140
|
||||||
|
updateNode(textNodeId, { zIndex: 5 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsLastFrame.value && lastFrameFile.value) {
|
||||||
|
const dataUrl = await fileToDataUrl(lastFrameFile.value)
|
||||||
|
const lastId = addNode('image', { x: baseX, y: baseY + 440 }, {
|
||||||
|
url: dataUrl,
|
||||||
|
base64: dataUrl,
|
||||||
|
label: '尾帧'
|
||||||
|
})
|
||||||
|
imageNodeIds.push({ id: lastId, role: 'last_frame_image' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoConfigNodeId = addNode('videoConfig', { x: videoX, y: promptY }, {
|
||||||
|
label: creationMode.value === 'text-video' ? '文生视频' : '生视频',
|
||||||
|
autoExecute: true
|
||||||
|
})
|
||||||
|
|
||||||
|
addEdge({
|
||||||
|
source: textNodeId,
|
||||||
|
target: videoConfigNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left',
|
||||||
|
type: 'promptOrder',
|
||||||
|
data: { promptOrder: 1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const item of imageNodeIds) {
|
||||||
|
addEdge({
|
||||||
|
source: item.id,
|
||||||
|
target: videoConfigNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left',
|
||||||
|
type: 'imageRole',
|
||||||
|
data: { imageRole: item.role }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
window.$message?.error(err.message || '创建失败')
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go back to home | 返回首页
|
||||||
|
const goBack = () => {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if mobile | 检测是否移动端
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth < 768
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load project by ID | 根据ID加载项目
|
||||||
|
const loadProjectById = (projectId) => {
|
||||||
|
// Update flow key to force VueFlow re-render | 更新 key 强制 VueFlow 重新渲染
|
||||||
|
flowKey.value = Date.now()
|
||||||
|
|
||||||
|
if (projectId && projectId !== 'new') {
|
||||||
|
loadProject(projectId)
|
||||||
|
} else {
|
||||||
|
// New project - clear canvas | 新项目 - 清空画布
|
||||||
|
clearCanvas()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for route changes | 监听路由变化
|
||||||
|
watch(
|
||||||
|
() => route.params.id,
|
||||||
|
(newId, oldId) => {
|
||||||
|
if (newId && newId !== oldId) {
|
||||||
|
// Save current project before switching | 切换前保存当前项目
|
||||||
|
if (oldId) {
|
||||||
|
saveProject()
|
||||||
|
}
|
||||||
|
// Load new project | 加载新项目
|
||||||
|
loadProjectById(newId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize | 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
|
||||||
|
// Initialize projects store | 初始化项目存储
|
||||||
|
initProjectsStore()
|
||||||
|
|
||||||
|
// Load project data | 加载项目数据
|
||||||
|
loadProjectById(route.params.id)
|
||||||
|
|
||||||
|
// Check for initial prompt from home page | 检查来自首页的初始提示词
|
||||||
|
const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt')
|
||||||
|
if (initialPrompt) {
|
||||||
|
sessionStorage.removeItem('ai-canvas-initial-prompt')
|
||||||
|
chatInput.value = initialPrompt
|
||||||
|
// Auto-send the message | 自动发送消息
|
||||||
|
nextTick(() => {
|
||||||
|
sendMessage()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cleanup on unmount | 卸载时清理
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
|
clearFrameFiles()
|
||||||
|
// Save project before leaving | 离开前保存项目
|
||||||
|
saveProject()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Import Vue Flow styles | 引入 Vue Flow 样式 */
|
||||||
|
@import '@vue-flow/core/dist/style.css';
|
||||||
|
@import '@vue-flow/core/dist/theme-default.css';
|
||||||
|
@import '@vue-flow/minimap/dist/style.css';
|
||||||
|
|
||||||
|
.canvas-flow {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
378
web/canvas-app/src/views/Home.vue
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Home page | 首页 -->
|
||||||
|
<div class="min-h-screen h-screen overflow-y-auto bg-[var(--bg-primary)]">
|
||||||
|
<!-- Header | 顶部导航 -->
|
||||||
|
<AppHeader>
|
||||||
|
<template #left>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<img src="/skg-logo-black.svg" alt="SKG" class="h-6 w-auto dark:invert" />
|
||||||
|
<span class="text-sm font-semibold text-[var(--text-secondary)]">营销内容工作台</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</AppHeader>
|
||||||
|
|
||||||
|
<!-- Main content | 主要内容 -->
|
||||||
|
<main class="max-w-5xl mx-auto px-4 py-8 md:py-16">
|
||||||
|
<!-- Welcome section | 欢迎区域 -->
|
||||||
|
<section class="text-center mb-12">
|
||||||
|
<div class="flex items-center justify-center gap-4 mb-8">
|
||||||
|
<img src="/skg-logo-black.svg" alt="SKG" class="h-9 w-auto dark:invert" />
|
||||||
|
<h1 class="text-2xl md:text-4xl font-bold text-[var(--text-primary)]">SKG 无限画布</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input area | 输入区域 -->
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="bg-[var(--bg-secondary)] rounded-2xl border border-[var(--border-color)] p-4 shadow-sm">
|
||||||
|
<textarea
|
||||||
|
v-model="inputText"
|
||||||
|
placeholder="输入要生成的营销图或视频创意,开始新画布"
|
||||||
|
class="w-full bg-transparent resize-none outline-none text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] min-h-[80px]"
|
||||||
|
@keydown.enter.ctrl="handleCreateWithInput"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center justify-between mt-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- <button class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||||||
|
<n-icon :size="18"><AddOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
<button class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||||||
|
<n-icon :size="18"><ImageOutline /></n-icon>
|
||||||
|
</button> -->
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click="handleCreateWithInput"
|
||||||
|
class="w-8 h-8 rounded-xl bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<n-icon :size="20" color="white"><SendOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick suggestions | 快捷建议 -->
|
||||||
|
<div class="flex flex-wrap items-center justify-center gap-2 mt-4">
|
||||||
|
<span class="text-sm text-[var(--text-secondary)]">推荐:</span>
|
||||||
|
<button
|
||||||
|
v-for="tag in suggestions"
|
||||||
|
:key="tag"
|
||||||
|
@click="inputText = tag"
|
||||||
|
class="px-3 py-1.5 text-sm rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] hover:border-[var(--accent-color)] transition-colors"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
<button class="p-1.5 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||||||
|
<n-icon :size="16"><RefreshOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- My projects section | 我的项目区域 -->
|
||||||
|
<section ref="projectsSection">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">我的项目</h2>
|
||||||
|
<button
|
||||||
|
@click="createNewProject"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white transition-colors"
|
||||||
|
>
|
||||||
|
<n-icon :size="16"><AddOutline /></n-icon>
|
||||||
|
新建项目
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state | 空状态 -->
|
||||||
|
<div v-if="projects.length === 0" class="text-center py-12 bg-[var(--bg-secondary)] rounded-xl border border-dashed border-[var(--border-color)]">
|
||||||
|
<n-icon :size="48" class="text-[var(--text-secondary)] mb-4"><FolderOutline /></n-icon>
|
||||||
|
<p class="text-[var(--text-secondary)] mb-4">还没有项目,创建一个开始吧</p>
|
||||||
|
<button
|
||||||
|
@click="createNewProject"
|
||||||
|
class="px-4 py-2 text-sm rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white transition-colors"
|
||||||
|
>
|
||||||
|
创建第一个项目
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects grid | 项目网格 -->
|
||||||
|
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="project in projects"
|
||||||
|
:key="project.id"
|
||||||
|
class="group relative"
|
||||||
|
>
|
||||||
|
<!-- Project card | 项目卡片 -->
|
||||||
|
<div
|
||||||
|
@click="openProject(project)"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="aspect-video rounded-xl overflow-hidden bg-[var(--bg-tertiary)] mb-2 border border-[var(--border-color)] relative"
|
||||||
|
@mouseenter="handleThumbnailHover(project, true)"
|
||||||
|
@mouseleave="handleThumbnailHover(project, false)"
|
||||||
|
>
|
||||||
|
<!-- Thumbnail or placeholder | 缩略图或占位 -->
|
||||||
|
<template v-if="project.thumbnail">
|
||||||
|
<!-- Video thumbnail | 视频缩略图 -->
|
||||||
|
<video
|
||||||
|
v-if="isVideoUrl(project.thumbnail)"
|
||||||
|
:ref="el => setVideoRef(project.id, el)"
|
||||||
|
:src="project.thumbnail"
|
||||||
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsinline
|
||||||
|
/>
|
||||||
|
<!-- Image thumbnail | 图片缩略图 -->
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="project.thumbnail"
|
||||||
|
:alt="project.name"
|
||||||
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<n-icon :size="32" class="text-[var(--text-secondary)]"><DocumentOutline /></n-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hover overlay | 悬浮遮罩 -->
|
||||||
|
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<span class="text-white text-sm">打开项目</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-[var(--text-primary)] truncate">{{ project.name }}</p>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)]">{{ formatDate(project.updatedAt) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project actions | 项目操作 -->
|
||||||
|
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
|
||||||
|
<n-dropdown :options="getProjectActions(project)" @select="(key) => handleProjectAction(key, project)" placement="bottom-end">
|
||||||
|
<button
|
||||||
|
@click.stop
|
||||||
|
class="p-1.5 bg-white/90 dark:bg-gray-800/90 rounded-lg shadow hover:bg-white dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<n-icon :size="16"><EllipsisHorizontalOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Left sidebar | 左侧边栏 -->
|
||||||
|
<aside class="fixed left-4 top-1/2 -translate-y-1/2 hidden md:flex flex-col gap-2 p-2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-sm">
|
||||||
|
<button
|
||||||
|
@click="createNewProject"
|
||||||
|
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
|
||||||
|
title="新建项目"
|
||||||
|
>
|
||||||
|
<n-icon :size="20"><DocumentOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="scrollToProjects"
|
||||||
|
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
|
||||||
|
title="我的项目"
|
||||||
|
>
|
||||||
|
<n-icon :size="20"><FolderOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Rename modal | 重命名弹窗 -->
|
||||||
|
<n-modal v-model:show="showRenameModal" preset="dialog" title="重命名项目">
|
||||||
|
<n-input v-model:value="renameValue" placeholder="请输入项目名称" />
|
||||||
|
<template #action>
|
||||||
|
<n-button @click="showRenameModal = false">取消</n-button>
|
||||||
|
<n-button type="primary" @click="confirmRename">确定</n-button>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Home view component | 首页视图组件
|
||||||
|
* Entry point with project list and creation input
|
||||||
|
*/
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { NIcon, NDropdown, NModal, NInput, NButton, useDialog } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
AddOutline,
|
||||||
|
ImageOutline,
|
||||||
|
SendOutline,
|
||||||
|
RefreshOutline,
|
||||||
|
DocumentOutline,
|
||||||
|
FolderOutline,
|
||||||
|
EllipsisHorizontalOutline,
|
||||||
|
CreateOutline,
|
||||||
|
CopyOutline,
|
||||||
|
TrashOutline
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
import {
|
||||||
|
projects,
|
||||||
|
initProjectsStore,
|
||||||
|
createProject,
|
||||||
|
deleteProject,
|
||||||
|
duplicateProject,
|
||||||
|
renameProject
|
||||||
|
} from '../stores/projects'
|
||||||
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const dialog = useDialog()
|
||||||
|
// Video refs for hover play | 视频引用用于悬停播放
|
||||||
|
const videoRefs = new Map()
|
||||||
|
|
||||||
|
// Set video ref | 设置视频引用
|
||||||
|
const setVideoRef = (projectId, el) => {
|
||||||
|
if (el) {
|
||||||
|
videoRefs.set(projectId, el)
|
||||||
|
} else {
|
||||||
|
videoRefs.delete(projectId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle thumbnail hover | 处理缩略图悬停
|
||||||
|
const handleThumbnailHover = (project, isHovering) => {
|
||||||
|
if (!isVideoUrl(project.thumbnail)) return
|
||||||
|
|
||||||
|
const video = videoRefs.get(project.id)
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
if (isHovering) {
|
||||||
|
video.play().catch(() => {
|
||||||
|
// Ignore play errors (e.g., autoplay policy)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
video.pause()
|
||||||
|
video.currentTime = 0 // Reset to start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input state | 输入状态
|
||||||
|
const inputText = ref('')
|
||||||
|
|
||||||
|
// Rename modal state | 重命名弹窗状态
|
||||||
|
const showRenameModal = ref(false)
|
||||||
|
const renameValue = ref('')
|
||||||
|
const renameTargetId = ref(null)
|
||||||
|
|
||||||
|
// Suggestions tags | 建议标签
|
||||||
|
const suggestions = [
|
||||||
|
'SKG 颈部按摩仪竖屏广告',
|
||||||
|
'办公室午休放松短片',
|
||||||
|
'详情页产品氛围图',
|
||||||
|
'首尾帧过渡视频'
|
||||||
|
]
|
||||||
|
|
||||||
|
// Format date | 格式化日期
|
||||||
|
const formatDate = (date) => {
|
||||||
|
if (!date) return ''
|
||||||
|
const d = new Date(date)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now - d
|
||||||
|
|
||||||
|
// Less than 1 minute | 小于1分钟
|
||||||
|
if (diff < 60000) return '刚刚'
|
||||||
|
// Less than 1 hour | 小于1小时
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||||
|
// Less than 1 day | 小于1天
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||||
|
// Less than 7 days | 小于7天
|
||||||
|
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`
|
||||||
|
// Format as date | 格式化为日期
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project actions | 获取项目操作选项
|
||||||
|
const getProjectActions = (project) => [
|
||||||
|
{ label: '重命名', key: 'rename', icon: () => h(NIcon, null, { default: () => h(CreateOutline) }) },
|
||||||
|
{ label: '复制', key: 'duplicate', icon: () => h(NIcon, null, { default: () => h(CopyOutline) }) },
|
||||||
|
{ type: 'divider' },
|
||||||
|
{ label: '删除', key: 'delete', icon: () => h(NIcon, null, { default: () => h(TrashOutline) }) }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Handle project action | 处理项目操作
|
||||||
|
const handleProjectAction = (key, project) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'rename':
|
||||||
|
renameTargetId.value = project.id
|
||||||
|
renameValue.value = project.name
|
||||||
|
showRenameModal.value = true
|
||||||
|
break
|
||||||
|
case 'duplicate':
|
||||||
|
const newId = duplicateProject(project.id)
|
||||||
|
if (newId) {
|
||||||
|
window.$message?.success('项目已复制')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'delete':
|
||||||
|
dialog.warning({
|
||||||
|
title: '删除项目',
|
||||||
|
content: `确定要删除项目「${project.name}」吗?此操作不可恢复。`,
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
deleteProject(project.id)
|
||||||
|
window.$message?.success('项目已删除')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm rename | 确认重命名
|
||||||
|
const confirmRename = () => {
|
||||||
|
if (renameTargetId.value && renameValue.value.trim()) {
|
||||||
|
renameProject(renameTargetId.value, renameValue.value.trim())
|
||||||
|
window.$message?.success('已重命名')
|
||||||
|
}
|
||||||
|
showRenameModal.value = false
|
||||||
|
renameTargetId.value = null
|
||||||
|
renameValue.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new project | 创建新项目
|
||||||
|
const createNewProject = () => {
|
||||||
|
const id = createProject('未命名项目')
|
||||||
|
router.push(`/p/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create project with input text | 使用输入文本创建项目
|
||||||
|
const handleCreateWithInput = () => {
|
||||||
|
const name = inputText.value.trim() || '未命名项目'
|
||||||
|
const id = createProject(name)
|
||||||
|
sessionStorage.setItem('ai-canvas-initial-prompt', inputText.value.trim())
|
||||||
|
inputText.value = ''
|
||||||
|
router.push(`/p/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open existing project | 打开已有项目
|
||||||
|
const openProject = (project) => {
|
||||||
|
router.push(`/p/${project.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if URL is a video | 检查 URL 是否为视频
|
||||||
|
const isVideoUrl = (url) => {
|
||||||
|
if (!url || typeof url !== 'string') return false
|
||||||
|
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv']
|
||||||
|
return videoExtensions.some(ext => url.toLowerCase().includes(ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import h for render functions | 导入 h 用于渲染函数
|
||||||
|
import { h } from 'vue'
|
||||||
|
|
||||||
|
// Projects section ref | 项目区域引用
|
||||||
|
const projectsSection = ref(null)
|
||||||
|
|
||||||
|
// Scroll to projects section | 滚动到项目区域
|
||||||
|
const scrollToProjects = () => {
|
||||||
|
if (projectsSection.value) {
|
||||||
|
projectsSection.value.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize projects store on mount | 挂载时初始化项目存储
|
||||||
|
onMounted(() => {
|
||||||
|
initProjectsStore()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
27
web/canvas-app/tailwind.config.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#f0fdf4',
|
||||||
|
100: '#dcfce7',
|
||||||
|
200: '#bbf7d0',
|
||||||
|
300: '#86efac',
|
||||||
|
400: '#4ade80',
|
||||||
|
500: '#22c55e',
|
||||||
|
600: '#16a34a',
|
||||||
|
700: '#15803d',
|
||||||
|
800: '#166534',
|
||||||
|
900: '#14532d',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
22
web/canvas-app/vite.config.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/canvas/',
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:4291',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -3,8 +3,11 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "pnpm build:canvas && next build",
|
||||||
|
"build:canvas": "cd canvas-app && pnpm build && node ../scripts/sync-canvas-dist.mjs",
|
||||||
|
"build:next": "next build",
|
||||||
"dev": "next dev -p 4290",
|
"dev": "next dev -p 4290",
|
||||||
|
"dev:canvas": "cd canvas-app && pnpm dev --host 0.0.0.0 --port 4292",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"start": "next start -p 4290"
|
"start": "next start -p 4290"
|
||||||
},
|
},
|
||||||
|
|||||||
17
web/scripts/sync-canvas-dist.mjs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { cp, mkdir, rm } from "node:fs/promises"
|
||||||
|
import { existsSync } from "node:fs"
|
||||||
|
import { dirname, resolve } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const webRoot = resolve(here, "..")
|
||||||
|
const source = resolve(webRoot, "canvas-app", "dist")
|
||||||
|
const target = resolve(webRoot, "public", "canvas")
|
||||||
|
|
||||||
|
if (!existsSync(source)) {
|
||||||
|
throw new Error(`Canvas build output missing: ${source}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(target, { recursive: true, force: true })
|
||||||
|
await mkdir(target, { recursive: true })
|
||||||
|
await cp(source, target, { recursive: true })
|
||||||