init
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
*.db
|
||||
.env
|
||||
config.yaml
|
||||
!config.example.yaml
|
||||
@@ -0,0 +1,40 @@
|
||||
# web2 - 单页 AI 视频生成
|
||||
|
||||
与现有 `web` 并列的前端项目,实现**单页**从故事到成片的完整流程,对接现有 Node 后端 `backend-node`。
|
||||
|
||||
**包版本:** `1.2.7`(与仓库根目录 [CHANGELOG](../CHANGELOG.md) 一致)
|
||||
|
||||
## 功能流程
|
||||
|
||||
1. **故事生成**:输入梗概 + 风格/类型 → 创建项目并保存第一集剧本
|
||||
2. **剧本生成**:编辑剧本、标题/语言/分镜风格 → 保存
|
||||
3. **角色生成**:AI 生成角色列表 → 每个角色可「AI 生成」形象(使用下方配置的图片模型)
|
||||
4. **道具生成**:从剧本提取 / 手动添加 → 每个道具可「AI 生成」图片
|
||||
5. **场景生成**:从剧本提取场景 → 每个场景可「AI 生成」图片
|
||||
6. **分镜生成**:根据当前集生成分镜
|
||||
7. **视频配置**:分辨率、配乐、音效、画质、字幕、水印;**AI 模型配置**(图片生成模型、视频生成模型)
|
||||
8. **生成视频**:提交合成任务
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 开发(默认端口 3013,代理到后端 5679)
|
||||
npm run dev
|
||||
|
||||
# 构建
|
||||
npm run build
|
||||
```
|
||||
|
||||
请先启动 `backend-node`(如 `http://localhost:5679`),并确保 `vite.config.js` 中 proxy 的 target 与后端一致。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3 + Vite
|
||||
- Element Plus
|
||||
- Pinia
|
||||
- Vue Router
|
||||
- Axios
|
||||
- 纯 JavaScript(无 TypeScript)
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>本地短剧助手 - AI 短剧生成工具</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "LocalMiniDrama-film",
|
||||
"version": "1.2.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"axios": "^1.6.0",
|
||||
"element-plus": "^2.5.0",
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html, body, #app, .app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
color: var(--text-primary);
|
||||
transition: background 0.25s, color 0.25s;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const aiAPI = {
|
||||
list(serviceType) {
|
||||
return request.get('/ai-configs', { params: serviceType ? { service_type: serviceType } : {} })
|
||||
},
|
||||
get(id) {
|
||||
return request.get(`/ai-configs/${id}`)
|
||||
},
|
||||
create(body) {
|
||||
return request.post('/ai-configs', body)
|
||||
},
|
||||
update(id, body) {
|
||||
return request.put(`/ai-configs/${id}`, body)
|
||||
},
|
||||
delete(id) {
|
||||
return request.delete(`/ai-configs/${id}`)
|
||||
},
|
||||
testConnection(body) {
|
||||
return request.post('/ai-configs/test', body)
|
||||
},
|
||||
/** 即梦2角色认证:GET /api/business/v1/assets(body: base_url, api_key, limit?, cursor?) */
|
||||
listJimeng2MaterialAssets(body) {
|
||||
return request.post('/ai-configs/jimeng2-list-assets', body)
|
||||
},
|
||||
/** ModelArk 私有资产库:action + payload,见 AI 配置页 SD2 资产管理 */
|
||||
modelArkAsset(body) {
|
||||
return request.post('/ai-configs/model-ark-asset', body)
|
||||
},
|
||||
getVendorLock() {
|
||||
return request.get('/ai-configs/vendor-lock')
|
||||
},
|
||||
bulkUpdateKey(apiKey) {
|
||||
return request.put('/ai-configs/bulk-update-key', { api_key: apiKey })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const characterLibraryAPI = {
|
||||
list(params) {
|
||||
return request.get('/character-library', { params })
|
||||
},
|
||||
get(id) {
|
||||
return request.get(`/character-library/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return request.post('/character-library', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return request.put(`/character-library/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return request.delete(`/character-library/${id}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const characterAPI = {
|
||||
get(characterId) {
|
||||
return request.get(`/characters/${characterId}`)
|
||||
},
|
||||
generateImage(characterId, model, style) {
|
||||
return request.post(`/characters/${characterId}/generate-image`, { model, style })
|
||||
},
|
||||
generatePrompt(characterId, model, style) {
|
||||
return request.post(`/characters/${characterId}/generate-prompt`, { model, style })
|
||||
},
|
||||
batchGenerateImages(characterIds, model, style) {
|
||||
return request.post('/characters/batch-generate-images', {
|
||||
character_ids: characterIds.map(String),
|
||||
model,
|
||||
style
|
||||
})
|
||||
},
|
||||
update(characterId, data) {
|
||||
return request.put(`/characters/${characterId}`, data)
|
||||
},
|
||||
putImage(characterId, data) {
|
||||
return request.put(`/characters/${characterId}/image`, data)
|
||||
},
|
||||
putRefImage(characterId, refImagePath) {
|
||||
return request.put(`/characters/${characterId}/image`, { ref_image: refImagePath })
|
||||
},
|
||||
delete(characterId) {
|
||||
return request.delete(`/characters/${characterId}`)
|
||||
},
|
||||
addToLibrary(characterId, body) {
|
||||
return request.post(`/characters/${characterId}/add-to-library`, body || {})
|
||||
},
|
||||
addToMaterialLibrary(characterId) {
|
||||
return request.post(`/characters/${characterId}/add-to-material-library`, {})
|
||||
},
|
||||
addToTeamLibrary(characterId, body = {}) {
|
||||
return request.post(`/characters/${characterId}/add-to-team-library`, body)
|
||||
},
|
||||
extractFromImage(characterId) {
|
||||
return request.post(`/characters/${characterId}/extract-from-image`, {})
|
||||
},
|
||||
extractAnchors(characterId) {
|
||||
return request.post(`/characters/${characterId}/extract-anchors`, {})
|
||||
},
|
||||
sd2Certify(characterId) {
|
||||
return request.post(`/characters/${characterId}/sd2-certify`, {})
|
||||
},
|
||||
sd2CertifyRefresh(characterId) {
|
||||
return request.post(`/characters/${characterId}/sd2-certify/refresh`, {})
|
||||
},
|
||||
sd2VoiceUpload(characterId, file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request.post(`/characters/${characterId}/sd2-voice-upload`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
sd2VoiceRefresh(characterId) {
|
||||
return request.post(`/characters/${characterId}/sd2-voice-refresh`, {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const dramaAPI = {
|
||||
list(params) {
|
||||
return request.get('/dramas', { params: params || {} })
|
||||
},
|
||||
create(data) {
|
||||
return request.post('/dramas', data)
|
||||
},
|
||||
get(id) {
|
||||
return request.get(`/dramas/${id}`)
|
||||
},
|
||||
update(id, data) {
|
||||
return request.put(`/dramas/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return request.delete(`/dramas/${id}`)
|
||||
},
|
||||
saveEpisodes(id, episodes) {
|
||||
return request.put(`/dramas/${id}/episodes`, { episodes })
|
||||
},
|
||||
saveCharacters(id, data) {
|
||||
return request.put(`/dramas/${id}/characters`, data)
|
||||
},
|
||||
/** 保存梗概/故事摘要到项目(outline),body: { summary, title?, genre?, tags? } */
|
||||
saveOutline(id, data) {
|
||||
return request.put(`/dramas/${id}/outline`, data)
|
||||
},
|
||||
saveProgress(id, data) {
|
||||
return request.put(`/dramas/${id}/progress`, data)
|
||||
},
|
||||
saveCanvasLayout(id, canvasLayout, workflowGroups) {
|
||||
const body = {}
|
||||
if (canvasLayout != null) body.canvas_layout = canvasLayout
|
||||
if (workflowGroups !== undefined) body.workflow_groups = workflowGroups
|
||||
return request.put(`/dramas/${id}/canvas-layout`, body)
|
||||
},
|
||||
getStoryboards(episodeId) {
|
||||
return request.get(`/episodes/${episodeId}/storyboards`)
|
||||
},
|
||||
generateStoryboard(episodeId, options) {
|
||||
// 兼容旧调用方式: generateStoryboard(episodeId, model, style)
|
||||
let body = {};
|
||||
if (arguments.length > 2 || typeof options === 'string') {
|
||||
body.model = arguments[1];
|
||||
body.style = arguments[2];
|
||||
} else {
|
||||
body = options || {};
|
||||
}
|
||||
return request.post(`/episodes/${episodeId}/storyboards`, body)
|
||||
},
|
||||
finalizeEpisode(episodeId, data) {
|
||||
return request.post(`/episodes/${episodeId}/finalize`, data || {})
|
||||
},
|
||||
extractBackgrounds(episodeId, body) {
|
||||
return request.post(`/images/episode/${episodeId}/backgrounds/extract`, body || {})
|
||||
},
|
||||
extractEpisodeCharacters(episodeId) {
|
||||
return request.post(`/episodes/${episodeId}/characters/extract`)
|
||||
},
|
||||
exportDrama(id) {
|
||||
return request.get(`/dramas/${id}/export`, { responseType: 'blob' })
|
||||
},
|
||||
importDrama(file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request.post('/dramas/import', form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
},
|
||||
listExamples() {
|
||||
return request.get('/dramas/examples')
|
||||
},
|
||||
importExample(filename) {
|
||||
return request.post('/dramas/import-example', { filename })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const generationAPI = {
|
||||
/**
|
||||
* @param {string|number} dramaId
|
||||
* @param {{ episode_id?: string|number, outline?: string, count?: number, model?: string }} [options] - episode_id 用于关联本集,outline 为梗概/剧本摘要
|
||||
*/
|
||||
generateCharacters(dramaId, options = {}) {
|
||||
const body = { drama_id: dramaId }
|
||||
if (options.episode_id != null) body.episode_id = options.episode_id
|
||||
if (options.outline != null && String(options.outline).trim()) body.outline = options.outline
|
||||
if (options.count != null) body.count = options.count
|
||||
if (options.model != null) body.model = options.model
|
||||
return request.post('/generation/characters', body)
|
||||
},
|
||||
/** 根据故事梗概 + 风格/类型/集数 生成剧本,返回 { episodes: [{episode, title, content}] } */
|
||||
generateStory(body) {
|
||||
return request.post('/generation/story', body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const imagesAPI = {
|
||||
list(params) {
|
||||
return request.get('/images', { params: params || {} })
|
||||
},
|
||||
create(data) {
|
||||
return request.post('/images', data)
|
||||
},
|
||||
upload(data) {
|
||||
return request.post('/images/upload', data)
|
||||
},
|
||||
delete(id) {
|
||||
return request.delete(`/images/${id}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const promptsAPI = {
|
||||
list() {
|
||||
return request.get('/settings/prompts')
|
||||
},
|
||||
update(key, content) {
|
||||
return request.put(`/settings/prompts/${key}`, { content })
|
||||
},
|
||||
reset(key) {
|
||||
return request.delete(`/settings/prompts/${key}`)
|
||||
},
|
||||
}
|
||||
|
||||
export const generationSettingsAPI = {
|
||||
get() {
|
||||
return request.get('/settings/generation')
|
||||
},
|
||||
update(data) {
|
||||
return request.put('/settings/generation', data)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const propLibraryAPI = {
|
||||
list(params) {
|
||||
return request.get('/prop-library', { params })
|
||||
},
|
||||
get(id) {
|
||||
return request.get(`/prop-library/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return request.post('/prop-library', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return request.put(`/prop-library/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return request.delete(`/prop-library/${id}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const propAPI = {
|
||||
get(id) {
|
||||
return request.get(`/props/${id}`)
|
||||
},
|
||||
list(dramaId) {
|
||||
return request.get(`/dramas/${dramaId}/props`)
|
||||
},
|
||||
create(data) {
|
||||
return request.post('/props', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return request.put(`/props/${id}`, data)
|
||||
},
|
||||
generatePrompt(id, model, style) {
|
||||
return request.post(`/props/${id}/generate-prompt`, { model, style })
|
||||
},
|
||||
generateImage(id, model, style) {
|
||||
const body = { model, style }
|
||||
if (body.model == null && body.style == null) return request.post(`/props/${id}/generate`)
|
||||
return request.post(`/props/${id}/generate`, body)
|
||||
},
|
||||
extractFromScript(episodeId) {
|
||||
return request.post(`/episodes/${episodeId}/props/extract`)
|
||||
},
|
||||
delete(id) {
|
||||
return request.delete(`/props/${id}`)
|
||||
},
|
||||
addToLibrary(id, body = {}) {
|
||||
return request.post(`/props/${id}/add-to-library`, body)
|
||||
},
|
||||
addToMaterialLibrary(id) {
|
||||
return request.post(`/props/${id}/add-to-material-library`, {})
|
||||
},
|
||||
extractFromImage(id) {
|
||||
return request.post(`/props/${id}/extract-from-image`, {})
|
||||
},
|
||||
putRefImage(id, refImagePath) {
|
||||
return request.put(`/props/${id}`, { ref_image: refImagePath ?? null })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const sceneLibraryAPI = {
|
||||
list(params) {
|
||||
return request.get('/scene-library', { params })
|
||||
},
|
||||
get(id) {
|
||||
return request.get(`/scene-library/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return request.post('/scene-library', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return request.put(`/scene-library/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return request.delete(`/scene-library/${id}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const sceneModelMapAPI = {
|
||||
list() {
|
||||
return request.get('/scene-model-map')
|
||||
},
|
||||
get(key) {
|
||||
return request.get(`/scene-model-map/${key}`)
|
||||
},
|
||||
create(body) {
|
||||
return request.post('/scene-model-map', body)
|
||||
},
|
||||
update(key, body) {
|
||||
return request.put(`/scene-model-map/${key}`, body)
|
||||
},
|
||||
delete(key) {
|
||||
return request.delete(`/scene-model-map/${key}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const sceneAPI = {
|
||||
get(sceneId) {
|
||||
return request.get(`/scenes/${sceneId}`)
|
||||
},
|
||||
list(dramaId) {
|
||||
return request.get(`/dramas/${dramaId}/scenes`)
|
||||
},
|
||||
generatePrompt(sceneId, model, style, mode) {
|
||||
return request.post(`/scenes/${sceneId}/generate-prompt`, { model, style, mode })
|
||||
},
|
||||
create(data) {
|
||||
return request.post('/scenes', data)
|
||||
},
|
||||
generateImage(data) {
|
||||
return request.post('/scenes/generate-image', data)
|
||||
},
|
||||
update(sceneId, data) {
|
||||
return request.put(`/scenes/${sceneId}`, data)
|
||||
},
|
||||
delete(sceneId) {
|
||||
return request.delete(`/scenes/${sceneId}`)
|
||||
},
|
||||
addToLibrary(sceneId, body = {}) {
|
||||
return request.post(`/scenes/${sceneId}/add-to-library`, body)
|
||||
},
|
||||
addToMaterialLibrary(sceneId) {
|
||||
return request.post(`/scenes/${sceneId}/add-to-material-library`, {})
|
||||
},
|
||||
addToTeamLibrary(sceneId, body = {}) {
|
||||
return request.post(`/scenes/${sceneId}/add-to-team-library`, body)
|
||||
},
|
||||
extractFromImage(sceneId) {
|
||||
return request.post(`/scenes/${sceneId}/extract-from-image`, {})
|
||||
},
|
||||
putRefImage(sceneId, refImagePath) {
|
||||
return request.put(`/scenes/${sceneId}`, { ref_image: refImagePath ?? null })
|
||||
},
|
||||
generateFourViewImage(sceneId, model, style) {
|
||||
return request.post(`/scenes/${sceneId}/generate-four-view-image`, { model, style })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {object} body
|
||||
* @param {(delta: string) => void} [onDelta]
|
||||
* @returns {Promise<{ universal_segment_text: string }>}
|
||||
*/
|
||||
function postUniversalSegmentNdjsonStream(url, body, onDelta) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/x-ndjson' },
|
||||
body: JSON.stringify(body || {}),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
let msg = `请求失败 (${res.status})`
|
||||
try {
|
||||
const j = await res.json()
|
||||
if (j?.error?.message) msg = j.error.message
|
||||
} catch (_) {
|
||||
try {
|
||||
const t = await res.text()
|
||||
if (t) msg = t.slice(0, 200)
|
||||
} catch (_) {}
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
const reader = res.body && res.body.getReader()
|
||||
if (!reader) throw new Error('浏览器不支持流式读取')
|
||||
const dec = new TextDecoder()
|
||||
let buf = ''
|
||||
let finalText = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += dec.decode(value, { stream: true })
|
||||
let nl
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl).trim()
|
||||
buf = buf.slice(nl + 1)
|
||||
if (!line) continue
|
||||
let obj
|
||||
try {
|
||||
obj = JSON.parse(line)
|
||||
} catch (_) {
|
||||
continue
|
||||
}
|
||||
if (obj.type === 'delta' && obj.text && typeof onDelta === 'function') onDelta(String(obj.text))
|
||||
if (obj.type === 'error') throw new Error(obj.message || '请求失败')
|
||||
if (obj.type === 'done') {
|
||||
finalText = (obj.universal_segment_text && String(obj.universal_segment_text).trim()) || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
const tail = buf.trim()
|
||||
if (tail) {
|
||||
try {
|
||||
const obj = JSON.parse(tail)
|
||||
if (obj.type === 'error') throw new Error(obj.message || '请求失败')
|
||||
if (obj.type === 'done') finalText = (obj.universal_segment_text && String(obj.universal_segment_text).trim()) || finalText
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message && !e.message.includes('JSON')) throw e
|
||||
}
|
||||
}
|
||||
return { universal_segment_text: finalText }
|
||||
})
|
||||
}
|
||||
|
||||
export const storyboardsAPI = {
|
||||
get(id) {
|
||||
return request.get(`/storyboards/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return request.post('/storyboards', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return request.put(`/storyboards/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return request.delete(`/storyboards/${id}`)
|
||||
},
|
||||
generateFramePrompt(id, data) {
|
||||
return request.post(`/storyboards/${id}/frame-prompt`, data)
|
||||
},
|
||||
getFramePrompts(id) {
|
||||
return request.get(`/storyboards/${id}/frame-prompts`)
|
||||
},
|
||||
/** 保存/覆盖首帧或尾帧提示词(用于用户手动编辑后保存) */
|
||||
saveFramePrompt(id, frameType, data) {
|
||||
return request.put(`/storyboards/${id}/frame-prompts/${frameType}`, data || {})
|
||||
},
|
||||
polishPrompt(id) {
|
||||
return request.post(`/storyboards/${id}/polish-prompt`, {})
|
||||
},
|
||||
/** 全能模式:根据分镜内容 AI 生成片段描述(非流式,兼容旧调用) */
|
||||
generateUniversalSegmentPrompt(id, body = {}) {
|
||||
return request.post(`/storyboards/${id}/universal-segment-prompt`, body)
|
||||
},
|
||||
/** 全能模式生成:NDJSON 流式,可选 body.duration、body.force_without_reference_images */
|
||||
generateUniversalSegmentPromptStream(id, body, onDelta) {
|
||||
return postUniversalSegmentNdjsonStream(
|
||||
`/api/v1/storyboards/${id}/universal-segment-prompt-stream`,
|
||||
body,
|
||||
onDelta
|
||||
)
|
||||
},
|
||||
/**
|
||||
* 流式润色全能片段:NDJSON 行 {type:'delta',text} / {type:'done',universal_segment_text} / {type:'error',message}
|
||||
* body.draft_universal_segment_text 为当前编辑区全文;可选 duration、force_without_reference_images
|
||||
*/
|
||||
polishUniversalSegmentPromptStream(id, body, onDelta) {
|
||||
return postUniversalSegmentNdjsonStream(
|
||||
`/api/v1/storyboards/${id}/universal-segment-polish-stream`,
|
||||
body,
|
||||
onDelta
|
||||
)
|
||||
},
|
||||
insertBefore(id) {
|
||||
return request.post(`/storyboards/${id}/insert-before`, {})
|
||||
},
|
||||
batchInferParams(episodeId, overwrite = false) {
|
||||
return request.post('/storyboards/batch-infer-params', { episode_id: episodeId, overwrite })
|
||||
},
|
||||
upscale(id) {
|
||||
return request.post(`/storyboards/${id}/upscale`, {})
|
||||
},
|
||||
/** 尾帧衔接:提取当前分镜视频最后一帧,设为下一个分镜的首帧 */
|
||||
linkTailFrame(id, data) {
|
||||
return request.post(`/storyboards/${id}/link-tail-frame`, data || {})
|
||||
},
|
||||
/** 一键 AI 重新生成/优化本分镜的 layout_description(空间布局合同),自动参考上下分镜 */
|
||||
regenerateLayoutDescription(id) {
|
||||
return request.post(`/storyboards/${id}/regenerate-layout-description`, {})
|
||||
},
|
||||
/** 按后端最新规则重建单镜 video_prompt(含音色锚点,不调用 AI) */
|
||||
rebuildVideoPrompt(id) {
|
||||
return request.post(`/storyboards/${id}/rebuild-video-prompt`, {})
|
||||
},
|
||||
/** 按对白/旁白拆成多条分镜(每条仅一人说话或仅画外旁白) */
|
||||
splitByAudio(id) {
|
||||
return request.post(`/storyboards/${id}/split-by-audio`, {})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const taskAPI = {
|
||||
get(taskId) {
|
||||
return request.get(`/tasks/${taskId}`)
|
||||
},
|
||||
listByResource(resourceId) {
|
||||
return request.get('/tasks', { params: { resource_id: String(resourceId) } })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const uploadAPI = {
|
||||
/**
|
||||
* 上传图片文件,返回 { url, local_path }。需传 File 对象。
|
||||
* @param {File} file
|
||||
* @param {{ dramaId?: number|string|null }} [opts] 有剧集 id 时写入 projects/…/uploads/,否则仍为根目录 uploads/
|
||||
*/
|
||||
uploadImage(file, opts = {}) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const did = opts.dramaId
|
||||
if (did != null && did !== '' && Number(did) > 0) {
|
||||
form.append('drama_id', String(did))
|
||||
}
|
||||
return request.post('/upload/image', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 从图片(base64 data URL 或 http URL)提取实体特征描述,不依赖已有实体 ID。
|
||||
* entityType: 'character' | 'scene' | 'prop'
|
||||
* imageUrl: data:image/xxx;base64,... 或 http URL
|
||||
*/
|
||||
extractDescriptionFromImage(entityType, imageUrl, entityName) {
|
||||
return request.post('/extract-description-from-image', {
|
||||
entity_type: entityType,
|
||||
image_url: imageUrl,
|
||||
entity_name: entityName || undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const videosAPI = {
|
||||
list(params) {
|
||||
return request.get('/videos', { params: params || {} })
|
||||
},
|
||||
/** 创建单条分镜视频生成任务,body: { drama_id, storyboard_id, prompt, image_url?, model?, ... } */
|
||||
create(body) {
|
||||
return request.post('/videos', body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="episode-batch-import-trigger">
|
||||
<el-button size="small" @click="openDialog">
|
||||
<el-icon><Upload /></el-icon>批量导入剧集
|
||||
</el-button>
|
||||
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="批量导入剧集"
|
||||
width="920px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
@close="resetState"
|
||||
>
|
||||
<div class="batch-import-dialog">
|
||||
<el-tabs v-model="activeTab" class="batch-import-tabs">
|
||||
<el-tab-pane label="1. 导入设置" name="config">
|
||||
<div class="batch-import-panel">
|
||||
<div class="batch-import-toolbar">
|
||||
<input ref="fileInputRef" type="file" accept=".txt,text/plain" style="display:none" @change="onFileChange" />
|
||||
<el-button @click="fileInputRef?.click()">
|
||||
<el-icon><Upload /></el-icon>选择 TXT 文件
|
||||
</el-button>
|
||||
<span class="batch-import-file" :class="{ 'is-empty': !fileName }">
|
||||
{{ fileName || '未选择文件' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<el-form label-width="120px" class="batch-import-form">
|
||||
<el-form-item label="章节正则">
|
||||
<el-input v-model="chapterPattern" placeholder="例如:^\s*(第\d+章[^\n]*)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="每集章节数">
|
||||
<el-input-number v-model="chaptersPerEpisode" :min="1" :max="100" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="batch-import-tip-block">
|
||||
<div class="batch-import-tip">将提前准备好的小说原文或者剧本内容的.txt文件导入系统</div>
|
||||
<div class="batch-import-tip">请正确输入用于匹配章节标题的正则表达式。</div>
|
||||
<div class="batch-import-tip">示例:<code class="batch-import-code">^\s*(第\d+章[^\n]*)</code>、<code class="batch-import-code">^\s*(第\d+集[^\n]*)</code></div>
|
||||
<div class="batch-import-tip">点击“确认导入配置”后,会先解析章节并切换到预览页。</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="2. 预览确认" name="preview" :disabled="!previewReady">
|
||||
<div class="batch-import-panel">
|
||||
<template v-if="previewEpisodes.length">
|
||||
<div class="batch-import-preview-header">
|
||||
<span>共识别 {{ previewChapters.length }} 章,预计导入 {{ previewEpisodes.length }} 集</span>
|
||||
</div>
|
||||
<el-table :data="previewEpisodes" border stripe height="420" class="batch-import-preview-table">
|
||||
<el-table-column prop="episode_number" label="集数" width="80" align="center" />
|
||||
<el-table-column prop="title" label="集标题" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="包含章节" min-width="260" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
{{ scope.row.chapter_titles.join('、') || '未识别章节标题' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内容预览" min-width="320" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div class="batch-import-preview-cell batch-import-preview-cell--single-line">
|
||||
{{ scope.row.script_content || '暂无内容' }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div v-else class="batch-import-empty">请先在上一步确认导入配置</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button v-if="activeTab === 'preview'" @click="activeTab = 'config'">上一步</el-button>
|
||||
<el-button
|
||||
v-if="activeTab === 'config'"
|
||||
type="primary"
|
||||
:disabled="!rawText.trim()"
|
||||
@click="confirmConfig"
|
||||
>确认导入配置</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="primary"
|
||||
:disabled="!previewEpisodes.length"
|
||||
:loading="importing"
|
||||
@click="confirmImport"
|
||||
>确认导入集数</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Upload } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
startEpisodeNumber: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['import'])
|
||||
|
||||
const visible = ref(false)
|
||||
const activeTab = ref('config')
|
||||
const previewReady = ref(false)
|
||||
const importing = ref(false)
|
||||
const fileInputRef = ref(null)
|
||||
const fileName = ref('')
|
||||
const rawText = ref('')
|
||||
const chapterPattern = ref('^\\s*(第[0-90-9零一二三四五六七八九十百千万]+[章回节][^\\n\\r]*)')
|
||||
const chaptersPerEpisode = ref(1)
|
||||
const previewChapters = ref([])
|
||||
const previewEpisodes = ref([])
|
||||
|
||||
function openDialog() {
|
||||
visible.value = true
|
||||
activeTab.value = 'config'
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openDialog,
|
||||
})
|
||||
|
||||
function resetState() {
|
||||
visible.value = false
|
||||
activeTab.value = 'config'
|
||||
previewReady.value = false
|
||||
importing.value = false
|
||||
fileName.value = ''
|
||||
rawText.value = ''
|
||||
chapterPattern.value = '^\\s*(第[0-90-9零一二三四五六七八九十百千万]+[章回节][^\\n\\r]*)'
|
||||
chaptersPerEpisode.value = 1
|
||||
previewChapters.value = []
|
||||
previewEpisodes.value = []
|
||||
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||
}
|
||||
|
||||
function onFileChange(event) {
|
||||
const file = event.target?.files?.[0]
|
||||
if (!file) return
|
||||
fileName.value = file.name
|
||||
previewReady.value = false
|
||||
previewChapters.value = []
|
||||
previewEpisodes.value = []
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
rawText.value = String(ev.target?.result || '')
|
||||
}
|
||||
reader.onerror = () => {
|
||||
ElMessage.error('读取文件失败')
|
||||
}
|
||||
reader.readAsText(file, 'utf-8')
|
||||
}
|
||||
|
||||
function createChapterRegex(pattern) {
|
||||
const source = String(pattern || '').trim()
|
||||
if (!source) throw new Error('请输入章节正则')
|
||||
try {
|
||||
return new RegExp(source, 'gm')
|
||||
} catch {
|
||||
throw new Error('章节正则格式不正确')
|
||||
}
|
||||
}
|
||||
|
||||
function splitNovelChapters(text, pattern) {
|
||||
const normalized = String(text || '').replace(/\r\n/g, '\n').trim()
|
||||
if (!normalized) return []
|
||||
const regex = createChapterRegex(pattern)
|
||||
const matches = [...normalized.matchAll(regex)]
|
||||
if (!matches.length) throw new Error('未匹配到任何章节,请调整章节正则')
|
||||
return matches.map((match, index) => {
|
||||
const title = String(match[1] || match[0] || '').trim()
|
||||
const titleStart = match.index ?? 0
|
||||
const contentStart = titleStart + String(match[0] || '').length
|
||||
const nextTitleStart = index + 1 < matches.length
|
||||
? (matches[index + 1].index ?? normalized.length)
|
||||
: normalized.length
|
||||
const content = normalized.slice(contentStart, nextTitleStart).trim()
|
||||
return {
|
||||
title: title || `第${index + 1}章`,
|
||||
content,
|
||||
}
|
||||
}).filter((chapter) => chapter.title || chapter.content)
|
||||
}
|
||||
|
||||
function buildEpisodesFromChapters(chapters, sizeValue) {
|
||||
const size = Math.max(1, Number(sizeValue) || 1)
|
||||
return chapters.reduce((list, chapter, index) => {
|
||||
const groupIndex = Math.floor(index / size)
|
||||
if (!list[groupIndex]) {
|
||||
list[groupIndex] = {
|
||||
title: '',
|
||||
script_content: '',
|
||||
chapter_titles: [],
|
||||
}
|
||||
}
|
||||
list[groupIndex].chapter_titles.push(chapter.title)
|
||||
list[groupIndex].script_content = [list[groupIndex].script_content, `${chapter.title}\n${chapter.content}`].filter(Boolean).join('\n\n')
|
||||
return list
|
||||
}, []).map((episode, index) => ({
|
||||
episode_number: props.startEpisodeNumber + index,
|
||||
title: episode.chapter_titles.length === 1
|
||||
? episode.chapter_titles[0]
|
||||
: `${episode.chapter_titles[0]} - ${episode.chapter_titles[episode.chapter_titles.length - 1]}`,
|
||||
script_content: episode.script_content,
|
||||
chapter_titles: episode.chapter_titles,
|
||||
}))
|
||||
}
|
||||
|
||||
function confirmConfig() {
|
||||
if (!rawText.value.trim()) {
|
||||
ElMessage.warning('请先选择 TXT 文件')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const chapters = splitNovelChapters(rawText.value, chapterPattern.value)
|
||||
const episodes = buildEpisodesFromChapters(chapters, chaptersPerEpisode.value)
|
||||
if (!episodes.length) {
|
||||
ElMessage.warning('未生成可导入的集数')
|
||||
return
|
||||
}
|
||||
previewChapters.value = chapters
|
||||
previewEpisodes.value = episodes
|
||||
previewReady.value = true
|
||||
activeTab.value = 'preview'
|
||||
ElMessage.success(`已识别 ${chapters.length} 章,可导入 ${episodes.length} 集`)
|
||||
} catch (e) {
|
||||
previewReady.value = false
|
||||
previewChapters.value = []
|
||||
previewEpisodes.value = []
|
||||
ElMessage.error(e.message || '章节预览失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
if (!previewEpisodes.value.length) {
|
||||
ElMessage.warning('请先完成预览')
|
||||
return
|
||||
}
|
||||
importing.value = true
|
||||
try {
|
||||
await emit('import', previewEpisodes.value.map((episode) => ({
|
||||
episode_number: episode.episode_number,
|
||||
title: episode.title,
|
||||
script_content: episode.script_content,
|
||||
description: null,
|
||||
duration: 0,
|
||||
})))
|
||||
ElMessage.success(`已导入 ${previewEpisodes.value.length} 集`)
|
||||
resetState()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '批量导入失败')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.episode-batch-import-trigger { display: inline-flex; }
|
||||
.batch-import-dialog { display: flex; flex-direction: column; }
|
||||
.batch-import-tabs { width: 100%; }
|
||||
.batch-import-panel { display: flex; flex-direction: column; gap: 16px; min-height: 420px; }
|
||||
.batch-import-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.batch-import-file { font-size: 0.85rem; color: #a1a1aa; }
|
||||
.batch-import-file.is-empty { color: #71717a; }
|
||||
.batch-import-form { margin-bottom: 0; }
|
||||
.batch-import-tip-block { display: flex; flex-direction: column; gap: 8px; }
|
||||
.batch-import-tip { font-size: 0.82rem; color: #71717a; }
|
||||
.batch-import-code { color: #c084fc; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; }
|
||||
.batch-import-empty { min-height: 320px; display: flex; align-items: center; justify-content: center; color: #71717a; border: 1px dashed #3f3f46; border-radius: 12px; }
|
||||
.batch-import-preview-header { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin-bottom: 12px; color: #c084fc; font-size: 0.85rem; flex-wrap: wrap; }
|
||||
.batch-import-preview-table :deep(.el-table) { --el-table-bg-color: transparent; --el-table-tr-bg-color: transparent; --el-table-border-color: #3f3f46; --el-table-header-bg-color: rgba(39, 39, 42, 0.9); --el-table-row-hover-bg-color: rgba(139, 92, 246, 0.08); color: #e4e4e7; }
|
||||
.batch-import-preview-table :deep(.el-table__inner-wrapper::before) { display: none; }
|
||||
.batch-import-preview-table :deep(th.el-table__cell) { color: #fafafa; }
|
||||
.batch-import-preview-table :deep(td.el-table__cell) { vertical-align: top; }
|
||||
.batch-import-preview-cell { line-height: 1.6; white-space: pre-wrap; color: #d4d4d8; }
|
||||
.batch-import-preview-cell--single-line { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.4; }
|
||||
</style>
|
||||
@@ -0,0 +1,387 @@
|
||||
<template>
|
||||
<div class="prompt-editor-page">
|
||||
<div v-if="loading" v-loading="true" class="loading-wrap" />
|
||||
<template v-else>
|
||||
<div class="editor-layout">
|
||||
<!-- 左侧菜单 -->
|
||||
<div class="left-sidebar">
|
||||
<div class="sidebar-menu">
|
||||
<div
|
||||
v-for="p in prompts"
|
||||
:key="p.key"
|
||||
:class="['menu-item', { active: currentKey === p.key }]"
|
||||
@click="selectPrompt(p.key)"
|
||||
>
|
||||
<div class="menu-item-content">
|
||||
<span class="menu-label">{{ p.label }}</span>
|
||||
<el-tag
|
||||
v-if="p.is_customized"
|
||||
type="warning"
|
||||
size="small"
|
||||
class="menu-tag"
|
||||
>已自定义</el-tag>
|
||||
<el-tag v-else type="info" size="small" class="menu-tag">默认</el-tag>
|
||||
</div>
|
||||
<div v-if="isDirty[p.key]" class="dirty-indicator" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧编辑区 -->
|
||||
<div class="right-content">
|
||||
<p class="page-desc">
|
||||
可自定义 AI 生成各阶段使用的提示词(System Prompt)。蓝色锁定区为 JSON
|
||||
格式要求,不可修改以确保输出格式正确。
|
||||
</p>
|
||||
|
||||
<div v-if="currentPrompt" class="prompt-card">
|
||||
<div class="prompt-card-header">
|
||||
<div class="prompt-card-meta">
|
||||
<span class="prompt-label">{{ currentPrompt.label }}</span>
|
||||
<el-tag
|
||||
v-if="currentPrompt.is_customized"
|
||||
type="warning"
|
||||
size="small"
|
||||
class="custom-tag"
|
||||
>已自定义</el-tag>
|
||||
<el-tag v-else type="info" size="small" class="custom-tag">使用默认</el-tag>
|
||||
</div>
|
||||
<p class="prompt-desc">{{ currentPrompt.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="prompt-edit-section">
|
||||
<div class="section-label">
|
||||
<el-icon class="section-icon"><Edit /></el-icon>
|
||||
<span>指令内容(可编辑)</span>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="editState[currentPrompt.key]"
|
||||
type="textarea"
|
||||
:rows="16"
|
||||
:placeholder="currentPrompt.default_body"
|
||||
class="prompt-textarea"
|
||||
@input="markDirty(currentPrompt.key)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="currentPrompt.locked_suffix" class="prompt-locked-section">
|
||||
<div class="section-label section-label--locked">
|
||||
<el-icon class="section-icon"><Lock /></el-icon>
|
||||
<span>JSON 格式要求(锁定,不可修改)</span>
|
||||
</div>
|
||||
<div class="locked-content">{{ currentPrompt.locked_suffix }}</div>
|
||||
</div>
|
||||
|
||||
<div class="prompt-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="savingKey === currentPrompt.key"
|
||||
:disabled="!isDirty[currentPrompt.key]"
|
||||
@click="save(currentPrompt)"
|
||||
>
|
||||
保存
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:loading="resettingKey === currentPrompt.key"
|
||||
:disabled="!currentPrompt.is_customized && !isDirty[currentPrompt.key]"
|
||||
@click="reset(currentPrompt)"
|
||||
>
|
||||
恢复默认
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Edit, Lock } from '@element-plus/icons-vue'
|
||||
import { promptsAPI } from '@/api/prompts'
|
||||
|
||||
const loading = ref(false)
|
||||
const prompts = ref([])
|
||||
const editState = ref({})
|
||||
const isDirty = ref({})
|
||||
const savingKey = ref(null)
|
||||
const resettingKey = ref(null)
|
||||
const currentKey = ref(null)
|
||||
|
||||
const currentPrompt = computed(() => {
|
||||
return prompts.value.find((p) => p.key === currentKey.value)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await promptsAPI.list()
|
||||
prompts.value = data.prompts || []
|
||||
for (const p of prompts.value) {
|
||||
editState.value[p.key] = p.current_body || p.default_body
|
||||
}
|
||||
// 默认选中第一个
|
||||
if (prompts.value.length > 0) {
|
||||
currentKey.value = prompts.value[0].key
|
||||
}
|
||||
} catch (_) {
|
||||
ElMessage.error('加载提示词失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectPrompt(key) {
|
||||
currentKey.value = key
|
||||
}
|
||||
|
||||
function markDirty(key) {
|
||||
const p = prompts.value.find((x) => x.key === key)
|
||||
if (!p) return
|
||||
const current = p.current_body || p.default_body
|
||||
isDirty.value[key] = editState.value[key] !== current
|
||||
}
|
||||
|
||||
async function save(p) {
|
||||
const content = editState.value[p.key]
|
||||
if (!content?.trim()) {
|
||||
ElMessage.warning('内容不能为空')
|
||||
return
|
||||
}
|
||||
savingKey.value = p.key
|
||||
try {
|
||||
await promptsAPI.update(p.key, content.trim())
|
||||
p.current_body = content.trim()
|
||||
p.is_customized = true
|
||||
isDirty.value[p.key] = false
|
||||
ElMessage.success('已保存')
|
||||
} catch (_) {
|
||||
} finally {
|
||||
savingKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function reset(p) {
|
||||
await ElMessageBox.confirm(`确定将「${p.label}」恢复为系统默认提示词?`, '恢复默认', {
|
||||
type: 'warning',
|
||||
})
|
||||
resettingKey.value = p.key
|
||||
try {
|
||||
await promptsAPI.reset(p.key)
|
||||
p.current_body = null
|
||||
p.is_customized = false
|
||||
editState.value[p.key] = p.default_body
|
||||
isDirty.value[p.key] = false
|
||||
ElMessage.success('已恢复默认')
|
||||
} catch (_) {
|
||||
} finally {
|
||||
resettingKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.prompt-editor-page {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.loading-wrap {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 左右布局 */
|
||||
.editor-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
/* 左侧菜单 */
|
||||
.left-sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-card, #fff);
|
||||
border-right: 1px solid var(--border-color, #e4e4e7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color, #e4e4e7);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright, #18181b);
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: var(--bg-inner, #f8f8f8);
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
background: var(--el-color-primary-light-9, #f3e8ff);
|
||||
}
|
||||
|
||||
.menu-item.active .menu-label {
|
||||
color: var(--el-color-primary, #7c3aed);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-bright, #18181b);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menu-tag {
|
||||
font-size: 10px;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.dirty-indicator {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--el-color-warning, #f59e0b);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 右侧内容区 */
|
||||
.right-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0 0 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted, #71717a);
|
||||
line-height: 1.6;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-inner, #f8f8f8);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--el-color-primary, #7c3aed);
|
||||
}
|
||||
|
||||
.prompt-card {
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e4e4e7);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.prompt-card-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.prompt-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.prompt-label {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright, #18181b);
|
||||
}
|
||||
|
||||
.custom-tag {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.prompt-desc {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted, #71717a);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #71717a);
|
||||
}
|
||||
|
||||
.section-label--locked {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.prompt-edit-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.prompt-textarea :deep(textarea) {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.prompt-locked-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.locked-content {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: #1e40af;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.prompt-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color, #e4e4e7);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,444 @@
|
||||
<template>
|
||||
<div class="scene-model-map-page">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<p class="page-desc">
|
||||
配置不同业务场景使用的 AI 模型路由。当调用 generateText 时传入 scene_key,系统会优先使用此处配置的模型。
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" @click="openAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加业务场景配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" v-loading="true" class="loading-wrap" />
|
||||
|
||||
<template v-else>
|
||||
<el-table
|
||||
:data="list"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="key" label="场景键 (scene_key)" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="">
|
||||
<code class="scene-key">{{ row.key }}</code>
|
||||
<span class="scene-key-label">{{ getSceneKeyLabel(row.key) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="service_type" label="服务类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="serviceTypeTagType(row.service_type)" size="small">
|
||||
{{ serviceTypeLabel(row.service_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="config_name" label="AI 配置" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.config_id">{{ row.config_name || '配置 #' + row.config_id }}</span>
|
||||
<el-tag v-else type="info" size="small">使用默认配置</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="model_override" label="模型覆盖" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.model_override" class="model-override">{{ row.model_override }}</span>
|
||||
<span v-else class="text-muted">使用配置默认模型</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="list.length === 0" description="暂无场景模型映射配置" />
|
||||
</template>
|
||||
|
||||
<!-- 添加/编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="editingKey ? '编辑业务场景映射' : '添加业务场景映射'"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
@closed="resetForm"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item prop="key" label="场景键">
|
||||
<el-select
|
||||
v-model="form.key"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="选择或输入场景键"
|
||||
style="width: 100%"
|
||||
:disabled="!!editingKey"
|
||||
@change="onKeyChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="k in predefinedKeys"
|
||||
:key="k.value"
|
||||
:label="k.label"
|
||||
:value="k.value"
|
||||
/>
|
||||
</el-select>
|
||||
<p class="field-tip">用于在代码中标识业务场景,选择后会自动设置对应的服务类型</p>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="service_type" label="服务类型">
|
||||
<el-select v-model="form.service_type" placeholder="选择服务类型" style="width: 100%" disabled>
|
||||
<el-option label="文本/对话" value="text" />
|
||||
<el-option label="文本生成图片" value="image" />
|
||||
<el-option label="分镜图片生成" value="storyboard_image" />
|
||||
<el-option label="视频生成" value="video" />
|
||||
<el-option label="语音合成 TTS" value="tts" />
|
||||
</el-select>
|
||||
<p class="field-tip">由场景键自动决定,不可更改</p>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="AI 配置">
|
||||
<el-select
|
||||
v-model="form.config_id"
|
||||
clearable
|
||||
placeholder="选择 AI 配置(留空使用默认)"
|
||||
style="width: 100%"
|
||||
@change="onConfigChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in filteredConfigs"
|
||||
:key="c.id"
|
||||
:label="`${c.name} (${c.provider})`"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
<p class="field-tip">指定具体的 AI 服务配置,不选则使用该类服务的默认配置</p>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="模型覆盖">
|
||||
<el-select
|
||||
v-model="form.model_override"
|
||||
clearable
|
||||
placeholder="选择模型(留空使用配置默认)"
|
||||
style="width: 100%"
|
||||
:disabled="!selectedConfigModels.length"
|
||||
>
|
||||
<el-option
|
||||
v-for="m in selectedConfigModels"
|
||||
:key="m"
|
||||
:label="m"
|
||||
:value="m"
|
||||
/>
|
||||
</el-select>
|
||||
<p class="field-tip">
|
||||
{{ selectedConfigModels.length ? '从该配置的可用模型中选择' : '请先选择 AI 配置' }}
|
||||
</p>
|
||||
</el-form-item>
|
||||
<el-form-item prop="description" label="描述">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
placeholder="输入场景描述,便于理解用途"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { sceneModelMapAPI } from '@/api/sceneModelMap'
|
||||
import { aiAPI } from '@/api/ai'
|
||||
import { getSelectableModels } from '@/utils/modelSelection'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const list = ref([])
|
||||
const configs = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingKey = ref(null)
|
||||
const formRef = ref(null)
|
||||
|
||||
const form = ref({
|
||||
key: '',
|
||||
description: '',
|
||||
service_type: 'text',
|
||||
config_id: null,
|
||||
model_override: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
key: [{ required: true, message: '请输入场景键', trigger: 'blur' }],
|
||||
service_type: [{ required: true, message: '请选择服务类型', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 预定义场景键及其对应的服务类型
|
||||
const predefinedKeys = [
|
||||
{ value: 'image_polish', label: 'image_polish - 分镜图提示词润色', service_type: 'text' },
|
||||
// 目前程序里只内置了一个场景键:image_polish
|
||||
// 以下为新增的场景键,已添加到对应的接口里
|
||||
{ value: 'role_image_polish', label: 'role_image_polish - 角色图提示词润色', service_type: 'text' },
|
||||
{ value: 'prop_image_polish', label: 'prop_image_polish - 道具图提示词润色', service_type: 'text' },
|
||||
{ value: 'scene_image_polish', label: 'scene_image_polish - 场景图提示词润色', service_type: 'text' },
|
||||
{ value: 'role_extraction', label: 'role_extraction - 角色提取', service_type: 'text' },
|
||||
{ value: 'prop_extraction', label: 'prop_extraction - 道具提取', service_type: 'text' },
|
||||
{ value: 'scene_extraction', label: 'scene_extraction - 场景提取', service_type: 'text' },
|
||||
{ value: 'storyboard_extraction', label: 'storyboard_extraction - 分镜生成', service_type: 'text' },
|
||||
{ value: 'identity_anchors', label: 'identity_anchors - 角色视觉锚点提炼', service_type: 'text' },
|
||||
{ value: 'frame_prompt', label: 'frame_prompt - 帧提示词生成', service_type: 'text' },
|
||||
{ value: 'novel_import', label: 'novel_import - 小说导入改写', service_type: 'text' },
|
||||
{ value: 'story_generation', label: 'story_generation - 故事生成', service_type: 'text' },
|
||||
// 以下是其他服务类型...未实现
|
||||
// 图片生成
|
||||
// { value: 'role_image_gen', label: 'role_image_gen - 角色图片生成', service_type: 'image' },
|
||||
// { value: 'prop_image_gen', label: 'prop_image_gen - 道具图片生成', service_type: 'image' },
|
||||
// { value: 'scene_image_gen', label: 'scene_image_gen - 场景图片生成', service_type: 'image' },
|
||||
// { value: 'storyboard_image_gen', label: 'storyboard_image_gen - 分镜图片生成', service_type: 'image' },
|
||||
// { value: 'video_frame_gen', label: 'video_frame_gen - 视频帧生成', service_type: 'video' },// 首尾帧视频生成
|
||||
// { value: 'video_full_gen', label: 'video_full_gen - 全能视频生成', service_type: 'video' },// 全能模式视频生成
|
||||
]
|
||||
|
||||
// 根据服务类型筛选配置
|
||||
const filteredConfigs = computed(() => {
|
||||
const currentServiceType = form.value.service_type
|
||||
console.log('filteredConfigs computed, service_type:', currentServiceType, 'configs:', configs.value.length)
|
||||
const filtered = configs.value.filter(c => {
|
||||
const match = c.service_type === currentServiceType && c.is_active
|
||||
console.log(' config:', c.name, 'service_type:', c.service_type, 'match:', match)
|
||||
return match
|
||||
})
|
||||
console.log(' filtered result:', filtered.length)
|
||||
return filtered
|
||||
})
|
||||
|
||||
// 获取选中配置的可用模型列表
|
||||
const selectedConfigModels = computed(() => {
|
||||
return getSelectableModels(configs.value, form.value.service_type, form.value.config_id)
|
||||
})
|
||||
|
||||
function serviceTypeLabel(type) {
|
||||
const map = {
|
||||
text: '文本/对话',
|
||||
image: '文本生成图片',
|
||||
storyboard_image: '分镜图片生成',
|
||||
video: '视频生成',
|
||||
tts: '语音合成 TTS'
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
function serviceTypeTagType(type) {
|
||||
const map = {
|
||||
text: 'primary',
|
||||
image: 'success',
|
||||
storyboard_image: 'warning',
|
||||
video: 'danger',
|
||||
tts: 'info'
|
||||
}
|
||||
return map[type] || ''
|
||||
}
|
||||
|
||||
// 获取场景键的 label
|
||||
function getSceneKeyLabel(key) {
|
||||
const matched = predefinedKeys.find(k => k.value === key)
|
||||
if (matched) {
|
||||
// 从 label 中提取描述部分(去掉 key 前缀)
|
||||
return matched.label.replace(matched.value + ' - ', '')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 场景键改变时自动设置服务类型
|
||||
function onKeyChange(key) {
|
||||
console.log('onKeyChange called with key:', key)
|
||||
const matched = predefinedKeys.find(k => k.value === key)
|
||||
console.log('matched predefined key:', matched)
|
||||
if (matched) {
|
||||
form.value.service_type = matched.service_type
|
||||
console.log('service_type set to:', form.value.service_type)
|
||||
}
|
||||
// 重置配置和模型选择
|
||||
form.value.config_id = null
|
||||
form.value.model_override = ''
|
||||
}
|
||||
|
||||
// 配置改变时重置模型选择
|
||||
function onConfigChange(configId) {
|
||||
form.value.model_override = ''
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [mapsData, configsData] = await Promise.all([
|
||||
sceneModelMapAPI.list(),
|
||||
aiAPI.list()
|
||||
])
|
||||
configs.value = configsData || []
|
||||
console.log('Loaded configs:', configs.value.map(c => ({ id: c.id, name: c.name, service_type: c.service_type, is_active: c.is_active })))
|
||||
|
||||
// 合并配置名称
|
||||
list.value = (mapsData || []).map(item => {
|
||||
const config = configs.value.find(c => c.id === item.config_id)
|
||||
return {
|
||||
...item,
|
||||
config_name: config?.name || null
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
ElMessage.error('加载场景模型映射失败: ' + (err.message || '未知错误'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editingKey.value = null
|
||||
form.value = {
|
||||
key: '',
|
||||
description: '',
|
||||
service_type: 'text',
|
||||
config_id: null,
|
||||
model_override: ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editingKey.value = row.key
|
||||
form.value = {
|
||||
key: row.key,
|
||||
description: row.description || '',
|
||||
service_type: row.service_type || 'text',
|
||||
config_id: row.config_id || null,
|
||||
model_override: row.model_override || ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const body = {
|
||||
description: form.value.description,
|
||||
service_type: form.value.service_type,
|
||||
config_id: form.value.config_id || null,
|
||||
model_override: form.value.model_override || null
|
||||
}
|
||||
|
||||
if (editingKey.value) {
|
||||
await sceneModelMapAPI.update(editingKey.value, body)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await sceneModelMapAPI.create({ ...body, key: form.value.key })
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} catch (err) {
|
||||
ElMessage.error('保存失败: ' + (err.message || '未知错误'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除场景 "${row.key}" 的模型映射配置吗?`,
|
||||
'确认删除',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await sceneModelMapAPI.delete(row.key)
|
||||
ElMessage.success('删除成功')
|
||||
await load()
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error('删除失败: ' + (err.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scene-model-map-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.loading-wrap {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.scene-key {
|
||||
background: #f5f7fa;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #409eff;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.scene-key-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.model-override {
|
||||
background: #e6f7ff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #096dd9;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field-tip {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,701 @@
|
||||
<template>
|
||||
<div class="sd2-asset-mgmt tab-content">
|
||||
<el-alert type="info" :closable="false" class="sd2-intro" show-icon>
|
||||
<template #title>
|
||||
<span>
|
||||
对接 BytePlus ModelArk / 火山方舟<strong>私有资产库</strong>(Seedance 2.0 等使用的 <code>Asset://</code> 素材)。
|
||||
官方流程:<a href="https://docs.byteplus.com/en/docs/ModelArk/2318270" target="_blank" rel="noopener">CreateAssetGroup</a>
|
||||
→ CreateAsset → List / Get / Update / Delete。
|
||||
带 <code>?Action=</code> 的接口为<strong>控制面 OpenAPI</strong>,须使用控制台
|
||||
<a href="https://console.volcengine.com/iam/keymanage" target="_blank" rel="noopener">访问密钥(AK/SK)</a>签名,不能用推理用的 ARK API Key 当 Bearer,否则会报 Invalid Authorization(见
|
||||
<a href="https://docs.byteplus.com/en/docs/ModelArk/1298459" target="_blank" rel="noopener">认证说明</a>)。
|
||||
若已能调通接口但返回 <strong>403</strong> 且含 <code>not authorized</code> / <code>ark:CreateAssetGroup</code>,说明 AK 对应 IAM 用户<strong>缺策略</strong>:在控制台为该用户绑定含 ModelArk 私有资产/资产组管理的权限(参见
|
||||
<a href="https://docs.byteplus.com/en/docs/ModelArk/1263493" target="_blank" rel="noopener">IAM 访问控制</a>),勿仅用「能推理」的极简权限。
|
||||
</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-form label-width="120px" class="sd2-form">
|
||||
<el-form-item label="Base URL">
|
||||
<el-input
|
||||
v-model="baseUrl"
|
||||
placeholder="须含 /api/v3,如 https://ark.ap-southeast-1.byteplusapi.com/api/v3(仅域名时后端会尝试自动补全)"
|
||||
clearable
|
||||
/>
|
||||
<p class="field-hint">OpenAPI 与推理共用前缀一般为 <code>/api/v3</code>;若只填域名可能导致路由不对、工程名不生效。</p>
|
||||
</el-form-item>
|
||||
<el-form-item label="鉴权方式">
|
||||
<el-radio-group v-model="authMode">
|
||||
<el-radio-button value="volc_sign">AK/SK 签名(官方 OpenAPI)</el-radio-button>
|
||||
<el-radio-button value="bearer">Bearer 推理 Key</el-radio-button>
|
||||
</el-radio-group>
|
||||
<p class="field-hint">选「官方 OpenAPI」路径时,请用本项并填写 AK/SK;选「Bearer」仅适合 <code>/asset/…</code> 等中转。</p>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="authMode === 'bearer'" label="API Key">
|
||||
<el-input v-model="apiKey" type="password" show-password placeholder="推理用 ARK / 中转 API Key" clearable />
|
||||
</el-form-item>
|
||||
<template v-else>
|
||||
<el-form-item label="Access Key ID">
|
||||
<el-input v-model="accessKeyId" placeholder="控制台 IAM Access Key ID" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="Secret Key">
|
||||
<el-input v-model="secretAccessKey" type="password" show-password placeholder="Secret Access Key" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="Region">
|
||||
<el-input v-model="signRegion" placeholder="可空:国内 ark 多为 cn-beijing;BytePlus 国际多为 ap-southeast-1" clearable />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="路径模式">
|
||||
<el-select v-model="pathMode" style="width: 100%">
|
||||
<el-option label="官方 OpenAPI:POST {Base}?Action=…&Version=…(火山/BytePlus 默认)" value="open_api_query" />
|
||||
<el-option label="路径:POST {Base}/asset/{Action}(部分中转)" value="asset_subpath" />
|
||||
<el-option label="扁平:POST {Base}/{Action}" value="flat" />
|
||||
</el-select>
|
||||
<p class="field-hint">官方接口必须在 Query 里带 <code>Action</code>;若用 AnyFast 等自建路径再选中转模式。</p>
|
||||
</el-form-item>
|
||||
<el-form-item label="API Version">
|
||||
<el-input v-model="apiVersion" placeholder="默认 2024-01-01(仅官方 OpenAPI 模式使用)" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="pathMode === 'open_api_query'" label="工程 / 项目名">
|
||||
<el-input
|
||||
v-model="projectName"
|
||||
placeholder="与控制台「项目」标识完全一致(区分大小写、下划线等)"
|
||||
clearable
|
||||
/>
|
||||
<p class="field-hint">
|
||||
会写入 <strong>Query</strong> 与 <strong>JSON Body</strong> 的 <code>ProjectName</code>(与 Action 一并签名)。
|
||||
若仍报 403 且文案里是 <code>project/*</code>,多为 IAM 未授权该动作;请确认策略里资源是否包含你的工程(或 <code>project/*</code>),错误提示不一定替换为具体工程名。
|
||||
</p>
|
||||
</el-form-item>
|
||||
<el-form-item label="model(可选)">
|
||||
<el-input v-model="billingModel" placeholder="部分中转要求计费模型,如 volc-asset;官方直连可留空" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="从配置填入">
|
||||
<el-select
|
||||
v-model="fillConfigId"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择已保存的视频类配置(火山等)"
|
||||
style="width: 100%"
|
||||
@change="onFillFromSaved"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in videoLikeConfigs"
|
||||
:key="c.id"
|
||||
:label="`${c.name} · ${c.base_url || ''}`"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="11">
|
||||
<div class="panel-title">资产组</div>
|
||||
<div class="panel-actions">
|
||||
<el-button type="primary" size="small" :loading="loadingGroups" @click="refreshGroups">刷新列表</el-button>
|
||||
<el-button type="success" size="small" @click="openCreateGroup">新建组</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
:data="groupRows"
|
||||
size="small"
|
||||
stripe
|
||||
highlight-current-row
|
||||
max-height="320"
|
||||
@current-change="onGroupRowChange"
|
||||
>
|
||||
<el-table-column prop="Id" label="Id" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="Name" label="名称" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="168" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="getGroupDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEditGroup(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteGroup(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-col>
|
||||
<el-col :span="13">
|
||||
<div class="panel-title">资产(需组 Id)</div>
|
||||
<div class="panel-actions row-gap">
|
||||
<el-input v-model="assetGroupIdInput" placeholder="组 Id,或左侧点选一行" clearable style="flex: 1; min-width: 140px" />
|
||||
<el-button type="primary" size="small" :loading="loadingAssets" @click="refreshAssets">刷新</el-button>
|
||||
<el-button type="success" size="small" @click="openCreateAsset">新建资产</el-button>
|
||||
</div>
|
||||
<el-table :data="assetRows" size="small" stripe max-height="320">
|
||||
<el-table-column prop="Id" label="Id" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="Name" label="名称" min-width="90" show-overflow-tooltip />
|
||||
<el-table-column prop="AssetType" label="类型" width="88" />
|
||||
<el-table-column label="操作" width="168" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="getAssetDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEditAsset(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteAsset(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="panel-title" style="margin-top: 16px">最近一次响应(调试)</div>
|
||||
<el-input v-model="lastRawJson" type="textarea" :rows="6" readonly class="mono" />
|
||||
|
||||
<!-- 新建资产组 -->
|
||||
<el-dialog v-model="dlgGroupCreate" title="CreateAssetGroup" width="480px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="Name" required>
|
||||
<el-input v-model="formGroupName" placeholder="资产组名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="扩展 JSON">
|
||||
<el-input v-model="formGroupExtraJson" type="textarea" :rows="3" placeholder='可选,合并进请求体,如 {"Description":"..."}' />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlgGroupCreate = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dlgLoading" @click="submitCreateGroup">提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑资产组 -->
|
||||
<el-dialog v-model="dlgGroupEdit" title="UpdateAssetGroup" width="520px" destroy-on-close>
|
||||
<el-alert type="warning" :closable="false" title="按官方文档填写需更新的字段;以下为常用名称修改。" style="margin-bottom: 12px" />
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="Id" required>
|
||||
<el-input v-model="editGroupId" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="Name">
|
||||
<el-input v-model="editGroupName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="完整 JSON">
|
||||
<el-input v-model="editGroupFullJson" type="textarea" :rows="6" placeholder='若填写则优先整段作为请求体(须含 Id)' />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlgGroupEdit = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dlgLoading" @click="submitUpdateGroup">提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新建资产 -->
|
||||
<el-dialog v-model="dlgAssetCreate" title="CreateAsset" width="520px" destroy-on-close>
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="GroupId" required>
|
||||
<el-input v-model="formAssetGroupId" placeholder="资产组 Id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Name" required>
|
||||
<el-input v-model="formAssetName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="AssetType">
|
||||
<el-select v-model="formAssetType" style="width: 100%">
|
||||
<el-option label="Image" value="Image" />
|
||||
<el-option label="Video" value="Video" />
|
||||
<el-option label="Audio" value="Audio" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="model">
|
||||
<el-input v-model="formAssetModel" placeholder="视频建议 volc-asset-video;音频 volc-asset-audio;图片可空" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="URL">
|
||||
<el-input v-model="formAssetUrl" type="textarea" :rows="2" placeholder="公网 URL / data:image/...;base64,..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlgAssetCreate = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dlgLoading" @click="submitCreateAsset">提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑资产 -->
|
||||
<el-dialog v-model="dlgAssetEdit" title="UpdateAsset" width="520px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="Id" required>
|
||||
<el-input v-model="editAssetId" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="Name">
|
||||
<el-input v-model="editAssetName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="完整 JSON">
|
||||
<el-input v-model="editAssetFullJson" type="textarea" :rows="6" placeholder="若填写则整段作为请求体(须含 Id)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlgAssetEdit = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dlgLoading" @click="submitUpdateAsset">提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情 JSON -->
|
||||
<el-dialog v-model="dlgDetail" title="详情" width="640px" destroy-on-close>
|
||||
<el-input :model-value="detailJson" type="textarea" :rows="16" readonly class="mono" />
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="dlgDetail = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { aiAPI } from '@/api/ai'
|
||||
|
||||
const props = defineProps({
|
||||
/** AI 配置列表(与 AI 配置页同源),用于一键填入 Base / Key */
|
||||
configs: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const baseUrl = ref('')
|
||||
const apiKey = ref('')
|
||||
const pathMode = ref('open_api_query')
|
||||
const apiVersion = ref('2024-01-01')
|
||||
/** OpenAPI 可选查询参数 ProjectName(与控制台项目对应,便于 IAM 精确到 project/某工程 而非 project/*) */
|
||||
const projectName = ref('')
|
||||
const authMode = ref('volc_sign')
|
||||
const accessKeyId = ref('')
|
||||
const secretAccessKey = ref('')
|
||||
const signRegion = ref('')
|
||||
/** 仅合并到 List / Create 类请求,避免影响 Get/Update/Delete */
|
||||
const billingModel = ref('')
|
||||
const fillConfigId = ref(null)
|
||||
const loadingGroups = ref(false)
|
||||
const loadingAssets = ref(false)
|
||||
const dlgLoading = ref(false)
|
||||
const lastRawJson = ref('')
|
||||
const assetGroupIdInput = ref('')
|
||||
const lastListGroupsPayload = ref(null)
|
||||
const lastListAssetsPayload = ref(null)
|
||||
|
||||
const dlgGroupCreate = ref(false)
|
||||
const formGroupName = ref('')
|
||||
const formGroupExtraJson = ref('')
|
||||
|
||||
const dlgGroupEdit = ref(false)
|
||||
const editGroupId = ref('')
|
||||
const editGroupName = ref('')
|
||||
const editGroupFullJson = ref('')
|
||||
|
||||
const dlgAssetCreate = ref(false)
|
||||
const formAssetGroupId = ref('')
|
||||
const formAssetName = ref('')
|
||||
const formAssetType = ref('Image')
|
||||
const formAssetModel = ref('')
|
||||
const formAssetUrl = ref('')
|
||||
|
||||
const dlgAssetEdit = ref(false)
|
||||
const editAssetId = ref('')
|
||||
const editAssetName = ref('')
|
||||
const editAssetFullJson = ref('')
|
||||
|
||||
const dlgDetail = ref(false)
|
||||
const detailJson = ref('')
|
||||
|
||||
const videoLikeConfigs = computed(() => {
|
||||
const rows = props.configs || []
|
||||
return rows.filter((c) => {
|
||||
if (c.service_type !== 'video') return false
|
||||
const u = (c.base_url || '').toLowerCase()
|
||||
const p = (c.api_protocol || '').toLowerCase()
|
||||
return (
|
||||
p.includes('volc') ||
|
||||
u.includes('volces.com') ||
|
||||
u.includes('byteplus') ||
|
||||
u.includes('byteplustech') ||
|
||||
u.includes('/ark')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function setLastJson(obj) {
|
||||
try {
|
||||
lastRawJson.value = JSON.stringify(obj, null, 2)
|
||||
} catch (_) {
|
||||
lastRawJson.value = String(obj)
|
||||
}
|
||||
}
|
||||
|
||||
function extractRows(resp) {
|
||||
if (!resp) return []
|
||||
if (Array.isArray(resp)) return resp
|
||||
const keys = [
|
||||
'Items',
|
||||
'List',
|
||||
'AssetGroups',
|
||||
'Assets',
|
||||
'Groups',
|
||||
'Data',
|
||||
]
|
||||
for (const k of keys) {
|
||||
if (Array.isArray(resp[k])) return resp[k]
|
||||
}
|
||||
const r = resp.Result || resp.result
|
||||
if (r && typeof r === 'object') {
|
||||
for (const k of keys) {
|
||||
if (Array.isArray(r[k])) return r[k]
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const groupRows = computed(() => extractRows(lastListGroupsPayload.value))
|
||||
const assetRows = computed(() => extractRows(lastListAssetsPayload.value))
|
||||
|
||||
function onFillFromSaved(id) {
|
||||
if (id == null || id === '') return
|
||||
const c = (props.configs || []).find((x) => x.id === id)
|
||||
if (!c) return
|
||||
baseUrl.value = (c.base_url || '').replace(/\/$/, '')
|
||||
apiKey.value = c.api_key || ''
|
||||
ElMessage.success('已填入所选配置的 Base URL 与 API Key')
|
||||
}
|
||||
|
||||
function onGroupRowChange(row) {
|
||||
if (row && row.Id) {
|
||||
assetGroupIdInput.value = row.Id
|
||||
}
|
||||
}
|
||||
|
||||
function mergeBillingModel(payload, withModel) {
|
||||
const p = { ...(payload || {}) }
|
||||
if (withModel && billingModel.value.trim() && !String(p.model || '').trim()) {
|
||||
p.model = billingModel.value.trim()
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
function connReady() {
|
||||
if (!baseUrl.value.trim()) return false
|
||||
if (authMode.value === 'volc_sign') {
|
||||
return !!(accessKeyId.value.trim() && secretAccessKey.value.trim())
|
||||
}
|
||||
return !!apiKey.value.trim()
|
||||
}
|
||||
|
||||
function connWarn() {
|
||||
if (!baseUrl.value.trim()) return '请先填写 Base URL'
|
||||
if (authMode.value === 'volc_sign') {
|
||||
if (!accessKeyId.value.trim() || !secretAccessKey.value.trim()) {
|
||||
return '官方 OpenAPI 请填写 Access Key ID 与 Secret Access Key(控制台 IAM,非推理 API Key)'
|
||||
}
|
||||
} else if (!apiKey.value.trim()) {
|
||||
return '请先填写 API Key'
|
||||
}
|
||||
if (authMode.value === 'volc_sign' && pathMode.value !== 'open_api_query') {
|
||||
return 'AK/SK 签名请配合「官方 OpenAPI」路径模式'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function call(action, payload, opts = {}) {
|
||||
const { withBillingModel = false } = opts
|
||||
const body = {
|
||||
base_url: baseUrl.value.trim(),
|
||||
action,
|
||||
path_mode: pathMode.value,
|
||||
api_version: apiVersion.value.trim() || undefined,
|
||||
auth_mode: authMode.value,
|
||||
payload: mergeBillingModel(payload, withBillingModel),
|
||||
}
|
||||
if (pathMode.value === 'open_api_query' && projectName.value.trim()) {
|
||||
body.project_name = projectName.value.trim()
|
||||
}
|
||||
if (authMode.value === 'bearer') {
|
||||
body.api_key = apiKey.value
|
||||
} else {
|
||||
body.access_key_id = accessKeyId.value.trim()
|
||||
body.secret_access_key = secretAccessKey.value.trim()
|
||||
if (signRegion.value.trim()) body.sign_region = signRegion.value.trim()
|
||||
}
|
||||
return aiAPI.modelArkAsset(body)
|
||||
}
|
||||
|
||||
async function refreshGroups() {
|
||||
const w = connWarn()
|
||||
if (!connReady() || w) {
|
||||
ElMessage.warning(w || '请先完成连接信息')
|
||||
return
|
||||
}
|
||||
loadingGroups.value = true
|
||||
try {
|
||||
const body = {
|
||||
PageNumber: 1,
|
||||
PageSize: 50,
|
||||
/** Filter、Filter.GroupType 均为官方 ListAssetGroups 必填;AIGC 为私有资产库常用类型 */
|
||||
Filter: {
|
||||
GroupType: 'AIGC',
|
||||
},
|
||||
}
|
||||
const data = await call('ListAssetGroups', body, { withBillingModel: true })
|
||||
lastListGroupsPayload.value = data
|
||||
setLastJson(data)
|
||||
} catch (e) {
|
||||
lastListGroupsPayload.value = null
|
||||
} finally {
|
||||
loadingGroups.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAssets() {
|
||||
const gid = assetGroupIdInput.value.trim()
|
||||
const w = connWarn()
|
||||
if (!connReady() || w) {
|
||||
ElMessage.warning(w || '请先完成连接信息')
|
||||
return
|
||||
}
|
||||
if (!gid) {
|
||||
ElMessage.warning('请填写或选择资产组 Id')
|
||||
return
|
||||
}
|
||||
loadingAssets.value = true
|
||||
try {
|
||||
const body = {
|
||||
PageNumber: 1,
|
||||
PageSize: 50,
|
||||
Filter: {
|
||||
GroupType: 'AIGC',
|
||||
GroupIds: [gid],
|
||||
},
|
||||
}
|
||||
const data = await call('ListAssets', body, { withBillingModel: true })
|
||||
lastListAssetsPayload.value = data
|
||||
setLastJson(data)
|
||||
} catch (e) {
|
||||
lastListAssetsPayload.value = null
|
||||
} finally {
|
||||
loadingAssets.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateGroup() {
|
||||
formGroupName.value = ''
|
||||
formGroupExtraJson.value = ''
|
||||
dlgGroupCreate.value = true
|
||||
}
|
||||
|
||||
async function submitCreateGroup() {
|
||||
if (!formGroupName.value.trim()) {
|
||||
ElMessage.warning('请填写 Name')
|
||||
return
|
||||
}
|
||||
dlgLoading.value = true
|
||||
try {
|
||||
let extra = {}
|
||||
if (formGroupExtraJson.value.trim()) {
|
||||
try {
|
||||
extra = JSON.parse(formGroupExtraJson.value)
|
||||
} catch (_) {
|
||||
ElMessage.error('扩展 JSON 格式无效')
|
||||
return
|
||||
}
|
||||
}
|
||||
const payload = { Name: formGroupName.value.trim(), ...extra }
|
||||
const data = await call('CreateAssetGroup', payload, { withBillingModel: true })
|
||||
setLastJson(data)
|
||||
ElMessage.success('已创建')
|
||||
dlgGroupCreate.value = false
|
||||
await refreshGroups()
|
||||
} finally {
|
||||
dlgLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getGroupDetail(row) {
|
||||
dlgLoading.value = true
|
||||
try {
|
||||
const data = await call('GetAssetGroup', { Id: row.Id })
|
||||
detailJson.value = JSON.stringify(data, null, 2)
|
||||
dlgDetail.value = true
|
||||
setLastJson(data)
|
||||
} finally {
|
||||
dlgLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEditGroup(row) {
|
||||
editGroupId.value = row.Id
|
||||
editGroupName.value = row.Name || ''
|
||||
editGroupFullJson.value = ''
|
||||
dlgGroupEdit.value = true
|
||||
}
|
||||
|
||||
async function submitUpdateGroup() {
|
||||
dlgLoading.value = true
|
||||
try {
|
||||
let payload
|
||||
if (editGroupFullJson.value.trim()) {
|
||||
try {
|
||||
payload = JSON.parse(editGroupFullJson.value)
|
||||
} catch (_) {
|
||||
ElMessage.error('完整 JSON 无效')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
payload = { Id: editGroupId.value, Name: editGroupName.value }
|
||||
}
|
||||
const data = await call('UpdateAssetGroup', payload)
|
||||
setLastJson(data)
|
||||
ElMessage.success('已更新')
|
||||
dlgGroupEdit.value = false
|
||||
await refreshGroups()
|
||||
} finally {
|
||||
dlgLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGroup(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除资产组「${row.Name || row.Id}」?`, 'DeleteAssetGroup', {
|
||||
type: 'warning',
|
||||
})
|
||||
} catch (_) {
|
||||
return
|
||||
}
|
||||
dlgLoading.value = true
|
||||
try {
|
||||
const data = await call('DeleteAssetGroup', { Id: row.Id })
|
||||
setLastJson(data)
|
||||
ElMessage.success('已删除')
|
||||
if (assetGroupIdInput.value === row.Id) assetGroupIdInput.value = ''
|
||||
await refreshGroups()
|
||||
} finally {
|
||||
dlgLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateAsset() {
|
||||
formAssetGroupId.value = assetGroupIdInput.value.trim()
|
||||
formAssetName.value = ''
|
||||
formAssetType.value = 'Image'
|
||||
formAssetModel.value = ''
|
||||
formAssetUrl.value = ''
|
||||
dlgAssetCreate.value = true
|
||||
}
|
||||
|
||||
async function submitCreateAsset() {
|
||||
if (!formAssetGroupId.value.trim() || !formAssetName.value.trim()) {
|
||||
ElMessage.warning('请填写 GroupId 与 Name')
|
||||
return
|
||||
}
|
||||
dlgLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
GroupId: formAssetGroupId.value.trim(),
|
||||
Name: formAssetName.value.trim(),
|
||||
AssetType: formAssetType.value,
|
||||
}
|
||||
if (formAssetUrl.value.trim()) payload.URL = formAssetUrl.value.trim()
|
||||
if (formAssetModel.value.trim()) payload.model = formAssetModel.value.trim()
|
||||
const data = await call('CreateAsset', payload, { withBillingModel: true })
|
||||
setLastJson(data)
|
||||
ElMessage.success('已创建')
|
||||
dlgAssetCreate.value = false
|
||||
await refreshAssets()
|
||||
} finally {
|
||||
dlgLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getAssetDetail(row) {
|
||||
dlgLoading.value = true
|
||||
try {
|
||||
const data = await call('GetAsset', { Id: row.Id })
|
||||
detailJson.value = JSON.stringify(data, null, 2)
|
||||
dlgDetail.value = true
|
||||
setLastJson(data)
|
||||
} finally {
|
||||
dlgLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEditAsset(row) {
|
||||
editAssetId.value = row.Id
|
||||
editAssetName.value = row.Name || ''
|
||||
editAssetFullJson.value = ''
|
||||
dlgAssetEdit.value = true
|
||||
}
|
||||
|
||||
async function submitUpdateAsset() {
|
||||
dlgLoading.value = true
|
||||
try {
|
||||
let payload
|
||||
if (editAssetFullJson.value.trim()) {
|
||||
try {
|
||||
payload = JSON.parse(editAssetFullJson.value)
|
||||
} catch (_) {
|
||||
ElMessage.error('完整 JSON 无效')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
payload = { Id: editAssetId.value, Name: editAssetName.value }
|
||||
}
|
||||
const data = await call('UpdateAsset', payload)
|
||||
setLastJson(data)
|
||||
ElMessage.success('已更新')
|
||||
dlgAssetEdit.value = false
|
||||
await refreshAssets()
|
||||
} finally {
|
||||
dlgLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAsset(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除资产「${row.Name || row.Id}」?`, 'DeleteAsset', { type: 'warning' })
|
||||
} catch (_) {
|
||||
return
|
||||
}
|
||||
dlgLoading.value = true
|
||||
try {
|
||||
const data = await call('DeleteAsset', { Id: row.Id })
|
||||
setLastJson(data)
|
||||
ElMessage.success('已删除')
|
||||
await refreshAssets()
|
||||
} finally {
|
||||
dlgLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sd2-asset-mgmt {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.sd2-intro {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.sd2-intro code {
|
||||
font-size: 12px;
|
||||
}
|
||||
.sd2-form {
|
||||
margin-bottom: 8px;
|
||||
max-width: 720px;
|
||||
}
|
||||
.field-hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.field-hint code {
|
||||
font-size: 11px;
|
||||
}
|
||||
.panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.panel-actions.row-gap {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.mono :deep(textarea) {
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class="style-picker-wrap">
|
||||
<!-- 触发按钮,外观与 el-select 一致 -->
|
||||
<div
|
||||
class="style-picker-trigger"
|
||||
:class="{ 'has-value': !!modelValue }"
|
||||
@click="visible = true"
|
||||
>
|
||||
<template v-if="selectedOption">
|
||||
<span class="spt-swatch" :style="swatchStyle(selectedOption)" />
|
||||
<span class="spt-label">{{ selectedOption.label }}</span>
|
||||
</template>
|
||||
<span v-else class="spt-placeholder">{{ placeholder }}</span>
|
||||
<el-icon class="spt-arrow"><ArrowDown /></el-icon>
|
||||
<span v-if="modelValue" class="spt-clear" @click.stop="emit('update:modelValue', ''); emit('change', '')">
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 选择弹窗 -->
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="选择生成风格"
|
||||
width="90vw"
|
||||
style="max-width: 1100px"
|
||||
class="style-picker-dialog"
|
||||
:append-to-body="true"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="spd-search">
|
||||
<el-input
|
||||
v-model="search"
|
||||
placeholder="搜索风格名称..."
|
||||
clearable
|
||||
style="width: 240px"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<span v-if="modelValue" class="spd-selected-hint">
|
||||
已选:{{ selectedOption?.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="spd-body">
|
||||
<template v-for="group in filteredGroups" :key="group.label">
|
||||
<div class="spd-group-title">{{ group.label }}</div>
|
||||
<div class="spd-grid">
|
||||
<div
|
||||
v-for="opt in group.options"
|
||||
:key="opt.value"
|
||||
class="spd-item"
|
||||
:class="{ 'is-active': modelValue === opt.value }"
|
||||
@click="select(opt)"
|
||||
>
|
||||
<div class="spd-thumb" :style="thumbStyle(opt)">
|
||||
<img
|
||||
v-if="opt.thumb"
|
||||
:src="opt.thumb"
|
||||
:alt="opt.label"
|
||||
loading="lazy"
|
||||
@error="(e) => e.target.style.display = 'none'"
|
||||
/>
|
||||
<span v-if="!opt.thumb" class="spd-thumb-text">{{ opt.label.slice(0, 2) }}</span>
|
||||
</div>
|
||||
<div class="spd-name">{{ opt.label }}</div>
|
||||
<div v-if="modelValue === opt.value" class="spd-check">✓</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="filteredGroups.length === 0" class="spd-empty">没有匹配的风格</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="clearAndClose">清除选择</el-button>
|
||||
<el-button type="primary" @click="visible = false">完成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ArrowDown, CircleClose, Search } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
options: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: '图片/视频风格' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const visible = ref(false)
|
||||
const search = ref('')
|
||||
|
||||
const allOptions = computed(() => props.options.flatMap((g) => g.options))
|
||||
const selectedOption = computed(() => allOptions.value.find((o) => o.value === props.modelValue) || null)
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
const kw = search.value.trim().toLowerCase()
|
||||
if (!kw) return props.options
|
||||
return props.options
|
||||
.map((g) => ({ ...g, options: g.options.filter((o) => o.label.toLowerCase().includes(kw) || o.value.toLowerCase().includes(kw)) }))
|
||||
.filter((g) => g.options.length > 0)
|
||||
})
|
||||
|
||||
function thumbStyle(opt) {
|
||||
if (opt.thumb) return {}
|
||||
return { background: opt.color || 'linear-gradient(135deg,#667eea,#764ba2)' }
|
||||
}
|
||||
|
||||
function swatchStyle(opt) {
|
||||
return { background: opt.color || 'linear-gradient(135deg,#667eea,#764ba2)' }
|
||||
}
|
||||
|
||||
function select(opt) {
|
||||
emit('update:modelValue', opt.value)
|
||||
emit('change', opt.value)
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function clearAndClose() {
|
||||
emit('update:modelValue', '')
|
||||
emit('change', '')
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.style-picker-wrap {
|
||||
display: inline-block;
|
||||
}
|
||||
.style-picker-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color-blank);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
user-select: none;
|
||||
min-width: 150px;
|
||||
transition: border-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
.style-picker-trigger:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
.style-picker-trigger.has-value {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.spt-swatch {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.spt-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.spt-placeholder {
|
||||
flex: 1;
|
||||
}
|
||||
.spt-arrow {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.spt-clear {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.spt-clear:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
/* 弹窗内部 */
|
||||
.spd-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.spd-selected-hint {
|
||||
font-size: 13px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.spd-body {
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
}
|
||||
.spd-group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-secondary);
|
||||
padding: 6px 0 8px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.spd-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.spd-item {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.15s, transform 0.1s;
|
||||
position: relative;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
.spd-item:hover {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.spd-item.is-active {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
.spd-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 3/4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.spd-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.spd-thumb-text {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.spd-name {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 4px 4px 5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.spd-check {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
.spd-empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.style-picker-dialog .el-dialog__body {
|
||||
padding: 16px 24px 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,717 @@
|
||||
<template>
|
||||
<div class="omni-at-wrap" ref="wrapRef">
|
||||
<div
|
||||
ref="editorRef"
|
||||
class="omni-at-editor"
|
||||
contenteditable="true"
|
||||
spellcheck="false"
|
||||
data-placeholder="输入 @ 选择素材;编辑区显示 @场景名 / @角色名,保存与提交仍为 @图片N"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown"
|
||||
@paste="onPaste"
|
||||
@compositionstart="composing = true"
|
||||
@compositionend="composing = false"
|
||||
/>
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-show="menuOpen"
|
||||
class="omni-at-menu"
|
||||
:style="menuStyle"
|
||||
role="listbox"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<div v-if="!slots.length" class="omni-at-menu-empty">当前没有可用的参考图(请为场景 / 角色 / 物品选择带图素材)</div>
|
||||
<button
|
||||
v-for="s in slots"
|
||||
:key="s.index"
|
||||
type="button"
|
||||
class="omni-at-menu-item"
|
||||
role="option"
|
||||
@click="onPickSlot(s.index)"
|
||||
>
|
||||
<span class="omni-at-menu-thumb-wrap">
|
||||
<img v-if="s.thumbUrl" :src="s.thumbUrl" class="omni-at-menu-thumb" alt="" />
|
||||
<span v-else class="omni-at-menu-thumb-ph">{{ (s.name || '?')[0] }}</span>
|
||||
</span>
|
||||
<span class="omni-at-menu-meta">
|
||||
<span class="omni-at-menu-tag" :class="'omni-at-menu-tag--' + s.kind">{{ kindLabel(s.kind) }}</span>
|
||||
<span class="omni-at-menu-name">{{ s.name }}</span>
|
||||
<span class="omni-at-menu-at">{{ menuPrimaryAt(s) }}</span>
|
||||
<span class="omni-at-menu-at-sub">提交 {{ canonicalAt(s.index) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</teleport>
|
||||
<div class="omni-at-footer">
|
||||
<el-tooltip content="复制为 @图片N 格式(与提交视频一致)" placement="top">
|
||||
<el-button type="default" text size="small" class="omni-at-copy-btn" @click="onCopyCanonical">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
复制提示词
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { DocumentCopy } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
/** { index: number, kind: 'scene'|'character'|'prop', name: string, thumbUrl: string }[] */
|
||||
slots: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'blur'])
|
||||
|
||||
const wrapRef = ref(null)
|
||||
const editorRef = ref(null)
|
||||
const menuOpen = ref(false)
|
||||
const menuStyle = ref({ top: '0px', left: '0px' })
|
||||
const composing = ref(false)
|
||||
|
||||
/** 'insert' at lone @ | 'replace' chip */
|
||||
let menuMode = 'insert'
|
||||
let insertAtOffset = 0
|
||||
let replaceChipEl = null
|
||||
|
||||
let skipNextModelWatch = false
|
||||
|
||||
const CHIP_CLASS = 'omni-at-chip'
|
||||
|
||||
function kindLabel(kind) {
|
||||
if (kind === 'scene') return '场景'
|
||||
if (kind === 'character') return '角色'
|
||||
if (kind === 'prop') return '物品'
|
||||
return '参考'
|
||||
}
|
||||
|
||||
function slotByIndex(index) {
|
||||
const list = props.slots || []
|
||||
return list.find((s) => Number(s.index) === Number(index))
|
||||
}
|
||||
|
||||
function canonicalAt(index) {
|
||||
const n = Number(index)
|
||||
if (!Number.isFinite(n) || n < 1) return '@图片1'
|
||||
return `@图片${n}`
|
||||
}
|
||||
|
||||
/** 编辑区展示用 @token;与存库/提交的 @图片N 一一对应 */
|
||||
function makeDisplayAtToken(index) {
|
||||
const n = Number(index)
|
||||
const slot = slotByIndex(n)
|
||||
const name = slot && slot.name != null ? String(slot.name).trim() : ''
|
||||
if (!name) return canonicalAt(n)
|
||||
const list = props.slots || []
|
||||
const dup = list.filter((x) => String(x.name || '').trim() === name).length > 1
|
||||
if (!dup) return `@${name}`
|
||||
const prefix = slot.kind === 'scene' ? '场景' : slot.kind === 'prop' ? '物品' : '角色'
|
||||
return `@${prefix}·${name}`
|
||||
}
|
||||
|
||||
function menuPrimaryAt(s) {
|
||||
return makeDisplayAtToken(s.index)
|
||||
}
|
||||
|
||||
function applyPlainTextToEditor(el, text) {
|
||||
if (!el) return
|
||||
const raw = text == null ? '' : String(text)
|
||||
el.innerHTML = ''
|
||||
if (!raw) {
|
||||
el.appendChild(document.createTextNode(''))
|
||||
return
|
||||
}
|
||||
const re = /@图片(\d+)/g
|
||||
let last = 0
|
||||
let m
|
||||
while ((m = re.exec(raw)) !== null) {
|
||||
if (m.index > last) el.appendChild(document.createTextNode(raw.slice(last, m.index)))
|
||||
const span = document.createElement('span')
|
||||
span.className = CHIP_CLASS
|
||||
span.contentEditable = 'false'
|
||||
span.dataset.n = m[1]
|
||||
const disp = makeDisplayAtToken(m[1])
|
||||
span.textContent = disp
|
||||
span.setAttribute('role', 'button')
|
||||
span.setAttribute('tabindex', '0')
|
||||
span.setAttribute('aria-label', `${disp}(提交为 ${canonicalAt(m[1])}),点击可更换`)
|
||||
span.addEventListener('mousedown', onChipMouseDown)
|
||||
span.addEventListener('click', onChipClick)
|
||||
el.appendChild(span)
|
||||
last = m.index + m[0].length
|
||||
}
|
||||
if (last < raw.length) el.appendChild(document.createTextNode(raw.slice(last)))
|
||||
}
|
||||
|
||||
/** 规范串:仅含 @图片N,供 v-model / 存库 / 提交视频 / 复制 */
|
||||
function serializeEditor(el) {
|
||||
if (!el) return ''
|
||||
let out = ''
|
||||
function walk(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
out += node.nodeValue || ''
|
||||
return
|
||||
}
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (node.classList?.contains(CHIP_CLASS)) {
|
||||
out += canonicalAt(node.dataset?.n)
|
||||
return
|
||||
}
|
||||
for (const c of node.childNodes) walk(c)
|
||||
}
|
||||
}
|
||||
walk(el)
|
||||
return out.replace(/\u00a0/g, ' ')
|
||||
}
|
||||
|
||||
function chipCanonicalLength(node) {
|
||||
if (!node?.classList?.contains(CHIP_CLASS)) return 0
|
||||
return canonicalAt(node.dataset?.n).length
|
||||
}
|
||||
|
||||
/** 光标在「规范串」中的偏移(与 serializeEditor 一致) */
|
||||
function getCaretCanonicalOffset(el) {
|
||||
const win = el?.ownerDocument?.defaultView || window
|
||||
const sel = win.getSelection()
|
||||
if (!sel || sel.rangeCount === 0 || !el) return 0
|
||||
const range = sel.getRangeAt(0)
|
||||
const r = el.ownerDocument.createRange()
|
||||
r.selectNodeContents(el)
|
||||
r.setEnd(range.endContainer, range.endOffset)
|
||||
let len = 0
|
||||
function measure(n) {
|
||||
if (n.nodeType === Node.TEXT_NODE) len += (n.textContent || '').length
|
||||
else if (n.nodeType === Node.ELEMENT_NODE) {
|
||||
if (n.classList?.contains(CHIP_CLASS)) len += chipCanonicalLength(n)
|
||||
else n.childNodes.forEach(measure)
|
||||
}
|
||||
}
|
||||
const frag = r.cloneContents()
|
||||
frag.childNodes.forEach(measure)
|
||||
return len
|
||||
}
|
||||
|
||||
function setCaretCanonicalOffset(el, target) {
|
||||
if (!el || target < 0) return
|
||||
const doc = el.ownerDocument
|
||||
const sel = (doc.defaultView || window).getSelection()
|
||||
const range = doc.createRange()
|
||||
let seen = 0
|
||||
let placed = false
|
||||
|
||||
function walk(node) {
|
||||
if (placed) return
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const L = (node.nodeValue || '').length
|
||||
if (seen + L >= target) {
|
||||
range.setStart(node, Math.min(target - seen, L))
|
||||
range.collapse(true)
|
||||
placed = true
|
||||
return
|
||||
}
|
||||
seen += L
|
||||
return
|
||||
}
|
||||
if (node.nodeType === Node.ELEMENT_NODE && node.classList?.contains(CHIP_CLASS)) {
|
||||
const L = chipCanonicalLength(node)
|
||||
if (seen + L >= target) {
|
||||
if (target <= seen) range.setStartBefore(node)
|
||||
else range.setStartAfter(node)
|
||||
range.collapse(true)
|
||||
placed = true
|
||||
return
|
||||
}
|
||||
seen += L
|
||||
return
|
||||
}
|
||||
for (const c of node.childNodes) walk(c)
|
||||
}
|
||||
|
||||
for (const c of el.childNodes) walk(c)
|
||||
if (!placed) {
|
||||
range.selectNodeContents(el)
|
||||
range.collapse(false)
|
||||
}
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
}
|
||||
|
||||
function positionMenuNearRect(rect) {
|
||||
const pad = 4
|
||||
const w = 280
|
||||
const maxH = 320
|
||||
let top = rect.bottom + pad + window.scrollY
|
||||
let left = rect.left + window.scrollX
|
||||
const vw = window.innerWidth
|
||||
if (left + w > vw - 8) left = Math.max(8, vw - w - 8)
|
||||
if (top + maxH > window.innerHeight + window.scrollY - 8) {
|
||||
top = rect.top + window.scrollY - maxH - pad
|
||||
}
|
||||
menuStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
minWidth: `${w}px`,
|
||||
maxHeight: `${maxH}px`,
|
||||
}
|
||||
}
|
||||
|
||||
function positionMenuAtCaret() {
|
||||
const el = editorRef.value
|
||||
if (!el) return
|
||||
const sel = window.getSelection()
|
||||
if (!sel || sel.rangeCount === 0) {
|
||||
const r = el.getBoundingClientRect()
|
||||
positionMenuNearRect({ left: r.left, top: r.top, bottom: r.top + 24, right: r.right })
|
||||
return
|
||||
}
|
||||
const range = sel.getRangeAt(0).cloneRange()
|
||||
range.collapse(true)
|
||||
const rects = range.getClientRects()
|
||||
const rect = rects.length ? rects[0] : range.getBoundingClientRect()
|
||||
positionMenuNearRect(rect)
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuOpen.value = false
|
||||
menuMode = 'insert'
|
||||
replaceChipEl = null
|
||||
}
|
||||
|
||||
function maybeOpenAtMenu() {
|
||||
if (composing.value) return
|
||||
const el = editorRef.value
|
||||
if (!el) return
|
||||
const s = serializeEditor(el)
|
||||
const off = getCaretCanonicalOffset(el)
|
||||
if (off < 1 || s[off - 1] !== '@') return
|
||||
const before = s.slice(0, off)
|
||||
if (/@图片\d+$/.test(before)) return
|
||||
if (before.endsWith('@@')) return
|
||||
insertAtOffset = off
|
||||
menuMode = 'insert'
|
||||
replaceChipEl = null
|
||||
nextTick(() => {
|
||||
positionMenuAtCaret()
|
||||
menuOpen.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
const el = editorRef.value
|
||||
if (!el) return
|
||||
const next = serializeEditor(el)
|
||||
skipNextModelWatch = true
|
||||
emit('update:modelValue', next)
|
||||
maybeOpenAtMenu()
|
||||
}
|
||||
|
||||
function onBlur(e) {
|
||||
const rel = e.relatedTarget
|
||||
if (rel && rel.closest?.('.omni-at-menu')) return
|
||||
closeMenu()
|
||||
emit('blur', e)
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Escape' && menuOpen.value) {
|
||||
e.preventDefault()
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
if (menuOpen.value && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function onPaste(e) {
|
||||
e.preventDefault()
|
||||
const text = e.clipboardData?.getData('text/plain') ?? ''
|
||||
try {
|
||||
document.execCommand('insertText', false, text)
|
||||
} catch (_) {
|
||||
const sel = window.getSelection()
|
||||
if (!sel?.rangeCount) return
|
||||
const range = sel.getRangeAt(0)
|
||||
range.deleteContents()
|
||||
range.insertNode(document.createTextNode(text))
|
||||
range.collapse(false)
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
}
|
||||
onInput()
|
||||
}
|
||||
|
||||
function onChipMouseDown(e) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onChipClick(e) {
|
||||
const chip = e.currentTarget
|
||||
if (!(chip instanceof HTMLElement) || !chip.classList.contains(CHIP_CLASS)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
editorRef.value?.focus()
|
||||
menuMode = 'replace'
|
||||
replaceChipEl = chip
|
||||
const r = chip.getBoundingClientRect()
|
||||
positionMenuNearRect(r)
|
||||
menuOpen.value = true
|
||||
}
|
||||
|
||||
function onPickSlot(index) {
|
||||
const el = editorRef.value
|
||||
if (!el) return
|
||||
if (menuMode === 'replace' && replaceChipEl) {
|
||||
replaceChipEl.dataset.n = String(index)
|
||||
const disp = makeDisplayAtToken(index)
|
||||
replaceChipEl.textContent = disp
|
||||
replaceChipEl.setAttribute('aria-label', `${disp}(提交为 ${canonicalAt(index)}),点击可更换`)
|
||||
const next = serializeEditor(el)
|
||||
skipNextModelWatch = true
|
||||
emit('update:modelValue', next)
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
const s = serializeEditor(el)
|
||||
const at = Math.max(1, insertAtOffset)
|
||||
if (s[at - 1] !== '@') {
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
const newS = s.slice(0, at - 1) + `@图片${index}` + s.slice(at)
|
||||
applyPlainTextToEditor(el, newS)
|
||||
const next = serializeEditor(el)
|
||||
skipNextModelWatch = true
|
||||
emit('update:modelValue', next)
|
||||
nextTick(() => {
|
||||
const pos = at - 1 + (`@图片${index}`).length
|
||||
setCaretCanonicalOffset(el, pos)
|
||||
el.focus()
|
||||
})
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (skipNextModelWatch) {
|
||||
skipNextModelWatch = false
|
||||
return
|
||||
}
|
||||
const el = editorRef.value
|
||||
if (!el) return
|
||||
const next = v == null ? '' : String(v)
|
||||
const cur = serializeEditor(el)
|
||||
if (cur === next) return
|
||||
const hadFocus = document.activeElement === el
|
||||
applyPlainTextToEditor(el, next)
|
||||
if (hadFocus) {
|
||||
setCaretCanonicalOffset(el, next.length)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.slots,
|
||||
() => {
|
||||
const el = editorRef.value
|
||||
if (!el) return
|
||||
el.querySelectorAll(`.${CHIP_CLASS}`).forEach((chip) => {
|
||||
if (!(chip instanceof HTMLElement)) return
|
||||
const n = chip.dataset?.n
|
||||
if (n == null) return
|
||||
const disp = makeDisplayAtToken(n)
|
||||
chip.textContent = disp
|
||||
chip.setAttribute('aria-label', `${disp}(提交为 ${canonicalAt(n)}),点击可更换`)
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function onDocClick(ev) {
|
||||
if (!menuOpen.value) return
|
||||
const t = ev.target
|
||||
if (wrapRef.value?.contains(t)) return
|
||||
if (t.closest?.('.omni-at-menu')) return
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
async function onCopyCanonical() {
|
||||
const el = editorRef.value
|
||||
const text = serializeEditor(el)
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已复制(@图片N 格式,与提交一致)')
|
||||
} catch (_) {
|
||||
try {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
ElMessage.success('已复制(@图片N 格式)')
|
||||
} catch (e2) {
|
||||
ElMessage.error(e2?.message || '复制失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = editorRef.value
|
||||
if (el) applyPlainTextToEditor(el, props.modelValue == null ? '' : String(props.modelValue))
|
||||
document.addEventListener('click', onDocClick, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocClick, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.omni-at-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.omni-at-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 6px 2px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.omni-at-copy-btn {
|
||||
color: #a78bfa !important;
|
||||
}
|
||||
.omni-at-copy-btn:hover {
|
||||
color: #c4b5fd !important;
|
||||
}
|
||||
html.light .omni-at-copy-btn {
|
||||
color: #6d28d9 !important;
|
||||
}
|
||||
.omni-at-editor {
|
||||
flex: 1;
|
||||
min-height: 220px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 11px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #e5e7eb;
|
||||
background: var(--omni-editor-bg, #141414);
|
||||
border: 1px solid #4c4d4f;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.omni-at-editor:focus {
|
||||
border-color: #a78bfa;
|
||||
box-shadow: 0 0 0 1px rgba(167, 139, 250, 0.25) inset;
|
||||
}
|
||||
.omni-at-editor:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #6b7280;
|
||||
pointer-events: none;
|
||||
}
|
||||
:deep(.omni-at-chip) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: baseline;
|
||||
margin: 0 1px;
|
||||
padding: 0 5px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
color: #c4b5fd;
|
||||
background: rgba(139, 92, 246, 0.22);
|
||||
border: 1px solid rgba(167, 139, 250, 0.45);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
:deep(.omni-at-chip:hover) {
|
||||
background: rgba(139, 92, 246, 0.38);
|
||||
border-color: #a78bfa;
|
||||
}
|
||||
html.light .omni-at-editor {
|
||||
color: #1f2937;
|
||||
background: var(--el-fill-color-blank, #fff);
|
||||
border-color: var(--el-border-color, #dcdfe6);
|
||||
}
|
||||
html.light .omni-at-editor:focus {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.2) inset;
|
||||
}
|
||||
html.light :deep(.omni-at-chip) {
|
||||
color: #5b21b6;
|
||||
background: rgba(124, 58, 237, 0.12);
|
||||
border-color: rgba(124, 58, 237, 0.35);
|
||||
}
|
||||
html.light :deep(.omni-at-chip:hover) {
|
||||
background: rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.omni-at-menu {
|
||||
position: absolute;
|
||||
z-index: 5000;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(248, 250, 252, 0.18);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
html.light .omni-at-menu {
|
||||
background: #fff;
|
||||
border-color: #e2e8f0;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
.omni-at-menu-empty {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
padding: 8px 6px;
|
||||
max-width: 260px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
html.light .omni-at-menu-empty {
|
||||
color: #64748b;
|
||||
}
|
||||
.omni-at-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
margin: 0 0 6px;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #f1f5f9;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.omni-at-menu-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.omni-at-menu-item:hover {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
html.light .omni-at-menu-item {
|
||||
color: #0f172a;
|
||||
}
|
||||
html.light .omni-at-menu-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.omni-at-menu-thumb-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #0f172a;
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
html.light .omni-at-menu-thumb-wrap {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
.omni-at-menu-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.omni-at-menu-thumb-ph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
.omni-at-menu-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.omni-at-menu-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
width: fit-content;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.omni-at-menu-tag--scene {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #86efac;
|
||||
}
|
||||
.omni-at-menu-tag--character {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #93c5fd;
|
||||
}
|
||||
.omni-at-menu-tag--prop {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #fcd34d;
|
||||
}
|
||||
html.light .omni-at-menu-tag {
|
||||
color: #475569;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
html.light .omni-at-menu-tag--scene {
|
||||
color: #166534;
|
||||
background: #dcfce7;
|
||||
}
|
||||
html.light .omni-at-menu-tag--character {
|
||||
color: #1e40af;
|
||||
background: #dbeafe;
|
||||
}
|
||||
html.light .omni-at-menu-tag--prop {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
}
|
||||
.omni-at-menu-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #e2e8f0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
html.light .omni-at-menu-name {
|
||||
color: #334155;
|
||||
}
|
||||
.omni-at-menu-at {
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: #a78bfa;
|
||||
}
|
||||
.omni-at-menu-at-sub {
|
||||
font-size: 10px;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: #94a3b8;
|
||||
}
|
||||
html.light .omni-at-menu-at {
|
||||
color: #6d28d9;
|
||||
}
|
||||
html.light .omni-at-menu-at-sub {
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="canvas-node-stack">
|
||||
<div class="canvas-asset-node" :class="['kind-' + data.kind, { highlighted: data.highlighted, dimmed: data.dimmed, focused: showPanel }]">
|
||||
<Handle type="source" :position="Position.Right" />
|
||||
<div class="cover">
|
||||
<img v-if="thumbUrl" :src="thumbUrl" alt="" />
|
||||
<div v-else class="cover-placeholder">{{ kindIcon }}</div>
|
||||
</div>
|
||||
<div class="info">
|
||||
<div class="name">{{ displayName }}</div>
|
||||
<div class="kind">{{ kindLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<CanvasAssetPanel
|
||||
v-if="showPanel"
|
||||
:kind="data.kind"
|
||||
:entity="data.entity"
|
||||
:node-id="id"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { assetImageUrl } from '@/utils/mediaUrl'
|
||||
import { useCanvasContext } from '@/composables/useCanvasContext'
|
||||
import CanvasAssetPanel from './CanvasAssetPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, required: true },
|
||||
data: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const ctx = useCanvasContext()
|
||||
const showPanel = computed(() => ctx?.focusedNodeId?.value === props.id)
|
||||
|
||||
const kindLabel = computed(() => {
|
||||
const map = { character: '角色', scene: '场景', prop: '道具' }
|
||||
return map[props.data.kind] || '素材'
|
||||
})
|
||||
|
||||
const kindIcon = computed(() => {
|
||||
const map = { character: '👤', scene: '🏞', prop: '🎭' }
|
||||
return map[props.data.kind] || '📦'
|
||||
})
|
||||
|
||||
const displayName = computed(() => {
|
||||
const e = props.data.entity || {}
|
||||
return e.name || e.location || '未命名'
|
||||
})
|
||||
|
||||
const thumbUrl = computed(() => assetImageUrl(props.data.entity))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.canvas-node-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.canvas-asset-node {
|
||||
width: 176px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-muted, #3f3f46);
|
||||
background: var(--bg-card, #18181b);
|
||||
box-shadow: var(--shadow, 0 4px 16px rgba(0, 0, 0, 0.35));
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.canvas-asset-node.focused {
|
||||
border-color: #34d399;
|
||||
box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.45), 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.cover {
|
||||
height: 108px;
|
||||
background: #09090b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.cover-placeholder {
|
||||
font-size: 28px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.info {
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
.name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright, #fafafa);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.kind {
|
||||
font-size: 11px;
|
||||
color: var(--text-subtle, #71717a);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.kind-character { border-color: rgba(52, 211, 153, 0.45); }
|
||||
.kind-scene { border-color: rgba(96, 165, 250, 0.45); }
|
||||
.kind-prop { border-color: rgba(251, 191, 36, 0.45); }
|
||||
.highlighted {
|
||||
box-shadow: 0 0 0 2px rgba(52, 211, 153, 0.65), 0 8px 24px rgba(52, 211, 153, 0.2);
|
||||
}
|
||||
.dimmed {
|
||||
opacity: 0.28;
|
||||
filter: grayscale(0.35);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="canvas-node-panel nodrag nopan nowheel" :class="'kind-' + kind" @pointerdown.stop @mousedown.stop>
|
||||
<div class="panel-head">{{ kindLabel }}</div>
|
||||
<div class="name">{{ displayName }}</div>
|
||||
<p v-if="description" class="desc">{{ description }}</p>
|
||||
<div class="panel-actions">
|
||||
<el-button v-if="canGenerate" size="small" type="primary" :loading="generating" @click="generateImage">
|
||||
生成参考图
|
||||
</el-button>
|
||||
<el-button size="small" plain @click="highlightRelated">查看关联分镜</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { characterAPI } from '@/api/characters'
|
||||
import { sceneAPI } from '@/api/scenes'
|
||||
import { propAPI } from '@/api/props'
|
||||
import { useCanvasContext } from '@/composables/useCanvasContext'
|
||||
import { assetImageUrl } from '@/utils/mediaUrl'
|
||||
|
||||
const props = defineProps({
|
||||
kind: { type: String, required: true },
|
||||
entity: { type: Object, required: true },
|
||||
nodeId: { type: String, required: true },
|
||||
})
|
||||
|
||||
const ctx = useCanvasContext()
|
||||
const generating = ref(false)
|
||||
|
||||
const kindLabel = computed(() => {
|
||||
const map = { character: '角色素材', scene: '场景素材', prop: '道具素材' }
|
||||
return map[props.kind] || '素材'
|
||||
})
|
||||
|
||||
const displayName = computed(() => props.entity?.name || props.entity?.location || '未命名')
|
||||
|
||||
const description = computed(() => {
|
||||
const e = props.entity || {}
|
||||
return (e.description || e.appearance || e.prompt || '').toString().trim()
|
||||
})
|
||||
|
||||
const canGenerate = computed(() => !assetImageUrl(props.entity))
|
||||
|
||||
async function generateImage() {
|
||||
generating.value = true
|
||||
try {
|
||||
if (props.kind === 'character') {
|
||||
await characterAPI.generateImage(props.entity.id)
|
||||
} else if (props.kind === 'scene') {
|
||||
await sceneAPI.generateImage({ scene_id: props.entity.id, drama_id: ctx?.drama?.value?.id })
|
||||
} else if (props.kind === 'prop') {
|
||||
await propAPI.generateImage(props.entity.id)
|
||||
}
|
||||
ElMessage.success('已提交生成任务')
|
||||
await ctx?.refresh?.()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '生成失败')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function highlightRelated() {
|
||||
ctx?.setHighlightAsset?.(props.nodeId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.canvas-node-panel {
|
||||
margin-top: 10px;
|
||||
width: 200px;
|
||||
padding: 10px 12px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(52, 211, 153, 0.4);
|
||||
background: rgba(15, 15, 18, 0.96);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.panel-head {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #6ee7b7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fafafa;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.desc {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: #a1a1aa;
|
||||
max-height: 60px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.panel-actions :deep(.el-button) {
|
||||
margin: 0;
|
||||
}
|
||||
.kind-scene { border-color: rgba(96, 165, 250, 0.45); }
|
||||
.kind-prop { border-color: rgba(251, 191, 36, 0.45); }
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="canvas-drama-header">
|
||||
<div class="title">{{ data.drama?.title || '未命名项目' }}</div>
|
||||
<div class="meta">
|
||||
<span v-if="data.drama?.style">风格 {{ data.drama.style }}</span>
|
||||
<span>{{ (data.drama?.episodes || []).length }} 集</span>
|
||||
<span>{{ assetCount }} 素材</span>
|
||||
<span>{{ storyboardCount }} 分镜</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const assetCount = computed(() => {
|
||||
const d = props.data.drama || {}
|
||||
return (d.characters?.length || 0) + (d.scenes?.length || 0) + (d.props?.length || 0)
|
||||
})
|
||||
|
||||
const storyboardCount = computed(() =>
|
||||
(props.data.drama?.episodes || []).reduce((n, ep) => n + (ep.storyboards?.length || 0), 0)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.canvas-drama-header {
|
||||
min-width: 280px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(129, 140, 248, 0.45);
|
||||
background: linear-gradient(135deg, rgba(49, 46, 129, 0.55), rgba(24, 24, 27, 0.92));
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #f4f4f5;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="canvas-episode-node">
|
||||
<Handle type="target" :position="Position.Left" />
|
||||
<span class="badge">第 {{ data.episode?.episode_number ?? '?' }} 集</span>
|
||||
<span class="title">{{ data.episode?.title || '未命名集' }}</span>
|
||||
<span class="count">{{ (data.episode?.storyboards || []).length }} 镜</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
data: { type: Object, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.canvas-episode-node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(167, 139, 250, 0.5);
|
||||
background: rgba(76, 29, 149, 0.35);
|
||||
color: #e9d5ff;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge {
|
||||
font-weight: 700;
|
||||
}
|
||||
.title {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.count {
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="canvas-label-node">{{ data.label }}</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
data: { type: Object, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.canvas-label-node {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #a1a1aa);
|
||||
letter-spacing: 0.02em;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="canvas-node-stack">
|
||||
<div class="canvas-media-node" :class="['kind-' + data.kind, { highlighted: data.highlighted, dimmed: data.dimmed, focused: showPanel }]">
|
||||
<Handle type="target" :position="Position.Left" />
|
||||
<Handle v-if="data.kind !== 'video' && data.kind !== 'audio'" type="source" :position="Position.Right" />
|
||||
<div class="tag">{{ kindLabel }}</div>
|
||||
<template v-if="data.kind === 'text'">
|
||||
<p class="text-body">{{ data.summary || '暂无文本' }}</p>
|
||||
</template>
|
||||
<template v-else-if="data.kind === 'universal'">
|
||||
<p class="text-body universal-body">{{ data.summary || '暂无全能分镜词' }}</p>
|
||||
</template>
|
||||
<template v-else-if="data.kind === 'image'">
|
||||
<img v-if="data.url" :src="data.url" alt="" class="media-img" />
|
||||
<div v-else class="empty">无分镜图</div>
|
||||
</template>
|
||||
<template v-else-if="data.kind === 'video'">
|
||||
<video v-if="data.url" :src="data.url" class="media-vid" muted playsinline />
|
||||
<div v-else class="empty">无视频</div>
|
||||
</template>
|
||||
<template v-else-if="data.kind === 'audio'">
|
||||
<div class="audio-wrap">
|
||||
<span>🎵</span>
|
||||
<span>{{ data.audioType === 'narration' ? '旁白' : '对白' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<CanvasMediaPanel
|
||||
v-if="showPanel"
|
||||
:kind="data.kind"
|
||||
:storyboard="data.storyboard"
|
||||
:summary="data.summary"
|
||||
:url="data.url"
|
||||
:audio-type="data.audioType"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { useCanvasContext } from '@/composables/useCanvasContext'
|
||||
import CanvasMediaPanel from './CanvasMediaPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, required: true },
|
||||
data: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const ctx = useCanvasContext()
|
||||
const showPanel = computed(() => ctx?.focusedNodeId?.value === props.id)
|
||||
|
||||
const kindLabel = computed(() => {
|
||||
if (props.data.frameLabel) return props.data.frameLabel
|
||||
const map = { text: '文本', universal: '全能分镜词', image: '分镜图', video: '视频', audio: '音频' }
|
||||
return map[props.data.kind] || props.data.kind
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.canvas-node-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.canvas-media-node {
|
||||
width: 168px;
|
||||
min-height: 100px;
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-muted, #3f3f46);
|
||||
background: rgba(24, 24, 27, 0.95);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.canvas-media-node.focused {
|
||||
border-color: #818cf8;
|
||||
box-shadow: 0 0 0 1px rgba(129, 140, 248, 0.35);
|
||||
}
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #818cf8;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.text-body {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: #d4d4d8;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 5;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.media-img {
|
||||
width: 100%;
|
||||
height: 92px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: #09090b;
|
||||
}
|
||||
.media-vid {
|
||||
width: 100%;
|
||||
height: 92px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: #000;
|
||||
}
|
||||
.audio-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px 8px;
|
||||
font-size: 12px;
|
||||
color: #fbbf24;
|
||||
}
|
||||
.empty {
|
||||
font-size: 11px;
|
||||
color: #71717a;
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.universal-body {
|
||||
-webkit-line-clamp: 8;
|
||||
}
|
||||
.kind-universal { border-color: rgba(167, 139, 250, 0.5); }
|
||||
.kind-universal .tag { color: #c4b5fd; }
|
||||
.kind-image { border-color: rgba(129, 140, 248, 0.4); }
|
||||
.kind-video { border-color: rgba(244, 114, 182, 0.4); }
|
||||
.kind-audio { border-color: rgba(251, 191, 36, 0.4); }
|
||||
.highlighted { box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.55); }
|
||||
.dimmed { opacity: 0.28; }
|
||||
</style>
|
||||
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div class="canvas-node-panel nodrag nopan nowheel" :class="'kind-' + kind" @pointerdown.stop @mousedown.stop>
|
||||
<div class="panel-head">{{ kindTitle }}</div>
|
||||
|
||||
<template v-if="kind === 'text'">
|
||||
<p class="summary">{{ summary }}</p>
|
||||
<el-button size="small" type="primary" plain @click="focusStoryboard">编辑分镜文案</el-button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="kind === 'universal'">
|
||||
<p class="summary universal-summary">{{ summary || '暂无全能分镜词' }}</p>
|
||||
<div class="panel-actions">
|
||||
<el-button size="small" plain @click="focusStoryboard">编辑全能词</el-button>
|
||||
<el-button size="small" type="primary" :loading="busy" @click="runStep('video')">重新生视频</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="kind === 'image'">
|
||||
<img v-if="url" :src="url" alt="" class="preview-img" />
|
||||
<el-button size="small" type="primary" :loading="busy" @click="runStep('image')">重新生图</el-button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="kind === 'video'">
|
||||
<video v-if="url" :src="url" class="preview-vid" controls playsinline />
|
||||
<el-button size="small" type="primary" :loading="busy" @click="runStep('video')">重新生视频</el-button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="kind === 'audio'">
|
||||
<div class="audio-label">{{ audioType === 'narration' ? '旁白音频' : '对白音频' }}</div>
|
||||
<audio v-if="url" :src="url" controls class="preview-aud" />
|
||||
<el-button size="small" type="warning" :loading="busy" @click="runStep('audio')">重新配音</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useCanvasContext } from '@/composables/useCanvasContext'
|
||||
import { runImageStep, runVideoStep, runAudioStep } from '@/composables/useCanvasWorkflowRunner'
|
||||
import { findStoryboardInDrama, getDramaGenerationOptions } from '@/utils/canvasWorkflow'
|
||||
|
||||
const props = defineProps({
|
||||
kind: { type: String, required: true },
|
||||
storyboard: { type: Object, default: null },
|
||||
summary: { type: String, default: '' },
|
||||
url: { type: String, default: '' },
|
||||
audioType: { type: String, default: 'dialogue' },
|
||||
})
|
||||
|
||||
const ctx = useCanvasContext()
|
||||
const busy = ref(false)
|
||||
|
||||
const kindTitle = computed(() => {
|
||||
const map = { text: '文本节点', universal: '全能分镜词', image: '分镜图', video: '视频', audio: '音频' }
|
||||
return map[props.kind] || '媒体'
|
||||
})
|
||||
|
||||
function focusStoryboard() {
|
||||
const sbId = props.storyboard?.id
|
||||
if (sbId) ctx?.setFocusedNode?.(`sb:${sbId}`)
|
||||
}
|
||||
|
||||
async function runStep(step) {
|
||||
const drama = ctx?.drama?.value
|
||||
const sbId = props.storyboard?.id
|
||||
if (!drama || !sbId) return
|
||||
busy.value = true
|
||||
try {
|
||||
const found = findStoryboardInDrama(drama, sbId)
|
||||
const sb = found?.storyboard || props.storyboard
|
||||
const genOpts = ctx?.getGenerationOptions?.() || getDramaGenerationOptions(drama)
|
||||
if (step === 'image') await runImageStep(drama, sb, genOpts)
|
||||
else if (step === 'video') await runVideoStep(drama, sb, genOpts)
|
||||
else if (step === 'audio') {
|
||||
const res = await runAudioStep(sb)
|
||||
if (res?.skipped) {
|
||||
ElMessage.info(res.reason || '已跳过')
|
||||
return
|
||||
}
|
||||
}
|
||||
ElMessage.success('生成完成')
|
||||
await ctx?.refresh?.()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '生成失败')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.canvas-node-panel {
|
||||
margin-top: 10px;
|
||||
width: 220px;
|
||||
padding: 10px 12px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(129, 140, 248, 0.4);
|
||||
background: rgba(15, 15, 18, 0.96);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.panel-head {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #a5b4fc;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.summary {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: #d4d4d8;
|
||||
max-height: 88px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.preview-img,
|
||||
.preview-vid {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
background: #09090b;
|
||||
}
|
||||
.preview-img { height: 100px; object-fit: cover; }
|
||||
.preview-vid { height: 100px; object-fit: cover; }
|
||||
.preview-aud {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.audio-label {
|
||||
font-size: 11px;
|
||||
color: #fbbf24;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.kind-video { border-color: rgba(244, 114, 182, 0.45); }
|
||||
.kind-universal { border-color: rgba(167, 139, 250, 0.45); }
|
||||
.universal-summary { max-height: 160px; }
|
||||
.kind-audio { border-color: rgba(251, 191, 36, 0.45); }
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="canvas-node-stack">
|
||||
<div class="canvas-sb-node" :class="{ selected: selected, highlighted: data.highlighted, dimmed: data.dimmed, processing: isProcessing, focused: showPanel }">
|
||||
<Handle id="chain-in" type="target" :position="Position.Top" />
|
||||
<Handle type="target" :position="Position.Left" />
|
||||
<Handle type="source" :position="Position.Right" />
|
||||
<Handle id="chain-out" type="source" :position="Position.Bottom" />
|
||||
<div class="head">
|
||||
<span class="num">#{{ data.storyboard?.storyboard_number ?? data.index }}</span>
|
||||
<span v-if="data.workflowGroup?.title" class="wf-badge">{{ data.workflowGroup.title }}</span>
|
||||
<span v-if="data.storyboard?.segment_title" class="seg">{{ data.storyboard.segment_title }}</span>
|
||||
<span v-if="data.storyboard?.creation_mode === 'universal'" class="mode-badge">全能</span>
|
||||
</div>
|
||||
<div class="title">{{ data.storyboard?.title || '分镜' }}</div>
|
||||
<div class="chips">
|
||||
<span v-if="data.storyboard?.shot_type">{{ data.storyboard.shot_type }}</span>
|
||||
<span v-if="data.storyboard?.duration">{{ data.storyboard.duration }}s</span>
|
||||
<span :class="'st-' + (data.storyboard?.status || 'pending')">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<div class="hint">{{ showPanel ? '下方可编辑与生成' : '单击展开操作 · 双击进列表' }}</div>
|
||||
</div>
|
||||
<CanvasStoryboardPanel
|
||||
v-if="showPanel"
|
||||
:storyboard="data.storyboard"
|
||||
:episode-id="data.episodeId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { useCanvasContext } from '@/composables/useCanvasContext'
|
||||
import CanvasStoryboardPanel from './CanvasStoryboardPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, required: true },
|
||||
data: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const ctx = useCanvasContext()
|
||||
const showPanel = computed(() => ctx?.focusedNodeId?.value === props.id)
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const s = props.data.storyboard?.status || 'pending'
|
||||
const map = { pending: '待处理', processing: '生成中', completed: '已完成', failed: '失败' }
|
||||
return map[s] || s
|
||||
})
|
||||
|
||||
const isProcessing = computed(() => props.data.storyboard?.status === 'processing')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.canvas-node-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.canvas-sb-node {
|
||||
width: 200px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(129, 140, 248, 0.35);
|
||||
background: var(--bg-card, #18181b);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.canvas-sb-node:hover,
|
||||
.canvas-sb-node.selected,
|
||||
.canvas-sb-node.focused {
|
||||
border-color: #818cf8;
|
||||
box-shadow: 0 0 0 1px rgba(129, 140, 248, 0.35), 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.num {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #a5b4fc;
|
||||
}
|
||||
.wf-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(251, 191, 36, 0.18);
|
||||
color: #fcd34d;
|
||||
max-width: 88px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.seg {
|
||||
font-size: 10px;
|
||||
color: #71717a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mode-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(167, 139, 250, 0.2);
|
||||
color: #c4b5fd;
|
||||
}
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright, #fafafa);
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.chips span {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #a1a1aa;
|
||||
}
|
||||
.st-completed { color: #34d399 !important; background: rgba(52, 211, 153, 0.12) !important; }
|
||||
.st-processing { color: #60a5fa !important; }
|
||||
.st-failed { color: #f87171 !important; }
|
||||
.processing {
|
||||
animation: sb-pulse 1.4s ease-in-out infinite;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
.highlighted {
|
||||
box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.75), 0 8px 28px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
.dimmed {
|
||||
opacity: 0.28;
|
||||
}
|
||||
@keyframes sb-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(96, 165, 250, 0.35); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(96, 165, 250, 0.08); }
|
||||
}
|
||||
.hint {
|
||||
font-size: 10px;
|
||||
color: #52525b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="canvas-node-panel nodrag nopan nowheel" @pointerdown.stop @mousedown.stop>
|
||||
<div class="panel-head">
|
||||
<span>分镜 #{{ storyboard?.storyboard_number ?? storyboard?.id }}</span>
|
||||
<el-button link size="small" type="primary" @click="openListMode">列表模式</el-button>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top" size="small" class="panel-form">
|
||||
<template v-if="isUniversal">
|
||||
<el-form-item label="全能分镜词">
|
||||
<el-input v-model="form.universal_segment_text" type="textarea" :rows="6" placeholder="全能模式片段描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="视频提示词">
|
||||
<el-input v-model="form.video_prompt" type="textarea" :rows="2" placeholder="生视频用提示词(可选)" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form-item label="动作">
|
||||
<el-input v-model="form.action" type="textarea" :rows="2" placeholder="画面动作描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="对白">
|
||||
<el-input v-model="form.dialogue" type="textarea" :rows="2" placeholder="角色对白" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图片提示词">
|
||||
<el-input v-model="form.image_prompt" type="textarea" :rows="3" placeholder="生图用提示词" />
|
||||
</el-form-item>
|
||||
<el-form-item label="视频提示词">
|
||||
<el-input v-model="form.video_prompt" type="textarea" :rows="2" placeholder="生视频用提示词" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<div class="panel-actions">
|
||||
<el-button size="small" :loading="saving" @click="saveFields">保存</el-button>
|
||||
<el-button v-if="!isUniversal" size="small" :loading="busyStep === 'polish'" @click="polishPrompt">润色提示词</el-button>
|
||||
</div>
|
||||
<div class="panel-actions gen-row">
|
||||
<el-button v-if="!isUniversal" size="small" type="primary" :loading="busyStep === 'image'" @click="runStep('image')">生图</el-button>
|
||||
<el-button size="small" type="primary" :loading="busyStep === 'video'" @click="runStep('video')">生视频</el-button>
|
||||
<el-button size="small" type="warning" :loading="busyStep === 'audio'" @click="runStep('audio')">配音</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { storyboardsAPI } from '@/api/storyboards'
|
||||
import { useCanvasContext } from '@/composables/useCanvasContext'
|
||||
import { runImageStep, runVideoStep, runAudioStep } from '@/composables/useCanvasWorkflowRunner'
|
||||
import { findStoryboardInDrama, getDramaGenerationOptions } from '@/utils/canvasWorkflow'
|
||||
|
||||
const props = defineProps({
|
||||
storyboard: { type: Object, required: true },
|
||||
episodeId: { type: Number, default: null },
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const ctx = useCanvasContext()
|
||||
const saving = ref(false)
|
||||
const busyStep = ref('')
|
||||
const form = reactive({
|
||||
action: '',
|
||||
dialogue: '',
|
||||
image_prompt: '',
|
||||
video_prompt: '',
|
||||
universal_segment_text: '',
|
||||
})
|
||||
|
||||
const isUniversal = computed(() => props.storyboard?.creation_mode === 'universal')
|
||||
|
||||
function syncForm(sb) {
|
||||
form.action = sb?.action || ''
|
||||
form.dialogue = sb?.dialogue || ''
|
||||
form.image_prompt = sb?.image_prompt || sb?.polished_prompt || ''
|
||||
form.video_prompt = sb?.video_prompt || ''
|
||||
form.universal_segment_text = sb?.universal_segment_text || ''
|
||||
}
|
||||
|
||||
watch(() => props.storyboard, (sb) => syncForm(sb), { immediate: true, deep: true })
|
||||
|
||||
function openListMode() {
|
||||
const dramaId = ctx?.drama?.value?.id
|
||||
if (!dramaId) return
|
||||
router.push({
|
||||
path: `/film/${dramaId}`,
|
||||
query: props.episodeId ? { episode: String(props.episodeId) } : {},
|
||||
hash: props.storyboard?.id ? `#sb-${props.storyboard.id}` : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async function persistForm(silent = false) {
|
||||
if (!props.storyboard?.id) return
|
||||
const payload = isUniversal.value
|
||||
? {
|
||||
universal_segment_text: form.universal_segment_text.trim() || null,
|
||||
video_prompt: form.video_prompt.trim() || null,
|
||||
}
|
||||
: {
|
||||
action: form.action.trim() || null,
|
||||
dialogue: form.dialogue.trim() || null,
|
||||
image_prompt: form.image_prompt.trim() || null,
|
||||
video_prompt: form.video_prompt.trim() || null,
|
||||
}
|
||||
await storyboardsAPI.update(props.storyboard.id, payload)
|
||||
if (!silent) ElMessage.success('已保存')
|
||||
}
|
||||
|
||||
async function saveFields() {
|
||||
if (!props.storyboard?.id) return
|
||||
saving.value = true
|
||||
try {
|
||||
await persistForm(false)
|
||||
await ctx?.refresh?.()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function polishPrompt() {
|
||||
if (!props.storyboard?.id) return
|
||||
busyStep.value = 'polish'
|
||||
try {
|
||||
const res = await storyboardsAPI.polishPrompt(props.storyboard.id)
|
||||
if (res?.polished_prompt) form.image_prompt = res.polished_prompt
|
||||
ElMessage.success('提示词已润色')
|
||||
await ctx?.refresh?.()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '润色失败')
|
||||
} finally {
|
||||
busyStep.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function runStep(step) {
|
||||
const drama = ctx?.drama?.value
|
||||
const sbId = props.storyboard?.id
|
||||
if (!drama || !sbId) return
|
||||
|
||||
if (step !== 'audio') {
|
||||
try {
|
||||
await persistForm(true)
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
busyStep.value = step
|
||||
try {
|
||||
const found = findStoryboardInDrama(drama, sbId)
|
||||
const sb = found?.storyboard || props.storyboard
|
||||
const genOpts = ctx?.getGenerationOptions?.() || getDramaGenerationOptions(drama)
|
||||
if (step === 'image') await runImageStep(drama, sb, genOpts)
|
||||
else if (step === 'video') await runVideoStep(drama, sb, genOpts)
|
||||
else if (step === 'audio') {
|
||||
const res = await runAudioStep(sb)
|
||||
if (res?.skipped) {
|
||||
ElMessage.info(res.reason || '已跳过')
|
||||
return
|
||||
}
|
||||
}
|
||||
ElMessage.success(step === 'image' ? '生图完成' : step === 'video' ? '视频生成完成' : '配音完成')
|
||||
await ctx?.refresh?.()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '生成失败')
|
||||
} finally {
|
||||
busyStep.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.canvas-node-panel {
|
||||
margin-top: 10px;
|
||||
width: 280px;
|
||||
padding: 10px 12px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(129, 140, 248, 0.45);
|
||||
background: rgba(15, 15, 18, 0.96);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #c7d2fe;
|
||||
}
|
||||
.panel-form :deep(.el-form-item) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.panel-form :deep(.el-form-item__label) {
|
||||
color: #71717a;
|
||||
font-size: 11px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.gen-row {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(63, 63, 70, 0.8);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function createLibraryMembershipState() {
|
||||
return {
|
||||
dramaSourceIds: ref(new Set()),
|
||||
materialSourceIds: ref(new Set()),
|
||||
}
|
||||
}
|
||||
|
||||
function assetSourceId(asset) {
|
||||
if (asset?.id == null) return ''
|
||||
return String(asset.id).trim()
|
||||
}
|
||||
|
||||
function itemSourceId(item) {
|
||||
if (item?.source_id == null) return ''
|
||||
return String(item.source_id).trim()
|
||||
}
|
||||
|
||||
function chunkArray(items, size) {
|
||||
const chunks = []
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
chunks.push(items.slice(i, i + size))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
async function fetchSourceIds(api, params, ids) {
|
||||
const out = new Set()
|
||||
for (const chunk of chunkArray(ids, 80)) {
|
||||
const res = await api.list({
|
||||
...params,
|
||||
source_ids: chunk.join(','),
|
||||
page_size: chunk.length || 1,
|
||||
})
|
||||
for (const item of res?.items || []) {
|
||||
const id = itemSourceId(item)
|
||||
if (id) out.add(id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function loadLibraryMembership({ api, sourceType, assets, dramaId, dramaSourceIds, materialSourceIds }) {
|
||||
const ids = [...new Set((assets || []).map(assetSourceId).filter(Boolean))]
|
||||
if (ids.length === 0) {
|
||||
dramaSourceIds.value = new Set()
|
||||
materialSourceIds.value = new Set()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const [dramaIds, materialIds] = await Promise.all([
|
||||
dramaId
|
||||
? fetchSourceIds(api, { drama_id: dramaId, source_type: sourceType }, ids)
|
||||
: Promise.resolve(new Set()),
|
||||
fetchSourceIds(api, { global: 1, source_type: sourceType }, ids),
|
||||
])
|
||||
dramaSourceIds.value = dramaIds
|
||||
materialSourceIds.value = materialIds
|
||||
} catch (err) {
|
||||
console.warn('[libraryMembership] load failed:', err?.message || err)
|
||||
dramaSourceIds.value = new Set()
|
||||
materialSourceIds.value = new Set()
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAssetInLibrary(sourceIdsRef, asset) {
|
||||
const id = assetSourceId(asset)
|
||||
return !!id && sourceIdsRef.value.has(id)
|
||||
}
|
||||
|
||||
export function markAssetInLibrary(sourceIdsRef, asset) {
|
||||
const id = assetSourceId(asset)
|
||||
if (!id) return
|
||||
sourceIdsRef.value = new Set([...sourceIdsRef.value, id])
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { characterAPI } from '@/api/characters'
|
||||
import { characterLibraryAPI } from '@/api/characterLibrary'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
import { generationAPI } from '@/api/generation'
|
||||
import { uploadAPI } from '@/api/upload'
|
||||
import { useGenerationTaskStore, GEN_RESOURCE } from '@/stores/generationTaskStore'
|
||||
import { buildExtractTaskMeta, isEpisodeExtractRunning } from '@/composables/useGenerationTaskSync'
|
||||
|
||||
/**
|
||||
* 角色管理 Composable
|
||||
* @param {object} deps - 共享依赖
|
||||
* @param {object} deps.store - Pinia store
|
||||
* @param {import('vue').ComputedRef} deps.dramaId
|
||||
* @param {import('vue').ComputedRef} deps.currentEpisodeId
|
||||
* @param {Function} deps.getSelectedStyle - 获取当前生成风格
|
||||
* @param {Function} deps.loadDrama - 重新加载剧集数据
|
||||
* @param {Function} deps.pollTask - 轮询异步任务
|
||||
* @param {Function} deps.pollUntilResourceHasImage - 等待资源有图片
|
||||
* @param {Function} deps.hasAssetImage - 判断资源是否有图片
|
||||
*/
|
||||
export function useCharacters(deps) {
|
||||
const { store, dramaId, currentEpisodeId, getSelectedStyle, loadDrama, pollTask, pollUntilResourceHasImage, hasAssetImage } = deps
|
||||
const genStore = useGenerationTaskStore()
|
||||
|
||||
function buildCharImageMeta(char) {
|
||||
const dramaTitle = store.drama?.title || ''
|
||||
const epNum = store.currentEpisode?.episode_number
|
||||
const epLabel = dramaTitle ? `${dramaTitle} · 第${epNum ?? ''}集` : `第${epNum ?? ''}集`
|
||||
return {
|
||||
dramaId: dramaId.value,
|
||||
episodeId: currentEpisodeId.value,
|
||||
dramaTitle,
|
||||
episodeNumber: epNum,
|
||||
resourceType: GEN_RESOURCE.CHAR_IMAGE,
|
||||
resourceId: char.id,
|
||||
label: `${epLabel} 角色图: ${char.name || char.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
function dataUrlToFile(dataUrl, filename) {
|
||||
const arr = dataUrl.split(',')
|
||||
const mime = (arr[0].match(/:(.*?);/) || [])[1] || 'image/png'
|
||||
const bstr = atob(arr[1])
|
||||
let n = bstr.length
|
||||
const u8arr = new Uint8Array(n)
|
||||
while (n--) u8arr[n] = bstr.charCodeAt(n)
|
||||
return new File([u8arr], filename || 'reference.png', { type: mime })
|
||||
}
|
||||
|
||||
// ── 角色弹窗状态 ─────────────────────────────────────
|
||||
const showEditCharacter = ref(false)
|
||||
const editCharacterForm = ref(null)
|
||||
const editCharacterSaving = ref(false)
|
||||
const editCharacterPromptGenerating = ref(false)
|
||||
const extractingCharAppearance = ref(false)
|
||||
const extractingAnchors = ref(false)
|
||||
const addCharRefImage = ref(null) // { dataUrl, filename }
|
||||
const addCharRefFileInput = ref(null)
|
||||
let editCharacterPollTimer = null
|
||||
|
||||
// ── 角色生成状态 ──────────────────────────────────────
|
||||
/** 仅当前集「提取角色」进行中时为 true(按集隔离,切集不误显示 loading) */
|
||||
const charactersGenerating = computed(() =>
|
||||
isEpisodeExtractRunning(genStore, dramaId.value, currentEpisodeId.value, GEN_RESOURCE.EXTRACT_CHARACTERS)
|
||||
)
|
||||
const generatingCharIds = reactive(new Set())
|
||||
const sd2CertifyingId = ref(null)
|
||||
const showCharSd2Cert = ref(false)
|
||||
const charSd2CertPayload = ref(null)
|
||||
const sd2VoiceUploadingId = ref(null)
|
||||
|
||||
// ── 角色库状态 ────────────────────────────────────────
|
||||
const showCharLibrary = ref(false)
|
||||
const charLibraryList = ref([])
|
||||
const charLibraryLoading = ref(false)
|
||||
const charLibraryPage = ref(1)
|
||||
const charLibraryPageSize = ref(20)
|
||||
const charLibraryTotal = ref(0)
|
||||
const charLibraryKeyword = ref('')
|
||||
const showEditCharLibrary = ref(false)
|
||||
const editCharLibraryForm = ref(null)
|
||||
const editCharLibrarySaving = ref(false)
|
||||
const addingCharToLibraryId = ref(null)
|
||||
const addingCharToMaterialId = ref(null)
|
||||
const addingCharFromLibraryId = ref(null)
|
||||
let charLibraryKeywordTimer = null
|
||||
|
||||
/** 角色库弹窗 Tab:library | drama | team */
|
||||
const charLibraryTab = ref('library')
|
||||
const dramaAllCharList = ref([])
|
||||
const dramaAllCharLoading = ref(false)
|
||||
const dramaAllCharPage = ref(1)
|
||||
const dramaAllCharPageSize = ref(20)
|
||||
const dramaAllCharTotal = ref(0)
|
||||
const dramaAllCharKeyword = ref('')
|
||||
let dramaAllCharKeywordTimer = null
|
||||
|
||||
|
||||
// ── 常量 ──────────────────────────────────────────────
|
||||
const CHAR_ROLE_LABEL = { main: '主角', supporting: '配角', minor: '次要角色' }
|
||||
function charRoleLabel(role) { return CHAR_ROLE_LABEL[role] || role || '' }
|
||||
|
||||
// ── 核心函数 ──────────────────────────────────────────
|
||||
async function onGenerateCharacters() {
|
||||
if (!store.dramaId) return
|
||||
const epId = currentEpisodeId.value
|
||||
if (!epId) {
|
||||
ElMessage.warning('请先选择集次')
|
||||
return
|
||||
}
|
||||
const meta = buildExtractTaskMeta(store, dramaId.value, epId, GEN_RESOURCE.EXTRACT_CHARACTERS, '提取角色')
|
||||
genStore.markRunning(meta)
|
||||
try {
|
||||
const outline =
|
||||
(store.scriptContent || '').toString().trim() || undefined
|
||||
const res = await generationAPI.generateCharacters(store.dramaId, {
|
||||
episode_id: epId,
|
||||
outline: outline || undefined
|
||||
})
|
||||
const taskId = res?.task_id
|
||||
if (taskId) {
|
||||
await pollTask(taskId, () => loadDrama(), meta)
|
||||
ElMessage.success('角色生成完成')
|
||||
} else {
|
||||
await loadDrama()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '生成失败')
|
||||
} finally {
|
||||
genStore.markDone(meta)
|
||||
}
|
||||
}
|
||||
|
||||
function openAddCharacter() {
|
||||
editCharacterForm.value = {
|
||||
name: '',
|
||||
role: '',
|
||||
appearance: '',
|
||||
personality: '',
|
||||
description: '',
|
||||
polished_prompt: ''
|
||||
}
|
||||
showEditCharacter.value = true
|
||||
}
|
||||
|
||||
function stopCharacterPromptPoll() {
|
||||
if (editCharacterPollTimer) {
|
||||
clearInterval(editCharacterPollTimer)
|
||||
editCharacterPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function editCharacter(char) {
|
||||
stopCharacterPromptPoll()
|
||||
editCharacterForm.value = {
|
||||
id: char.id,
|
||||
name: char.name || '',
|
||||
role: char.role || '',
|
||||
appearance: char.appearance || '',
|
||||
personality: char.personality || '',
|
||||
description: char.description || '',
|
||||
polished_prompt: char.polished_prompt || '',
|
||||
image_url: char.image_url || '',
|
||||
local_path: char.local_path || '',
|
||||
ref_image: char.ref_image || '',
|
||||
identity_anchors: char.identity_anchors || '',
|
||||
stages: char.stages ? (typeof char.stages === 'string' ? char.stages : JSON.stringify(char.stages, null, 2)) : '',
|
||||
}
|
||||
showEditCharacter.value = true
|
||||
if (!char.polished_prompt && char.id && (char.appearance || char.description)) {
|
||||
editCharacterPromptGenerating.value = true
|
||||
let elapsed = 0
|
||||
editCharacterPollTimer = setInterval(async () => {
|
||||
elapsed += 3
|
||||
try {
|
||||
const res = await characterAPI.get(char.id)
|
||||
const prompt = res?.character?.polished_prompt
|
||||
if (prompt) {
|
||||
if (editCharacterForm.value?.id === char.id) {
|
||||
editCharacterForm.value.polished_prompt = prompt
|
||||
}
|
||||
stopCharacterPromptPoll()
|
||||
editCharacterPromptGenerating.value = false
|
||||
} else if (elapsed >= 60) {
|
||||
stopCharacterPromptPoll()
|
||||
editCharacterPromptGenerating.value = false
|
||||
}
|
||||
} catch (_) {
|
||||
stopCharacterPromptPoll()
|
||||
editCharacterPromptGenerating.value = false
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCharRefImageIfAny(characterId) {
|
||||
const refImg = addCharRefImage.value
|
||||
if (!refImg || !characterId) return
|
||||
try {
|
||||
const file = dataUrlToFile(refImg.dataUrl, refImg.filename || 'reference.png')
|
||||
const uploadRes = await uploadAPI.uploadImage(file, { dramaId: dramaId.value })
|
||||
const refPath = uploadRes.local_path || uploadRes.url || ''
|
||||
await characterAPI.putRefImage(characterId, refPath)
|
||||
} catch (e) {
|
||||
console.warn('[saveCharRefImage] 保存参考图失败:', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEditCharacter() {
|
||||
const form = editCharacterForm.value
|
||||
if (!form?.name?.trim() || !store.dramaId) return
|
||||
editCharacterSaving.value = true
|
||||
try {
|
||||
if (form.id) {
|
||||
await characterAPI.update(form.id, {
|
||||
name: form.name.trim(),
|
||||
role: form.role || undefined,
|
||||
appearance: form.appearance || undefined,
|
||||
personality: form.personality || undefined,
|
||||
description: form.description || undefined,
|
||||
polished_prompt: form.polished_prompt || undefined,
|
||||
stages: form.stages ? form.stages.trim() || undefined : undefined
|
||||
})
|
||||
await saveCharRefImageIfAny(form.id)
|
||||
ElMessage.success('角色已保存')
|
||||
} else {
|
||||
const existing = (store.drama?.characters || []).map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name || '',
|
||||
role: c.role || undefined,
|
||||
description: c.description || undefined,
|
||||
personality: c.personality || undefined,
|
||||
appearance: c.appearance || undefined,
|
||||
image_url: c.image_url || undefined,
|
||||
local_path: c.local_path || undefined
|
||||
}))
|
||||
await dramaAPI.saveCharacters(store.dramaId, {
|
||||
characters: [...existing, {
|
||||
name: form.name.trim(),
|
||||
role: form.role || undefined,
|
||||
appearance: form.appearance || undefined,
|
||||
personality: form.personality || undefined,
|
||||
description: form.description || undefined
|
||||
}],
|
||||
episode_id: currentEpisodeId.value ?? undefined
|
||||
})
|
||||
await loadDrama()
|
||||
if (addCharRefImage.value) {
|
||||
const newChar = (store.drama?.characters || []).find(c => c.name === form.name.trim())
|
||||
if (newChar?.id) await saveCharRefImageIfAny(newChar.id)
|
||||
}
|
||||
ElMessage.success('角色已添加')
|
||||
}
|
||||
await loadDrama()
|
||||
showEditCharacter.value = false
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || (form.id ? '保存失败' : '添加失败'))
|
||||
} finally {
|
||||
editCharacterSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doGenerateCharacterPrompt() {
|
||||
const form = editCharacterForm.value
|
||||
if (!form?.id) return
|
||||
editCharacterPromptGenerating.value = true
|
||||
try {
|
||||
const res = await characterAPI.generatePrompt(form.id)
|
||||
if (res?.polished_prompt) {
|
||||
form.polished_prompt = res.polished_prompt
|
||||
ElMessage.success('提示词已生成')
|
||||
await loadDrama()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '生成提示词失败')
|
||||
} finally {
|
||||
editCharacterPromptGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doExtractCharFromImage() {
|
||||
const form = editCharacterForm.value
|
||||
if (!form?.id) return
|
||||
extractingCharAppearance.value = true
|
||||
try {
|
||||
const res = await characterAPI.extractFromImage(form.id)
|
||||
if (res?.appearance) {
|
||||
form.appearance = res.appearance
|
||||
ElMessage.success('已从图片提取外貌描述')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '提取失败,请检查角色是否已上传参考图片')
|
||||
} finally {
|
||||
extractingCharAppearance.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCharRefImage() {
|
||||
const form = editCharacterForm.value
|
||||
if (!form?.id) return
|
||||
try {
|
||||
await characterAPI.putRefImage(form.id, null)
|
||||
form.ref_image = ''
|
||||
ElMessage.success('参考图已移除')
|
||||
} catch (e) {
|
||||
ElMessage.error('移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onCloseCharDialog() {
|
||||
showEditCharacter.value = false
|
||||
stopCharacterPromptPoll()
|
||||
editCharacterPromptGenerating.value = false
|
||||
addCharRefImage.value = null
|
||||
}
|
||||
|
||||
async function onDeleteCharacter(char) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除角色「${(char.name || '未命名').slice(0, 20)}」吗?此操作不可恢复。`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
await characterAPI.delete(char.id)
|
||||
await loadDrama()
|
||||
ElMessage.success('角色已删除')
|
||||
} catch (e) {
|
||||
if (e === 'cancel') return
|
||||
ElMessage.error(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onGenerateCharacterImage(char) {
|
||||
char.errorMsg = ''
|
||||
char.error_msg = ''
|
||||
const meta = buildCharImageMeta(char)
|
||||
generatingCharIds.add(char.id)
|
||||
genStore.markRunning(meta)
|
||||
try {
|
||||
const res = await characterAPI.generateImage(char.id, undefined, getSelectedStyle())
|
||||
const taskId = res?.image_generation?.task_id ?? res?.task_id
|
||||
if (taskId) {
|
||||
const pollRes = await pollTask(taskId, () => loadDrama(), meta)
|
||||
if (pollRes?.status === 'failed') {
|
||||
char.errorMsg = pollRes.error || '生成失败'
|
||||
} else {
|
||||
ElMessage.success('角色图片已生成')
|
||||
}
|
||||
} else {
|
||||
await loadDrama()
|
||||
await pollUntilResourceHasImage(() => {
|
||||
const list = store.drama?.characters ?? store.currentEpisode?.characters ?? []
|
||||
const c = list.find((x) => Number(x.id) === Number(char.id))
|
||||
return !!(c && (c.image_url || c.local_path))
|
||||
})
|
||||
ElMessage.success('角色图片已生成')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
char.errorMsg = e.message || '生成失败'
|
||||
ElMessage.error(e.message || '提交失败')
|
||||
} finally {
|
||||
generatingCharIds.delete(char.id)
|
||||
genStore.markDone(meta)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 角色库函数 ────────────────────────────────────────
|
||||
async function loadCharLibraryList() {
|
||||
charLibraryLoading.value = true
|
||||
try {
|
||||
const res = await characterLibraryAPI.list({
|
||||
drama_id: dramaId.value,
|
||||
page: charLibraryPage.value,
|
||||
page_size: charLibraryPageSize.value,
|
||||
keyword: charLibraryKeyword.value || undefined
|
||||
})
|
||||
charLibraryList.value = res?.items ?? []
|
||||
const pagination = res?.pagination ?? {}
|
||||
charLibraryTotal.value = pagination.total ?? 0
|
||||
if (pagination.page != null) charLibraryPage.value = pagination.page
|
||||
if (pagination.page_size != null) charLibraryPageSize.value = pagination.page_size
|
||||
} catch (e) {
|
||||
charLibraryList.value = []
|
||||
} finally {
|
||||
charLibraryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedLoadCharLibrary() {
|
||||
if (charLibraryKeywordTimer) clearTimeout(charLibraryKeywordTimer)
|
||||
charLibraryKeywordTimer = setTimeout(() => {
|
||||
charLibraryPage.value = 1
|
||||
loadCharLibraryList()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function loadDramaAllCharList() {
|
||||
if (!dramaId.value) {
|
||||
dramaAllCharList.value = []
|
||||
dramaAllCharTotal.value = 0
|
||||
return
|
||||
}
|
||||
dramaAllCharLoading.value = true
|
||||
try {
|
||||
const res = await dramaAPI.getCharacters(dramaId.value)
|
||||
let list = Array.isArray(res) ? res : (res?.characters ?? res?.items ?? [])
|
||||
const kw = (dramaAllCharKeyword.value || '').trim().toLowerCase()
|
||||
if (kw) {
|
||||
list = list.filter((c) => {
|
||||
const name = (c.name || '').toLowerCase()
|
||||
const desc = (c.description || '').toLowerCase()
|
||||
const app = (c.appearance || '').toLowerCase()
|
||||
return name.includes(kw) || desc.includes(kw) || app.includes(kw)
|
||||
})
|
||||
}
|
||||
dramaAllCharTotal.value = list.length
|
||||
const start = (dramaAllCharPage.value - 1) * dramaAllCharPageSize.value
|
||||
dramaAllCharList.value = list.slice(start, start + dramaAllCharPageSize.value)
|
||||
} catch {
|
||||
dramaAllCharList.value = []
|
||||
dramaAllCharTotal.value = 0
|
||||
} finally {
|
||||
dramaAllCharLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedLoadDramaAllCharList() {
|
||||
if (dramaAllCharKeywordTimer) clearTimeout(dramaAllCharKeywordTimer)
|
||||
dramaAllCharKeywordTimer = setTimeout(() => {
|
||||
dramaAllCharPage.value = 1
|
||||
loadDramaAllCharList()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onCharLibraryDialogOpen() {
|
||||
if (charLibraryTab.value === 'library') loadCharLibraryList()
|
||||
else if (charLibraryTab.value === 'drama') loadDramaAllCharList()
|
||||
}
|
||||
|
||||
function onCharLibraryTabChange() {
|
||||
if (charLibraryTab.value === 'library') {
|
||||
charLibraryPage.value = 1
|
||||
loadCharLibraryList()
|
||||
} else if (charLibraryTab.value === 'drama') {
|
||||
dramaAllCharPage.value = 1
|
||||
loadDramaAllCharList()
|
||||
}
|
||||
}
|
||||
|
||||
function charAddToEpisodeLoadingKey(scope, id) {
|
||||
return `${scope}-${id}`
|
||||
}
|
||||
|
||||
function isCharAddToEpisodeLoading(scope, id) {
|
||||
return addingCharFromLibraryId.value === charAddToEpisodeLoadingKey(scope, id)
|
||||
}
|
||||
|
||||
function openEditCharLibrary(item) {
|
||||
editCharLibraryForm.value = {
|
||||
id: item.id,
|
||||
name: item.name ?? '',
|
||||
category: item.category ?? '',
|
||||
description: item.description ?? '',
|
||||
tags: item.tags ?? ''
|
||||
}
|
||||
showEditCharLibrary.value = true
|
||||
}
|
||||
|
||||
async function submitEditCharLibrary() {
|
||||
if (!editCharLibraryForm.value?.id) return
|
||||
editCharLibrarySaving.value = true
|
||||
try {
|
||||
await characterLibraryAPI.update(editCharLibraryForm.value.id, {
|
||||
name: editCharLibraryForm.value.name,
|
||||
category: editCharLibraryForm.value.category || null,
|
||||
description: editCharLibraryForm.value.description || null,
|
||||
tags: editCharLibraryForm.value.tags || null
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
showEditCharLibrary.value = false
|
||||
loadCharLibraryList()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
} finally {
|
||||
editCharLibrarySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteCharLibrary(item) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除公共角色「${(item.name || '未命名').slice(0, 20)}」吗?`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
await characterLibraryAPI.delete(item.id)
|
||||
ElMessage.success('已删除')
|
||||
loadCharLibraryList()
|
||||
} catch (e) {
|
||||
if (e === 'cancel') return
|
||||
ElMessage.error(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddCharacterToLibrary(char) {
|
||||
if (!hasAssetImage(char)) { ElMessage.warning('请先为该角色生成或上传图片'); return }
|
||||
addingCharToLibraryId.value = char.id
|
||||
try {
|
||||
await characterAPI.addToLibrary(char.id, {})
|
||||
ElMessage.success('已加入本剧角色库')
|
||||
if (showCharLibrary.value) loadCharLibraryList()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加入失败')
|
||||
} finally {
|
||||
addingCharToLibraryId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddCharacterToMaterialLibrary(char) {
|
||||
if (!hasAssetImage(char)) { ElMessage.warning('请先为该角色生成或上传图片'); return }
|
||||
addingCharToMaterialId.value = char.id
|
||||
try {
|
||||
await characterAPI.addToMaterialLibrary(char.id)
|
||||
ElMessage.success('已加入全局素材库')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加入失败')
|
||||
} finally {
|
||||
addingCharToMaterialId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function addCharToEpisode(item, scope) {
|
||||
if (!store.dramaId) return
|
||||
if (!currentEpisodeId.value) {
|
||||
ElMessage.warning('请先选择本集')
|
||||
return
|
||||
}
|
||||
const loadingKey = charAddToEpisodeLoadingKey(scope, item.id)
|
||||
addingCharFromLibraryId.value = loadingKey
|
||||
try {
|
||||
const existing = (store.characters || []).map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name || '',
|
||||
role: c.role || undefined,
|
||||
appearance: c.appearance || undefined,
|
||||
personality: c.personality || undefined,
|
||||
description: c.description || undefined,
|
||||
image_url: c.image_url || undefined,
|
||||
local_path: c.local_path || undefined,
|
||||
}))
|
||||
const newCharacters = [...existing]
|
||||
const existingChar = newCharacters.find((c) => c.name === (item.name || '未命名'))
|
||||
if (existingChar) {
|
||||
existingChar.description = item.description || existingChar.description
|
||||
existingChar.appearance = item.appearance || existingChar.appearance
|
||||
existingChar.image_url = item.image_url || existingChar.image_url
|
||||
existingChar.local_path = item.local_path || existingChar.local_path
|
||||
if (item.role && !existingChar.role) existingChar.role = item.role
|
||||
} else {
|
||||
newCharacters.push({
|
||||
name: item.name || '未命名',
|
||||
role: item.role || undefined,
|
||||
description: item.description || undefined,
|
||||
appearance: item.appearance || undefined,
|
||||
personality: item.personality || undefined,
|
||||
image_url: item.image_url || undefined,
|
||||
local_path: item.local_path || undefined,
|
||||
})
|
||||
}
|
||||
await dramaAPI.saveCharacters(store.dramaId, {
|
||||
characters: newCharacters,
|
||||
episode_id: currentEpisodeId.value ?? undefined,
|
||||
})
|
||||
await loadDrama()
|
||||
ElMessage.success(`「${item.name || '角色'}」已加入本集`)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加入失败')
|
||||
} finally {
|
||||
addingCharFromLibraryId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onAddCharFromLibrary(item) {
|
||||
return addCharToEpisode(item, 'library')
|
||||
}
|
||||
|
||||
function onAddDramaCharToEpisode(item) {
|
||||
return addCharToEpisode(item, 'drama')
|
||||
}
|
||||
|
||||
async function extractIdentityAnchors() {
|
||||
const form = editCharacterForm.value
|
||||
if (!form?.id) return
|
||||
if (!form.appearance) {
|
||||
ElMessage.warning('请先填写角色外貌描述')
|
||||
return
|
||||
}
|
||||
extractingAnchors.value = true
|
||||
try {
|
||||
await characterAPI.extractAnchors(form.id)
|
||||
ElMessage.success('视觉锚点提炼已启动,请稍后查看')
|
||||
// 轮询等待锚点写入
|
||||
let elapsed = 0
|
||||
const timer = setInterval(async () => {
|
||||
elapsed += 3
|
||||
try {
|
||||
const res = await characterAPI.get(form.id)
|
||||
const anchors = res?.character?.identity_anchors
|
||||
if (anchors && editCharacterForm.value?.id === form.id) {
|
||||
editCharacterForm.value.identity_anchors = anchors
|
||||
clearInterval(timer)
|
||||
extractingAnchors.value = false
|
||||
} else if (elapsed >= 60) {
|
||||
clearInterval(timer)
|
||||
extractingAnchors.value = false
|
||||
}
|
||||
} catch (_) {
|
||||
clearInterval(timer)
|
||||
extractingAnchors.value = false
|
||||
}
|
||||
}, 3000)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '提炼失败')
|
||||
extractingAnchors.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSd2CertifyCharacter(char) {
|
||||
if (!char?.id) return
|
||||
if (!hasAssetImage(char)) {
|
||||
ElMessage.warning('请先为该角色生成或上传图片')
|
||||
return
|
||||
}
|
||||
sd2CertifyingId.value = char.id
|
||||
try {
|
||||
await characterAPI.sd2Certify(char.id)
|
||||
await loadDrama()
|
||||
ElMessage.success('SD2 认证请求已提交')
|
||||
} catch (e) {
|
||||
const msg = e?.message || ''
|
||||
if (/已存在|已认证|already/i.test(msg)) {
|
||||
try {
|
||||
await characterAPI.sd2CertifyRefresh(char.id)
|
||||
await loadDrama()
|
||||
ElMessage.success('SD2 认证状态已刷新')
|
||||
return
|
||||
} catch (_) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
ElMessage.error(msg || 'SD2 认证失败')
|
||||
} finally {
|
||||
sd2CertifyingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onSd2CertifyRefresh(char) {
|
||||
if (!char?.id) return
|
||||
sd2CertifyingId.value = char.id
|
||||
try {
|
||||
await characterAPI.sd2CertifyRefresh(char.id)
|
||||
await loadDrama()
|
||||
ElMessage.success('SD2 认证状态已刷新')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '刷新失败')
|
||||
} finally {
|
||||
sd2CertifyingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function sd2ActionLabel(char) {
|
||||
const status = String(char?.seedance2_asset?.status || '').toLowerCase()
|
||||
if (status === 'active') return '查看认证'
|
||||
if (status === 'processing') return '刷新认证'
|
||||
if (status === 'failed') return '重新认证'
|
||||
return 'sd2认证'
|
||||
}
|
||||
|
||||
async function onSd2PrimaryAction(char) {
|
||||
const status = String(char?.seedance2_asset?.status || '').toLowerCase()
|
||||
if (status === 'active') {
|
||||
openCharSd2CertDialog(char)
|
||||
return
|
||||
}
|
||||
if (status === 'processing') {
|
||||
await onSd2CertifyRefresh(char)
|
||||
return
|
||||
}
|
||||
await onSd2CertifyCharacter(char)
|
||||
}
|
||||
|
||||
function openCharSd2CertDialog(char) {
|
||||
charSd2CertPayload.value = char?.seedance2_asset ? { ...char.seedance2_asset } : null
|
||||
showCharSd2Cert.value = true
|
||||
}
|
||||
|
||||
function sd2VoiceActionLabel(char) {
|
||||
const status = String(char?.seedance2_voice_asset?.status || '').toLowerCase()
|
||||
if (status === 'active') return '音色参考'
|
||||
if (status === 'processing') return '刷新音色'
|
||||
if (status === 'failed') return '重新上传'
|
||||
return '上传音色'
|
||||
}
|
||||
|
||||
async function onSd2VoicePrimaryAction(char) {
|
||||
const status = String(char?.seedance2_voice_asset?.status || '').toLowerCase()
|
||||
if (status === 'active') {
|
||||
ElMessage.info('音色参考已设置,将在 Seedance 2.0 模型中使用')
|
||||
return
|
||||
}
|
||||
if (status === 'processing' || status === 'stale') {
|
||||
await onSd2VoiceRefresh(char)
|
||||
return
|
||||
}
|
||||
// 触发文件选择上传
|
||||
await triggerSd2VoiceUpload(char)
|
||||
}
|
||||
|
||||
// 专门用于“更换”:无论当前是否 active,都直接触发文件选择上传(覆盖)
|
||||
async function onSd2VoiceReplace(char) {
|
||||
await triggerSd2VoiceUpload(char)
|
||||
}
|
||||
|
||||
async function onSd2VoiceRefresh(char) {
|
||||
if (!char?.id) return
|
||||
sd2VoiceUploadingId.value = char.id
|
||||
try {
|
||||
const res = await characterAPI.sd2VoiceRefresh(char.id)
|
||||
await loadDrama()
|
||||
ElMessage.success(res?.data?.message || '音色状态已刷新')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '刷新失败')
|
||||
} finally {
|
||||
sd2VoiceUploadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerSd2VoiceUpload(char) {
|
||||
if (!char?.id) return
|
||||
// 创建隐藏的 file input
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'audio/*'
|
||||
input.onchange = async () => {
|
||||
const file = input.files && input.files[0]
|
||||
if (!file) return
|
||||
sd2VoiceUploadingId.value = char.id
|
||||
try {
|
||||
const res = await characterAPI.sd2VoiceUpload(char.id, file)
|
||||
ElMessage.success('Seedance 2.0 音色参考已上传')
|
||||
// 强制重新加载整个剧本数据,确保 seedance2_voice_asset 被正确解析并更新到 store
|
||||
await loadDrama()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '音色上传失败')
|
||||
} finally {
|
||||
sd2VoiceUploadingId.value = null
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
// 播放 Seedance 2.0 音色参考(仅 active 状态)
|
||||
function playSd2Voice(char) {
|
||||
const url = char?.seedance2_voice_asset?.url
|
||||
if (!url) {
|
||||
ElMessage.warning('该角色暂无音色参考音频')
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 统一使用相对 /static/...(与图片 assetImageUrl 一致),由当前页面 origin + Vite/后端代理或静态服务处理
|
||||
const audio = new Audio(url)
|
||||
audio.onerror = () => {
|
||||
// 常见原因:文件不在 static 根目录下(后端写盘路径与 express.static(storageRoot) 不一致)、404、格式不支持
|
||||
ElMessage.error('音频播放失败:文件可能不存在或路径不匹配,请尝试重新上传该音色参考')
|
||||
}
|
||||
audio.play().catch((err) => {
|
||||
ElMessage.error('音频播放失败,请检查文件或稍后重试')
|
||||
})
|
||||
} catch (e) {
|
||||
ElMessage.error('无法播放音频')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 弹窗状态
|
||||
showEditCharacter,
|
||||
editCharacterForm,
|
||||
editCharacterSaving,
|
||||
editCharacterPromptGenerating,
|
||||
extractingCharAppearance,
|
||||
extractingAnchors,
|
||||
addCharRefImage,
|
||||
addCharRefFileInput,
|
||||
// 生成状态
|
||||
charactersGenerating,
|
||||
generatingCharIds,
|
||||
sd2CertifyingId,
|
||||
showCharSd2Cert,
|
||||
charSd2CertPayload,
|
||||
sd2VoiceUploadingId,
|
||||
// 库状态
|
||||
showCharLibrary,
|
||||
charLibraryList,
|
||||
charLibraryLoading,
|
||||
charLibraryPage,
|
||||
charLibraryPageSize,
|
||||
charLibraryTotal,
|
||||
charLibraryKeyword,
|
||||
charLibraryTab,
|
||||
dramaAllCharList,
|
||||
dramaAllCharLoading,
|
||||
dramaAllCharPage,
|
||||
dramaAllCharPageSize,
|
||||
dramaAllCharTotal,
|
||||
dramaAllCharKeyword,
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
showEditCharLibrary,
|
||||
editCharLibraryForm,
|
||||
editCharLibrarySaving,
|
||||
addingCharToLibraryId,
|
||||
addingCharToMaterialId,
|
||||
|
||||
addingCharFromLibraryId,
|
||||
// 函数
|
||||
charRoleLabel,
|
||||
onGenerateCharacters,
|
||||
openAddCharacter,
|
||||
stopCharacterPromptPoll,
|
||||
editCharacter,
|
||||
saveCharRefImageIfAny,
|
||||
submitEditCharacter,
|
||||
doGenerateCharacterPrompt,
|
||||
doExtractCharFromImage,
|
||||
extractIdentityAnchors,
|
||||
clearCharRefImage,
|
||||
onCloseCharDialog,
|
||||
onDeleteCharacter,
|
||||
onGenerateCharacterImage,
|
||||
onSd2CertifyCharacter,
|
||||
onSd2CertifyRefresh,
|
||||
sd2ActionLabel,
|
||||
onSd2PrimaryAction,
|
||||
openCharSd2CertDialog,
|
||||
onSd2VoicePrimaryAction,
|
||||
onSd2VoiceReplace,
|
||||
sd2VoiceActionLabel,
|
||||
playSd2Voice,
|
||||
loadCharLibraryList,
|
||||
debouncedLoadCharLibrary,
|
||||
loadDramaAllCharList,
|
||||
debouncedLoadDramaAllCharList,
|
||||
|
||||
|
||||
onCharLibraryDialogOpen,
|
||||
onCharLibraryTabChange,
|
||||
isCharAddToEpisodeLoading,
|
||||
openEditCharLibrary,
|
||||
submitEditCharLibrary,
|
||||
onDeleteCharLibrary,
|
||||
onAddCharacterToLibrary,
|
||||
onAddCharacterToMaterialLibrary,
|
||||
|
||||
|
||||
onAddCharFromLibrary,
|
||||
onAddDramaCharToEpisode,
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const NAV_AUTO_COLLAPSE_WIDTH = 960
|
||||
|
||||
/**
|
||||
* 左侧导航折叠/展开逻辑
|
||||
*/
|
||||
export function useNavigation() {
|
||||
const navCollapsed = ref(false)
|
||||
const storyboardMenuExpanded = ref(false)
|
||||
let _navAutoCollapsed = false
|
||||
|
||||
function _syncNavCollapse() {
|
||||
const narrow = window.innerWidth < NAV_AUTO_COLLAPSE_WIDTH
|
||||
if (narrow && !_navAutoCollapsed && !navCollapsed.value) {
|
||||
_navAutoCollapsed = true
|
||||
navCollapsed.value = true
|
||||
} else if (!narrow && _navAutoCollapsed) {
|
||||
_navAutoCollapsed = false
|
||||
navCollapsed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNav() {
|
||||
navCollapsed.value = !navCollapsed.value
|
||||
_navAutoCollapsed = false
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function scrollToAnchor(id) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
_syncNavCollapse()
|
||||
window.addEventListener('resize', _syncNavCollapse)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', _syncNavCollapse)
|
||||
})
|
||||
|
||||
return {
|
||||
navCollapsed,
|
||||
storyboardMenuExpanded,
|
||||
toggleNav,
|
||||
scrollToTop,
|
||||
scrollToAnchor,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { propAPI } from '@/api/props'
|
||||
import { propLibraryAPI } from '@/api/propLibrary'
|
||||
import { uploadAPI } from '@/api/upload'
|
||||
import { useGenerationTaskStore, GEN_RESOURCE } from '@/stores/generationTaskStore'
|
||||
import { buildExtractTaskMeta, isEpisodeExtractRunning } from '@/composables/useGenerationTaskSync'
|
||||
|
||||
/**
|
||||
* 道具管理 Composable
|
||||
* @param {object} deps - 共享依赖
|
||||
* @param {object} deps.store - Pinia store
|
||||
* @param {import('vue').ComputedRef} deps.dramaId
|
||||
* @param {import('vue').ComputedRef} deps.currentEpisodeId
|
||||
* @param {Function} deps.getSelectedStyle
|
||||
* @param {Function} deps.loadDrama
|
||||
* @param {Function} deps.pollTask
|
||||
* @param {Function} deps.pollUntilResourceHasImage
|
||||
* @param {Function} deps.hasAssetImage
|
||||
*/
|
||||
export function useProps(deps) {
|
||||
const { store, dramaId, currentEpisodeId, getSelectedStyle, loadDrama, pollTask, pollUntilResourceHasImage, hasAssetImage } = deps
|
||||
const genStore = useGenerationTaskStore()
|
||||
|
||||
function buildPropImageMeta(prop) {
|
||||
const dramaTitle = store.drama?.title || ''
|
||||
const epNum = store.currentEpisode?.episode_number
|
||||
const epLabel = dramaTitle ? `${dramaTitle} · 第${epNum ?? ''}集` : `第${epNum ?? ''}集`
|
||||
return {
|
||||
dramaId: dramaId.value,
|
||||
episodeId: currentEpisodeId.value,
|
||||
dramaTitle,
|
||||
episodeNumber: epNum,
|
||||
resourceType: GEN_RESOURCE.PROP_IMAGE,
|
||||
resourceId: prop.id,
|
||||
label: `${epLabel} 道具图: ${prop.name || prop.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
function dataUrlToFile(dataUrl, filename) {
|
||||
const arr = dataUrl.split(',')
|
||||
const mime = (arr[0].match(/:(.*?);/) || [])[1] || 'image/png'
|
||||
const bstr = atob(arr[1])
|
||||
let n = bstr.length
|
||||
const u8arr = new Uint8Array(n)
|
||||
while (n--) u8arr[n] = bstr.charCodeAt(n)
|
||||
return new File([u8arr], filename || 'reference.png', { type: mime })
|
||||
}
|
||||
|
||||
// ── 道具弹窗状态 ──────────────────────────────────────
|
||||
const showAddProp = ref(false)
|
||||
const addPropSaving = ref(false)
|
||||
const addPropForm = ref({ name: '', type: '', description: '', prompt: '' })
|
||||
|
||||
const showEditProp = ref(false)
|
||||
const editPropForm = ref(null)
|
||||
const editPropSaving = ref(false)
|
||||
const editPropPromptGenerating = ref(false)
|
||||
const extractingPropDesc = ref(false)
|
||||
const addPropRefImage = ref(null) // { dataUrl, filename }
|
||||
const addPropRefFileInput = ref(null)
|
||||
let editPropPollTimer = null
|
||||
|
||||
// 「添加道具」简单弹窗的独立参考图状态
|
||||
const addPropAddRefImage = ref(null)
|
||||
const addPropAddRefFileInput = ref(null)
|
||||
const extractingPropAddDesc = ref(false)
|
||||
|
||||
// ── 道具生成状态 ──────────────────────────────────────
|
||||
const propsExtracting = computed(() =>
|
||||
isEpisodeExtractRunning(genStore, dramaId.value, currentEpisodeId.value, GEN_RESOURCE.EXTRACT_PROPS)
|
||||
)
|
||||
const generatingPropIds = reactive(new Set())
|
||||
|
||||
// ── 道具库状态 ────────────────────────────────────────
|
||||
const showPropLibrary = ref(false)
|
||||
const propLibraryList = ref([])
|
||||
const propLibraryLoading = ref(false)
|
||||
const propLibraryPage = ref(1)
|
||||
const propLibraryPageSize = ref(20)
|
||||
const propLibraryTotal = ref(0)
|
||||
const propLibraryKeyword = ref('')
|
||||
const showEditPropLibrary = ref(false)
|
||||
const editPropLibraryForm = ref(null)
|
||||
const editPropLibrarySaving = ref(false)
|
||||
const addingPropToLibraryId = ref(null)
|
||||
const addingPropToMaterialId = ref(null)
|
||||
const addingPropFromLibraryId = ref(null)
|
||||
let propLibraryKeywordTimer = null
|
||||
|
||||
const propLibraryTab = ref('library')
|
||||
const dramaAllPropList = ref([])
|
||||
const dramaAllPropLoading = ref(false)
|
||||
const dramaAllPropPage = ref(1)
|
||||
const dramaAllPropPageSize = ref(20)
|
||||
const dramaAllPropTotal = ref(0)
|
||||
const dramaAllPropKeyword = ref('')
|
||||
let dramaAllPropKeywordTimer = null
|
||||
|
||||
|
||||
// ── 函数 ──────────────────────────────────────────────
|
||||
async function onExtractProps() {
|
||||
if (!currentEpisodeId.value) {
|
||||
ElMessage.warning('请先完成剧本并保存')
|
||||
return
|
||||
}
|
||||
const epId = currentEpisodeId.value
|
||||
const meta = buildExtractTaskMeta(store, dramaId.value, epId, GEN_RESOURCE.EXTRACT_PROPS, '提取道具')
|
||||
genStore.markRunning(meta)
|
||||
try {
|
||||
const res = await propAPI.extractFromScript(epId)
|
||||
const taskId = res?.task_id
|
||||
if (taskId) {
|
||||
const pollRes = await pollTask(taskId, () => loadDrama(), meta)
|
||||
if (pollRes?.status !== 'failed') {
|
||||
ElMessage.success('道具提取完成')
|
||||
}
|
||||
} else {
|
||||
await loadDrama()
|
||||
ElMessage.success('道具提取任务已提交')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '提取失败')
|
||||
} finally {
|
||||
genStore.markDone(meta)
|
||||
}
|
||||
}
|
||||
|
||||
function stopPropPromptPoll() {
|
||||
if (editPropPollTimer) { clearInterval(editPropPollTimer); editPropPollTimer = null }
|
||||
}
|
||||
|
||||
function editProp(prop) {
|
||||
stopPropPromptPoll()
|
||||
editPropForm.value = {
|
||||
id: prop.id,
|
||||
name: prop.name || '',
|
||||
type: prop.type || '',
|
||||
description: prop.description || '',
|
||||
prompt: prop.prompt || '',
|
||||
image_url: prop.image_url || '',
|
||||
local_path: prop.local_path || '',
|
||||
ref_image: prop.ref_image || '',
|
||||
}
|
||||
showEditProp.value = true
|
||||
if (!prop.prompt && prop.id && prop.description) {
|
||||
editPropPromptGenerating.value = true
|
||||
let elapsed = 0
|
||||
editPropPollTimer = setInterval(async () => {
|
||||
elapsed += 3
|
||||
try {
|
||||
const res = await propAPI.get(prop.id)
|
||||
const p = res?.prop?.prompt
|
||||
if (p) {
|
||||
if (editPropForm.value?.id === prop.id) editPropForm.value.prompt = p
|
||||
stopPropPromptPoll()
|
||||
editPropPromptGenerating.value = false
|
||||
} else if (elapsed >= 60) {
|
||||
stopPropPromptPoll()
|
||||
editPropPromptGenerating.value = false
|
||||
}
|
||||
} catch (_) {
|
||||
stopPropPromptPoll()
|
||||
editPropPromptGenerating.value = false
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
async function doGeneratePropPrompt() {
|
||||
const form = editPropForm.value
|
||||
if (!form?.id) return
|
||||
editPropPromptGenerating.value = true
|
||||
try {
|
||||
const res = await propAPI.generatePrompt(form.id)
|
||||
if (res?.prompt) {
|
||||
form.prompt = res.prompt
|
||||
ElMessage.success('提示词已生成')
|
||||
await loadDrama()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '生成提示词失败')
|
||||
} finally {
|
||||
editPropPromptGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function savePropRefImageIfAny(propId) {
|
||||
const refImg = addPropRefImage.value
|
||||
if (!refImg || !propId) return
|
||||
try {
|
||||
const file = dataUrlToFile(refImg.dataUrl, refImg.filename || 'reference.png')
|
||||
const uploadRes = await uploadAPI.uploadImage(file, { dramaId: dramaId.value })
|
||||
const refPath = uploadRes.local_path || uploadRes.url || ''
|
||||
await propAPI.putRefImage(propId, refPath)
|
||||
} catch (e) {
|
||||
console.warn('[savePropRefImage] 保存参考图失败:', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPropRefImage() {
|
||||
const form = editPropForm.value
|
||||
if (!form?.id) return
|
||||
try {
|
||||
await propAPI.putRefImage(form.id, null)
|
||||
form.ref_image = ''
|
||||
ElMessage.success('参考图已移除')
|
||||
} catch (e) {
|
||||
ElMessage.error('移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function doExtractPropFromImage() {
|
||||
const form = editPropForm.value
|
||||
if (!form?.id) return
|
||||
extractingPropDesc.value = true
|
||||
try {
|
||||
const res = await propAPI.extractFromImage(form.id)
|
||||
if (res?.description) {
|
||||
form.description = res.description
|
||||
ElMessage.success('已从图片提取道具描述')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '提取失败,请检查道具是否已上传参考图片')
|
||||
} finally {
|
||||
extractingPropDesc.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEditProp() {
|
||||
if (!editPropForm.value?.id) return
|
||||
editPropSaving.value = true
|
||||
try {
|
||||
await propAPI.update(editPropForm.value.id, {
|
||||
name: editPropForm.value.name?.trim(),
|
||||
type: editPropForm.value.type || undefined,
|
||||
description: editPropForm.value.description || undefined,
|
||||
prompt: editPropForm.value.prompt || undefined
|
||||
})
|
||||
await savePropRefImageIfAny(editPropForm.value.id)
|
||||
await loadDrama()
|
||||
showEditProp.value = false
|
||||
ElMessage.success('道具已保存')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
} finally {
|
||||
editPropSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAddProp() {
|
||||
const name = (addPropForm.value.name || '').trim()
|
||||
if (!name || !store.dramaId) return
|
||||
addPropSaving.value = true
|
||||
try {
|
||||
await propAPI.create({
|
||||
drama_id: store.dramaId,
|
||||
episode_id: currentEpisodeId.value ?? undefined,
|
||||
name,
|
||||
type: addPropForm.value.type?.trim() || undefined,
|
||||
description: addPropForm.value.description?.trim() || undefined,
|
||||
prompt: addPropForm.value.prompt?.trim() || undefined
|
||||
})
|
||||
showAddProp.value = false
|
||||
await loadDrama()
|
||||
ElMessage.success('道具已添加')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '添加失败')
|
||||
} finally {
|
||||
addPropSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onClosePropDialog() {
|
||||
showEditProp.value = false
|
||||
stopPropPromptPoll()
|
||||
editPropPromptGenerating.value = false
|
||||
addPropRefImage.value = null
|
||||
}
|
||||
|
||||
async function onDeleteProp(prop) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除道具「${(prop.name || '未命名').slice(0, 20)}」吗?此操作不可恢复。`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
await propAPI.delete(prop.id)
|
||||
await loadDrama()
|
||||
ElMessage.success('道具已删除')
|
||||
} catch (e) {
|
||||
if (e === 'cancel') return
|
||||
ElMessage.error(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onGeneratePropImage(prop, useQuadGrid = false) {
|
||||
prop.errorMsg = ''
|
||||
prop.error_msg = ''
|
||||
const meta = buildPropImageMeta(prop)
|
||||
generatingPropIds.add(prop.id)
|
||||
genStore.markRunning(meta)
|
||||
try {
|
||||
const res = await propAPI.generateImage(prop.id, undefined, getSelectedStyle(), !!useQuadGrid)
|
||||
const taskId = res?.task_id
|
||||
if (taskId) {
|
||||
const pollRes = await pollTask(taskId, () => loadDrama(), meta)
|
||||
if (pollRes?.status === 'failed') {
|
||||
prop.errorMsg = pollRes.error || '生成失败'
|
||||
} else {
|
||||
ElMessage.success('道具图片已生成')
|
||||
}
|
||||
} else {
|
||||
await loadDrama()
|
||||
await pollUntilResourceHasImage(() => {
|
||||
const list = store.drama?.props ?? store.currentEpisode?.props ?? []
|
||||
const p = list.find((x) => Number(x.id) === Number(prop.id))
|
||||
return !!(p && (p.image_url || p.local_path))
|
||||
})
|
||||
ElMessage.success('道具图片已生成')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
prop.errorMsg = e.message || '生成失败'
|
||||
ElMessage.error(e.message || '提交失败')
|
||||
} finally {
|
||||
generatingPropIds.delete(prop.id)
|
||||
genStore.markDone(meta)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 道具库函数 ────────────────────────────────────────
|
||||
async function loadPropLibraryList() {
|
||||
propLibraryLoading.value = true
|
||||
try {
|
||||
const res = await propLibraryAPI.list({
|
||||
drama_id: dramaId.value,
|
||||
page: propLibraryPage.value,
|
||||
page_size: propLibraryPageSize.value,
|
||||
keyword: propLibraryKeyword.value || undefined
|
||||
})
|
||||
propLibraryList.value = res?.items ?? []
|
||||
const pagination = res?.pagination ?? {}
|
||||
propLibraryTotal.value = pagination.total ?? 0
|
||||
if (pagination.page != null) propLibraryPage.value = pagination.page
|
||||
if (pagination.page_size != null) propLibraryPageSize.value = pagination.page_size
|
||||
} catch (e) {
|
||||
propLibraryList.value = []
|
||||
} finally {
|
||||
propLibraryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedLoadPropLibrary() {
|
||||
if (propLibraryKeywordTimer) clearTimeout(propLibraryKeywordTimer)
|
||||
propLibraryKeywordTimer = setTimeout(() => {
|
||||
propLibraryPage.value = 1
|
||||
loadPropLibraryList()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function loadDramaAllPropList() {
|
||||
if (!dramaId.value) {
|
||||
dramaAllPropList.value = []
|
||||
dramaAllPropTotal.value = 0
|
||||
return
|
||||
}
|
||||
dramaAllPropLoading.value = true
|
||||
try {
|
||||
const res = await propAPI.list(dramaId.value)
|
||||
let list = Array.isArray(res) ? res : (res?.items ?? res?.props ?? [])
|
||||
const kw = (dramaAllPropKeyword.value || '').trim().toLowerCase()
|
||||
if (kw) {
|
||||
list = list.filter((p) => {
|
||||
const name = (p.name || '').toLowerCase()
|
||||
const desc = (p.description || '').toLowerCase()
|
||||
const prompt = (p.prompt || '').toLowerCase()
|
||||
return name.includes(kw) || desc.includes(kw) || prompt.includes(kw)
|
||||
})
|
||||
}
|
||||
dramaAllPropTotal.value = list.length
|
||||
const start = (dramaAllPropPage.value - 1) * dramaAllPropPageSize.value
|
||||
dramaAllPropList.value = list.slice(start, start + dramaAllPropPageSize.value)
|
||||
} catch {
|
||||
dramaAllPropList.value = []
|
||||
dramaAllPropTotal.value = 0
|
||||
} finally {
|
||||
dramaAllPropLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedLoadDramaAllPropList() {
|
||||
if (dramaAllPropKeywordTimer) clearTimeout(dramaAllPropKeywordTimer)
|
||||
dramaAllPropKeywordTimer = setTimeout(() => {
|
||||
dramaAllPropPage.value = 1
|
||||
loadDramaAllPropList()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onPropLibraryDialogOpen() {
|
||||
if (propLibraryTab.value === 'library') loadPropLibraryList()
|
||||
else if (propLibraryTab.value === 'drama') loadDramaAllPropList()
|
||||
|
||||
}
|
||||
|
||||
function onPropLibraryTabChange() {
|
||||
if (propLibraryTab.value === 'library') {
|
||||
propLibraryPage.value = 1
|
||||
loadPropLibraryList()
|
||||
} else if (propLibraryTab.value === 'drama') {
|
||||
dramaAllPropPage.value = 1
|
||||
loadDramaAllPropList()
|
||||
}
|
||||
}
|
||||
|
||||
function propAddToEpisodeLoadingKey(scope, id) {
|
||||
return `${scope}-${id}`
|
||||
}
|
||||
|
||||
function isPropAddToEpisodeLoading(scope, id) {
|
||||
return addingPropFromLibraryId.value === propAddToEpisodeLoadingKey(scope, id)
|
||||
}
|
||||
|
||||
function openEditPropLibrary(item) {
|
||||
editPropLibraryForm.value = {
|
||||
id: item.id,
|
||||
name: item.name ?? '',
|
||||
category: item.category ?? '',
|
||||
description: item.description ?? '',
|
||||
tags: item.tags ?? ''
|
||||
}
|
||||
showEditPropLibrary.value = true
|
||||
}
|
||||
|
||||
async function submitEditPropLibrary() {
|
||||
if (!editPropLibraryForm.value?.id) return
|
||||
editPropLibrarySaving.value = true
|
||||
try {
|
||||
await propLibraryAPI.update(editPropLibraryForm.value.id, {
|
||||
name: editPropLibraryForm.value.name,
|
||||
category: editPropLibraryForm.value.category || null,
|
||||
description: editPropLibraryForm.value.description || null,
|
||||
tags: editPropLibraryForm.value.tags || null
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
showEditPropLibrary.value = false
|
||||
loadPropLibraryList()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
} finally {
|
||||
editPropLibrarySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeletePropLibrary(item) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除公共道具「${(item.name || '未命名').slice(0, 20)}」吗?`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
await propLibraryAPI.delete(item.id)
|
||||
ElMessage.success('已删除')
|
||||
loadPropLibraryList()
|
||||
} catch (e) {
|
||||
if (e === 'cancel') return
|
||||
ElMessage.error(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddPropToLibrary(prop) {
|
||||
if (!hasAssetImage(prop)) { ElMessage.warning('请先为该道具生成或上传图片'); return }
|
||||
addingPropToLibraryId.value = prop.id
|
||||
try {
|
||||
await propAPI.addToLibrary(prop.id, {})
|
||||
ElMessage.success('已加入本剧道具库')
|
||||
if (showPropLibrary.value) loadPropLibraryList()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加入失败')
|
||||
} finally {
|
||||
addingPropToLibraryId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddPropToMaterialLibrary(prop) {
|
||||
if (!hasAssetImage(prop)) { ElMessage.warning('请先为该道具生成或上传图片'); return }
|
||||
addingPropToMaterialId.value = prop.id
|
||||
try {
|
||||
await propAPI.addToMaterialLibrary(prop.id)
|
||||
ElMessage.success('已加入全局素材库')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加入失败')
|
||||
} finally {
|
||||
addingPropToMaterialId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function addPropToEpisode(item, scope) {
|
||||
if (!store.dramaId || !currentEpisodeId.value) {
|
||||
ElMessage.warning('请先选择本集')
|
||||
return
|
||||
}
|
||||
const loadingKey = propAddToEpisodeLoadingKey(scope, item.id)
|
||||
addingPropFromLibraryId.value = loadingKey
|
||||
try {
|
||||
const existingProp = (store.props || []).find((p) => p.name === item.name)
|
||||
if (existingProp) {
|
||||
await propAPI.update(existingProp.id, {
|
||||
name: item.name || existingProp.name,
|
||||
type: item.type || existingProp.type || undefined,
|
||||
description: item.description || existingProp.description || undefined,
|
||||
prompt: item.prompt || existingProp.prompt || undefined,
|
||||
image_url: item.image_url || existingProp.image_url || undefined,
|
||||
local_path: item.local_path || existingProp.local_path || undefined,
|
||||
})
|
||||
ElMessage.success(`「${item.name || '道具'}」已更新到本集`)
|
||||
} else {
|
||||
await propAPI.create({
|
||||
drama_id: store.dramaId,
|
||||
episode_id: currentEpisodeId.value,
|
||||
name: item.name || '',
|
||||
type: item.type || undefined,
|
||||
description: item.description || undefined,
|
||||
prompt: item.prompt || undefined,
|
||||
image_url: item.image_url || undefined,
|
||||
local_path: item.local_path || undefined,
|
||||
})
|
||||
ElMessage.success(`「${item.name || '道具'}」已加入本集`)
|
||||
}
|
||||
await loadDrama()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加入失败')
|
||||
} finally {
|
||||
addingPropFromLibraryId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onAddPropFromLibrary(item) {
|
||||
return addPropToEpisode(item, 'library')
|
||||
}
|
||||
|
||||
function onAddDramaPropToEpisode(item) {
|
||||
return addPropToEpisode(item, 'drama')
|
||||
}
|
||||
|
||||
function onAddTeamPropToEpisode(item) {
|
||||
return addPropToEpisode(item, 'team')
|
||||
}
|
||||
|
||||
// ── 添加道具简单弹窗的参考图 extract ─────────────────
|
||||
async function doExtractFromRef2(type) {
|
||||
if (type !== 'addProp') return
|
||||
const refImage = addPropAddRefImage.value
|
||||
if (!refImage) return
|
||||
extractingPropAddDesc.value = true
|
||||
try {
|
||||
const entityName = addPropForm.value?.name || ''
|
||||
const res = await uploadAPI.extractDescriptionFromImage('prop', refImage.dataUrl, entityName)
|
||||
if (res?.description) {
|
||||
addPropForm.value.description = res.description
|
||||
ElMessage.success('已从参考图提取特征描述')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '提取失败,请检查 AI 配置中是否有支持视觉的模型')
|
||||
} finally {
|
||||
extractingPropAddDesc.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 弹窗状态
|
||||
showAddProp,
|
||||
addPropSaving,
|
||||
addPropForm,
|
||||
showEditProp,
|
||||
editPropForm,
|
||||
editPropSaving,
|
||||
editPropPromptGenerating,
|
||||
extractingPropDesc,
|
||||
addPropRefImage,
|
||||
addPropRefFileInput,
|
||||
addPropAddRefImage,
|
||||
addPropAddRefFileInput,
|
||||
extractingPropAddDesc,
|
||||
// 生成状态
|
||||
propsExtracting,
|
||||
generatingPropIds,
|
||||
// 库状态
|
||||
showPropLibrary,
|
||||
propLibraryList,
|
||||
propLibraryLoading,
|
||||
propLibraryPage,
|
||||
propLibraryPageSize,
|
||||
propLibraryTotal,
|
||||
propLibraryKeyword,
|
||||
propLibraryTab,
|
||||
dramaAllPropList,
|
||||
dramaAllPropLoading,
|
||||
dramaAllPropPage,
|
||||
dramaAllPropPageSize,
|
||||
dramaAllPropTotal,
|
||||
dramaAllPropKeyword,
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
showEditPropLibrary,
|
||||
editPropLibraryForm,
|
||||
editPropLibrarySaving,
|
||||
addingPropToLibraryId,
|
||||
addingPropToMaterialId,
|
||||
|
||||
addingPropFromLibraryId,
|
||||
// 函数
|
||||
onExtractProps,
|
||||
stopPropPromptPoll,
|
||||
editProp,
|
||||
doGeneratePropPrompt,
|
||||
savePropRefImageIfAny,
|
||||
clearPropRefImage,
|
||||
doExtractPropFromImage,
|
||||
submitEditProp,
|
||||
submitAddProp,
|
||||
onClosePropDialog,
|
||||
onDeleteProp,
|
||||
onGeneratePropImage,
|
||||
loadPropLibraryList,
|
||||
debouncedLoadPropLibrary,
|
||||
loadDramaAllPropList,
|
||||
debouncedLoadDramaAllPropList,
|
||||
|
||||
|
||||
onPropLibraryDialogOpen,
|
||||
onPropLibraryTabChange,
|
||||
isPropAddToEpisodeLoading,
|
||||
openEditPropLibrary,
|
||||
submitEditPropLibrary,
|
||||
onDeletePropLibrary,
|
||||
onAddPropToLibrary,
|
||||
onAddPropToMaterialLibrary,
|
||||
|
||||
|
||||
|
||||
onAddPropFromLibrary,
|
||||
onAddDramaPropToEpisode,
|
||||
|
||||
doExtractFromRef2,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { sceneAPI } from '@/api/scenes'
|
||||
import { sceneLibraryAPI } from '@/api/sceneLibrary'
|
||||
import { uploadAPI } from '@/api/upload'
|
||||
import { useGenerationTaskStore, GEN_RESOURCE } from '@/stores/generationTaskStore'
|
||||
import { buildExtractTaskMeta, isEpisodeExtractRunning } from '@/composables/useGenerationTaskSync'
|
||||
|
||||
/**
|
||||
* 场景管理 Composable
|
||||
* @param {object} deps - 共享依赖
|
||||
* @param {object} deps.store - Pinia store
|
||||
* @param {import('vue').ComputedRef} deps.dramaId
|
||||
* @param {import('vue').ComputedRef} deps.currentEpisodeId
|
||||
* @param {Function} deps.getSelectedStyle
|
||||
* @param {Function} deps.scriptLanguage - ref
|
||||
* @param {Function} deps.loadDrama
|
||||
* @param {Function} deps.pollTask
|
||||
* @param {Function} deps.pollUntilResourceHasImage
|
||||
* @param {Function} deps.hasAssetImage
|
||||
* @param {object} deps.dramaAPI
|
||||
*/
|
||||
export function useScenes(deps) {
|
||||
const { store, dramaId, currentEpisodeId, getSelectedStyle, scriptLanguage, loadDrama, pollTask, pollUntilResourceHasImage, hasAssetImage, dramaAPI } = deps
|
||||
const genStore = useGenerationTaskStore()
|
||||
|
||||
function buildSceneImageMeta(scene) {
|
||||
const dramaTitle = store.drama?.title || ''
|
||||
const epNum = store.currentEpisode?.episode_number
|
||||
const epLabel = dramaTitle ? `${dramaTitle} · 第${epNum ?? ''}集` : `第${epNum ?? ''}集`
|
||||
return {
|
||||
dramaId: dramaId.value,
|
||||
episodeId: currentEpisodeId.value,
|
||||
dramaTitle,
|
||||
episodeNumber: epNum,
|
||||
resourceType: GEN_RESOURCE.SCENE_IMAGE,
|
||||
resourceId: scene.id,
|
||||
label: `${epLabel} 场景图: ${scene.location || scene.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
function dataUrlToFile(dataUrl, filename) {
|
||||
const arr = dataUrl.split(',')
|
||||
const mime = (arr[0].match(/:(.*?);/) || [])[1] || 'image/png'
|
||||
const bstr = atob(arr[1])
|
||||
let n = bstr.length
|
||||
const u8arr = new Uint8Array(n)
|
||||
while (n--) u8arr[n] = bstr.charCodeAt(n)
|
||||
return new File([u8arr], filename || 'reference.png', { type: mime })
|
||||
}
|
||||
|
||||
// ── 场景弹窗状态 ──────────────────────────────────────
|
||||
const showEditScene = ref(false)
|
||||
const editSceneForm = ref(null)
|
||||
const editSceneSaving = ref(false)
|
||||
const editScenePromptGenerating = ref(false)
|
||||
const extractingSceneDesc = ref(false)
|
||||
const addSceneRefImage = ref(null) // { dataUrl, filename }
|
||||
const addSceneRefFileInput = ref(null)
|
||||
let editScenePollTimer = null
|
||||
|
||||
// ── 场景生成状态 ──────────────────────────────────────
|
||||
const scenesExtracting = computed(() =>
|
||||
isEpisodeExtractRunning(genStore, dramaId.value, currentEpisodeId.value, GEN_RESOURCE.EXTRACT_SCENES)
|
||||
)
|
||||
const generatingSceneIds = reactive(new Set())
|
||||
|
||||
// ── 场景库状态 ────────────────────────────────────────
|
||||
const showSceneLibrary = ref(false)
|
||||
const sceneLibraryList = ref([])
|
||||
const sceneLibraryLoading = ref(false)
|
||||
const sceneLibraryPage = ref(1)
|
||||
const sceneLibraryPageSize = ref(20)
|
||||
const sceneLibraryTotal = ref(0)
|
||||
const sceneLibraryKeyword = ref('')
|
||||
const showEditSceneLibrary = ref(false)
|
||||
const editSceneLibraryForm = ref(null)
|
||||
const editSceneLibrarySaving = ref(false)
|
||||
const addingSceneToLibraryId = ref(null)
|
||||
const addingSceneToMaterialId = ref(null)
|
||||
const addingSceneFromLibraryId = ref(null)
|
||||
let sceneLibraryKeywordTimer = null
|
||||
|
||||
const sceneLibraryTab = ref('library')
|
||||
const dramaAllSceneList = ref([])
|
||||
const dramaAllSceneLoading = ref(false)
|
||||
const dramaAllScenePage = ref(1)
|
||||
const dramaAllScenePageSize = ref(20)
|
||||
const dramaAllSceneTotal = ref(0)
|
||||
const dramaAllSceneKeyword = ref('')
|
||||
let dramaAllSceneKeywordTimer = null
|
||||
|
||||
|
||||
// ── 函数 ──────────────────────────────────────────────
|
||||
async function onExtractScenes() {
|
||||
if (!currentEpisodeId.value) return
|
||||
const epId = currentEpisodeId.value
|
||||
const meta = buildExtractTaskMeta(store, dramaId.value, epId, GEN_RESOURCE.EXTRACT_SCENES, '提取场景')
|
||||
genStore.markRunning(meta)
|
||||
try {
|
||||
const res = await dramaAPI.extractBackgrounds(epId, {
|
||||
model: undefined,
|
||||
style: getSelectedStyle(),
|
||||
language: scriptLanguage.value
|
||||
})
|
||||
const taskId = res?.task_id
|
||||
if (taskId) {
|
||||
const pollRes = await pollTask(taskId, () => loadDrama(), meta)
|
||||
if (pollRes?.status !== 'failed') {
|
||||
ElMessage.success('场景提取完成')
|
||||
}
|
||||
} else {
|
||||
await loadDrama()
|
||||
ElMessage.success('场景提取任务已提交')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '提取失败')
|
||||
} finally {
|
||||
genStore.markDone(meta)
|
||||
}
|
||||
}
|
||||
|
||||
function openAddScene() {
|
||||
editSceneForm.value = { location: '', time: '', prompt: '' }
|
||||
showEditScene.value = true
|
||||
}
|
||||
|
||||
function stopScenePromptPoll() {
|
||||
if (editScenePollTimer) { clearInterval(editScenePollTimer); editScenePollTimer = null }
|
||||
}
|
||||
|
||||
function editScene(scene) {
|
||||
stopScenePromptPoll()
|
||||
editSceneForm.value = {
|
||||
id: scene.id,
|
||||
location: scene.location || '',
|
||||
time: scene.time || '',
|
||||
prompt: scene.prompt || '',
|
||||
polished_prompt: scene.polished_prompt || '',
|
||||
polished_prompt_single: scene.polished_prompt_single || '',
|
||||
image_url: scene.image_url || '',
|
||||
local_path: scene.local_path || '',
|
||||
ref_image: scene.ref_image || '',
|
||||
}
|
||||
showEditScene.value = true
|
||||
if (!scene.polished_prompt && scene.id && (scene.location || scene.time)) {
|
||||
editScenePromptGenerating.value = true
|
||||
let elapsed = 0
|
||||
editScenePollTimer = setInterval(async () => {
|
||||
elapsed += 3
|
||||
try {
|
||||
const res = await sceneAPI.get(scene.id)
|
||||
const p = res?.scene?.polished_prompt
|
||||
if (p) {
|
||||
if (editSceneForm.value?.id === scene.id) editSceneForm.value.polished_prompt = p
|
||||
stopScenePromptPoll()
|
||||
editScenePromptGenerating.value = false
|
||||
} else if (elapsed >= 60) {
|
||||
stopScenePromptPoll()
|
||||
editScenePromptGenerating.value = false
|
||||
}
|
||||
} catch (_) {
|
||||
stopScenePromptPoll()
|
||||
editScenePromptGenerating.value = false
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
async function doGenerateScenePrompt() {
|
||||
const form = editSceneForm.value
|
||||
if (!form?.id) return
|
||||
editScenePromptGenerating.value = true
|
||||
try {
|
||||
const res = await sceneAPI.generatePrompt(form.id)
|
||||
if (res?.polished_prompt) {
|
||||
form.polished_prompt = res.polished_prompt
|
||||
ElMessage.success('提示词已生成')
|
||||
await loadDrama()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '生成提示词失败')
|
||||
} finally {
|
||||
editScenePromptGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doGenerateSceneSinglePrompt() {
|
||||
const form = editSceneForm.value
|
||||
if (!form?.id) return
|
||||
editScenePromptGenerating.value = true
|
||||
try {
|
||||
const res = await sceneAPI.generatePrompt(form.id, undefined, undefined, 'single')
|
||||
if (res?.polished_prompt_single) {
|
||||
form.polished_prompt_single = res.polished_prompt_single
|
||||
ElMessage.success('单图提示词已生成')
|
||||
await loadDrama()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '生成提示词失败')
|
||||
} finally {
|
||||
editScenePromptGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSceneRefImageIfAny(sceneId) {
|
||||
const refImg = addSceneRefImage.value
|
||||
if (!refImg || !sceneId) return
|
||||
try {
|
||||
const file = dataUrlToFile(refImg.dataUrl, refImg.filename || 'reference.png')
|
||||
const uploadRes = await uploadAPI.uploadImage(file, { dramaId: dramaId.value })
|
||||
const refPath = uploadRes.local_path || uploadRes.url || ''
|
||||
await sceneAPI.putRefImage(sceneId, refPath)
|
||||
} catch (e) {
|
||||
console.warn('[saveSceneRefImage] 保存参考图失败:', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearSceneRefImage() {
|
||||
const form = editSceneForm.value
|
||||
if (!form?.id) return
|
||||
try {
|
||||
await sceneAPI.putRefImage(form.id, null)
|
||||
form.ref_image = ''
|
||||
ElMessage.success('参考图已移除')
|
||||
} catch (e) {
|
||||
ElMessage.error('移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function doExtractSceneFromImage() {
|
||||
const form = editSceneForm.value
|
||||
if (!form?.id) return
|
||||
extractingSceneDesc.value = true
|
||||
try {
|
||||
const res = await sceneAPI.extractFromImage(form.id)
|
||||
if (res?.prompt) {
|
||||
form.prompt = res.prompt
|
||||
ElMessage.success('已从图片提取场景描述')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '提取失败,请检查场景是否已上传参考图片')
|
||||
} finally {
|
||||
extractingSceneDesc.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEditScene() {
|
||||
const form = editSceneForm.value
|
||||
if (!form?.location?.trim() || !store.dramaId) return
|
||||
editSceneSaving.value = true
|
||||
try {
|
||||
if (form.id) {
|
||||
await sceneAPI.update(form.id, {
|
||||
location: form.location.trim(),
|
||||
time: form.time || undefined,
|
||||
prompt: form.prompt || undefined,
|
||||
polished_prompt: form.polished_prompt || undefined,
|
||||
polished_prompt_single: form.polished_prompt_single || undefined
|
||||
})
|
||||
await saveSceneRefImageIfAny(form.id)
|
||||
ElMessage.success('场景已保存')
|
||||
} else {
|
||||
await sceneAPI.create({
|
||||
drama_id: store.dramaId,
|
||||
episode_id: currentEpisodeId.value || undefined,
|
||||
location: form.location.trim(),
|
||||
time: form.time || undefined,
|
||||
prompt: form.prompt || undefined
|
||||
})
|
||||
await loadDrama()
|
||||
if (addSceneRefImage.value) {
|
||||
const newScene = (store.drama?.scenes || []).find(
|
||||
s => s.location === form.location.trim() && (s.time || '') === (form.time || '')
|
||||
)
|
||||
if (newScene?.id) await saveSceneRefImageIfAny(newScene.id)
|
||||
}
|
||||
ElMessage.success('场景已添加')
|
||||
}
|
||||
await loadDrama()
|
||||
showEditScene.value = false
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || (form.id ? '保存失败' : '添加失败'))
|
||||
} finally {
|
||||
editSceneSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCloseSceneDialog() {
|
||||
showEditScene.value = false
|
||||
stopScenePromptPoll()
|
||||
editScenePromptGenerating.value = false
|
||||
addSceneRefImage.value = null
|
||||
}
|
||||
|
||||
async function onDeleteScene(scene) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除场景「${(scene.location || scene.time || '未命名').slice(0, 20)}」吗?此操作不可恢复。`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
await sceneAPI.delete(scene.id)
|
||||
await loadDrama()
|
||||
ElMessage.success('场景已删除')
|
||||
} catch (e) {
|
||||
if (e === 'cancel') return
|
||||
ElMessage.error(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onGenerateSceneImage(scene, useQuadGrid = false) {
|
||||
scene.errorMsg = ''
|
||||
scene.error_msg = ''
|
||||
const meta = buildSceneImageMeta(scene)
|
||||
generatingSceneIds.add(scene.id)
|
||||
genStore.markRunning(meta)
|
||||
try {
|
||||
const res = await sceneAPI.generateImage({
|
||||
scene_id: scene.id,
|
||||
model: undefined,
|
||||
style: getSelectedStyle(),
|
||||
use_quad_grid: !!useQuadGrid
|
||||
})
|
||||
const taskId = res?.image_generation?.task_id ?? res?.task_id
|
||||
if (taskId) {
|
||||
const pollRes = await pollTask(taskId, () => loadDrama(), meta)
|
||||
if (pollRes?.status === 'failed') {
|
||||
scene.errorMsg = pollRes.error || '生成失败'
|
||||
} else {
|
||||
ElMessage.success('场景图片已生成')
|
||||
}
|
||||
} else {
|
||||
await loadDrama()
|
||||
await pollUntilResourceHasImage(() => {
|
||||
const list = store.drama?.scenes ?? store.currentEpisode?.scenes ?? []
|
||||
const s = list.find((x) => Number(x.id) === Number(scene.id))
|
||||
return !!(s && (s.image_url || s.local_path))
|
||||
})
|
||||
ElMessage.success('场景图片已生成')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
scene.errorMsg = e.message || '生成失败'
|
||||
ElMessage.error(e.message || '提交失败')
|
||||
} finally {
|
||||
generatingSceneIds.delete(scene.id)
|
||||
genStore.markDone(meta)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 场景库函数 ────────────────────────────────────────
|
||||
async function loadSceneLibraryList() {
|
||||
sceneLibraryLoading.value = true
|
||||
try {
|
||||
const res = await sceneLibraryAPI.list({
|
||||
drama_id: dramaId.value,
|
||||
page: sceneLibraryPage.value,
|
||||
page_size: sceneLibraryPageSize.value,
|
||||
keyword: sceneLibraryKeyword.value || undefined
|
||||
})
|
||||
sceneLibraryList.value = res?.items ?? []
|
||||
const pagination = res?.pagination ?? {}
|
||||
sceneLibraryTotal.value = pagination.total ?? 0
|
||||
if (pagination.page != null) sceneLibraryPage.value = pagination.page
|
||||
if (pagination.page_size != null) sceneLibraryPageSize.value = pagination.page_size
|
||||
} catch (e) {
|
||||
sceneLibraryList.value = []
|
||||
} finally {
|
||||
sceneLibraryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedLoadSceneLibrary() {
|
||||
if (sceneLibraryKeywordTimer) clearTimeout(sceneLibraryKeywordTimer)
|
||||
sceneLibraryKeywordTimer = setTimeout(() => {
|
||||
sceneLibraryPage.value = 1
|
||||
loadSceneLibraryList()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function loadDramaAllSceneList() {
|
||||
if (!dramaId.value) {
|
||||
dramaAllSceneList.value = []
|
||||
dramaAllSceneTotal.value = 0
|
||||
return
|
||||
}
|
||||
dramaAllSceneLoading.value = true
|
||||
try {
|
||||
const res = await sceneAPI.list(dramaId.value)
|
||||
let list = Array.isArray(res) ? res : (res?.items ?? res?.scenes ?? [])
|
||||
const kw = (dramaAllSceneKeyword.value || '').trim().toLowerCase()
|
||||
if (kw) {
|
||||
list = list.filter((s) => {
|
||||
const loc = (s.location || '').toLowerCase()
|
||||
const time = (s.time || '').toLowerCase()
|
||||
const desc = (s.description || '').toLowerCase()
|
||||
const prompt = (s.prompt || '').toLowerCase()
|
||||
return loc.includes(kw) || time.includes(kw) || desc.includes(kw) || prompt.includes(kw)
|
||||
})
|
||||
}
|
||||
dramaAllSceneTotal.value = list.length
|
||||
const start = (dramaAllScenePage.value - 1) * dramaAllScenePageSize.value
|
||||
dramaAllSceneList.value = list.slice(start, start + dramaAllScenePageSize.value)
|
||||
} catch {
|
||||
dramaAllSceneList.value = []
|
||||
dramaAllSceneTotal.value = 0
|
||||
} finally {
|
||||
dramaAllSceneLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedLoadDramaAllSceneList() {
|
||||
if (dramaAllSceneKeywordTimer) clearTimeout(dramaAllSceneKeywordTimer)
|
||||
dramaAllSceneKeywordTimer = setTimeout(() => {
|
||||
dramaAllScenePage.value = 1
|
||||
loadDramaAllSceneList()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onSceneLibraryDialogOpen() {
|
||||
if (sceneLibraryTab.value === 'library') loadSceneLibraryList()
|
||||
else if (sceneLibraryTab.value === 'drama') loadDramaAllSceneList()
|
||||
}
|
||||
|
||||
function onSceneLibraryTabChange() {
|
||||
if (sceneLibraryTab.value === 'library') {
|
||||
sceneLibraryPage.value = 1
|
||||
loadSceneLibraryList()
|
||||
} else if (sceneLibraryTab.value === 'drama') {
|
||||
dramaAllScenePage.value = 1
|
||||
loadDramaAllSceneList()
|
||||
}
|
||||
}
|
||||
|
||||
function sceneAddToEpisodeLoadingKey(scope, id) {
|
||||
return `${scope}-${id}`
|
||||
}
|
||||
|
||||
function isSceneAddToEpisodeLoading(scope, id) {
|
||||
return addingSceneFromLibraryId.value === sceneAddToEpisodeLoadingKey(scope, id)
|
||||
}
|
||||
|
||||
function openEditSceneLibrary(item) {
|
||||
editSceneLibraryForm.value = {
|
||||
id: item.id,
|
||||
location: item.location ?? '',
|
||||
time: item.time ?? '',
|
||||
category: item.category ?? '',
|
||||
description: item.description ?? '',
|
||||
tags: item.tags ?? ''
|
||||
}
|
||||
showEditSceneLibrary.value = true
|
||||
}
|
||||
|
||||
async function submitEditSceneLibrary() {
|
||||
if (!editSceneLibraryForm.value?.id) return
|
||||
editSceneLibrarySaving.value = true
|
||||
try {
|
||||
await sceneLibraryAPI.update(editSceneLibraryForm.value.id, {
|
||||
location: editSceneLibraryForm.value.location,
|
||||
time: editSceneLibraryForm.value.time || null,
|
||||
category: editSceneLibraryForm.value.category || null,
|
||||
description: editSceneLibraryForm.value.description || null,
|
||||
tags: editSceneLibraryForm.value.tags || null
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
showEditSceneLibrary.value = false
|
||||
loadSceneLibraryList()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
} finally {
|
||||
editSceneLibrarySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteSceneLibrary(item) {
|
||||
try {
|
||||
const name = (item.location || item.time || '未命名').slice(0, 20)
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除公共场景「${name}」吗?`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
await sceneLibraryAPI.delete(item.id)
|
||||
ElMessage.success('已删除')
|
||||
loadSceneLibraryList()
|
||||
} catch (e) {
|
||||
if (e === 'cancel') return
|
||||
ElMessage.error(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddSceneToLibrary(scene) {
|
||||
if (!hasAssetImage(scene)) { ElMessage.warning('请先为该场景生成或上传图片'); return }
|
||||
addingSceneToLibraryId.value = scene.id
|
||||
try {
|
||||
await sceneAPI.addToLibrary(scene.id, {})
|
||||
ElMessage.success('已加入本剧场景库')
|
||||
if (showSceneLibrary.value) loadSceneLibraryList()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加入失败')
|
||||
} finally {
|
||||
addingSceneToLibraryId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddSceneToMaterialLibrary(scene) {
|
||||
if (!hasAssetImage(scene)) { ElMessage.warning('请先为该场景生成或上传图片'); return }
|
||||
addingSceneToMaterialId.value = scene.id
|
||||
try {
|
||||
await sceneAPI.addToMaterialLibrary(scene.id)
|
||||
ElMessage.success('已加入全局素材库')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加入失败')
|
||||
} finally {
|
||||
addingSceneToMaterialId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function addSceneToEpisode(item, scope) {
|
||||
if (!store.dramaId || !currentEpisodeId.value) {
|
||||
ElMessage.warning('请先选择本集')
|
||||
return
|
||||
}
|
||||
const loadingKey = sceneAddToEpisodeLoadingKey(scope, item.id)
|
||||
addingSceneFromLibraryId.value = loadingKey
|
||||
try {
|
||||
const existingScene = (store.scenes || []).find((s) => s.location === item.location)
|
||||
if (existingScene) {
|
||||
await sceneAPI.update(existingScene.id, {
|
||||
location: item.location || existingScene.location,
|
||||
time: item.time || existingScene.time,
|
||||
prompt: existingScene.prompt || item.prompt || '',
|
||||
image_url: item.image_url || existingScene.image_url || undefined,
|
||||
local_path: item.local_path || existingScene.local_path || undefined,
|
||||
})
|
||||
ElMessage.success(`「${item.location || '场景'}」已更新到本集`)
|
||||
} else {
|
||||
await sceneAPI.create({
|
||||
drama_id: store.dramaId,
|
||||
episode_id: currentEpisodeId.value,
|
||||
location: item.location || '',
|
||||
time: item.time || '',
|
||||
prompt: item.prompt || '',
|
||||
image_url: item.image_url || undefined,
|
||||
local_path: item.local_path || undefined,
|
||||
})
|
||||
ElMessage.success(`「${item.location || '场景'}」已加入本集`)
|
||||
}
|
||||
await loadDrama()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加入失败')
|
||||
} finally {
|
||||
addingSceneFromLibraryId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onAddSceneFromLibrary(item) {
|
||||
return addSceneToEpisode(item, 'library')
|
||||
}
|
||||
|
||||
function onAddDramaSceneToEpisode(item) {
|
||||
return addSceneToEpisode(item, 'drama')
|
||||
}
|
||||
|
||||
function onAddTeamSceneToEpisode(item) {
|
||||
return addSceneToEpisode(item, 'team')
|
||||
}
|
||||
|
||||
return {
|
||||
// 弹窗状态
|
||||
showEditScene,
|
||||
editSceneForm,
|
||||
editSceneSaving,
|
||||
editScenePromptGenerating,
|
||||
extractingSceneDesc,
|
||||
addSceneRefImage,
|
||||
addSceneRefFileInput,
|
||||
// 生成状态
|
||||
scenesExtracting,
|
||||
generatingSceneIds,
|
||||
// 库状态
|
||||
showSceneLibrary,
|
||||
sceneLibraryList,
|
||||
sceneLibraryLoading,
|
||||
sceneLibraryPage,
|
||||
sceneLibraryPageSize,
|
||||
sceneLibraryTotal,
|
||||
sceneLibraryKeyword,
|
||||
sceneLibraryTab,
|
||||
dramaAllSceneList,
|
||||
dramaAllSceneLoading,
|
||||
dramaAllScenePage,
|
||||
dramaAllScenePageSize,
|
||||
dramaAllSceneTotal,
|
||||
dramaAllSceneKeyword,
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
showEditSceneLibrary,
|
||||
editSceneLibraryForm,
|
||||
editSceneLibrarySaving,
|
||||
addingSceneToLibraryId,
|
||||
addingSceneToMaterialId,
|
||||
|
||||
addingSceneFromLibraryId,
|
||||
// 函数
|
||||
onExtractScenes,
|
||||
openAddScene,
|
||||
stopScenePromptPoll,
|
||||
editScene,
|
||||
doGenerateScenePrompt,
|
||||
doGenerateSceneSinglePrompt,
|
||||
saveSceneRefImageIfAny,
|
||||
clearSceneRefImage,
|
||||
doExtractSceneFromImage,
|
||||
submitEditScene,
|
||||
onCloseSceneDialog,
|
||||
onDeleteScene,
|
||||
onGenerateSceneImage,
|
||||
loadSceneLibraryList,
|
||||
debouncedLoadSceneLibrary,
|
||||
loadDramaAllSceneList,
|
||||
debouncedLoadDramaAllSceneList,
|
||||
|
||||
|
||||
onSceneLibraryDialogOpen,
|
||||
onSceneLibraryTabChange,
|
||||
isSceneAddToEpisodeLoading,
|
||||
openEditSceneLibrary,
|
||||
submitEditSceneLibrary,
|
||||
onDeleteSceneLibrary,
|
||||
onAddSceneToLibrary,
|
||||
onAddSceneToMaterialLibrary,
|
||||
|
||||
|
||||
|
||||
onAddSceneFromLibrary,
|
||||
onAddDramaSceneToEpisode,
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { inject } from 'vue'
|
||||
|
||||
export const CANVAS_CONTEXT_KEY = Symbol('dramaCanvasContext')
|
||||
|
||||
export function useCanvasContext() {
|
||||
return inject(CANVAS_CONTEXT_KEY, null)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ref } from 'vue'
|
||||
import { imagesAPI } from '@/api/images'
|
||||
import { videosAPI } from '@/api/videos'
|
||||
|
||||
/**
|
||||
* 加载当前剧集分镜的 images / videos 列表(与 FilmCreate.loadStoryboardMedia 对齐)
|
||||
*/
|
||||
export function useCanvasStoryboardMedia() {
|
||||
const imagesBySbId = ref({})
|
||||
const videosBySbId = ref({})
|
||||
const mediaLoading = ref(false)
|
||||
|
||||
async function loadForStoryboards(storyboards) {
|
||||
const boards = storyboards || []
|
||||
if (!boards.length) {
|
||||
imagesBySbId.value = {}
|
||||
videosBySbId.value = {}
|
||||
return
|
||||
}
|
||||
mediaLoading.value = true
|
||||
try {
|
||||
const nextImages = { ...imagesBySbId.value }
|
||||
const nextVideos = { ...videosBySbId.value }
|
||||
await Promise.all(
|
||||
boards.map(async (sb) => {
|
||||
try {
|
||||
const [imgRes, vidRes] = await Promise.all([
|
||||
imagesAPI.list({ storyboard_id: sb.id, page: 1, page_size: 100 }),
|
||||
videosAPI.list({ storyboard_id: sb.id, page: 1, page_size: 50 }),
|
||||
])
|
||||
nextImages[sb.id] = imgRes?.items || []
|
||||
nextVideos[sb.id] = vidRes?.items || []
|
||||
} catch (_) {
|
||||
nextImages[sb.id] = []
|
||||
nextVideos[sb.id] = []
|
||||
}
|
||||
})
|
||||
)
|
||||
imagesBySbId.value = nextImages
|
||||
videosBySbId.value = nextVideos
|
||||
} finally {
|
||||
mediaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadForDrama(drama, episodeId = null) {
|
||||
const episodes = episodeId
|
||||
? (drama?.episodes || []).filter((ep) => ep.id === episodeId)
|
||||
: (drama?.episodes || [])
|
||||
const boards = episodes.flatMap((ep) => ep.storyboards || [])
|
||||
await loadForStoryboards(boards)
|
||||
}
|
||||
|
||||
return {
|
||||
imagesBySbId,
|
||||
videosBySbId,
|
||||
mediaLoading,
|
||||
loadForStoryboards,
|
||||
loadForDrama,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { taskAPI } from '@/api/task'
|
||||
import { imagesAPI } from '@/api/images'
|
||||
import { videosAPI } from '@/api/videos'
|
||||
import request from '@/utils/request'
|
||||
import { storyboardImageUrl } from '@/utils/mediaUrl'
|
||||
import {
|
||||
DEFAULT_PIPELINE,
|
||||
findStoryboardInDrama,
|
||||
getDramaGenerationOptions,
|
||||
toAbsoluteMediaUrl,
|
||||
} from '@/utils/canvasWorkflow'
|
||||
import { dramaUsesFirstLastFrame, sbVideoFirstLastUrls } from '@/utils/storyboardMedia'
|
||||
|
||||
async function pollTaskSimple(taskId, options = {}) {
|
||||
if (!taskId) return { status: 'failed', error: '缺少 task_id' }
|
||||
const maxAttempts = options.maxAttempts ?? 450
|
||||
const interval = options.interval ?? 2000
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise((r) => setTimeout(r, interval))
|
||||
try {
|
||||
const t = await taskAPI.get(taskId)
|
||||
if (t.status === 'completed') return { status: 'completed', result: t.result }
|
||||
if (t.status === 'failed') {
|
||||
return { status: 'failed', error: t.error?.message || t.error || '任务失败' }
|
||||
}
|
||||
} catch (e) {
|
||||
if (i === maxAttempts - 1) return { status: 'failed', error: e.message || '轮询失败' }
|
||||
}
|
||||
}
|
||||
return { status: 'timeout', error: '任务超时' }
|
||||
}
|
||||
|
||||
export async function runImageStep(drama, sb, genOpts) {
|
||||
const prompt = sb.polished_prompt || sb.image_prompt || sb.description || sb.action || ''
|
||||
if (!prompt.trim()) throw new Error(`分镜 #${sb.storyboard_number ?? sb.id} 缺少图片提示词`)
|
||||
const res = await imagesAPI.create({
|
||||
storyboard_id: sb.id,
|
||||
drama_id: drama.id,
|
||||
prompt,
|
||||
style: genOpts.style || undefined,
|
||||
aspect_ratio: genOpts.aspectRatio,
|
||||
})
|
||||
if (res?.task_id) {
|
||||
const polled = await pollTaskSimple(res.task_id)
|
||||
if (polled.status !== 'completed') throw new Error(polled.error || '分镜图生成失败')
|
||||
}
|
||||
}
|
||||
|
||||
export async function runVideoStep(drama, sb, genOpts) {
|
||||
const useFirstLast = dramaUsesFirstLastFrame(drama)
|
||||
const imagesBySbId = genOpts?.imagesBySbId || {}
|
||||
const { first, last } = sbVideoFirstLastUrls(sb, imagesBySbId, useFirstLast)
|
||||
const imgPath = first || storyboardImageUrl(sb)
|
||||
if (!imgPath && !sb.video_prompt && !last) {
|
||||
throw new Error(`分镜 #${sb.storyboard_number ?? sb.id} 缺少分镜图,无法生成视频`)
|
||||
}
|
||||
const absoluteFirst = toAbsoluteMediaUrl(imgPath)
|
||||
const absoluteLast = last ? toAbsoluteMediaUrl(last) : undefined
|
||||
const prompt = sb.video_prompt || sb.polished_prompt || sb.image_prompt || sb.description || ''
|
||||
const res = await videosAPI.create({
|
||||
drama_id: drama.id,
|
||||
storyboard_id: sb.id,
|
||||
prompt,
|
||||
image_url: absoluteFirst || undefined,
|
||||
first_frame_url: absoluteFirst || undefined,
|
||||
last_frame_url: absoluteLast,
|
||||
style: genOpts.style || undefined,
|
||||
aspect_ratio: genOpts.aspectRatio,
|
||||
resolution: genOpts.videoResolution || undefined,
|
||||
duration: sb.duration || undefined,
|
||||
})
|
||||
if (res?.task_id) {
|
||||
const polled = await pollTaskSimple(res.task_id)
|
||||
if (polled.status !== 'completed') throw new Error(polled.error || '视频生成失败')
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAudioStep(sb) {
|
||||
const text = (sb.dialogue || '').trim()
|
||||
if (!text) return { skipped: true, reason: '无对白' }
|
||||
await request.post('/audio/extract', {
|
||||
storyboard_id: sb.id,
|
||||
text,
|
||||
tts_kind: 'dialogue',
|
||||
})
|
||||
return { skipped: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单个分镜按 pipeline 顺序执行生成
|
||||
* @param {'image'|'video'|'audio'}[] pipeline
|
||||
*/
|
||||
export async function runStoryboardPipeline(drama, storyboardId, pipeline, hooks = {}) {
|
||||
const found = findStoryboardInDrama(drama, storyboardId)
|
||||
if (!found) throw new Error(`找不到分镜 ${storyboardId}`)
|
||||
let { storyboard: sb } = found
|
||||
const genOpts = {
|
||||
...getDramaGenerationOptions(drama),
|
||||
...(hooks.generationOptions || {}),
|
||||
}
|
||||
const steps = pipeline?.length ? pipeline : DEFAULT_PIPELINE
|
||||
const results = []
|
||||
|
||||
for (const step of steps) {
|
||||
hooks.onStepStart?.({ storyboardId, step, sb })
|
||||
try {
|
||||
if (step === 'image') {
|
||||
await runImageStep(drama, sb, genOpts)
|
||||
if (hooks.reloadStoryboard) {
|
||||
sb = (await hooks.reloadStoryboard(storyboardId)) || sb
|
||||
}
|
||||
} else if (step === 'video') {
|
||||
await runVideoStep(drama, sb, genOpts)
|
||||
if (hooks.reloadStoryboard) {
|
||||
sb = (await hooks.reloadStoryboard(storyboardId)) || sb
|
||||
}
|
||||
} else if (step === 'audio') {
|
||||
const audioRes = await runAudioStep(sb)
|
||||
results.push({ step, ...audioRes })
|
||||
}
|
||||
hooks.onStepComplete?.({ storyboardId, step, sb })
|
||||
} catch (err) {
|
||||
hooks.onStepError?.({ storyboardId, step, error: err })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/** 按工作流组顺序执行(组内分镜按 storyboard_ids 顺序) */
|
||||
export async function runWorkflowGroup(drama, group, hooks = {}) {
|
||||
const pipeline = group.pipeline || DEFAULT_PIPELINE
|
||||
const ids = group.storyboard_ids || []
|
||||
const summary = { groupId: group.id, ok: [], failed: [] }
|
||||
|
||||
for (const sbId of ids) {
|
||||
hooks.onStoryboardStart?.({ group, storyboardId: sbId })
|
||||
try {
|
||||
await runStoryboardPipeline(drama, sbId, pipeline, hooks)
|
||||
summary.ok.push(sbId)
|
||||
hooks.onStoryboardComplete?.({ group, storyboardId: sbId })
|
||||
} catch (err) {
|
||||
summary.failed.push({ storyboardId: sbId, error: err.message || String(err) })
|
||||
hooks.onStoryboardError?.({ group, storyboardId: sbId, error: err })
|
||||
if (hooks.stopOnError) break
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { GEN_RESOURCE } from '@/stores/generationTaskStore'
|
||||
|
||||
export function buildExtractTaskMeta(store, dramaId, episodeId, resourceType, labelSuffix) {
|
||||
const dramaTitle = store.drama?.title || ''
|
||||
const ep = store.drama?.episodes?.find((e) => Number(e.id) === Number(episodeId))
|
||||
|| (Number(store.currentEpisode?.id) === Number(episodeId) ? store.currentEpisode : null)
|
||||
const epNum = ep?.episode_number ?? store.currentEpisode?.episode_number
|
||||
const epLabel = dramaTitle ? `${dramaTitle} · 第${epNum ?? ''}集` : `第${epNum ?? ''}集`
|
||||
return {
|
||||
dramaId,
|
||||
episodeId,
|
||||
dramaTitle,
|
||||
episodeNumber: epNum,
|
||||
resourceType,
|
||||
resourceId: episodeId,
|
||||
label: `${epLabel} ${labelSuffix}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function isEpisodeExtractRunning(genStore, dramaId, episodeId, resourceType) {
|
||||
if (dramaId == null || episodeId == null || !genStore) return false
|
||||
return genStore.isRunning({
|
||||
dramaId,
|
||||
episodeId,
|
||||
resourceType,
|
||||
resourceId: episodeId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将全局任务 store 中当前集的运行中任务同步到 FilmCreate 本地 loading Sets。
|
||||
*/
|
||||
export function syncGeneratingSetsFromStore(genStore, dramaId, episodeId, sets) {
|
||||
if (dramaId == null || episodeId == null || !genStore) return
|
||||
|
||||
const running = genStore.getRunningForEpisode(dramaId, episodeId)
|
||||
const runningCharIds = new Set()
|
||||
const runningPropIds = new Set()
|
||||
const runningSceneIds = new Set()
|
||||
const runningSbImageIds = new Set()
|
||||
const runningSbFirstIds = new Set()
|
||||
const runningSbLastIds = new Set()
|
||||
const runningSbVideoIds = new Set()
|
||||
|
||||
for (const t of running) {
|
||||
const id = t.resourceId
|
||||
if (id == null) continue
|
||||
switch (t.resourceType) {
|
||||
case GEN_RESOURCE.CHAR_IMAGE:
|
||||
runningCharIds.add(id)
|
||||
sets.generatingCharIds?.add(id)
|
||||
break
|
||||
case GEN_RESOURCE.PROP_IMAGE:
|
||||
runningPropIds.add(id)
|
||||
sets.generatingPropIds?.add(id)
|
||||
break
|
||||
case GEN_RESOURCE.SCENE_IMAGE:
|
||||
runningSceneIds.add(id)
|
||||
sets.generatingSceneIds?.add(id)
|
||||
break
|
||||
case GEN_RESOURCE.SB_IMAGE:
|
||||
runningSbImageIds.add(id)
|
||||
sets.generatingSbImageIds?.add(id)
|
||||
break
|
||||
case GEN_RESOURCE.SB_FIRST_IMAGE:
|
||||
runningSbFirstIds.add(id)
|
||||
sets.generatingSbFirstImageIds?.add(id)
|
||||
break
|
||||
case GEN_RESOURCE.SB_LAST_IMAGE:
|
||||
runningSbLastIds.add(id)
|
||||
sets.generatingSbLastImageIds?.add(id)
|
||||
break
|
||||
case GEN_RESOURCE.SB_VIDEO:
|
||||
runningSbVideoIds.add(id)
|
||||
sets.generatingSbVideoIds?.add(id)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 移除 store 已非 running 的本地 loading(避免僵尸条目)
|
||||
if (sets.generatingCharIds) {
|
||||
for (const id of [...sets.generatingCharIds]) {
|
||||
if (!runningCharIds.has(id)) sets.generatingCharIds.delete(id)
|
||||
}
|
||||
}
|
||||
if (sets.generatingPropIds) {
|
||||
for (const id of [...sets.generatingPropIds]) {
|
||||
if (!runningPropIds.has(id)) sets.generatingPropIds.delete(id)
|
||||
}
|
||||
}
|
||||
if (sets.generatingSceneIds) {
|
||||
for (const id of [...sets.generatingSceneIds]) {
|
||||
if (!runningSceneIds.has(id)) sets.generatingSceneIds.delete(id)
|
||||
}
|
||||
}
|
||||
if (sets.generatingSbImageIds) {
|
||||
for (const id of [...sets.generatingSbImageIds]) {
|
||||
if (!runningSbImageIds.has(id)) sets.generatingSbImageIds.delete(id)
|
||||
}
|
||||
}
|
||||
if (sets.generatingSbFirstImageIds) {
|
||||
for (const id of [...sets.generatingSbFirstImageIds]) {
|
||||
if (!runningSbFirstIds.has(id)) sets.generatingSbFirstImageIds.delete(id)
|
||||
}
|
||||
}
|
||||
if (sets.generatingSbLastImageIds) {
|
||||
for (const id of [...sets.generatingSbLastImageIds]) {
|
||||
if (!runningSbLastIds.has(id)) sets.generatingSbLastImageIds.delete(id)
|
||||
}
|
||||
}
|
||||
if (sets.generatingSbVideoIds) {
|
||||
for (const id of [...sets.generatingSbVideoIds]) {
|
||||
if (!runningSbVideoIds.has(id)) sets.generatingSbVideoIds.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEpisodeContext(store, dramaId, episodeId) {
|
||||
const drama = store.drama
|
||||
const ep = drama?.episodes?.find((e) => Number(e.id) === Number(episodeId))
|
||||
|| (Number(store.currentEpisode?.id) === Number(episodeId) ? store.currentEpisode : null)
|
||||
return {
|
||||
dramaId,
|
||||
episodeId,
|
||||
dramaTitle: drama?.title || '',
|
||||
episodeNumber: ep?.episode_number ?? store.currentEpisode?.episode_number,
|
||||
storyboards: ep?.storyboards || store.storyboards || [],
|
||||
characters: ep?.characters || store.characters || [],
|
||||
scenes: ep?.scenes || store.scenes || [],
|
||||
props: ep?.props || store.props || [],
|
||||
allCharacters: collectDramaAssets(drama, 'characters'),
|
||||
allProps: collectDramaAssets(drama, 'props'),
|
||||
allScenes: collectDramaAssets(drama, 'scenes'),
|
||||
}
|
||||
}
|
||||
|
||||
function collectDramaAssets(drama, field) {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const ep of drama?.episodes || []) {
|
||||
for (const item of ep[field] || []) {
|
||||
const id = item?.id
|
||||
if (id == null || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
out.push(item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
import { generationAPI } from '@/api/generation'
|
||||
import { stylePromptMetadataForSave } from '@/constants/styleOptions'
|
||||
|
||||
/**
|
||||
* 从故事梗概调用 AI 生成多集剧本并写入 drama(与 FilmCreate.onGenerateStory 一致)
|
||||
* @returns {Promise<{ ok: boolean, dramaId?: number, episodeCount?: number, error?: string }>}
|
||||
*/
|
||||
export async function runGenerateStoryFromPremise({
|
||||
premise,
|
||||
storyStyle,
|
||||
storyType,
|
||||
storyEpisodeCount,
|
||||
scriptTitle,
|
||||
generationStyle,
|
||||
projectAspectRatio,
|
||||
store,
|
||||
router,
|
||||
route,
|
||||
loadDrama,
|
||||
savedCurrentEpisodeNumber,
|
||||
selectedEpisodeId,
|
||||
onEpisodeSelect,
|
||||
storyGenerating,
|
||||
scriptGenerating,
|
||||
replaceRouteWhenNew = true,
|
||||
onComplete,
|
||||
/** 为 true 时保存集数/梗概后不调用 loadDrama(用于剧本管理页生成后直接 router.push 进创作页) */
|
||||
skipPostLoad = false,
|
||||
}) {
|
||||
const text = (premise || '').trim()
|
||||
if (!text) {
|
||||
ElMessage.warning('请先输入故事梗概')
|
||||
return { ok: false }
|
||||
}
|
||||
|
||||
storyGenerating.value = true
|
||||
try {
|
||||
const res = await generationAPI.generateStory({
|
||||
premise: text,
|
||||
style: storyStyle || undefined,
|
||||
type: storyType || undefined,
|
||||
episode_count: storyEpisodeCount || 1,
|
||||
})
|
||||
|
||||
const episodes = res?.episodes || []
|
||||
if (episodes.length === 0) {
|
||||
ElMessage.error('AI 未能生成剧本,请重试')
|
||||
return { ok: false }
|
||||
}
|
||||
|
||||
scriptGenerating.value = true
|
||||
try {
|
||||
let dramaId = store.dramaId
|
||||
if (!dramaId) {
|
||||
const drama = await dramaAPI.create({
|
||||
title: scriptTitle || '新故事',
|
||||
description: text,
|
||||
genre: storyType || undefined,
|
||||
style: generationStyle || undefined,
|
||||
metadata: {
|
||||
...stylePromptMetadataForSave(generationStyle),
|
||||
story_style: storyStyle || undefined,
|
||||
aspect_ratio: projectAspectRatio || '16:9',
|
||||
},
|
||||
})
|
||||
store.setDrama(drama)
|
||||
dramaId = drama.id
|
||||
if (replaceRouteWhenNew && route?.params?.id === 'new' && router) {
|
||||
router.replace('/film/' + dramaId)
|
||||
}
|
||||
}
|
||||
|
||||
const epPayload = episodes.map((ep, i) => ({
|
||||
episode_number: ep.episode ?? i + 1,
|
||||
title: ep.title || `第${ep.episode ?? i + 1}集`,
|
||||
script_content: ep.content || '',
|
||||
}))
|
||||
savedCurrentEpisodeNumber.value = 1
|
||||
await dramaAPI.saveEpisodes(dramaId, epPayload)
|
||||
|
||||
await dramaAPI.saveOutline(dramaId, {
|
||||
summary: text,
|
||||
genre: storyType || undefined,
|
||||
style: generationStyle || undefined,
|
||||
metadata: {
|
||||
...stylePromptMetadataForSave(generationStyle),
|
||||
story_style: storyStyle || undefined,
|
||||
aspect_ratio: projectAspectRatio || '16:9',
|
||||
},
|
||||
}).catch(() => {})
|
||||
|
||||
if (!skipPostLoad) {
|
||||
await loadDrama()
|
||||
|
||||
const firstEp = (store.drama?.episodes || [])[0]
|
||||
if (firstEp) {
|
||||
selectedEpisodeId.value = firstEp.id
|
||||
onEpisodeSelect(firstEp.id)
|
||||
}
|
||||
}
|
||||
|
||||
const n = episodes.length
|
||||
if (!skipPostLoad) {
|
||||
ElMessage.success(n > 1 ? `剧本已生成,共 ${n} 集,已默认选中第1集` : '剧本已生成并已保存')
|
||||
} else {
|
||||
ElMessage.success(n > 1 ? `剧本已生成,共 ${n} 集` : '剧本已生成并已保存')
|
||||
}
|
||||
if (typeof onComplete === 'function') {
|
||||
onComplete({ episodeCount: n, dramaId })
|
||||
}
|
||||
return { ok: true, dramaId, episodeCount: n }
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '保存剧本失败')
|
||||
return { ok: false, error: e.message }
|
||||
} finally {
|
||||
scriptGenerating.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '故事生成失败')
|
||||
return { ok: false, error: e.message }
|
||||
} finally {
|
||||
storyGenerating.value = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ref, watchEffect } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'lmd-theme'
|
||||
const isDark = ref(localStorage.getItem(STORAGE_KEY) === 'dark')
|
||||
|
||||
function apply() {
|
||||
if (isDark.value) {
|
||||
document.documentElement.classList.remove('light')
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
document.documentElement.classList.add('light')
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, isDark.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
// 初始立即应用一次
|
||||
apply()
|
||||
|
||||
watchEffect(apply)
|
||||
|
||||
export function useTheme() {
|
||||
function toggle() {
|
||||
isDark.value = !isDark.value
|
||||
}
|
||||
return { isDark, toggle }
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/** 影像风格选项 - 静态配置数据 */
|
||||
export const generationStyleOptions = [
|
||||
{
|
||||
label: '写实 / 影视',
|
||||
options: [
|
||||
{ label: '写实', value: 'realistic',
|
||||
prompt: '超写实摄影风格,8K超清细节,精准自然光照,真实皮肤纹理,专业摄影机拍摄,RAW原片质感,超高清锐度,人物面部毛孔清晰可见',
|
||||
promptEn: 'photorealistic, ultra-detailed, 8k uhd, sharp focus, natural lighting, real skin texture, hyperrealism, professional photography, RAW photo',
|
||||
color: 'linear-gradient(135deg,#c9a87c,#7c5e3c)', thumb: '/style-thumbs/realistic.jpg' },
|
||||
{ label: '电影感', value: 'cinematic',
|
||||
prompt: '电影级大片画面,变形镜头压缩感,胶片颗粒质感,伦勃朗式戏剧性布光,浅景深虚化背景,专业调色风格,史诗级构图,35mm胶片美学,宽画幅银幕比例',
|
||||
promptEn: 'cinematic movie still, anamorphic lens, film grain, dramatic rembrandt lighting, shallow depth of field, color graded, epic composition, professional cinematography, 35mm film, widescreen',
|
||||
color: 'linear-gradient(135deg,#1a1a2e,#c9aa71)', thumb: '/style-thumbs/cinematic.jpg' },
|
||||
{ label: '纪录片', value: 'documentary',
|
||||
prompt: '纪录片摄影风格,自然可用光源,抓拍式真实瞬间,手持摄影机晃动感,新闻摄影美学,粗粝真实质感,颗粒感胶片,非摆拍自然状态',
|
||||
promptEn: 'documentary photography style, natural available light, candid authentic moment, handheld camera look, photojournalism, raw gritty realism, grain texture, unposed',
|
||||
color: 'linear-gradient(135deg,#4a6741,#8fbc8f)', thumb: '/style-thumbs/documentary.jpg' },
|
||||
{ label: '黑色电影', value: 'noir',
|
||||
prompt: '黑色电影风格,高对比度黑白影调,强烈明暗光影雕刻,百叶窗投影光纹,1940年代侦探片氛围,悬疑神秘气质,烟雾缭绕与雨夜街景',
|
||||
promptEn: 'film noir, dramatic high-contrast black and white, hard chiaroscuro shadows, venetian blind light patterns, moody 1940s detective aesthetic, mystery atmosphere, smoke and rain',
|
||||
color: 'linear-gradient(135deg,#1a1a1a,#666)', thumb: '/style-thumbs/noir.jpg' },
|
||||
{ label: '复古胶片', value: 'retro film',
|
||||
prompt: '复古胶片摄影美学,柯达色彩体系,漏光与光晕效果,浓重35mm胶片颗粒,褪色暖调色彩,模拟胶片质感,怀旧复古氛围,轻微过曝处理',
|
||||
promptEn: 'vintage retro film photography, kodachrome color palette, light leaks, heavy 35mm grain, faded warm tones, analog film aesthetics, nostalgic atmosphere, slightly overexposed',
|
||||
color: 'linear-gradient(135deg,#d4a373,#8b6914)', thumb: '/style-thumbs/retro.jpg' },
|
||||
{ label: '恐怖', value: 'horror',
|
||||
prompt: '恐怖氛围渲染,阴暗压抑情绪,浓厚大气雾气,深重戏剧阴影,诡异冷色布光,令人不安的构图,哥特元素点缀,去饱和暗调色板,心理悬疑张力',
|
||||
promptEn: 'horror atmosphere, dark ominous mood, dense atmospheric fog, deep dramatic shadows, eerie cold lighting, unsettling composition, gothic elements, desaturated dark palette, psychological tension',
|
||||
color: 'linear-gradient(135deg,#1a0a0a,#7b1111)', thumb: '/style-thumbs/horror.jpg' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '动漫 / 卡通',
|
||||
options: [
|
||||
{ label: '日本动漫', value: 'anime style',
|
||||
prompt: '日本动漫画风,精细赛璐璐上色,清晰黑色线稿,高饱和鲜艳配色,极具表现力的角色设计,动画工作室级别质量,漫画美学影响,关键帧视觉插图风格',
|
||||
promptEn: 'anime style, Japanese animation, clean cel shading, precise black linework, vibrant saturated colors, expressive character design, studio quality, manga influence, key visual illustration',
|
||||
color: 'linear-gradient(135deg,#ff9fd2,#a97cdb)', thumb: '/style-thumbs/anime.jpg' },
|
||||
{ label: '欧美漫画', value: 'comic style',
|
||||
prompt: '欧美漫画风格,粗犷墨线勾勒,半调网点纹理,充满动感的动作构图,平涂鲜艳色彩,超级英雄插画美学,墨水上色分格效果',
|
||||
promptEn: 'western comic book style, bold ink linework, halftone dot texture, dynamic action composition, flat vibrant colors, superhero illustration aesthetic, inked and colored panels',
|
||||
color: 'linear-gradient(135deg,#4169e1,#ff6b47)', thumb: '/style-thumbs/comic.jpg' },
|
||||
{ label: '卡通', value: 'cartoon',
|
||||
prompt: '卡通插画风格,简洁粗犷轮廓线,平涂纯色块面,夸张表情与肢体动作,活泼友好的设计感,欧美动画片风格,干净的矢量感画质',
|
||||
promptEn: 'cartoon illustration, simple bold outlines, flat solid colors, exaggerated expressive features, playful friendly design, western animation style, clean vector-like quality',
|
||||
color: 'linear-gradient(135deg,#ffd700,#ff6b6b)', thumb: '/style-thumbs/cartoon.jpg' },
|
||||
{ label: '2D 动画', value: '2d animation',
|
||||
prompt: '二维动画风格,流畅动画单帧画面,干净平面设计感,粗犷轮廓线条,鲜艳饱和色彩,动画长片级别质量,关键帧插画美学',
|
||||
promptEn: '2D animation style, smooth animated frame, clean flat design, bold outlines, vibrant colors, animated feature film quality, keyframe illustration',
|
||||
color: 'linear-gradient(135deg,#43e97b,#38f9d7)', thumb: '/style-thumbs/2d-animation.jpg' },
|
||||
{ label: '写实二次元', value: 'realistic anime',
|
||||
prompt: '写实二次元风格,动漫角色比例与精致五官,真实皮肤与头发微细节,细腻赛璐璐与软写实混合上色,电影级体积光与环境反射,现代都市或室内真实场景,镜头感构图与浅景深,保留二次元清晰轮廓同时具备影视级材质质感,日漫与国漫高质量宣传视觉气质',
|
||||
promptEn: 'realistic anime style, anime character proportions with refined facial features, realistic skin texture and detailed hair strands, hybrid cel shading and soft semi-realistic rendering, cinematic volumetric lighting and environment reflections, modern urban or interior real-world setting, cinematic composition with shallow depth of field, keep clean anime linework while preserving film-grade material realism, high-end Japanese and Chinese anime promotional visual aesthetic',
|
||||
color: 'linear-gradient(135deg,#7c83fd,#2dd4bf)', thumb: '/style-thumbs/realisticanime.png' },
|
||||
{ label: '都市3D风格', value: 'urban 3d',
|
||||
prompt: '都市三维风格,当代摩天楼与玻璃幕墙街景,钢混结构与金属反光,PBR物理材质与柔和全局光照,天空与建筑环境反射,轻微景深虚化车流与行人轮廓,干净偏写实三维渲染,国产都市剧与商业广告CG常见气质,高细节环境光遮蔽与体积雾点缀',
|
||||
promptEn: 'urban contemporary 3D CGI, modern skyscrapers and glass curtain wall streetscape, steel concrete architecture with subtle metal reflections, PBR materials soft global illumination sky and building reflections, gentle depth of field for traffic and pedestrian silhouettes, clean semi-realistic 3D render Chinese urban drama and commercial CG aesthetic, high detail ambient occlusion light atmospheric haze',
|
||||
color: 'linear-gradient(135deg,#1e3a5f,#38bdf8)', thumb: '/style-thumbs/3d-render.jpg' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '中国风格',
|
||||
options: [
|
||||
{ label: '国画水墨', value: 'ink wash',
|
||||
prompt: '中国传统水墨画风格,泼墨写意技法,单色笔墨晕染,竹毫笔触肌理,极简留白构图,宣纸纸张质感,诗意朦胧云雾氛围,国画工笔与写意结合',
|
||||
promptEn: 'traditional Chinese ink wash painting, sumi-e style, monochrome brushwork, bamboo brush strokes, minimalist composition, generous negative space, xuan paper texture, poetic misty atmosphere, guohua style',
|
||||
color: 'linear-gradient(135deg,#e8e0d5,#8b7355)', thumb: '/style-thumbs/ink-wash.jpg' },
|
||||
{ label: '中国风', value: 'chinese style',
|
||||
prompt: '中国传统美学,精致汉服服饰,朱红描金器物,精工刺绣纹样,明清朝代设计元素,古典建筑与亭台楼阁,景深悠远的意境',
|
||||
promptEn: 'Chinese traditional aesthetics, elegant hanfu costumes, red lacquer and gold ornaments, intricate embroidered patterns, Ming-Qing dynasty design elements, classical architecture, atmospheric depth',
|
||||
color: 'linear-gradient(135deg,#c0392b,#8b0000)', thumb: '/style-thumbs/chinese.jpg' },
|
||||
{ label: '古装', value: 'historical',
|
||||
prompt: '中国历史古装剧风格,唐宋朝代电影美学,飘逸汉服广袖,皇宫殿宇建筑,古典园林景观,浓郁暖调色彩分级,高制作水准影视质感',
|
||||
promptEn: 'Chinese historical drama, ancient China setting, Tang-Song dynasty cinematic aesthetic, flowing traditional hanfu robes, imperial palace architecture, classical garden, rich warm color grading, high production value',
|
||||
color: 'linear-gradient(135deg,#d4af37,#8b5e14)', thumb: '/style-thumbs/historical.jpg' },
|
||||
{ label: '武侠', value: 'wuxia',
|
||||
prompt: '武侠史诗画风,古代中国山河背景,丝绸长袍飞扬动感,云雾缥缈的山水胜景,戏剧性剑术对决姿态,水墨晕染氛围影响,侠客剑士英雄美学,史诗宽幅电影构图,烟雾光芒交织的悬疑气氛',
|
||||
promptEn: 'wuxia martial arts epic, ancient China, flowing silk robes in dynamic motion, misty mountain landscape, dramatic sword fighting pose, atmospheric ink wash influence, hero and swordsman aesthetic, cinematic epic wide shot, moody fog and light rays',
|
||||
color: 'linear-gradient(135deg,#2c3e50,#3498db)', thumb: '/style-thumbs/wuxia.jpg' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '国漫 / 现言',
|
||||
options: [
|
||||
{ label: '2D 古风', value: '2d gufeng',
|
||||
prompt: '国产二维古风插画,清瘦线稿与赛璐璐平涂,低饱和雅致配色,汉服与发饰精细刻画,亭台楼阁或山水留白,网文封面与番剧人设常见气质,干净无噪点数码绘',
|
||||
promptEn: 'Chinese 2D guofeng illustration, delicate linework and cel shading, soft muted elegant palette, detailed hanfu and hair ornaments, pavilion or misty landscape, web novel cover and donghua character art style, clean digital painting',
|
||||
color: 'linear-gradient(135deg,#e8dcc8,#9b7653)', thumb: '/style-thumbs/2d-gufeng.jpg' },
|
||||
{ label: '仙侠 3D', value: 'xianxia 3d',
|
||||
prompt: '仙侠玄幻三维渲染,空灵仙境氛围,灵力流光与法术粒子,广袖仙袍与玉冠发饰,云海奇峰与宫阙楼阁,国产仙侠剧与游戏CG审美,柔和体积光与景深',
|
||||
promptEn: 'Chinese xianxia fantasy 3D render, ethereal immortal realm, spiritual glow and spell particles, flowing immortal robes and jade hair crown, sea of clouds and celestial palace, Chinese fantasy drama and game CG aesthetic, soft volumetric light and depth of field',
|
||||
color: 'linear-gradient(135deg,#1a3a52,#7ec8e3)', thumb: '/style-thumbs/xianxia-3d.jpg' },
|
||||
{ label: '古风 3D', value: 'gufeng 3d',
|
||||
prompt: '古风写实三维角色与场景,次表面散射肤质与丝绸布料,高盘发与步摇细节,宫殿园林或市井街景,古装剧级服化道,暖调电影级调色,精致但不过分卡通',
|
||||
promptEn: 'Chinese historical 3D realistic character and scene, subsurface skin and silk fabric detail, elaborate hairpins and hanfu, palace garden or ancient street, costume drama level production design, warm cinematic color grading, refined semi-realistic 3D',
|
||||
color: 'linear-gradient(135deg,#5c4033,#c9a227)', thumb: '/style-thumbs/gufeng-3d.jpg' },
|
||||
{ label: '新中式国潮', value: 'neo chinese guochao',
|
||||
prompt: '新中式国潮视觉,传统纹样与书法笔触融入现代平面设计,高饱和撞色与霓虹点缀,祥云龙纹水墨几何化,海报插画感,年轻潮流与东方符号并存',
|
||||
promptEn: 'neo-Chinese guochao graphic style, traditional patterns and brush strokes in modern flat design, bold saturated colors with neon accents, stylized clouds dragons ink geometry, poster illustration vibe, youthful street fashion meets oriental motifs',
|
||||
color: 'linear-gradient(135deg,#c41e3a,#1a1a2e)', thumb: '/style-thumbs/neo-chinese-guochao.jpg' },
|
||||
{ label: '新古风', value: 'neo gufeng',
|
||||
prompt: '新古风插画,在古典意境上偏清新明亮,柔焦轮廓与细腻渐变,言情与仙侠题材常见,人物唯美表情细腻,背景水墨氤氲但不压抑,适合竖版封面',
|
||||
promptEn: 'neo guofeng illustration, classical mood with fresh bright tones, soft edges and smooth gradients, romance and xianxia novel aesthetic, delicate faces and expressive eyes, misty ink wash background, vertical cover art composition',
|
||||
color: 'linear-gradient(135deg,#f5e6d3,#b8860b)', thumb: '/style-thumbs/neo-gufeng.jpg' },
|
||||
{ label: '都市现言漫画', value: 'urban romance comic',
|
||||
prompt: '都市现代言情漫画风,明亮清透上色,写字楼咖啡厅街景,人物美型大眼简化鼻唇,条漫分镜感,点缀星光或柔焦浪漫光斑,国产现言漫常见甜宠气质',
|
||||
promptEn: 'urban contemporary romance manhua style, bright clean coloring, office cafe city street backgrounds, pretty stylized faces big eyes, webcomic panel feel, sparkle and soft bokeh romantic lighting, sweet modern Chinese romance comic aesthetic',
|
||||
color: 'linear-gradient(135deg,#ffeef8,#a78bfa)', thumb: '/style-thumbs/urban-romance-comic.jpg' },
|
||||
{ label: '韩漫纯爱', value: 'korean romance webtoon',
|
||||
prompt: '韩式条漫纯爱画风,极简干净线稿,柔和粉彩与渐变,角色清秀少年感,竖构图留白,心跳初恋氛围,柔边阴影与高光,类似韩国恋爱类网漫',
|
||||
promptEn: 'Korean romance webtoon style, clean minimal linework, soft pastel gradients, delicate youthful characters, vertical scroll composition, innocent first love mood, soft cel shading and highlights, Korean BL or romance manhwa aesthetic',
|
||||
color: 'linear-gradient(135deg,#ffd6e8,#b4a7d6)', thumb: '/style-thumbs/korean-romance-webtoon.jpg' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '绘画艺术',
|
||||
options: [
|
||||
{ label: '水彩', value: 'watercolor',
|
||||
prompt: '水彩绘画风格,湿润叠色柔边,透明色彩晕染,流动颜料自然扩散,纸张纤维质感,印象派笔触,明亮柔和色调,精致手绘插画质量',
|
||||
promptEn: 'watercolor painting, soft wet-on-wet edges, transparent color washes, flowing pigment blooms, delicate paper texture, impressionistic strokes, luminous pastel tones, fine art illustration',
|
||||
color: 'linear-gradient(135deg,#a8d8ea,#ffd3b6)', thumb: '/style-thumbs/watercolor.jpg' },
|
||||
{ label: '油画', value: 'oil painting',
|
||||
prompt: '布面油画风格,厚涂肌理质感,有力方向性笔触,深沉饱和色彩,古典大师明暗对比光法,博物馆级精品,文艺复兴美学传承',
|
||||
promptEn: 'oil painting on canvas, rich impasto textures, thick directional brushwork, deep saturated colors, old master chiaroscuro lighting, museum quality fine art, classical Renaissance aesthetic',
|
||||
color: 'linear-gradient(135deg,#d4a76a,#6b3728)', thumb: '/style-thumbs/oil-painting.jpg' },
|
||||
{ label: '素描', value: 'sketch',
|
||||
prompt: '精细铅笔素描,石墨绘画质感,精准排线与交叉网线,明暗调子处理,美术速写本质量,黑白单色,原始艺术张力,炭笔纸面肌理',
|
||||
promptEn: 'detailed pencil sketch, graphite drawing, precise hatching and crosshatching, tonal shading, fine art sketchbook quality, monochrome, raw artistic energy, charcoal texture',
|
||||
color: 'linear-gradient(135deg,#f0f0f0,#888)', thumb: '/style-thumbs/sketch.jpg' },
|
||||
{ label: '版画', value: 'woodblock print',
|
||||
prompt: '传统木刻版画风格,浮世绘美学,大块平涂色域,有限和谐色系,日本版画制作美学,图形化线条,北斋构图风格',
|
||||
promptEn: 'traditional woodblock print, ukiyo-e inspired, bold flat color areas, limited harmonious palette, Japanese printmaking aesthetic, graphic linework, Hokusai style composition',
|
||||
color: 'linear-gradient(135deg,#4a3728,#c9a87c)', thumb: '/style-thumbs/woodblock.jpg' },
|
||||
{ label: '印象派', value: 'impressionist',
|
||||
prompt: '印象派油画风格,松散表现性笔触,斑驳阳光光影效果,鲜明互补色彩,莫奈雷诺阿风格,户外写生自然光,大气光色交融',
|
||||
promptEn: 'impressionist oil painting, loose expressive brushstrokes, dappled sunlight effect, vibrant complementary colors, Monet-Renoir style, plein air outdoor painting, atmospheric light and color',
|
||||
color: 'linear-gradient(135deg,#7ec8e3,#f9c74f)', thumb: '/style-thumbs/impressionist.jpg' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '幻想 / 科幻',
|
||||
options: [
|
||||
{ label: '奇幻', value: 'fantasy',
|
||||
prompt: '史诗奇幻数字艺术,神奇空灵大气,戏剧性黄金时刻光效,神话生物与魔法世界,壮阔全景风光,高度细腻概念艺术,绘画插图质量',
|
||||
promptEn: 'epic fantasy digital art, magical ethereal atmosphere, dramatic golden hour lighting, mythical creatures and enchanted world, sweeping landscape, highly detailed concept art, painterly illustration quality',
|
||||
color: 'linear-gradient(135deg,#6a0572,#e8b86d)', thumb: '/style-thumbs/fantasy.jpg' },
|
||||
{ label: '暗黑奇幻', value: 'dark fantasy',
|
||||
prompt: '黑暗奇幻艺术风格,哥特式阴郁氛围,压抑暗沉色调,戏剧性边缘补光,克苏鲁秘法元素,巴洛克繁复细节,严酷粗粝的世界观,恐怖奇幻交融',
|
||||
promptEn: 'dark fantasy art, gothic ominous atmosphere, brooding dark palette, dramatic rim lighting, eldritch and arcane elements, baroque ornate detail, grim and gritty world, horror fantasy crossover',
|
||||
color: 'linear-gradient(135deg,#0d0d0d,#6b0f1a)', thumb: '/style-thumbs/dark-fantasy.jpg' },
|
||||
{ label: '科幻', value: 'sci-fi',
|
||||
prompt: '科幻概念艺术,未来科技元素,全息投影界面,先进文明设计美学,简洁科幻质感,太空时代材质,发光交互界面,硬科幻写实风格',
|
||||
promptEn: 'science fiction concept art, futuristic technology, holographic displays, sleek advanced civilization design, clean sci-fi aesthetic, space age materials, glowing interfaces, hard sci-fi realism',
|
||||
color: 'linear-gradient(135deg,#0a0a2e,#00d4ff)', thumb: '/style-thumbs/sci-fi.jpg' },
|
||||
{ label: '赛博朋克', value: 'cyberpunk',
|
||||
prompt: '赛博朋克美学,霓虹浸润雨后街道,反乌托邦巨型都市,高科技低生活世界,发光广告牌林立,漆黑雨夜氛围,霓虹粉紫与电光蓝,银翼杀手黑色电影气质',
|
||||
promptEn: 'cyberpunk aesthetic, neon-soaked rain-slicked streets, dystopian megacity, high tech low life, glowing advertising billboards, dark wet night, neon pink magenta and electric blue, blade runner noir atmosphere',
|
||||
color: 'linear-gradient(135deg,#0d0221,#ff00ff)', thumb: '/style-thumbs/cyberpunk.jpg' },
|
||||
{ label: '蒸汽朋克', value: 'steampunk',
|
||||
prompt: '蒸汽朋克美学,维多利亚时代工业幻想,光亮黄铜齿轮与铜管构件,蒸汽驱动机械装置,棕褐色暖调,精巧机械装置,护目镜与礼帽造型,华丽钟表机芯细节',
|
||||
promptEn: 'steampunk aesthetic, Victorian era industrial fantasy, polished brass gears and copper cogs, steam powered machinery, sepia warm tones, elaborate mechanical contraptions, goggles and top hats, ornate clockwork',
|
||||
color: 'linear-gradient(135deg,#3d2b1f,#c87941)', thumb: '/style-thumbs/steampunk.jpg' },
|
||||
{ label: '末世废土', value: 'post-apocalyptic',
|
||||
prompt: '末世废土荒漠,文明崩塌遗迹,灰暗低饱和色调,生存末日氛围,腐朽建筑与废墟,尘埃与碎石漫天,强烈戏剧光照,疯狂麦克斯美学',
|
||||
promptEn: 'post-apocalyptic wasteland, ruined crumbling civilization, harsh desaturated color palette, survival atmosphere, decayed architecture, dust and debris, harsh dramatic light, Mad Max aesthetic',
|
||||
color: 'linear-gradient(135deg,#3d3117,#8b7355)', thumb: '/style-thumbs/post-apoc.jpg' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '数字 / 现代',
|
||||
options: [
|
||||
{ label: '3D 渲染', value: '3d render',
|
||||
prompt: '三维CGI渲染,光线追踪全局光照,次表面散射写实质感,HDRI工作室照明,高精度多边形模型,物理渲染流程,Octane或Redshift级别品质,产品级可视化精度',
|
||||
promptEn: '3D CGI render, ray tracing global illumination, photorealistic subsurface scattering, studio HDRI lighting, high polygon model, physically based rendering, Octane or Redshift quality, product visualization',
|
||||
color: 'linear-gradient(135deg,#1a1a2e,#4facfe)', thumb: '/style-thumbs/3d-render.jpg' },
|
||||
{ label: '像素风', value: 'pixel art',
|
||||
prompt: '像素艺术风格,16位复古游戏美学,有限色板,清晰硬边像素颗粒,精灵图艺术质感,经典日式RPG视觉风格,等距或横版游戏画面',
|
||||
promptEn: 'pixel art, 16-bit retro game aesthetic, limited color palette, crisp hard pixels, sprite art style, classic JRPG visual, isometric or side-scroll game art',
|
||||
color: 'linear-gradient(135deg,#6272a4,#50fa7b)', thumb: '/style-thumbs/pixel-art.jpg' },
|
||||
{ label: '低多边形', value: 'low poly',
|
||||
prompt: '低多边形几何艺术,平面三角形切面,极简多边形数量,干净彩色切面组合,现代几何美学,三维折纸风格,抽象数字艺术感',
|
||||
promptEn: 'low poly geometric art, flat triangular faceted surfaces, minimal polygon count, clean colorful facets, modern geometric aesthetic, 3D origami style, abstract digital art',
|
||||
color: 'linear-gradient(135deg,#2193b0,#6dd5ed)', thumb: '/style-thumbs/low-poly.jpg' },
|
||||
{ label: '极简', value: 'minimalist',
|
||||
prompt: '极简主义设计美学,干净无杂乱构图,大量留白呼吸感,简洁几何形态,有限单色色系,包豪斯现代主义,优雅克制的简约美感',
|
||||
promptEn: 'minimalist design, clean uncluttered composition, generous negative space, simple geometric forms, limited monochromatic palette, modern Bauhaus aesthetic, sophisticated elegant simplicity',
|
||||
color: 'linear-gradient(135deg,#e0e0e0,#bdbdbd)', thumb: '/style-thumbs/minimalist.jpg' },
|
||||
{ label: '唯美梦幻', value: 'dreamy',
|
||||
prompt: '唯美梦幻美学,奶油色柔虚背景,粉彩柔和色调,空灵发光氛围,浪漫柔光打亮,细腻雾气与光晕,童话魔法质感,软焦梦境感',
|
||||
promptEn: 'dreamy aesthetic, creamy soft bokeh background, pastel color palette, ethereal glowing atmosphere, romantic soft lighting, delicate haze and glow, fairy tale magical quality, soft focus dreamy',
|
||||
color: 'linear-gradient(135deg,#ffecd2,#fcb69f)', thumb: '/style-thumbs/dreamy.jpg' },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 根据 value 查找风格选项对象
|
||||
* @param {string} val
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function findStyleOption(val) {
|
||||
for (const group of generationStyleOptions) {
|
||||
const found = group.options.find(o => o.value === val)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取传给图像/视频 AI 用的英文 prompt(效果最好)
|
||||
* @param {string} val - 风格 value
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function getStylePromptEn(val) {
|
||||
const v = (val || '').toString().trim()
|
||||
if (!v) return undefined
|
||||
const opt = findStyleOption(v)
|
||||
if (opt) return opt.promptEn || opt.prompt || v
|
||||
return v
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取中文风格描述(用于界面展示或中文场景提示词拼接)
|
||||
* @param {string} val - 风格 value
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function getStylePromptZh(val) {
|
||||
const v = (val || '').toString().trim()
|
||||
if (!v) return undefined
|
||||
const opt = findStyleOption(v)
|
||||
if (opt) return opt.prompt || opt.promptEn || v
|
||||
return v
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存剧集 metadata 时用:与后端约定字段 style_prompt_zh / style_prompt_en。
|
||||
* 未选风格时返回空串,写入后可覆盖旧 metadata 中的画风字段。
|
||||
*/
|
||||
export function stylePromptMetadataForSave(styleValue) {
|
||||
const v = (styleValue || '').toString().trim()
|
||||
if (!v) return { style_prompt_zh: '', style_prompt_en: '' }
|
||||
const opt = findStyleOption(v)
|
||||
if (!opt) return { style_prompt_zh: v, style_prompt_en: v }
|
||||
return {
|
||||
style_prompt_zh: opt.prompt || opt.promptEn || '',
|
||||
style_prompt_en: opt.promptEn || opt.prompt || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧项目只有 dramas.style(value)而 metadata 里没有画风长文案时,自动 saveOutline 合并写入,
|
||||
* 便于后端 mergeCfgStyleWithDrama 能拿到 default_style_en。不覆盖已有 style_prompt_en。
|
||||
* @returns {Promise<object>} 更新后的剧集对象(失败或未改则原样返回 drama)
|
||||
*/
|
||||
export async function backfillDramaStylePromptMetadataIfNeeded(dramaAPI, dramaId, drama) {
|
||||
if (!drama || dramaId == null) return drama
|
||||
const styleVal = (drama.style || '').toString().trim()
|
||||
if (!styleVal) return drama
|
||||
const hasEn = (drama.metadata?.style_prompt_en || '').toString().trim()
|
||||
if (hasEn) return drama
|
||||
const patch = stylePromptMetadataForSave(styleVal)
|
||||
if (!(patch.style_prompt_en || '').toString().trim()) return drama
|
||||
try {
|
||||
await dramaAPI.saveOutline(dramaId, { metadata: patch })
|
||||
return await dramaAPI.get(dramaId)
|
||||
} catch (_) {
|
||||
return drama
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createApp, h } from 'vue'
|
||||
import './styles/theme.css'
|
||||
// 初始化主题(必须在挂载前执行)
|
||||
import './composables/useTheme.js'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { ElConfigProvider } from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp({
|
||||
name: 'RootProvider',
|
||||
render() {
|
||||
return h(
|
||||
ElConfigProvider,
|
||||
{
|
||||
message: {
|
||||
duration: 5000,
|
||||
showClose: true,
|
||||
offset: 28,
|
||||
},
|
||||
},
|
||||
() => h(App)
|
||||
)
|
||||
},
|
||||
})
|
||||
const pinia = createPinia()
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'list',
|
||||
component: () => import('@/views/FilmList.vue'),
|
||||
meta: { title: '项目列表' }
|
||||
},
|
||||
{
|
||||
path: '/drama/:id',
|
||||
name: 'drama-detail',
|
||||
component: () => import('@/views/DramaDetail.vue'),
|
||||
meta: { title: '剧集管理' }
|
||||
},
|
||||
{
|
||||
path: '/film/:id',
|
||||
name: 'film',
|
||||
component: () => import('@/views/FilmCreate.vue'),
|
||||
meta: { title: 'AI 视频生成' }
|
||||
},
|
||||
{
|
||||
path: '/film/:id/canvas',
|
||||
name: 'film-canvas',
|
||||
component: () => import('@/views/DramaCanvas.vue'),
|
||||
meta: { title: '画布模式' }
|
||||
},
|
||||
{
|
||||
path: '/ai-config',
|
||||
name: 'ai-config',
|
||||
component: () => import('@/views/AiConfig.vue'),
|
||||
meta: { title: 'AI 配置' }
|
||||
},
|
||||
{
|
||||
path: '/free-create',
|
||||
name: 'free-create',
|
||||
component: () => import('@/views/FreeCreate.vue'),
|
||||
meta: { title: '自由创作' }
|
||||
},
|
||||
{
|
||||
path: '/media-library',
|
||||
name: 'media-library',
|
||||
component: () => import('@/views/MediaLibrary.vue'),
|
||||
meta: { title: '媒体素材库' }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - LocalMiniDrama`
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,131 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
function episodeVideoKey(dramaId, episodeId) {
|
||||
if (dramaId == null || episodeId == null) return null
|
||||
return `${dramaId}:${episodeId}`
|
||||
}
|
||||
|
||||
export const useFilmStore = defineStore('film', () => {
|
||||
const drama = ref(null)
|
||||
const currentEpisode = ref(null)
|
||||
const storyInput = ref('')
|
||||
const scriptContent = ref('')
|
||||
const videoResolution = ref('480p')
|
||||
/** 按 dramaId:episodeId 存储合成视频进度与状态 */
|
||||
const videoStateByKey = ref({})
|
||||
|
||||
const dramaId = computed(() => drama.value?.id ?? null)
|
||||
// 角色/道具/场景默认只显示本集资源(随「选择第几集」变化)
|
||||
const characters = computed(() => currentEpisode.value?.characters ?? [])
|
||||
const scenes = computed(() => currentEpisode.value?.scenes ?? [])
|
||||
const props = computed(() => currentEpisode.value?.props ?? [])
|
||||
const storyboards = computed(() => currentEpisode.value?.storyboards ?? [])
|
||||
|
||||
const currentVideoKey = computed(() =>
|
||||
episodeVideoKey(drama.value?.id ?? null, currentEpisode.value?.id ?? null)
|
||||
)
|
||||
|
||||
const videoProgress = computed(() => {
|
||||
const k = currentVideoKey.value
|
||||
if (!k) return 0
|
||||
return videoStateByKey.value[k]?.progress ?? 0
|
||||
})
|
||||
|
||||
const videoStatus = computed(() => {
|
||||
const k = currentVideoKey.value
|
||||
if (!k) return 'idle'
|
||||
return videoStateByKey.value[k]?.status ?? 'idle'
|
||||
})
|
||||
|
||||
function _ensureVideoState(key) {
|
||||
if (!key) return null
|
||||
if (!videoStateByKey.value[key]) {
|
||||
videoStateByKey.value = {
|
||||
...videoStateByKey.value,
|
||||
[key]: { status: 'idle', progress: 0 },
|
||||
}
|
||||
}
|
||||
return videoStateByKey.value[key]
|
||||
}
|
||||
|
||||
function setDrama(d) {
|
||||
drama.value = d
|
||||
}
|
||||
|
||||
function setCurrentEpisode(ep) {
|
||||
currentEpisode.value = ep
|
||||
}
|
||||
|
||||
function setStoryInput(text) {
|
||||
storyInput.value = text
|
||||
}
|
||||
|
||||
function setScriptContent(text) {
|
||||
scriptContent.value = text
|
||||
}
|
||||
|
||||
function setVideoProgress(p, dId, eId) {
|
||||
const key = episodeVideoKey(
|
||||
dId ?? drama.value?.id ?? null,
|
||||
eId ?? currentEpisode.value?.id ?? null
|
||||
)
|
||||
if (!key) return
|
||||
const prev = _ensureVideoState(key)
|
||||
videoStateByKey.value = {
|
||||
...videoStateByKey.value,
|
||||
[key]: { ...prev, progress: p },
|
||||
}
|
||||
}
|
||||
|
||||
function setVideoStatus(s, dId, eId) {
|
||||
const key = episodeVideoKey(
|
||||
dId ?? drama.value?.id ?? null,
|
||||
eId ?? currentEpisode.value?.id ?? null
|
||||
)
|
||||
if (!key) return
|
||||
const prev = _ensureVideoState(key)
|
||||
videoStateByKey.value = {
|
||||
...videoStateByKey.value,
|
||||
[key]: { ...prev, status: s },
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoStatus(dId, eId) {
|
||||
const key = episodeVideoKey(dId, eId)
|
||||
if (!key) return 'idle'
|
||||
return videoStateByKey.value[key]?.status ?? 'idle'
|
||||
}
|
||||
|
||||
function reset() {
|
||||
drama.value = null
|
||||
currentEpisode.value = null
|
||||
storyInput.value = ''
|
||||
scriptContent.value = ''
|
||||
// 保留 videoStateByKey:跨剧切换时其它项目的合成状态不丢失
|
||||
}
|
||||
|
||||
return {
|
||||
drama,
|
||||
currentEpisode,
|
||||
storyInput,
|
||||
scriptContent,
|
||||
videoResolution,
|
||||
videoStateByKey,
|
||||
videoProgress,
|
||||
videoStatus,
|
||||
dramaId,
|
||||
characters,
|
||||
scenes,
|
||||
props,
|
||||
storyboards,
|
||||
setDrama,
|
||||
setCurrentEpisode,
|
||||
setStoryInput,
|
||||
setScriptContent,
|
||||
setVideoProgress,
|
||||
setVideoStatus,
|
||||
getVideoStatus,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,591 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { taskAPI } from '@/api/task'
|
||||
import { imagesAPI } from '@/api/images'
|
||||
import { videosAPI } from '@/api/videos'
|
||||
|
||||
/** 资源类型常量 */
|
||||
export const GEN_RESOURCE = {
|
||||
CHAR_IMAGE: 'char_image',
|
||||
PROP_IMAGE: 'prop_image',
|
||||
SCENE_IMAGE: 'scene_image',
|
||||
SB_IMAGE: 'sb_image',
|
||||
SB_FIRST_IMAGE: 'sb_first_image',
|
||||
SB_LAST_IMAGE: 'sb_last_image',
|
||||
SB_VIDEO: 'sb_video',
|
||||
EPISODE_MERGE: 'episode_merge',
|
||||
EXTRACT_CHARACTERS: 'extract_characters',
|
||||
EXTRACT_PROPS: 'extract_props',
|
||||
EXTRACT_SCENES: 'extract_scenes',
|
||||
GENERATE_STORYBOARD: 'generate_storyboard',
|
||||
}
|
||||
|
||||
/** 超过此时间仍为 running 且无进展则自动清理(毫秒) */
|
||||
const STALE_TASK_MS = 30 * 60 * 1000
|
||||
|
||||
const LAST_FRAME_TYPES = new Set(['last', 'storyboard_last', 'tail', 'last_frame'])
|
||||
const FIRST_FRAME_TYPES = new Set(['first', 'storyboard_first', 'head', 'first_frame'])
|
||||
|
||||
function taskKey({ dramaId, episodeId, resourceType, resourceId }) {
|
||||
return `${dramaId}:${episodeId}:${resourceType}:${resourceId}`
|
||||
}
|
||||
|
||||
function isLastFrameType(frameType) {
|
||||
if (frameType == null || frameType === '') return false
|
||||
return LAST_FRAME_TYPES.has(String(frameType).toLowerCase())
|
||||
}
|
||||
|
||||
function isFirstFrameType(frameType) {
|
||||
if (frameType == null || frameType === '') return false
|
||||
return FIRST_FRAME_TYPES.has(String(frameType).toLowerCase())
|
||||
}
|
||||
|
||||
function sbImageResourceType(frameType) {
|
||||
if (isLastFrameType(frameType)) return GEN_RESOURCE.SB_LAST_IMAGE
|
||||
if (isFirstFrameType(frameType)) return GEN_RESOURCE.SB_FIRST_IMAGE
|
||||
return GEN_RESOURCE.SB_IMAGE
|
||||
}
|
||||
|
||||
function isActiveTaskStatus(status) {
|
||||
return status === 'pending' || status === 'processing' || status === 'running'
|
||||
}
|
||||
|
||||
function taskFailMessage(t) {
|
||||
if (!t) return '任务失败'
|
||||
return (t.error || t.message || '任务失败').trim()
|
||||
}
|
||||
|
||||
export const useGenerationTaskStore = defineStore('generationTask', () => {
|
||||
/** @type {Map<string, object>} */
|
||||
const tasks = ref(new Map())
|
||||
/** @type {Map<string, Promise>} taskId → poll promise */
|
||||
const pollPromises = ref(new Map())
|
||||
/** 本会话已处理过的恢复 taskId,避免切集重复注册 */
|
||||
const recoveredTaskIds = ref(new Set())
|
||||
/** 用户或系统主动停止轮询的 taskId */
|
||||
const cancelledPollTaskIds = ref(new Set())
|
||||
|
||||
const runningTasks = computed(() => {
|
||||
return [...tasks.value.values()].filter((t) => t.status === 'running')
|
||||
})
|
||||
|
||||
function _setTask(key, task) {
|
||||
const next = new Map(tasks.value)
|
||||
next.set(key, task)
|
||||
tasks.value = next
|
||||
}
|
||||
|
||||
function _deleteTask(key) {
|
||||
const next = new Map(tasks.value)
|
||||
next.delete(key)
|
||||
tasks.value = next
|
||||
}
|
||||
|
||||
function _findKeysByTaskId(taskId) {
|
||||
if (!taskId) return []
|
||||
return [...tasks.value.entries()]
|
||||
.filter(([, t]) => t.taskId === taskId)
|
||||
.map(([k]) => k)
|
||||
}
|
||||
|
||||
function _finishKeys(keys, status, error) {
|
||||
for (const key of keys) {
|
||||
const existing = tasks.value.get(key)
|
||||
if (!existing) continue
|
||||
_setTask(key, {
|
||||
...existing,
|
||||
status,
|
||||
error: error || '',
|
||||
finishedAt: Date.now(),
|
||||
})
|
||||
const delay = status === 'failed' ? 8000 : 3000
|
||||
setTimeout(() => _deleteTask(key), delay)
|
||||
}
|
||||
}
|
||||
|
||||
function markRunning(meta) {
|
||||
const key = taskKey(meta)
|
||||
if (!key || key.includes('undefined') || key.includes('null')) return key
|
||||
_setTask(key, {
|
||||
...meta,
|
||||
key,
|
||||
status: 'running',
|
||||
startedAt: Date.now(),
|
||||
})
|
||||
return key
|
||||
}
|
||||
|
||||
function markDone(meta) {
|
||||
const key = typeof meta === 'string' ? meta : taskKey(meta)
|
||||
const existing = tasks.value.get(key)
|
||||
const taskId = existing?.taskId || (typeof meta === 'object' ? meta?.taskId : null)
|
||||
const keys = taskId ? _findKeysByTaskId(taskId) : [key]
|
||||
if (keys.length === 0 && key) keys.push(key)
|
||||
_finishKeys(keys, 'completed')
|
||||
}
|
||||
|
||||
function markFailed(meta, error) {
|
||||
const key = typeof meta === 'string' ? meta : taskKey(meta)
|
||||
const existing = tasks.value.get(key)
|
||||
const taskId = existing?.taskId || (typeof meta === 'object' ? meta?.taskId : null)
|
||||
const keys = taskId ? _findKeysByTaskId(taskId) : [key]
|
||||
if (keys.length === 0 && key) keys.push(key)
|
||||
_finishKeys(keys, 'failed', error)
|
||||
}
|
||||
|
||||
function isRunning(meta) {
|
||||
const key = taskKey(meta)
|
||||
const t = tasks.value.get(key)
|
||||
return t?.status === 'running'
|
||||
}
|
||||
|
||||
function getRunningForEpisode(dramaId, episodeId) {
|
||||
if (dramaId == null || episodeId == null) return []
|
||||
return runningTasks.value.filter(
|
||||
(t) => Number(t.dramaId) === Number(dramaId) && Number(t.episodeId) === Number(episodeId)
|
||||
)
|
||||
}
|
||||
|
||||
function getAllRunningTasks() {
|
||||
return runningTasks.value
|
||||
}
|
||||
|
||||
/** 停止指定 taskId 的轮询并清除 store 中的 running 状态 */
|
||||
function stopPollingTask(taskId, reason) {
|
||||
if (!taskId) return
|
||||
cancelledPollTaskIds.value = new Set([...cancelledPollTaskIds.value, taskId])
|
||||
markFailed({ taskId }, reason || '任务已停止')
|
||||
}
|
||||
|
||||
/** 清除所有 running 任务(页面级兜底) */
|
||||
function clearAllRunningTasks(reason) {
|
||||
for (const t of [...runningTasks.value]) {
|
||||
if (t.taskId) stopPollingTask(t.taskId, reason)
|
||||
else markFailed(t, reason || '已清除')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 store 中 running 任务是否与后端一致;清理已完成/失败/超时的僵尸条目。
|
||||
*/
|
||||
async function reconcileRunningTasks(ctx = {}) {
|
||||
const { characters = [], props = [], scenes = [], storyboards = [] } = ctx
|
||||
const running = [...runningTasks.value]
|
||||
const now = Date.now()
|
||||
|
||||
for (const t of running) {
|
||||
if (t.startedAt && now - t.startedAt > STALE_TASK_MS) {
|
||||
markFailed(t, '任务等待超时,已自动清除(请刷新确认是否已完成)')
|
||||
continue
|
||||
}
|
||||
|
||||
if (t.taskId) {
|
||||
try {
|
||||
const remote = await taskAPI.get(t.taskId)
|
||||
if (remote.status === 'completed') {
|
||||
markDone(t)
|
||||
continue
|
||||
}
|
||||
if (remote.status === 'failed') {
|
||||
markFailed(t, taskFailMessage(remote))
|
||||
continue
|
||||
}
|
||||
if (!isActiveTaskStatus(remote.status)) {
|
||||
markDone(t)
|
||||
}
|
||||
} catch (_) {
|
||||
// 网络异常跳过,下次 reconcile 再试
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (t.resourceType === GEN_RESOURCE.CHAR_IMAGE && t.resourceId != null) {
|
||||
const c = characters.find((x) => Number(x.id) === Number(t.resourceId))
|
||||
if (c && (c.image_url || c.local_path)) markDone(t)
|
||||
} else if (t.resourceType === GEN_RESOURCE.PROP_IMAGE && t.resourceId != null) {
|
||||
const p = props.find((x) => Number(x.id) === Number(t.resourceId))
|
||||
if (p && (p.image_url || p.local_path)) markDone(t)
|
||||
} else if (t.resourceType === GEN_RESOURCE.SCENE_IMAGE && t.resourceId != null) {
|
||||
const s = scenes.find((x) => Number(x.id) === Number(t.resourceId))
|
||||
if (s && (s.image_url || s.local_path)) markDone(t)
|
||||
}
|
||||
}
|
||||
|
||||
void storyboards
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询异步任务;同一 taskId 只轮询一次,多路 await 共享结果。
|
||||
*/
|
||||
function pollTask(taskId, meta, onDone, options = {}) {
|
||||
if (!taskId) return Promise.resolve({ status: 'failed', error: '缺少 task_id' })
|
||||
|
||||
const key = markRunning({ ...meta, taskId })
|
||||
|
||||
if (pollPromises.value.has(taskId)) {
|
||||
return pollPromises.value.get(taskId)
|
||||
}
|
||||
|
||||
const maxAttempts = options.maxAttempts ?? 450
|
||||
const interval = options.interval ?? 2000
|
||||
const showErrorToast = options.showErrorToast !== false
|
||||
const showTimeoutToast = options.showTimeoutToast !== false
|
||||
|
||||
let attempts = 0
|
||||
let stopped = false
|
||||
const promise = new Promise((resolve) => {
|
||||
const tick = async () => {
|
||||
if (stopped || cancelledPollTaskIds.value.has(taskId)) {
|
||||
markFailed(key, '任务轮询已停止')
|
||||
return resolve({ status: 'cancelled', error: '任务轮询已停止' })
|
||||
}
|
||||
attempts++
|
||||
try {
|
||||
const t = await taskAPI.get(taskId)
|
||||
if (t.status === 'completed') {
|
||||
if (onDone) {
|
||||
try {
|
||||
await onDone()
|
||||
} catch (e) {
|
||||
console.warn('[generationTaskStore] onDone failed:', e?.message)
|
||||
}
|
||||
}
|
||||
markDone(key)
|
||||
return resolve({ status: 'completed', result: t.result })
|
||||
}
|
||||
if (t.status === 'failed') {
|
||||
const errMsg = taskFailMessage(t)
|
||||
markFailed(key, errMsg)
|
||||
if (showErrorToast && options.ElMessage) {
|
||||
options.ElMessage.error(errMsg)
|
||||
}
|
||||
return resolve({ status: 'failed', error: errMsg })
|
||||
}
|
||||
} catch (pollErr) {
|
||||
console.warn('[generationTaskStore] poll attempt failed:', pollErr?.message)
|
||||
}
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(tick, interval)
|
||||
} else {
|
||||
const timeoutMsg = options.timeoutMessage
|
||||
|| '生成任务已超时(超过15分钟),请刷新页面查看是否已完成'
|
||||
markFailed(key, timeoutMsg)
|
||||
if (showTimeoutToast && options.ElMessage) {
|
||||
options.ElMessage.warning(timeoutMsg)
|
||||
}
|
||||
resolve({ status: 'timeout', error: timeoutMsg })
|
||||
}
|
||||
}
|
||||
setTimeout(tick, interval)
|
||||
})
|
||||
|
||||
const nextPolls = new Map(pollPromises.value)
|
||||
nextPolls.set(taskId, promise)
|
||||
pollPromises.value = nextPolls
|
||||
|
||||
return promise.finally(() => {
|
||||
stopped = true
|
||||
cancelledPollTaskIds.value = new Set([...cancelledPollTaskIds.value, taskId])
|
||||
const cleaned = new Map(pollPromises.value)
|
||||
cleaned.delete(taskId)
|
||||
pollPromises.value = cleaned
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 若 task 仍在运行且尚未轮询,则 attach 轮询(用于页面刷新/切集恢复)。
|
||||
*/
|
||||
async function attachPollIfNeeded(taskId, meta, onDone, options = {}) {
|
||||
if (!taskId) return null
|
||||
|
||||
if (pollPromises.value.has(taskId)) {
|
||||
markRunning({ ...meta, taskId })
|
||||
return pollPromises.value.get(taskId)
|
||||
}
|
||||
|
||||
try {
|
||||
const t = await taskAPI.get(taskId)
|
||||
if (t.status === 'completed') {
|
||||
if (onDone) await onDone()
|
||||
markDone({ ...meta, taskId })
|
||||
return { status: 'completed', result: t.result }
|
||||
}
|
||||
if (t.status === 'failed') {
|
||||
markFailed({ ...meta, taskId }, taskFailMessage(t))
|
||||
return { status: 'failed', error: taskFailMessage(t) }
|
||||
}
|
||||
if (!isActiveTaskStatus(t.status)) {
|
||||
markDone({ ...meta, taskId })
|
||||
return { status: 'completed', result: t.result }
|
||||
}
|
||||
} catch (_) {
|
||||
// 网络异常时仍尝试轮询
|
||||
}
|
||||
|
||||
markRunning({ ...meta, taskId })
|
||||
return pollTask(taskId, meta, onDone, { ...options, showErrorToast: false, showTimeoutToast: false })
|
||||
}
|
||||
|
||||
async function _recoverAttachTask(taskId, meta, onDone, pollOpts) {
|
||||
if (!taskId) return
|
||||
if (recoveredTaskIds.value.has(taskId)) {
|
||||
try {
|
||||
const t = await taskAPI.get(taskId)
|
||||
if (t.status === 'completed') markDone({ ...meta, taskId })
|
||||
else if (t.status === 'failed') markFailed({ ...meta, taskId }, taskFailMessage(t))
|
||||
else if (!isActiveTaskStatus(t.status)) markDone({ ...meta, taskId })
|
||||
else if (cancelledPollTaskIds.value.has(taskId)) markFailed({ ...meta, taskId }, '任务轮询已停止')
|
||||
} catch (_) {}
|
||||
return
|
||||
}
|
||||
recoveredTaskIds.value = new Set([...recoveredTaskIds.value, taskId])
|
||||
const res = await attachPollIfNeeded(taskId, meta, onDone, pollOpts)
|
||||
if (res?.status === 'failed' || res?.status === 'timeout') {
|
||||
markFailed({ ...meta, taskId }, res.error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从后端恢复当前集进行中的图片/视频/合成任务,并重新 attach 轮询。
|
||||
*/
|
||||
async function recoverPendingForEpisode(ctx) {
|
||||
const {
|
||||
dramaId,
|
||||
episodeId,
|
||||
dramaTitle,
|
||||
episodeNumber,
|
||||
storyboards = [],
|
||||
characters = [],
|
||||
scenes = [],
|
||||
props = [],
|
||||
allCharacters = [],
|
||||
allProps = [],
|
||||
allScenes = [],
|
||||
callbacks = {},
|
||||
ElMessage,
|
||||
} = ctx
|
||||
|
||||
if (dramaId == null || episodeId == null) return
|
||||
|
||||
const reconcileAssets = {
|
||||
characters: allCharacters.length ? allCharacters : characters,
|
||||
props: allProps.length ? allProps : props,
|
||||
scenes: allScenes.length ? allScenes : scenes,
|
||||
storyboards,
|
||||
}
|
||||
|
||||
await reconcileRunningTasks(reconcileAssets)
|
||||
|
||||
const sbIdSet = new Set(storyboards.map((s) => Number(s.id)))
|
||||
const charIdSet = new Set(characters.map((c) => Number(c.id)))
|
||||
const sceneIdSet = new Set(scenes.map((s) => Number(s.id)))
|
||||
const propIdSet = new Set(props.map((p) => Number(p.id)))
|
||||
const epLabel = dramaTitle
|
||||
? `${dramaTitle} · 第${episodeNumber ?? ''}集`
|
||||
: `第${episodeNumber ?? episodeId}集`
|
||||
|
||||
const pollOpts = { ElMessage, showErrorToast: false, showTimeoutToast: false }
|
||||
|
||||
const attachImage = (img) => {
|
||||
if (!img || !['pending', 'processing'].includes(img.status)) return
|
||||
if (!img.task_id) return
|
||||
|
||||
let resourceType = GEN_RESOURCE.SB_IMAGE
|
||||
let resourceId = null
|
||||
let label = ''
|
||||
|
||||
if (img.storyboard_id != null && sbIdSet.has(Number(img.storyboard_id))) {
|
||||
resourceType = sbImageResourceType(img.frame_type)
|
||||
resourceId = Number(img.storyboard_id)
|
||||
const sb = storyboards.find((s) => Number(s.id) === resourceId)
|
||||
const num = sb?.storyboard_number ?? resourceId
|
||||
label = resourceType === GEN_RESOURCE.SB_LAST_IMAGE
|
||||
? `${epLabel} 尾帧 #${num}`
|
||||
: resourceType === GEN_RESOURCE.SB_FIRST_IMAGE
|
||||
? `${epLabel} 首帧 #${num}`
|
||||
: `${epLabel} 分镜图 #${num}`
|
||||
} else if (img.character_id != null && charIdSet.has(Number(img.character_id))) {
|
||||
resourceType = GEN_RESOURCE.CHAR_IMAGE
|
||||
resourceId = Number(img.character_id)
|
||||
const c = characters.find((x) => Number(x.id) === resourceId)
|
||||
label = `${epLabel} 角色图: ${c?.name || resourceId}`
|
||||
} else if (img.scene_id != null && sceneIdSet.has(Number(img.scene_id))) {
|
||||
resourceType = GEN_RESOURCE.SCENE_IMAGE
|
||||
resourceId = Number(img.scene_id)
|
||||
const s = scenes.find((x) => Number(x.id) === resourceId)
|
||||
label = `${epLabel} 场景图: ${s?.location || resourceId}`
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
const meta = {
|
||||
dramaId,
|
||||
episodeId,
|
||||
dramaTitle,
|
||||
episodeNumber,
|
||||
resourceType,
|
||||
resourceId,
|
||||
label,
|
||||
}
|
||||
const onDone = resourceType.startsWith('sb_')
|
||||
? () => callbacks.onStoryboardMedia?.(resourceId)
|
||||
: () => callbacks.onDramaRefresh?.()
|
||||
_recoverAttachTask(img.task_id, meta, onDone, pollOpts)
|
||||
}
|
||||
|
||||
const attachVideo = (vid) => {
|
||||
if (!vid?.storyboard_id || !sbIdSet.has(Number(vid.storyboard_id))) return
|
||||
if (!['pending', 'processing'].includes(vid.status)) return
|
||||
if (!vid.task_id) return
|
||||
const resourceId = Number(vid.storyboard_id)
|
||||
const sb = storyboards.find((s) => Number(s.id) === resourceId)
|
||||
const num = sb?.storyboard_number ?? resourceId
|
||||
const meta = {
|
||||
dramaId,
|
||||
episodeId,
|
||||
dramaTitle,
|
||||
episodeNumber,
|
||||
resourceType: GEN_RESOURCE.SB_VIDEO,
|
||||
resourceId,
|
||||
label: `${epLabel} 分镜视频 #${num}`,
|
||||
}
|
||||
_recoverAttachTask(vid.task_id, meta, () => callbacks.onStoryboardMedia?.(resourceId), pollOpts)
|
||||
}
|
||||
|
||||
try {
|
||||
const [pendingImg, processingImg, processingVid, episodeTasks] = await Promise.all([
|
||||
imagesAPI.list({ drama_id: dramaId, status: 'pending', page_size: 100 }).catch(() => ({ items: [] })),
|
||||
imagesAPI.list({ drama_id: dramaId, status: 'processing', page_size: 100 }).catch(() => ({ items: [] })),
|
||||
videosAPI.list({ drama_id: dramaId, status: 'processing', page_size: 100 }).catch(() => ({ items: [] })),
|
||||
taskAPI.listByResource(String(episodeId)).catch(() => []),
|
||||
])
|
||||
|
||||
const seenImg = new Set()
|
||||
for (const img of [...(pendingImg.items || []), ...(processingImg.items || [])]) {
|
||||
const dedupe = `${img.id}:${img.status}`
|
||||
if (seenImg.has(dedupe)) continue
|
||||
seenImg.add(dedupe)
|
||||
attachImage(img)
|
||||
}
|
||||
|
||||
for (const vid of processingVid.items || []) {
|
||||
attachVideo(vid)
|
||||
}
|
||||
|
||||
for (const t of episodeTasks || []) {
|
||||
if (!isActiveTaskStatus(t.status)) continue
|
||||
if (t.type === 'video_merge') {
|
||||
const meta = {
|
||||
dramaId,
|
||||
episodeId,
|
||||
dramaTitle,
|
||||
episodeNumber,
|
||||
resourceType: GEN_RESOURCE.EPISODE_MERGE,
|
||||
resourceId: Number(episodeId),
|
||||
label: `${epLabel} 合成视频`,
|
||||
taskId: t.id,
|
||||
}
|
||||
_recoverAttachTask(t.id, meta, () => callbacks.onDramaRefresh?.(), pollOpts)
|
||||
continue
|
||||
}
|
||||
const extractTypeMap = {
|
||||
prop_extraction: { resourceType: GEN_RESOURCE.EXTRACT_PROPS, label: `${epLabel} 提取道具` },
|
||||
background_extraction: { resourceType: GEN_RESOURCE.EXTRACT_SCENES, label: `${epLabel} 提取场景` },
|
||||
storyboard_generation: { resourceType: GEN_RESOURCE.GENERATE_STORYBOARD, label: `${epLabel} AI生成分镜` },
|
||||
}
|
||||
const extractCfg = extractTypeMap[t.type]
|
||||
if (extractCfg) {
|
||||
const meta = {
|
||||
dramaId,
|
||||
episodeId,
|
||||
dramaTitle,
|
||||
episodeNumber,
|
||||
resourceType: extractCfg.resourceType,
|
||||
resourceId: Number(episodeId),
|
||||
label: extractCfg.label,
|
||||
taskId: t.id,
|
||||
}
|
||||
_recoverAttachTask(t.id, meta, () => callbacks.onDramaRefresh?.(), pollOpts)
|
||||
}
|
||||
}
|
||||
|
||||
// 角色提取 task 挂在 dramaId 上,同一 taskId 只恢复一次(避免多集重复显示)
|
||||
const dramaTasks = await taskAPI.listByResource(String(dramaId)).catch(() => [])
|
||||
for (const t of dramaTasks || []) {
|
||||
if (!isActiveTaskStatus(t.status)) continue
|
||||
if (t.type !== 'character_generation') continue
|
||||
if (recoveredTaskIds.value.has(t.id)) continue
|
||||
if (pollPromises.value.has(t.id)) continue
|
||||
const meta = {
|
||||
dramaId,
|
||||
episodeId,
|
||||
dramaTitle,
|
||||
episodeNumber,
|
||||
resourceType: GEN_RESOURCE.EXTRACT_CHARACTERS,
|
||||
resourceId: Number(episodeId),
|
||||
label: `${epLabel} 提取角色`,
|
||||
taskId: t.id,
|
||||
}
|
||||
_recoverAttachTask(t.id, meta, () => callbacks.onDramaRefresh?.(), pollOpts)
|
||||
break
|
||||
}
|
||||
|
||||
const attachResourceTask = (resourceId, resourceType, label) => {
|
||||
return taskAPI.listByResource(String(resourceId)).then((tasks) => {
|
||||
for (const t of tasks || []) {
|
||||
if (!isActiveTaskStatus(t.status)) continue
|
||||
const meta = {
|
||||
dramaId,
|
||||
episodeId,
|
||||
dramaTitle,
|
||||
episodeNumber,
|
||||
resourceType,
|
||||
resourceId: Number(resourceId),
|
||||
label,
|
||||
taskId: t.id,
|
||||
}
|
||||
_recoverAttachTask(t.id, meta, () => callbacks.onDramaRefresh?.(), pollOpts)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
...[...charIdSet].map((id) => {
|
||||
const c = characters.find((x) => Number(x.id) === Number(id))
|
||||
return attachResourceTask(id, GEN_RESOURCE.CHAR_IMAGE, `${epLabel} 角色图: ${c?.name || id}`)
|
||||
}),
|
||||
...[...propIdSet].map((id) => {
|
||||
const p = props.find((x) => Number(x.id) === Number(id))
|
||||
return attachResourceTask(id, GEN_RESOURCE.PROP_IMAGE, `${epLabel} 道具图: ${p?.name || id}`)
|
||||
}),
|
||||
...[...sceneIdSet].map((id) => {
|
||||
const s = scenes.find((x) => Number(x.id) === Number(id))
|
||||
return attachResourceTask(id, GEN_RESOURCE.SCENE_IMAGE, `${epLabel} 场景图: ${s?.location || id}`)
|
||||
}),
|
||||
])
|
||||
|
||||
await reconcileRunningTasks(reconcileAssets)
|
||||
} catch (e) {
|
||||
console.warn('[generationTaskStore] recoverPendingForEpisode failed:', e?.message)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
GEN_RESOURCE,
|
||||
tasks,
|
||||
runningTasks,
|
||||
markRunning,
|
||||
markDone,
|
||||
markFailed,
|
||||
isRunning,
|
||||
getRunningForEpisode,
|
||||
getAllRunningTasks,
|
||||
pollTask,
|
||||
attachPollIfNeeded,
|
||||
recoverPendingForEpisode,
|
||||
reconcileRunningTasks,
|
||||
stopPollingTask,
|
||||
clearAllRunningTasks,
|
||||
taskKey,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,484 @@
|
||||
/* =====================================================
|
||||
暗色模式(默认)— html.dark 或无类名时生效
|
||||
===================================================== */
|
||||
:root,
|
||||
html.dark {
|
||||
--bg-page: #0f0f12;
|
||||
--bg-card: #18181b;
|
||||
--bg-inner: #1c1c1e;
|
||||
--bg-hover: #27272a;
|
||||
--border-color: #27272a;
|
||||
--border-muted: #3f3f46;
|
||||
--text-primary: #e4e4e7;
|
||||
--text-bright: #fafafa;
|
||||
--text-muted: #a1a1aa;
|
||||
--text-subtle: #71717a;
|
||||
--text-faint: #52525b;
|
||||
--shadow: 0 2px 12px rgba(0,0,0,.4);
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
亮色模式 — html.light 时生效
|
||||
===================================================== */
|
||||
html.light {
|
||||
--bg-page: #f4f4f5;
|
||||
--bg-card: #ffffff;
|
||||
--bg-inner: #fafafa;
|
||||
--bg-hover: #f0f0f1;
|
||||
--border-color: #e4e4e7;
|
||||
--border-muted: #d4d4d8;
|
||||
--text-primary: #18181b;
|
||||
--text-bright: #09090b;
|
||||
--text-muted: #52525b;
|
||||
--text-subtle: #71717a;
|
||||
--text-faint: #a1a1aa;
|
||||
--shadow: 0 2px 12px rgba(0,0,0,.08);
|
||||
}
|
||||
|
||||
/* ── 通用基础 ── */
|
||||
html, body, #app, .app {
|
||||
background: var(--bg-page) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* ── 各页面结构 ── */
|
||||
html.light .film-list,
|
||||
html.light .film-create,
|
||||
html.light .drama-detail,
|
||||
html.light .drama-canvas-page {
|
||||
background: var(--bg-page);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* header */
|
||||
html.light .header {
|
||||
background: var(--bg-card) !important;
|
||||
border-bottom-color: var(--border-color) !important;
|
||||
}
|
||||
html.light .logo { color: var(--text-bright) !important; }
|
||||
html.light .page-title { color: var(--text-muted) !important; }
|
||||
|
||||
/* card / section */
|
||||
html.light .card,
|
||||
html.light .section.card {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
html.light .section-title { color: var(--text-bright) !important; }
|
||||
|
||||
/* project card (FilmList) */
|
||||
html.light .project-card {
|
||||
background: var(--bg-inner) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
html.light .project-card:hover { border-color: var(--el-color-primary) !important; }
|
||||
html.light .project-title { color: var(--text-bright) !important; }
|
||||
html.light .project-desc { color: var(--text-muted) !important; }
|
||||
html.light .project-meta { color: var(--text-faint) !important; }
|
||||
|
||||
/* episode card */
|
||||
html.light .episode-card {
|
||||
background: var(--bg-inner) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
html.light .episode-title { color: var(--text-bright) !important; }
|
||||
html.light .episode-num,
|
||||
html.light .episode-preview { color: var(--text-muted) !important; }
|
||||
|
||||
/* quick-nav */
|
||||
html.light .quick-nav {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
html.light .nav-item:hover { background: var(--bg-hover) !important; }
|
||||
html.light .nav-label { color: var(--text-primary) !important; }
|
||||
html.light .nav-sub-item:hover { background: var(--bg-hover) !important; }
|
||||
|
||||
/* asset / library items */
|
||||
html.light .asset-cover {
|
||||
background: var(--bg-inner) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
html.light .library-item {
|
||||
background: var(--bg-inner) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
html.light .library-item-name { color: var(--text-bright) !important; }
|
||||
html.light .library-item-desc { color: var(--text-muted) !important; }
|
||||
html.light .library-empty { color: var(--text-subtle) !important; }
|
||||
|
||||
/* storyboard */
|
||||
html.light .sb-card,
|
||||
html.light .storyboard-row {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
html.light .sb-panel {
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
html.light .sb-panel-title { color: var(--text-bright) !important; }
|
||||
html.light .sb-video-placeholder {
|
||||
background: var(--bg-inner) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
/* collapse / blocks */
|
||||
html.light .resource-block,
|
||||
html.light .block-header {
|
||||
background: var(--bg-inner) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
/* textarea / input overrides */
|
||||
html.light .story-textarea textarea {
|
||||
background: var(--bg-inner) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
/* progress / pipeline */
|
||||
html.light .pipeline-panel {
|
||||
background: var(--bg-inner) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
html.light .pipeline-error-item { color: #dc2626 !important; }
|
||||
|
||||
/* ── Element Plus 亮色覆盖 ── */
|
||||
html.light {
|
||||
--el-bg-color: var(--bg-card);
|
||||
--el-bg-color-page: var(--bg-page);
|
||||
--el-bg-color-overlay: var(--bg-card);
|
||||
--el-text-color-primary: var(--text-primary);
|
||||
--el-text-color-regular: var(--text-primary);
|
||||
--el-text-color-secondary: var(--text-muted);
|
||||
--el-text-color-placeholder: var(--text-faint);
|
||||
--el-border-color: var(--border-color);
|
||||
--el-border-color-light: var(--border-muted);
|
||||
--el-border-color-lighter: var(--border-muted);
|
||||
--el-fill-color: var(--bg-inner);
|
||||
--el-fill-color-blank: var(--bg-card);
|
||||
--el-fill-color-light: var(--bg-hover);
|
||||
--el-fill-color-lighter: var(--bg-hover);
|
||||
--el-mask-color: rgba(255,255,255,.7);
|
||||
}
|
||||
|
||||
html.light .el-card,
|
||||
html.light .el-dialog {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
html.light .el-dialog__header { border-bottom-color: var(--border-color) !important; }
|
||||
html.light .el-dialog__title { color: var(--text-bright) !important; }
|
||||
|
||||
html.light .el-input__wrapper,
|
||||
html.light .el-textarea,
|
||||
html.light .el-textarea__wrapper {
|
||||
background: var(--bg-inner) !important;
|
||||
box-shadow: 0 0 0 1px var(--border-color) !important;
|
||||
}
|
||||
html.light .el-input__inner,
|
||||
html.light .el-textarea__inner {
|
||||
background: transparent !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
html.light .el-select-dropdown {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
html.light .el-select-dropdown__item { color: var(--text-primary) !important; }
|
||||
html.light .el-select-dropdown__item.hover,
|
||||
html.light .el-select-dropdown__item:hover { background: var(--bg-hover) !important; }
|
||||
|
||||
html.light .el-collapse-item__header,
|
||||
html.light .el-collapse-item__wrap {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
html.light .el-table,
|
||||
html.light .el-table__header-wrapper,
|
||||
html.light .el-table tr {
|
||||
background: var(--bg-card) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
html.light .el-table td,
|
||||
html.light .el-table th { border-color: var(--border-color) !important; }
|
||||
|
||||
html.light .el-pagination { color: var(--text-primary) !important; }
|
||||
html.light .el-pager li { background: var(--bg-inner) !important; color: var(--text-primary) !important; }
|
||||
|
||||
html.light .el-radio-button__inner {
|
||||
background: var(--bg-inner) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
html.light .el-radio-button__original-radio:checked + .el-radio-button__inner {
|
||||
background: var(--el-color-primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
html.light .el-message-box {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
html.light .el-message-box__title { color: var(--text-bright) !important; }
|
||||
html.light .el-message-box__content { color: var(--text-primary) !important; }
|
||||
|
||||
/* 标签、小字 */
|
||||
html.light span, html.light label, html.light p {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* AI 配置字段帮助提示框 */
|
||||
.cfg-tip-popper.el-popper {
|
||||
max-width: 300px;
|
||||
}
|
||||
.cfg-tip-content {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
亮色模式增强 — 更现代的视觉效果
|
||||
===================================================== */
|
||||
|
||||
/* 主色改为紫色系 */
|
||||
html.light {
|
||||
--el-color-primary: #7c3aed;
|
||||
--el-color-primary-light-3: #a78bfa;
|
||||
--el-color-primary-light-5: #c4b5fd;
|
||||
--el-color-primary-light-7: #ddd6fe;
|
||||
--el-color-primary-light-8: #ede9fe;
|
||||
--el-color-primary-light-9: #f5f3ff;
|
||||
--el-color-primary-dark-2: #6d28d9;
|
||||
}
|
||||
|
||||
/* 卡片升级 */
|
||||
html.light .card,
|
||||
html.light .el-card {
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.06), 0 4px 16px rgba(0,0,0,.04) !important;
|
||||
border-color: #e8e8ef !important;
|
||||
border-radius: 14px !important;
|
||||
}
|
||||
|
||||
/* 页面标题区美化 */
|
||||
html.light .header {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #faf9ff 100%) !important;
|
||||
border-bottom: 1px solid #e8e8ef !important;
|
||||
box-shadow: 0 1px 8px rgba(0,0,0,.05) !important;
|
||||
}
|
||||
|
||||
/* Logo 加紫色渐变 */
|
||||
html.light .logo {
|
||||
background: linear-gradient(135deg, #7c3aed, #a78bfa) !important;
|
||||
-webkit-background-clip: text !important;
|
||||
-webkit-text-fill-color: transparent !important;
|
||||
background-clip: text !important;
|
||||
}
|
||||
|
||||
/* 项目卡片美化 */
|
||||
html.light .project-card {
|
||||
background: #ffffff !important;
|
||||
border: 1px solid #e8e8ef !important;
|
||||
border-radius: 14px !important;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.05), 0 6px 20px rgba(0,0,0,.04) !important;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease !important;
|
||||
}
|
||||
html.light .project-card:hover {
|
||||
transform: translateY(-2px) !important;
|
||||
box-shadow: 0 4px 12px rgba(124,58,237,.12), 0 12px 32px rgba(124,58,237,.06) !important;
|
||||
border-color: #a78bfa !important;
|
||||
}
|
||||
|
||||
/* 操作卡片(快速开始)美化 */
|
||||
html.light .action-card {
|
||||
background: linear-gradient(135deg, #faf5ff 0%, #f5f3ff 100%) !important;
|
||||
border: 2px dashed #c4b5fd !important;
|
||||
}
|
||||
html.light .action-card:hover {
|
||||
border-color: #a78bfa !important;
|
||||
background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%) !important;
|
||||
}
|
||||
html.light .action-card-title {
|
||||
color: #7c3aed !important;
|
||||
}
|
||||
|
||||
/* Section card 美化 */
|
||||
html.light .section.card {
|
||||
border-radius: 14px !important;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.05), 0 6px 20px rgba(0,0,0,.03) !important;
|
||||
}
|
||||
|
||||
/* 资源库 library-item 美化 */
|
||||
html.light .library-item {
|
||||
background: #fafafa !important;
|
||||
border: 1px solid #ebebf0 !important;
|
||||
border-radius: 10px !important;
|
||||
transition: box-shadow 0.15s, border-color 0.15s !important;
|
||||
}
|
||||
html.light .library-item:hover {
|
||||
box-shadow: 0 2px 8px rgba(124,58,237,.1) !important;
|
||||
border-color: #c4b5fd !important;
|
||||
}
|
||||
|
||||
/* Storyboard 卡片 */
|
||||
html.light .sb-card,
|
||||
html.light .storyboard-row {
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.05) !important;
|
||||
}
|
||||
|
||||
/* 表格美化 */
|
||||
html.light .el-table {
|
||||
border-radius: 10px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
html.light .el-table th.el-table__cell {
|
||||
background: #f8f7ff !important;
|
||||
color: #4c1d95 !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
html.light .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
||||
background: #fdfcff !important;
|
||||
}
|
||||
|
||||
/* 表格内 link 按钮颜色恢复(防被 tr color !important 覆盖) */
|
||||
html.light .el-table tr .el-button.is-link,
|
||||
html.light .el-table tr .el-button.is-link span {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
html.light .el-table tr .el-button.is-link.el-button--danger,
|
||||
html.light .el-table tr .el-button.is-link.el-button--danger span {
|
||||
color: var(--el-color-danger) !important;
|
||||
}
|
||||
|
||||
/* 按钮通用圆角 */
|
||||
html.light .el-button {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
/* 勿作用于 is-plain:否则背景被强制为实色渐变而字色仍为 plain 主色,对比度丢失 */
|
||||
html.light .el-button--primary:not(.is-link):not(.is-plain) {
|
||||
background: linear-gradient(135deg, #7c3aed, #6d28d9) !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: 0 2px 8px rgba(124,58,237,.25) !important;
|
||||
}
|
||||
html.light .el-button--primary:not(.is-link):not(.is-plain):hover {
|
||||
background: linear-gradient(135deg, #6d28d9, #5b21b6) !important;
|
||||
box-shadow: 0 4px 12px rgba(124,58,237,.35) !important;
|
||||
}
|
||||
html.light .el-button.is-link {
|
||||
background: transparent !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 输入框 */
|
||||
html.light .el-input__wrapper {
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 0 0 1px #ddd6fe !important;
|
||||
transition: box-shadow 0.2s !important;
|
||||
}
|
||||
html.light .el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px #a78bfa !important;
|
||||
}
|
||||
html.light .el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 2px #7c3aed !important;
|
||||
}
|
||||
html.light .el-textarea__wrapper {
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 0 0 1px #ddd6fe !important;
|
||||
}
|
||||
html.light .el-textarea__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px #a78bfa !important;
|
||||
}
|
||||
|
||||
/* 对话框 */
|
||||
html.light .el-dialog {
|
||||
border-radius: 16px !important;
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,.12) !important;
|
||||
}
|
||||
|
||||
/* 分集卡片 */
|
||||
html.light .episode-card {
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.06) !important;
|
||||
transition: transform 0.15s, box-shadow 0.15s !important;
|
||||
}
|
||||
html.light .episode-card:hover {
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow: 0 4px 16px rgba(124,58,237,.1) !important;
|
||||
border-color: #a78bfa !important;
|
||||
}
|
||||
|
||||
/* page bg 更细腻 */
|
||||
html.light {
|
||||
--bg-page: #f4f2fa;
|
||||
}
|
||||
html.light html, html.light body, html.light #app, html.light .app {
|
||||
background: #f4f2fa !important;
|
||||
}
|
||||
|
||||
/* 资产管理 header banner */
|
||||
html.light .asset-cover {
|
||||
border-radius: 10px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* el-tabs 美化 */
|
||||
html.light .el-tabs__item {
|
||||
color: #71717a !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
html.light .el-tabs__item.is-active {
|
||||
color: #7c3aed !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
html.light .el-tabs__active-bar {
|
||||
background: #7c3aed !important;
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
html.light .el-tabs__nav-wrap::after {
|
||||
background: #ebebf0 !important;
|
||||
}
|
||||
|
||||
/* 导航 tab(按钮型)美化 */
|
||||
html.light .el-radio-button__inner {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* 页面整体内容区最大宽度限制样式 */
|
||||
html.light .main {
|
||||
padding-top: 28px !important;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
全局 ElMessage:更大、更易读、略长停留(与 main.js message.duration 配合)
|
||||
placement=top 时横向已为居中,此处强化宽度与正文排版
|
||||
===================================================== */
|
||||
.el-message {
|
||||
min-width: min(92vw, 520px) !important;
|
||||
max-width: min(94vw, 720px) !important;
|
||||
padding: 14px 20px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
.el-message .el-message__content {
|
||||
font-size: 15px !important;
|
||||
line-height: 1.55 !important;
|
||||
text-align: center !important;
|
||||
word-break: break-word;
|
||||
}
|
||||
.el-message .el-message__icon {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
.el-message .el-message__closeBtn {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
html.dark .el-message--error {
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
html.light .el-message--error {
|
||||
box-shadow: 0 4px 20px rgba(245, 108, 108, 0.18);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/** 从 drama.metadata 解析画布布局(旧 JSON 无此字段时返回 null) */
|
||||
export function parseCanvasLayout(metadata) {
|
||||
if (metadata == null) return null
|
||||
let meta = metadata
|
||||
if (typeof meta === 'string') {
|
||||
try {
|
||||
meta = JSON.parse(meta)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (!meta || typeof meta !== 'object') return null
|
||||
return meta.canvas_layout || null
|
||||
}
|
||||
|
||||
/** 合并 metadata 并写入 canvas_layout(阶段 B 使用) */
|
||||
export function mergeCanvasLayoutIntoMetadata(metadata, canvasLayout) {
|
||||
let meta = metadata
|
||||
if (typeof meta === 'string') {
|
||||
try {
|
||||
meta = JSON.parse(meta)
|
||||
} catch {
|
||||
meta = {}
|
||||
}
|
||||
}
|
||||
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) meta = {}
|
||||
return {
|
||||
...meta,
|
||||
canvas_layout: canvasLayout,
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取已保存节点坐标,无则返回 fallback */
|
||||
export function resolveNodePosition(savedLayout, nodeId, fallback) {
|
||||
const saved = savedLayout?.nodes?.[nodeId]
|
||||
if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
|
||||
return { x: saved.x, y: saved.y }
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function resolveViewport(savedLayout, fallback = { x: 0, y: 0, zoom: 0.75 }) {
|
||||
const v = savedLayout?.viewport
|
||||
if (v && Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.zoom)) {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
const NON_DRAGGABLE_TYPES = new Set(['canvasLabel'])
|
||||
|
||||
/** 从当前 Vue Flow 节点与视口构建可持久化的 canvas_layout */
|
||||
export function buildCanvasLayoutPayload(flowNodes, viewport, existingLayout = null) {
|
||||
const nodes = { ...(existingLayout?.nodes || {}) }
|
||||
for (const node of flowNodes || []) {
|
||||
if (!node?.id || NON_DRAGGABLE_TYPES.has(node.type)) continue
|
||||
if (!node.position) continue
|
||||
nodes[node.id] = {
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
viewport: {
|
||||
x: Number(viewport?.x) || 0,
|
||||
y: Number(viewport?.y) || 0,
|
||||
zoom: Number(viewport?.zoom) || 0.75,
|
||||
},
|
||||
nodes,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDramaMetadata(metadata) {
|
||||
if (metadata == null) return {}
|
||||
if (typeof metadata === 'object' && !Array.isArray(metadata)) return metadata
|
||||
if (typeof metadata === 'string') {
|
||||
try {
|
||||
return JSON.parse(metadata)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { parseDramaMetadata } from './canvasLayout'
|
||||
|
||||
export const DEFAULT_PIPELINE = ['image', 'video', 'audio']
|
||||
|
||||
export function parseWorkflowGroups(metadata) {
|
||||
const meta = parseDramaMetadata(metadata)
|
||||
const groups = meta.workflow_groups
|
||||
return Array.isArray(groups) ? groups : []
|
||||
}
|
||||
|
||||
export function storyboardIdFromNodeId(nodeId) {
|
||||
if (!nodeId || typeof nodeId !== 'string') return null
|
||||
if (!nodeId.startsWith('sb:')) return null
|
||||
const id = Number(nodeId.slice(3))
|
||||
return Number.isFinite(id) ? id : null
|
||||
}
|
||||
|
||||
export function nodeIdFromStoryboardId(storyboardId) {
|
||||
return `sb:${storyboardId}`
|
||||
}
|
||||
|
||||
export function getStoryboardGroupMap(workflowGroups) {
|
||||
const map = new Map()
|
||||
for (const group of workflowGroups || []) {
|
||||
for (const sbId of group.storyboard_ids || []) {
|
||||
map.set(Number(sbId), group)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function createWorkflowGroup(existingGroups, { title, storyboardIds, pipeline = DEFAULT_PIPELINE }) {
|
||||
const ids = [...new Set((storyboardIds || []).map(Number).filter(Number.isFinite))]
|
||||
if (!ids.length) throw new Error('请至少选择一个分镜')
|
||||
const group = {
|
||||
id: `wg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: title || `工作流 ${(existingGroups?.length || 0) + 1}`,
|
||||
storyboard_ids: ids,
|
||||
pipeline: normalizePipeline(pipeline),
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
return [...(existingGroups || []), group]
|
||||
}
|
||||
|
||||
export function deleteWorkflowGroup(existingGroups, groupId) {
|
||||
return (existingGroups || []).filter((g) => g.id !== groupId)
|
||||
}
|
||||
|
||||
export function normalizePipeline(pipeline) {
|
||||
const allowed = ['image', 'video', 'audio']
|
||||
const list = Array.isArray(pipeline) ? pipeline.filter((s) => allowed.includes(s)) : []
|
||||
return list.length ? list : [...DEFAULT_PIPELINE]
|
||||
}
|
||||
|
||||
export function findStoryboardInDrama(drama, storyboardId) {
|
||||
for (const ep of drama?.episodes || []) {
|
||||
const sb = (ep.storyboards || []).find((s) => s.id === storyboardId)
|
||||
if (sb) return { storyboard: sb, episode: ep }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getDramaGenerationOptions(drama) {
|
||||
const meta = parseDramaMetadata(drama?.metadata)
|
||||
return {
|
||||
aspectRatio: meta.aspect_ratio || '16:9',
|
||||
style: meta.style_prompt_en || meta.style_prompt_zh || drama?.style || '',
|
||||
videoResolution: meta.video_resolution || '480p',
|
||||
}
|
||||
}
|
||||
|
||||
export function toAbsoluteMediaUrl(url) {
|
||||
if (!url) return ''
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
if (typeof window !== 'undefined') {
|
||||
const path = url.startsWith('/') ? url : `/static/${url.replace(/^\//, '')}`
|
||||
return `${window.location.origin}${path}`
|
||||
}
|
||||
return url
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { parseCanvasLayout, resolveNodePosition } from './canvasLayout'
|
||||
import { getStoryboardGroupMap, parseWorkflowGroups } from './canvasWorkflow'
|
||||
import { assetImageUrl, storyboardImageUrl, storyboardVideoUrl, audioUrl } from './mediaUrl'
|
||||
import {
|
||||
dramaUsesFirstLastFrame,
|
||||
imageRecordUrl,
|
||||
resolveSbFirstImageRecord,
|
||||
resolveSbLastImageRecord,
|
||||
resolveSbMainImageRecord,
|
||||
resolveSbVideoRecord,
|
||||
videoRecordUrl,
|
||||
} from './storyboardMedia'
|
||||
|
||||
const ASSET_X = 48
|
||||
const ASSET_SECTION_GAP = 36
|
||||
const ASSET_ROW_H = 188
|
||||
const PIPELINE_X = 360
|
||||
const EPISODE_ROW_GAP = 48
|
||||
const SB_GAP_Y = 280
|
||||
const MEDIA_OFFSET_X = 228
|
||||
const MEDIA_GAP_X = 188
|
||||
/** 单行流水线(分镜 + 媒体)大致宽度,用于画布 bounds */
|
||||
const SB_PIPELINE_WIDTH = MEDIA_OFFSET_X + 5 * MEDIA_GAP_X + 200
|
||||
|
||||
const ASSET_EDGE_STYLE = { stroke: '#34d399', strokeWidth: 1.5, strokeDasharray: '6 4' }
|
||||
const PIPELINE_EDGE_STYLE = { stroke: '#818cf8', strokeWidth: 2 }
|
||||
const CHAIN_EDGE_STYLE = { stroke: '#a78bfa', strokeWidth: 1.5, strokeDasharray: '4 3' }
|
||||
|
||||
/** Vue Flow 贝塞尔曲线(curvature 越大弧线越明显) */
|
||||
function makeEdge(props) {
|
||||
return {
|
||||
type: 'default',
|
||||
pathOptions: { curvature: 0.62 },
|
||||
...props,
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(text, max = 72) {
|
||||
if (!text) return ''
|
||||
const s = String(text).replace(/\s+/g, ' ').trim()
|
||||
return s.length > max ? s.slice(0, max) + '…' : s
|
||||
}
|
||||
|
||||
function storyboardSummary(sb) {
|
||||
if (sb.creation_mode === 'universal' && sb.universal_segment_text) {
|
||||
return truncate(sb.universal_segment_text, 90)
|
||||
}
|
||||
const parts = [sb.action, sb.dialogue, sb.result].filter(Boolean)
|
||||
return truncate(parts.join(' · '), 90) || truncate(sb.description, 90) || '暂无描述'
|
||||
}
|
||||
|
||||
function sectionLabel(id, label, x, y) {
|
||||
return {
|
||||
id,
|
||||
type: 'canvasLabel',
|
||||
position: { x, y },
|
||||
data: { label },
|
||||
selectable: false,
|
||||
draggable: false,
|
||||
connectable: false,
|
||||
}
|
||||
}
|
||||
|
||||
function makeNode(base) {
|
||||
const draggable = base.type !== 'canvasLabel'
|
||||
return { ...base, draggable: base.draggable ?? draggable }
|
||||
}
|
||||
|
||||
function buildAssetNodes(drama, savedLayout, startY) {
|
||||
const nodes = []
|
||||
const edges = []
|
||||
let y = startY
|
||||
|
||||
const sections = [
|
||||
{ key: 'characters', label: '👤 角色', items: drama.characters || [], kind: 'character', prefix: 'char' },
|
||||
{ key: 'scenes', label: '🏞 场景', items: drama.scenes || [], kind: 'scene', prefix: 'scene' },
|
||||
{ key: 'props', label: '🎭 道具', items: drama.props || [], kind: 'prop', prefix: 'prop' },
|
||||
]
|
||||
|
||||
for (const sec of sections) {
|
||||
if (!sec.items.length) continue
|
||||
nodes.push(sectionLabel(`label:${sec.key}`, sec.label, ASSET_X, y))
|
||||
y += 36
|
||||
for (const item of sec.items) {
|
||||
const id = `${sec.prefix}:${item.id}`
|
||||
nodes.push(makeNode({
|
||||
id,
|
||||
type: 'canvasAsset',
|
||||
position: resolveNodePosition(savedLayout, id, { x: ASSET_X, y }),
|
||||
data: { kind: sec.kind, entity: item },
|
||||
}))
|
||||
y += ASSET_ROW_H
|
||||
}
|
||||
y += ASSET_SECTION_GAP
|
||||
}
|
||||
|
||||
return { nodes, edges, nextY: y }
|
||||
}
|
||||
|
||||
function universalSegmentText(sb) {
|
||||
return (sb?.universal_segment_text || sb?.video_prompt || sb?.description || '').trim()
|
||||
}
|
||||
|
||||
function appendUniversalNode(nodes, edges, ctx) {
|
||||
const { savedLayout, sb, sbId, fromId, mediaX, mediaY, uniId } = ctx
|
||||
const text = universalSegmentText(sb)
|
||||
if (!text) return fromId
|
||||
nodes.push(makeNode({
|
||||
id: uniId,
|
||||
type: 'canvasMedia',
|
||||
position: resolveNodePosition(savedLayout, uniId, { x: mediaX, y: mediaY }),
|
||||
data: {
|
||||
kind: 'universal',
|
||||
storyboard: sb,
|
||||
summary: text,
|
||||
},
|
||||
}))
|
||||
edges.push(makeEdge({
|
||||
id: `e-${fromId}-${uniId}`,
|
||||
source: fromId,
|
||||
target: uniId,
|
||||
style: PIPELINE_EDGE_STYLE,
|
||||
}))
|
||||
return uniId
|
||||
}
|
||||
|
||||
function appendMediaImageNode(nodes, edges, ctx) {
|
||||
const {
|
||||
savedLayout, sb, sbId, fromId, mediaX, mediaY, imgId, url, frameKind, frameLabel,
|
||||
} = ctx
|
||||
if (!url) return fromId
|
||||
nodes.push(makeNode({
|
||||
id: imgId,
|
||||
type: 'canvasMedia',
|
||||
position: resolveNodePosition(savedLayout, imgId, { x: mediaX, y: mediaY }),
|
||||
data: {
|
||||
kind: 'image',
|
||||
storyboard: sb,
|
||||
url,
|
||||
frameKind: frameKind || null,
|
||||
frameLabel: frameLabel || null,
|
||||
},
|
||||
}))
|
||||
edges.push(makeEdge({
|
||||
id: `e-${fromId}-${imgId}`,
|
||||
source: fromId,
|
||||
target: imgId,
|
||||
style: PIPELINE_EDGE_STYLE,
|
||||
}))
|
||||
return imgId
|
||||
}
|
||||
|
||||
function buildEpisodePipeline(episode, savedLayout, startY, options = {}) {
|
||||
const nodes = []
|
||||
const edges = []
|
||||
const storyboards = episode.storyboards || []
|
||||
const groupMap = options.workflowGroupMap || new Map()
|
||||
const imagesBySbId = options.imagesBySbId || {}
|
||||
const videosBySbId = options.videosBySbId || {}
|
||||
const useFirstLastFrame = options.useFirstLastFrame ?? false
|
||||
if (!storyboards.length) return { nodes, edges, nextY: startY + 120 }
|
||||
|
||||
const epId = `episode:${episode.id}`
|
||||
nodes.push(makeNode({
|
||||
id: epId,
|
||||
type: 'canvasEpisode',
|
||||
position: resolveNodePosition(savedLayout, epId, { x: PIPELINE_X, y: startY }),
|
||||
data: { episode },
|
||||
}))
|
||||
|
||||
const rowYBase = startY + 56
|
||||
let prevSbId = null
|
||||
|
||||
storyboards.forEach((sb, index) => {
|
||||
const sbId = `sb:${sb.id}`
|
||||
const sbX = PIPELINE_X
|
||||
const rowY = rowYBase + index * SB_GAP_Y
|
||||
const wfGroup = groupMap.get(sb.id)
|
||||
nodes.push(makeNode({
|
||||
id: sbId,
|
||||
type: 'canvasStoryboard',
|
||||
position: resolveNodePosition(savedLayout, sbId, { x: sbX, y: rowY }),
|
||||
data: {
|
||||
storyboard: sb,
|
||||
episodeId: episode.id,
|
||||
index: index + 1,
|
||||
workflowGroup: wfGroup ? { id: wfGroup.id, title: wfGroup.title } : null,
|
||||
},
|
||||
}))
|
||||
|
||||
let mediaX = sbX + MEDIA_OFFSET_X
|
||||
const mediaY = rowY + 8
|
||||
const isUniversal = sb.creation_mode === 'universal'
|
||||
let pipelineTailId = sbId
|
||||
|
||||
if (isUniversal) {
|
||||
const uniId = `sbuni:${sb.id}`
|
||||
const nextId = appendUniversalNode(nodes, edges, {
|
||||
savedLayout, sb, sbId, fromId: sbId, mediaX, mediaY, uniId,
|
||||
})
|
||||
if (nextId !== sbId) {
|
||||
pipelineTailId = nextId
|
||||
mediaX += MEDIA_GAP_X
|
||||
}
|
||||
} else {
|
||||
const txtId = `sbtxt:${sb.id}`
|
||||
nodes.push(makeNode({
|
||||
id: txtId,
|
||||
type: 'canvasMedia',
|
||||
position: resolveNodePosition(savedLayout, txtId, { x: mediaX, y: mediaY }),
|
||||
data: { kind: 'text', storyboard: sb, summary: storyboardSummary(sb) },
|
||||
}))
|
||||
edges.push(makeEdge({
|
||||
id: `e-${sbId}-${txtId}`,
|
||||
source: sbId,
|
||||
target: txtId,
|
||||
style: PIPELINE_EDGE_STYLE,
|
||||
animated: false,
|
||||
}))
|
||||
mediaX += MEDIA_GAP_X
|
||||
pipelineTailId = txtId
|
||||
|
||||
const useFirstLast = useFirstLastFrame
|
||||
|
||||
if (useFirstLast) {
|
||||
const firstUrl = imageRecordUrl(resolveSbFirstImageRecord(sb, imagesBySbId))
|
||||
if (firstUrl) {
|
||||
const imgId = `sbimg-first:${sb.id}`
|
||||
pipelineTailId = appendMediaImageNode(nodes, edges, {
|
||||
savedLayout, sb, sbId, fromId: pipelineTailId, mediaX, mediaY, imgId, url: firstUrl,
|
||||
frameKind: 'first', frameLabel: '首帧',
|
||||
})
|
||||
mediaX += MEDIA_GAP_X
|
||||
}
|
||||
const lastUrl = imageRecordUrl(resolveSbLastImageRecord(sb, imagesBySbId))
|
||||
if (lastUrl) {
|
||||
const imgId = `sbimg-last:${sb.id}`
|
||||
pipelineTailId = appendMediaImageNode(nodes, edges, {
|
||||
savedLayout, sb, sbId, fromId: pipelineTailId, mediaX, mediaY, imgId, url: lastUrl,
|
||||
frameKind: 'last', frameLabel: '尾帧',
|
||||
})
|
||||
mediaX += MEDIA_GAP_X
|
||||
}
|
||||
} else {
|
||||
const mainUrl = imageRecordUrl(resolveSbMainImageRecord(sb, imagesBySbId)) || storyboardImageUrl(sb)
|
||||
if (mainUrl) {
|
||||
const imgId = `sbimg:${sb.id}`
|
||||
pipelineTailId = appendMediaImageNode(nodes, edges, {
|
||||
savedLayout, sb, sbId, fromId: pipelineTailId, mediaX, mediaY, imgId, url: mainUrl,
|
||||
frameKind: null, frameLabel: '分镜图',
|
||||
})
|
||||
mediaX += MEDIA_GAP_X
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const vidUrl = videoRecordUrl(resolveSbVideoRecord(sb, videosBySbId)) || storyboardVideoUrl(sb)
|
||||
if (vidUrl) {
|
||||
const vidId = `sbvid:${sb.id}`
|
||||
nodes.push(makeNode({
|
||||
id: vidId,
|
||||
type: 'canvasMedia',
|
||||
position: resolveNodePosition(savedLayout, vidId, { x: mediaX, y: mediaY }),
|
||||
data: { kind: 'video', storyboard: sb, url: vidUrl },
|
||||
}))
|
||||
edges.push(makeEdge({
|
||||
id: `e-${pipelineTailId}-${vidId}`,
|
||||
source: pipelineTailId,
|
||||
target: vidId,
|
||||
style: PIPELINE_EDGE_STYLE,
|
||||
}))
|
||||
mediaX += MEDIA_GAP_X
|
||||
}
|
||||
|
||||
if (sb.audio_local_path) {
|
||||
const audId = `sbaud:${sb.id}:dialogue`
|
||||
nodes.push(makeNode({
|
||||
id: audId,
|
||||
type: 'canvasMedia',
|
||||
position: resolveNodePosition(savedLayout, audId, { x: mediaX, y: mediaY }),
|
||||
data: { kind: 'audio', storyboard: sb, url: audioUrl(sb.audio_local_path), audioType: 'dialogue' },
|
||||
}))
|
||||
edges.push(makeEdge({
|
||||
id: `e-sb-aud-${sb.id}`,
|
||||
source: sbId,
|
||||
target: audId,
|
||||
style: { stroke: '#fbbf24', strokeWidth: 1.5 },
|
||||
}))
|
||||
}
|
||||
|
||||
const charIds = Array.isArray(sb.characters) ? sb.characters : []
|
||||
for (const charId of charIds) {
|
||||
const source = `char:${charId}`
|
||||
edges.push(makeEdge({
|
||||
id: `e-char-${charId}-sb-${sb.id}`,
|
||||
source,
|
||||
target: sbId,
|
||||
style: ASSET_EDGE_STYLE,
|
||||
}))
|
||||
}
|
||||
|
||||
if (sb.scene_id) {
|
||||
edges.push(makeEdge({
|
||||
id: `e-scene-${sb.scene_id}-sb-${sb.id}`,
|
||||
source: `scene:${sb.scene_id}`,
|
||||
target: sbId,
|
||||
style: ASSET_EDGE_STYLE,
|
||||
}))
|
||||
}
|
||||
|
||||
const propIds = Array.isArray(sb.prop_ids) ? sb.prop_ids : []
|
||||
for (const propId of propIds) {
|
||||
edges.push(makeEdge({
|
||||
id: `e-prop-${propId}-sb-${sb.id}`,
|
||||
source: `prop:${propId}`,
|
||||
target: sbId,
|
||||
style: ASSET_EDGE_STYLE,
|
||||
}))
|
||||
}
|
||||
|
||||
if (prevSbId) {
|
||||
edges.push(makeEdge({
|
||||
id: `e-chain-${prevSbId}-${sbId}`,
|
||||
source: prevSbId,
|
||||
target: sbId,
|
||||
sourceHandle: 'chain-out',
|
||||
targetHandle: 'chain-in',
|
||||
style: CHAIN_EDGE_STYLE,
|
||||
}))
|
||||
}
|
||||
prevSbId = sbId
|
||||
})
|
||||
|
||||
const rowWidth = SB_PIPELINE_WIDTH
|
||||
const nextY = rowYBase + storyboards.length * SB_GAP_Y + EPISODE_ROW_GAP
|
||||
return { nodes, edges, nextY, rowWidth }
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 drama API 数据转为 Vue Flow 图(兼容无 canvas_layout 的旧 JSON)
|
||||
* @param {object} drama
|
||||
* @param {{ episodeId?: number|null }} options
|
||||
*/
|
||||
export function buildDramaCanvasGraph(drama, options = {}) {
|
||||
if (!drama) return { nodes: [], edges: [] }
|
||||
|
||||
const savedLayout = options.savedLayout ?? parseCanvasLayout(drama.metadata)
|
||||
const workflowGroupMap = options.workflowGroupMap ?? getStoryboardGroupMap(
|
||||
options.workflowGroups ?? parseWorkflowGroups(drama.metadata)
|
||||
)
|
||||
const useFirstLastFrame = options.useFirstLastFrame ?? dramaUsesFirstLastFrame(drama)
|
||||
const episodeId = options.episodeId ?? null
|
||||
const episodes = episodeId
|
||||
? (drama.episodes || []).filter((ep) => ep.id === episodeId)
|
||||
: (drama.episodes || [])
|
||||
|
||||
const nodes = []
|
||||
const edges = []
|
||||
|
||||
const headerId = 'drama:header'
|
||||
nodes.push(makeNode({
|
||||
id: headerId,
|
||||
type: 'canvasDramaHeader',
|
||||
position: resolveNodePosition(savedLayout, headerId, { x: PIPELINE_X, y: 16 }),
|
||||
data: { drama },
|
||||
}))
|
||||
|
||||
const assetBlock = buildAssetNodes(drama, savedLayout, 80)
|
||||
nodes.push(...assetBlock.nodes)
|
||||
|
||||
let pipelineY = 88
|
||||
let maxPipelineX = PIPELINE_X
|
||||
|
||||
for (const ep of episodes) {
|
||||
const block = buildEpisodePipeline(ep, savedLayout, pipelineY, {
|
||||
...options,
|
||||
workflowGroupMap,
|
||||
useFirstLastFrame,
|
||||
})
|
||||
nodes.push(...block.nodes)
|
||||
edges.push(...block.edges)
|
||||
pipelineY = block.nextY
|
||||
if (block.rowWidth) maxPipelineX = Math.max(maxPipelineX, PIPELINE_X + block.rowWidth)
|
||||
}
|
||||
|
||||
if (!episodes.length) {
|
||||
nodes.push(sectionLabel('label:empty', '暂无剧集数据,请先在列表模式创建剧本与分镜', PIPELINE_X, pipelineY))
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
savedLayout,
|
||||
bounds: {
|
||||
width: Math.max(maxPipelineX + 200, 1200),
|
||||
height: Math.max(pipelineY + 80, assetBlock.nextY, 600),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoryboardRefFromNode(node) {
|
||||
if (!node?.data?.storyboard) return null
|
||||
return {
|
||||
storyboardId: node.data.storyboard.id,
|
||||
episodeId: node.data.episodeId || node.data.storyboard.episode_id,
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击素材时,计算应高亮的节点与连线 */
|
||||
export function getAssetRelationHighlight(drama, assetNodeId) {
|
||||
const nodeIds = new Set([assetNodeId])
|
||||
const edgeIds = new Set()
|
||||
if (!drama || !assetNodeId) return { nodeIds, edgeIds }
|
||||
|
||||
const [prefix, rawId] = assetNodeId.split(':')
|
||||
const entityId = Number(rawId)
|
||||
if (!entityId) return { nodeIds, edgeIds }
|
||||
|
||||
for (const ep of drama.episodes || []) {
|
||||
for (const sb of ep.storyboards || []) {
|
||||
let linked = false
|
||||
if (prefix === 'char' && (sb.characters || []).includes(entityId)) linked = true
|
||||
if (prefix === 'scene' && sb.scene_id === entityId) linked = true
|
||||
if (prefix === 'prop' && (sb.prop_ids || []).includes(entityId)) linked = true
|
||||
if (!linked) continue
|
||||
|
||||
const sbId = `sb:${sb.id}`
|
||||
nodeIds.add(sbId)
|
||||
nodeIds.add(`sbtxt:${sb.id}`)
|
||||
nodeIds.add(`sbuni:${sb.id}`)
|
||||
nodeIds.add(`sbimg:${sb.id}`)
|
||||
nodeIds.add(`sbimg-first:${sb.id}`)
|
||||
nodeIds.add(`sbimg-last:${sb.id}`)
|
||||
if (storyboardVideoUrl(sb)) nodeIds.add(`sbvid:${sb.id}`)
|
||||
if (sb.audio_local_path) nodeIds.add(`sbaud:${sb.id}:dialogue`)
|
||||
|
||||
if (prefix === 'char') edgeIds.add(`e-char-${entityId}-sb-${sb.id}`)
|
||||
if (prefix === 'scene') edgeIds.add(`e-scene-${entityId}-sb-${sb.id}`)
|
||||
if (prefix === 'prop') {
|
||||
edgeIds.add(`e-prop-${entityId}-sb-${sb.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { nodeIds, edgeIds }
|
||||
}
|
||||
|
||||
export function applyCanvasHighlight(nodes, edges, highlightNodeId, drama) {
|
||||
if (!highlightNodeId) {
|
||||
return {
|
||||
nodes: nodes.map((n) => ({ ...n, class: undefined, data: { ...n.data, dimmed: false, highlighted: false } })),
|
||||
edges: edges.map((e) => ({
|
||||
...e,
|
||||
animated: false,
|
||||
style: e._baseStyle || e.style,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const { nodeIds, edgeIds } = getAssetRelationHighlight(drama, highlightNodeId)
|
||||
return {
|
||||
nodes: nodes.map((n) => {
|
||||
const highlighted = nodeIds.has(n.id)
|
||||
const dimmed = !highlighted
|
||||
return {
|
||||
...n,
|
||||
class: highlighted ? 'canvas-node-highlight' : 'canvas-node-dim',
|
||||
data: { ...n.data, highlighted, dimmed },
|
||||
}
|
||||
}),
|
||||
edges: edges.map((e) => {
|
||||
const baseStyle = e._baseStyle || e.style
|
||||
const highlighted = edgeIds.has(e.id)
|
||||
return {
|
||||
...e,
|
||||
_baseStyle: baseStyle,
|
||||
animated: highlighted,
|
||||
style: highlighted
|
||||
? { ...baseStyle, stroke: '#34d399', strokeWidth: 2.5, opacity: 1 }
|
||||
: { ...baseStyle, opacity: 0.15 },
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 为边附加 _baseStyle 便于高亮恢复 */
|
||||
export function stampEdgeBaseStyles(edges) {
|
||||
return edges.map((e) => ({ ...e, _baseStyle: e.style ? { ...e.style } : undefined }))
|
||||
}
|
||||