feat: 新增项目配置选项和任务日志功能

添加分辨率配置选项,支持在项目设置中选择1K/2K/4K分辨率
为任务模型添加logs字段,记录任务执行过程中的详细日志
实现任务日志记录功能,包括时间戳和状态更新
新增多个前端对话框组件:终端日志、历史记录、JSON编辑器等
优化AI服务生成图像接口,支持传入分辨率和宽高比参数
重构任务管理界面,添加折叠功能和日志查看入口
This commit is contained in:
p
2026-01-17 18:35:57 +08:00
parent cc65e16349
commit 3f0815e997
16 changed files with 1958 additions and 970 deletions
+4 -4
View File
@@ -80,10 +80,10 @@ Behaviors and Rules:
}
- 'characters': ['List of characters appearing in this group of panels']
- 'plot_breakdown': [
{'panel': 1, 'scene': '...', 'action': '...', 'dialogue': '...'},
{'panel': 2, 'scene': '...', 'action': '...', 'dialogue': '...'},
{'panel': 3, 'scene': '...', 'action': '...', 'dialogue': '...'},
{'panel': 4, 'scene': '...', 'action': '...', 'dialogue': '...'}
{'panel': 1, 'scene': '...', 'action': '...', 'dialogue': '...', 'prompt': 'Detailed visual description for image generation...'},
{'panel': 2, 'scene': '...', 'action': '...', 'dialogue': '...', 'prompt': '...'},
{'panel': 3, 'scene': '...', 'action': '...', 'dialogue': '...', 'prompt': '...'},
{'panel': 4, 'scene': '...', 'action': '...', 'dialogue': '...', 'prompt': '...'}
]
3) Quality Control (Quality Control):
+2
View File
@@ -22,6 +22,7 @@ class ProjectBase(SQLModel):
language: Optional[str] = "zh-CN"
panel_count: Optional[int] = 16
aspect_ratio: Optional[str] = "16:9"
resolution: Optional[str] = "2K"
class CharacterBase(SQLModel):
name: str
@@ -41,6 +42,7 @@ class TaskBase(SQLModel):
status: str # 'pending', 'processing', 'completed', 'failed'
progress: int = 0 # 0-100
message: Optional[str] = None
logs: List[str] = Field(default=[], sa_column=Column(JSON))
name: Optional[str] = None
description: Optional[str] = None
result: Dict = Field(default={}, sa_column=Column(JSON))
+120 -54
View File
@@ -18,6 +18,24 @@ import sys
logging.basicConfig(level=logging.INFO, stream=sys.stdout)
logger = logging.getLogger(__name__)
def log_task_event(session, task_id, message):
logger.info(message)
try:
task = session.get(Task, task_id)
if task:
if task.logs is None:
task.logs = []
import datetime
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
# Create new list to ensure SQLModel detects change
current_logs = list(task.logs) if task.logs else []
current_logs.append(f"[{timestamp}] {message}")
task.logs = current_logs
session.add(task)
session.commit()
except Exception as e:
logger.error(f"Failed to log task event: {e}")
def save_generated_image(session, project_id, entity_type, entity_id, image_bytes):
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
project_static_dir = os.path.join(base_dir, "static", project_id)
@@ -72,7 +90,7 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str):
try:
project = crud_project.get_project(session, project_id)
logger.info(f"Project found: {project.title}")
log_task_event(session, task_id, f"Project found: {project.title}")
# Save User Input (Persist it)
project.story_input = user_input
@@ -116,7 +134,7 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str):
if prefs:
final_prompt += "\n\nRequirements:\n" + "\n".join(prefs)
logger.info("Calling AI service for storyboard generation...")
log_task_event(session, task_id, "Calling AI service for storyboard generation... This may take a while.")
generated_text = ai.generate_storyboard(system_prompt, final_prompt)
# --- Save Generated Text to Temp File ---
@@ -131,12 +149,12 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str):
try:
with open(temp_file, "w", encoding="utf-8") as f:
f.write(generated_text)
logger.info(f"Saved raw AI output to {temp_file}")
log_task_event(session, task_id, f"Saved raw AI output to {temp_file}")
except Exception as e:
logger.error(f"Failed to save temp AI output: {e}")
# ----------------------------------------
logger.info("AI generation complete. Extracting JSON blocks...")
log_task_event(session, task_id, "AI generation complete. Extracting JSON blocks...")
json_blocks = extract_json_blocks(generated_text)
char_blocks = [b for b in json_blocks if b.get("type") == "character_sheet"]
@@ -171,7 +189,7 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str):
missing_chars.append(name)
if missing_chars:
logger.info(f"Detected missing characters: {missing_chars}. Requesting AI to generate them...")
log_task_event(session, task_id, f"Detected missing characters: {missing_chars}. Requesting AI to generate them...")
fix_prompt = f"You missed generating character sheets for the following characters that appeared in the storyboard: {', '.join(missing_chars)}. Please generate 'character_sheet' JSON blocks for them now. Do not generate anything else."
try:
@@ -179,7 +197,7 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str):
fix_blocks = extract_json_blocks(fix_response)
new_chars = [b for b in fix_blocks if b.get("type") == "character_sheet"]
if new_chars:
logger.info(f"Successfully generated {len(new_chars)} missing characters.")
log_task_event(session, task_id, f"Successfully generated {len(new_chars)} missing characters.")
char_blocks.extend(new_chars)
except Exception as e:
logger.error(f"Failed to generate missing characters: {e}")
@@ -283,35 +301,49 @@ def generate_all_images_task(task_id: str, project_id: str):
# 1. Generate Characters
total_chars = len(project.characters)
logger.info(f"Generating {total_chars} characters...")
log_task_event(session, task_id, f"Generating {total_chars} characters...")
for i, char in enumerate(project.characters):
if char.image_url:
logger.info(f"Character {char.name} already has image, skipping.")
log_task_event(session, task_id, f"Character {char.name} already has image, skipping.")
continue
logger.info(f"Generating image for character: {char.name}")
log_task_event(session, task_id, f"Generating image for character: {char.name}")
json_prompt = json.dumps(char.data, ensure_ascii=False, indent=2)
json_prompt += "\n\n generate a character design sheet with 4 panels: front view, side view, clothing details, accessories."
try:
image_bytes = ai.generate_image(json_prompt)
image_bytes = ai.generate_image(
json_prompt,
aspect_ratio=project.aspect_ratio or "16:9",
resolution=project.resolution or "2K"
)
relative_url = save_generated_image(session, project_id, "character", char.id, image_bytes)
char.image_url = relative_url
session.add(char)
session.commit()
logger.info(f"Character {char.name} generated successfully.")
log_task_event(session, task_id, f"Character {char.name} generated successfully.")
# Update task progress
progress = int(((i + 1) / total_chars) * 100)
task.progress = progress
session.add(task)
session.commit()
except Exception as e:
logger.error(f"Failed to generate char {char.id}: {e}")
print(f"Failed to generate char {char.id}: {e}")
log_task_event(session, task_id, f"Failed to generate char {char.id}: {e}")
# Update task progress (Characters are 20% of work?)
# Let's simple split: chars + storyboard items
# Update task progress
# progress = int(((i + 1) / total_chars) * 100)
# task.progress = progress
# session.add(task)
# session.commit()
# 2. Generate Storyboard Items (Sequential)
# Re-fetch items to ensure order
items = sorted(project.storyboard_items, key=lambda x: x.sequence)
total_items = len(items)
logger.info(f"Generating {total_items} storyboard panels...")
log_task_event(session, task_id, f"Generating {total_items} storyboard panels...")
generated_history = [] # Keep track of generated images for context
@@ -321,25 +353,26 @@ def generate_all_images_task(task_id: str, project_id: str):
# For "one click", let's assume we scan all items.
for i, item in enumerate(items):
task.progress = int((i / total_items) * 100)
session.add(task)
session.commit()
# Update progress at start of loop
# task.progress = int((i / total_items) * 100)
# session.add(task)
# session.commit()
if item.image_url:
# Add to history
filename = os.path.basename(item.image_url)
# We need to find where it is stored.
# Assuming standard structure
# We need absolute path for history
# item.image_url is like /static/{project_id}/panels/{filename}
rel_path = item.image_url.lstrip("/")
abs_path = os.path.join(base_dir, rel_path.replace("/", os.sep))
# if item.image_url:
# # Add to history
# filename = os.path.basename(item.image_url)
# # We need to find where it is stored.
# # Assuming standard structure
# # We need absolute path for history
# # item.image_url is like /static/{project_id}/panels/{filename}
# rel_path = item.image_url.lstrip("/")
# abs_path = os.path.join(base_dir, rel_path.replace("/", os.sep))
#
# if os.path.exists(abs_path):
# generated_history.append(abs_path)
# continue
if os.path.exists(abs_path):
generated_history.append(abs_path)
continue
logger.info(f"Generating panel {item.sequence}...")
log_task_event(session, task_id, f"Generating panel {item.sequence}...")
# Prepare Context
context_images = []
@@ -381,13 +414,23 @@ def generate_all_images_task(task_id: str, project_id: str):
json_prompt += "\n\n use json block as user input prompt to generate 2*2 grid comic image."
try:
image_bytes = ai.generate_image(json_prompt, context_images)
image_bytes = ai.generate_image(
json_prompt,
context_images=context_images,
aspect_ratio=project.aspect_ratio or "16:9",
resolution=project.resolution or "2K"
)
relative_url = save_generated_image(session, project_id, "panel", item.id, image_bytes)
item.image_url = relative_url
session.add(item)
session.commit()
# Update progress
task.progress = int(((i + 1) / total_items) * 100)
session.add(task)
session.commit()
# Add to history (absolute path for context usage)
# We need absolute path for next context
# save_generated_image returns relative /static/...
@@ -395,17 +438,17 @@ def generate_all_images_task(task_id: str, project_id: str):
# strip leading /
abs_path = os.path.join(base_dir, relative_url.lstrip("/").replace("/", os.sep))
generated_history.append(abs_path)
logger.info(f"Panel {item.sequence} generated successfully.")
log_task_event(session, task_id, f"Panel {item.sequence} generated successfully.")
except Exception as e:
logger.error(f"Failed to generate panel {item.id}: {e}")
print(f"Failed to generate panel {item.id}: {e}")
log_task_event(session, task_id, f"Failed to generate panel {item.id}: {e}")
task.status = "completed"
task.progress = 100
session.add(task)
session.commit()
logger.info(f"Batch generation task {task_id} completed successfully.")
log_task_event(session, task_id, f"Batch generation task {task_id} completed successfully.")
except Exception as e:
logger.error(f"Batch generation task {task_id} failed: {e}")
@@ -433,14 +476,14 @@ def generate_all_characters_task(task_id: str, project_id: str):
ai = AIService(session)
total_chars = len(project.characters)
logger.info(f"Generating {total_chars} characters...")
log_task_event(session, task_id, f"Generating {total_chars} characters...")
for i, char in enumerate(project.characters):
if char.image_url:
logger.info(f"Character {char.name} already has image, skipping.")
continue
# if char.image_url:
# logger.info(f"Character {char.name} already has image, skipping.")
# continue
logger.info(f"Generating image for character: {char.name}")
log_task_event(session, task_id, f"Generating image for character: {char.name}")
# Construct Natural Language Prompt from JSON
data = char.data
@@ -475,26 +518,38 @@ Ensure the character's expression and pose reflect their personality: {personali
"""
try:
image_bytes = ai.generate_image(prompt)
image_bytes = ai.generate_image(
prompt,
aspect_ratio=project.aspect_ratio or "16:9",
resolution=project.resolution or "2K"
)
relative_url = save_generated_image(session, project_id, "character", char.id, image_bytes)
char.image_url = relative_url
session.add(char)
session.commit()
logger.info(f"Character {char.name} generated successfully.")
log_task_event(session, task_id, f"Character {char.name} generated successfully.")
# Update progress
progress = int(((i + 1) / total_chars) * 100)
task.progress = progress
session.add(task)
session.commit()
except Exception as e:
logger.error(f"Failed to generate char {char.id}: {e}")
log_task_event(session, task_id, f"Failed to generate char {char.id}: {e}")
# Update progress
progress = int(((i + 1) / total_chars) * 100)
task.progress = progress
session.add(task)
session.commit()
# progress = int(((i + 1) / total_chars) * 100)
# task.progress = progress
# session.add(task)
# session.commit()
task.status = "completed"
task.progress = 100
session.add(task)
session.commit()
logger.info(f"Batch character generation task {task_id} completed successfully.")
log_task_event(session, task_id, f"Batch character generation task {task_id} completed successfully.")
except Exception as e:
logger.error(f"Batch character generation task {task_id} failed: {e}")
@@ -556,8 +611,12 @@ Include Front View, Side View, and detailed clothing/accessories.
Ensure the character's expression and pose reflect their personality: {personality}.
"""
logger.info(f"Calling AI service for character {char.name}...")
image_bytes = ai.generate_image(prompt)
log_task_event(session, task_id, f"Calling AI service for character {char.name}...")
image_bytes = ai.generate_image(
prompt,
aspect_ratio=char.project.aspect_ratio or "16:9",
resolution=char.project.resolution or "2K"
)
relative_url = save_generated_image(session, char.project_id, "character", char.id, image_bytes)
@@ -568,10 +627,11 @@ Ensure the character's expression and pose reflect their personality: {personali
task.progress = 100
session.add(task)
session.commit()
logger.info(f"Character task {task_id} completed successfully.")
log_task_event(session, task_id, f"Character task {task_id} completed successfully.")
except Exception as e:
logger.error(f"Character task {task_id} failed: {e}")
log_task_event(session, task_id, f"Character task {task_id} failed: {e}")
traceback.print_exc()
task.status = "failed"
task.message = str(e)
@@ -789,8 +849,13 @@ def generate_panel_task(task_id: str, item_id: int):
if meta_style:
json_prompt += f"\n\nStyle Consistency Requirement: {meta_style}. Ensure the visual style matches the provided context images."
logger.info(f"Calling AI service for panel {item.sequence}...")
image_bytes = ai.generate_image(json_prompt, context_images)
log_task_event(session, task_id, f"Calling AI service for panel {item.sequence}...")
image_bytes = ai.generate_image(
json_prompt,
context_images=context_images,
aspect_ratio=project.aspect_ratio or "16:9",
resolution=project.resolution or "2K"
)
relative_url = save_generated_image(session, project.id, "panel", item.id, image_bytes)
item.image_url = relative_url
@@ -800,10 +865,11 @@ def generate_panel_task(task_id: str, item_id: int):
task.progress = 100
session.add(task)
session.commit()
logger.info(f"Panel task {task_id} completed successfully.")
log_task_event(session, task_id, f"Panel task {task_id} completed successfully.")
except Exception as e:
logger.error(f"Panel task {task_id} failed: {e}")
log_task_event(session, task_id, f"Panel task {task_id} failed: {e}")
traceback.print_exc()
task.status = "failed"
task.message = str(e)
+1
View File
@@ -49,6 +49,7 @@ class ProjectUpdate(BaseModel):
language: Optional[str] = None
panel_count: Optional[int] = None
aspect_ratio: Optional[str] = None
resolution: Optional[str] = None
class ProjectRead(ProjectBase):
id: str
+8 -1
View File
@@ -4,6 +4,7 @@ from typing import List, Optional
from sqlmodel import Session
from app.models.models import ModelConfig
from google import genai
from google.genai import types
from PIL import Image
import io
@@ -39,7 +40,7 @@ class AIService:
print(f"Error generating storyboard: {e}")
raise e
def generate_image(self, prompt: str, context_images: List[str] = None) -> bytes:
def generate_image(self, prompt: str, context_images: List[str] = None, aspect_ratio: str = "16:9", resolution: str = "2K") -> bytes:
client, model_name = self._get_client("image")
contents = [prompt]
@@ -62,6 +63,12 @@ class AIService:
response = client.models.generate_content(
model=model_name,
contents=contents,
config=types.GenerateContentConfig(
image_config=types.ImageConfig(
aspect_ratio=aspect_ratio,
image_size=resolution
),
)
)
if response.parts:
File diff suppressed because it is too large Load Diff
+462
View File
@@ -0,0 +1,462 @@
<template>
<div class="character-tab">
<div class="mb-2 flex-row-between">
<div class="left-actions">
<el-popconfirm
title="This will regenerate ALL character images, overwriting existing ones. Continue?"
confirm-button-text="Yes, Overwrite"
cancel-button-text="Cancel"
@confirm="generateAllCharacters"
>
<template #reference>
<el-button type="primary" size="large" :disabled="!project.characters.length || isTaskRunning">
Draw All Characters (Overwrite)
</el-button>
</template>
</el-popconfirm>
<el-button @click="emit('open-merge-dialog')" size="large" :disabled="project.characters.length < 2">
Merge Characters
</el-button>
</div>
</div>
<el-container class="char-studio-container">
<el-aside width="250px" class="char-list-aside">
<el-menu
:default-active="activeCharId"
@select="handleCharSelect"
background-color="#1e1e1e"
text-color="#fff"
active-text-color="#409EFF"
class="char-menu"
>
<el-menu-item v-for="char in project.characters" :key="char.id" :index="String(char.id)">
<span class="text-truncate">{{ char.name }}</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main class="char-main">
<div v-if="selectedChar" class="char-detail">
<div class="char-header">
<h2>{{ selectedChar.name }}</h2>
<div class="header-actions">
<el-popconfirm title="Are you sure you want to delete this character?" @confirm="deleteCharacter(selectedChar.id)">
<template #reference>
<el-button type="danger" plain>Delete</el-button>
</template>
</el-popconfirm>
<el-button @click="emit('open-history', 'character', selectedChar.id)">History</el-button>
<el-button type="primary" @click="generateCharacter(selectedChar.id)" :loading="loading">
Draw / Redraw (Background)
</el-button>
</div>
</div>
<el-row :gutter="24">
<!-- Left: Attributes -->
<el-col :xs="24" :sm="10" :md="8" :lg="8">
<div class="info-card">
<div class="info-header">
<h4>Character Attributes</h4>
<el-button size="small" type="primary" link @click="openJsonEditor">Edit JSON</el-button>
</div>
<el-scrollbar max-height="600px">
<div v-if="Object.keys(displayData).length" class="attributes-list">
<div v-for="(value, key) in displayData" :key="key" class="attr-wrapper">
<!-- Complex Data (Array or Object) -->
<div v-if="isComplex(value)" class="complex-attr">
<div class="complex-label">{{ formatKey(key) }}</div>
<!-- Array -->
<div v-if="Array.isArray(value)" class="array-list">
<div v-for="(item, idx) in value" :key="idx" class="array-item">
<template v-if="isComplex(item)">
<div v-for="(v, k) in item" :key="k" class="nested-item">
<span class="nested-label">{{ formatKey(k) }}:</span>
<span class="nested-value">{{ v }}</span>
</div>
</template>
<template v-else>{{ item }}</template>
</div>
</div>
<!-- Object -->
<div v-else class="object-grid">
<div v-for="(v, k) in value" :key="k" class="grid-item">
<span class="nested-label">{{ formatKey(k) }}:</span>
<span class="nested-value">{{ v }}</span>
</div>
</div>
</div>
<!-- Simple Data -->
<div v-else class="attr-item">
<span class="attr-label">{{ formatKey(key) }}:</span>
<span class="attr-value">{{ value }}</span>
</div>
</div>
</div>
<div v-else class="text-gray p-2">
No structured attributes found. Click Edit JSON to add details.
</div>
<div v-if="selectedChar.data?.description" class="mt-4">
<span class="attr-label">Description:</span>
<p class="desc-text">{{ selectedChar.data.description }}</p>
</div>
</el-scrollbar>
</div>
</el-col>
<!-- Right: Preview -->
<el-col :xs="24" :sm="14" :md="16" :lg="16">
<div class="preview-card">
<h4>Character Preview</h4>
<div class="image-wrapper">
<el-image
v-if="selectedChar.image_url"
:src="`${selectedChar.image_url}?v=${imageVersion}`"
fit="contain"
class="image-preview"
:preview-src-list="[`${selectedChar.image_url}?v=${imageVersion}`]"
>
<template #error>
<div class="image-slot">
<el-icon><icon-picture /></el-icon>
</div>
</template>
</el-image>
<div v-else class="no-image">
<span>No Image Generated</span>
<span class="sub-text">Click "Draw / Redraw" to generate</span>
</div>
</div>
<div v-if="selectedChar.image_url" class="image-actions mt-2" style="text-align: center;">
<a :href="selectedChar.image_url" :download="`${selectedChar.name}.png`" target="_blank">
<el-button size="small" type="info" plain>Download Image</el-button>
</a>
</div>
</div>
</el-col>
</el-row>
</div>
<div v-else class="empty-state">
<el-empty description="Select a character to view details" />
</div>
</el-main>
</el-container>
<JsonEditorDialog
v-model:visible="showJsonEditor"
:content="characterEditor"
:title="`Edit ${selectedChar?.name || 'Character'}`"
@save="handleJsonSave"
/>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { Picture as IconPicture } from '@element-plus/icons-vue'
import JsonEditorDialog from './JsonEditorDialog.vue'
const props = defineProps({
project: Object,
projectId: [String, Number],
isTaskRunning: Boolean,
imageVersion: Number
})
const emit = defineEmits(['task-started', 'refresh-project', 'open-merge-dialog', 'open-history'])
const activeCharId = ref('')
const characterEditor = ref('')
const loading = ref(false)
const showJsonEditor = ref(false)
const selectedChar = computed(() => {
if (!activeCharId.value) return null
return props.project.characters.find(c => String(c.id) === activeCharId.value)
})
const displayData = computed(() => {
if (!selectedChar.value || !selectedChar.value.data) return {}
const data = selectedChar.value.data
const ignoredKeys = ['description', 'id', 'name', 'image_url', 'created_at']
const result = {}
for (const key in data) {
if (ignoredKeys.includes(key)) continue
if (data[key] !== null && data[key] !== undefined) {
result[key] = data[key]
}
}
return result
})
const isComplex = (val) => {
return typeof val === 'object' && val !== null
}
watch(() => props.project.characters, (newChars) => {
if (newChars && newChars.length > 0 && !activeCharId.value) {
activeCharId.value = String(newChars[0].id)
}
}, { immediate: true })
watch(selectedChar, (newChar) => {
if (newChar) {
characterEditor.value = JSON.stringify(newChar.data, null, 2)
} else {
characterEditor.value = ''
}
})
const handleCharSelect = (index) => {
activeCharId.value = index
}
const formatKey = (key) => {
// Convert snake_case or camelCase to Title Case
return key.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.replace(/_/g, ' ')
}
const openJsonEditor = () => {
if (!selectedChar.value) return
// Ensure editor has latest data
characterEditor.value = JSON.stringify(selectedChar.value.data, null, 2)
showJsonEditor.value = true
}
const handleJsonSave = async (newContent) => {
characterEditor.value = newContent
await updateCharacter(selectedChar.value.id)
}
const updateCharacter = async (charId) => {
try {
const data = JSON.parse(characterEditor.value)
await axios.put(`/api/v1/projects/${props.projectId}/characters/${charId}`, data)
ElMessage.success('Character setting saved')
emit('refresh-project')
} catch (e) {
ElMessage.error('JSON format error or save failed: ' + e.message)
}
}
const generateCharacter = async (charId) => {
try {
await axios.post(`/api/v1/generate/character/${charId}`)
emit('task-started')
ElMessage.info('Character drawing task started in background...')
} catch (error) {
console.error(error)
ElMessage.error('Generation failed: ' + (error.response?.data?.detail || error.message))
}
}
const generateAllCharacters = async () => {
try {
await axios.post(`/api/v1/generate/all-characters/${props.projectId}`)
emit('task-started')
ElMessage.info('Batch character drawing task started in background...')
} catch (error) {
ElMessage.error('Failed to start task: ' + (error.response?.data?.detail || error.message))
}
}
const deleteCharacter = async (charId) => {
try {
await axios.delete(`/api/v1/projects/${props.projectId}/characters/${charId}`)
ElMessage.success('Character deleted')
if (activeCharId.value === String(charId)) {
activeCharId.value = ''
}
emit('refresh-project')
} catch (e) {
ElMessage.error('Delete failed')
}
}
</script>
<style scoped>
.flex-row-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.left-actions {
display: flex;
gap: 12px;
}
.char-studio-container {
height: calc(100vh - 200px); /* Dynamic height based on viewport */
min-height: 600px;
border: 1px solid #333;
background: #1e1e1e;
border-radius: 8px;
overflow: hidden;
}
.char-list-aside {
background-color: #1a1a1a;
border-right: 1px solid #333;
}
.char-menu {
border-right: none;
background-color: transparent;
}
.char-main {
padding: 24px;
overflow-y: auto;
}
.char-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #333;
}
.header-actions {
display: flex;
gap: 12px;
}
.info-card, .preview-card {
background: #252525;
border-radius: 8px;
padding: 20px;
height: 100%;
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.info-header h4, .preview-card h4 {
margin: 0;
color: #eee;
font-size: 1.1rem;
}
.attributes-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.attr-item {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #333;
padding-bottom: 8px;
}
.attr-label {
color: #909399;
font-weight: 500;
}
.attr-value {
color: #E5EAF3;
text-align: right;
max-width: 60%;
white-space: pre-wrap;
word-break: break-word;
}
.desc-text {
color: #ccc;
line-height: 1.6;
margin-top: 8px;
font-size: 0.95rem;
}
.image-wrapper {
margin-top: 16px;
width: 100%;
/* Flexible height container */
min-height: 400px;
display: flex;
justify-content: center;
background: #1a1a1a;
border-radius: 4px;
padding: 10px;
}
.image-preview {
width: 100%;
height: auto; /* Allow height to grow */
display: block;
}
.no-image {
width: 100%;
height: 400px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: #2a2a2a;
color: #666;
border-radius: 4px;
gap: 10px;
}
.sub-text {
font-size: 0.8rem;
color: #555;
}
.text-truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.mb-2 { margin-bottom: 16px; }
.mt-4 { margin-top: 24px; }
/* Complex Attributes Styling */
.complex-attr {
margin-bottom: 16px;
background: #2a2a2a;
border-radius: 6px;
padding: 10px;
}
.complex-label {
color: #409EFF;
font-weight: 600;
margin-bottom: 8px;
font-size: 0.95rem;
border-bottom: 1px solid #333;
padding-bottom: 4px;
}
.array-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.array-item {
background: #333;
padding: 8px;
border-radius: 4px;
font-size: 0.9em;
}
.object-grid {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.nested-item {
margin-bottom: 4px;
}
.nested-label {
color: #bbb;
font-weight: 500;
margin-right: 4px;
}
.nested-value {
color: #eee;
}
</style>
@@ -0,0 +1,55 @@
<template>
<el-dialog
:model-value="visible"
@update:model-value="emit('update:visible', $event)"
title="Export Comic"
width="30%"
>
<span>Confirm export of current project comic?</span>
<div class="mt-2">
<el-checkbox v-model="splitImages">Auto-split 4-panel storyboard (1:1 split)</el-checkbox>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="emit('update:visible', false)">Cancel</el-button>
<el-button type="primary" @click="confirmExport" :loading="loading">Confirm Export</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const props = defineProps({
visible: Boolean,
projectId: [String, Number]
})
const emit = defineEmits(['update:visible'])
const splitImages = ref(false)
const loading = ref(false)
const confirmExport = async () => {
loading.value = true
try {
const res = await axios.get(`/api/v1/export/${props.projectId}`, {
params: { split_images: splitImages.value }
})
window.open(res.data.download_url, '_blank')
emit('update:visible', false)
ElMessage.success('Export download started')
} catch (error) {
ElMessage.error('Export failed: ' + (error.response?.data?.detail || error.message))
} finally {
loading.value = false
}
}
</script>
<style scoped>
.mt-2 { margin-top: 10px; }
</style>
@@ -0,0 +1,115 @@
<template>
<el-dialog
:model-value="visible"
@update:model-value="emit('update:visible', $event)"
title="Generation History"
width="60%"
@open="loadHistory"
>
<div class="history-list" v-loading="loading">
<div v-for="h in historyList" :key="h.id" class="history-item" @click="selectHistoryImage(h)" :class="{ active: isCurrentHistory(h) }">
<el-image :src="h.image_url" fit="cover" class="history-img" />
<div class="history-meta">
<span class="history-time">{{ new Date(h.created_at + 'Z').toLocaleString() }}</span>
<el-tag size="small" v-if="isCurrentHistory(h)" type="success">Current</el-tag>
</div>
</div>
<div v-if="historyList.length === 0 && !loading" class="empty-history">No History</div>
</div>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const props = defineProps({
visible: Boolean,
type: String, // 'character' or 'panel'
entityId: [String, Number],
currentImageUrl: String
})
const emit = defineEmits(['update:visible', 'image-selected'])
const loading = ref(false)
const historyList = ref([])
const loadHistory = async () => {
if (!props.type || !props.entityId) return
loading.value = true
try {
const res = await axios.get(`/api/v1/history/${props.type}/${props.entityId}`)
historyList.value = res.data
} catch (e) {
ElMessage.error('Failed to load history')
} finally {
loading.value = false
}
}
const selectHistoryImage = async (historyItem) => {
try {
await axios.post(`/api/v1/history/select/${historyItem.id}`)
ElMessage.success('Image switched')
emit('update:visible', false)
emit('image-selected')
} catch (e) {
ElMessage.error('Switch failed')
}
}
const isCurrentHistory = (h) => {
return h.image_url === props.currentImageUrl
}
</script>
<style scoped>
.history-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
max-height: 500px;
overflow-y: auto;
padding: 10px;
}
.history-item {
cursor: pointer;
border: 2px solid transparent;
border-radius: 4px;
overflow: hidden;
position: relative;
background: #222;
transition: all 0.2s;
}
.history-item:hover {
border-color: #409EFF;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
.history-item.active {
border-color: #67C23A;
}
.history-img {
width: 100%;
height: 180px;
display: block;
}
.history-meta {
padding: 8px;
font-size: 0.8em;
color: #888;
background: #1a1a1a;
text-align: center;
display: flex;
flex-direction: column;
gap: 5px;
align-items: center;
}
.empty-history {
text-align: center;
color: #666;
padding: 40px;
}
</style>
@@ -0,0 +1,63 @@
<template>
<el-dialog
:model-value="visible"
@update:model-value="emit('update:visible', $event)"
:title="title || 'Edit JSON'"
width="60%"
:close-on-click-modal="false"
>
<div class="json-editor-container">
<el-input
type="textarea"
:rows="20"
v-model="localContent"
class="json-textarea"
spellcheck="false"
/>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="emit('update:visible', false)">Cancel</el-button>
<el-button type="primary" @click="handleSave">Save Changes</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
visible: Boolean,
content: String, // Expecting stringified JSON
title: String
})
const emit = defineEmits(['update:visible', 'save'])
const localContent = ref('')
watch(() => props.visible, (val) => {
if (val) {
localContent.value = props.content || ''
}
})
const handleSave = () => {
try {
// Validate JSON
const parsed = JSON.parse(localContent.value)
emit('save', JSON.stringify(parsed, null, 2)) // Format it nicely
emit('update:visible', false)
} catch (e) {
ElMessage.error('Invalid JSON format: ' + e.message)
}
}
</script>
<style scoped>
.json-textarea {
font-family: monospace;
}
</style>
@@ -0,0 +1,83 @@
<template>
<el-dialog
:model-value="visible"
@update:model-value="emit('update:visible', $event)"
title="Merge Characters"
width="40%"
@open="resetForm"
>
<div class="merge-container">
<p class="mb-2">Merge duplicate characters into a target character. Merged characters will be deleted, and character names in the storyboard will be automatically updated to the target character.</p>
<el-form label-width="120px">
<el-form-item label="Keep Character">
<el-select v-model="mergeTargetId" placeholder="Select character to keep (Target)" style="width: 100%">
<el-option v-for="c in characters" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="Merge Source">
<el-select v-model="mergeSourceIds" multiple placeholder="Select characters to merge (Will be deleted)" style="width: 100%">
<el-option
v-for="c in characters"
:key="c.id"
:label="c.name"
:value="c.id"
:disabled="c.id === mergeTargetId"
/>
</el-select>
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="emit('update:visible', false)">Cancel</el-button>
<el-button type="primary" @click="confirmMerge" :disabled="!mergeTargetId || !mergeSourceIds.length" :loading="loading">Confirm Merge</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const props = defineProps({
visible: Boolean,
characters: Array,
projectId: [String, Number]
})
const emit = defineEmits(['update:visible', 'merged'])
const mergeTargetId = ref(null)
const mergeSourceIds = ref([])
const loading = ref(false)
const resetForm = () => {
mergeTargetId.value = null
mergeSourceIds.value = []
}
const confirmMerge = async () => {
loading.value = true
try {
await axios.post(`/api/v1/projects/${props.projectId}/characters/merge`, {
target_char_id: mergeTargetId.value,
source_char_ids: mergeSourceIds.value
})
ElMessage.success('Merge successful')
emit('update:visible', false)
emit('merged', { targetId: mergeTargetId.value, sourceIds: mergeSourceIds.value })
} catch (e) {
ElMessage.error('Merge failed: ' + (e.response?.data?.detail || e.message))
} finally {
loading.value = false
}
}
</script>
<style scoped>
.mb-2 { margin-bottom: 10px; }
</style>
@@ -0,0 +1,28 @@
<template>
<div class="header">
<h2>{{ title }}</h2>
<div class="actions">
<el-button type="success" @click="emit('export')">Export Comic</el-button>
</div>
</div>
</template>
<script setup>
defineProps({
title: String
})
const emit = defineEmits(['export'])
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.actions {
display: flex;
gap: 10px;
}
</style>
+243
View File
@@ -0,0 +1,243 @@
<template>
<div class="story-tab-content">
<!-- Top Section: Settings -->
<el-card class="box-card mb-4" shadow="never">
<template #header>
<div class="card-header">
<span>Project Configuration</span>
<el-button type="primary" link @click="openGlobalConfig">Advanced Config (JSON)</el-button>
</div>
</template>
<el-form :model="project" label-width="120px" class="settings-form" :inline="true">
<el-form-item label="Theme">
<el-input v-model="project.theme" placeholder="e.g. Cyberpunk" @change="saveSettings" class="w-200" />
</el-form-item>
<el-form-item label="Language">
<el-select v-model="project.language" placeholder="Select" @change="saveSettings" class="w-200">
<el-option label="Simplified Chinese" value="zh-CN" />
<el-option label="English" value="en-US" />
<el-option label="Japanese" value="ja-JP" />
</el-select>
</el-form-item>
<el-form-item label="Panels">
<el-input-number v-model="project.panel_count" :min="1" :max="100" @change="saveSettings" class="w-150" />
</el-form-item>
<el-form-item label="Aspect Ratio">
<el-select v-model="project.aspect_ratio" placeholder="Select" @change="saveSettings" class="w-150">
<el-option label="1:1" value="1:1" />
<el-option label="2:3" value="2:3" />
<el-option label="3:2" value="3:2" />
<el-option label="3:4" value="3:4" />
<el-option label="4:3" value="4:3" />
<el-option label="4:5" value="4:5" />
<el-option label="5:4" value="5:4" />
<el-option label="9:16" value="9:16" />
<el-option label="16:9" value="16:9" />
<el-option label="21:9" value="21:9" />
</el-select>
</el-form-item>
<el-form-item label="Resolution">
<el-select v-model="project.resolution" placeholder="Select" @change="saveSettings" class="w-150">
<el-option label="1K" value="1K" />
<el-option label="2K" value="2K" />
<el-option label="4K" value="4K" />
</el-select>
</el-form-item>
</el-form>
</el-card>
<!-- Main Section: Story Input -->
<el-card class="box-card story-card" shadow="never">
<template #header>
<div class="card-header">
<span>Story Input</span>
<div class="upload-area">
<input type="file" ref="fileInput" @change="handleFileUpload" accept=".txt" style="display: none" />
<el-button size="small" @click="$refs.fileInput.click()">Import from File (.txt)</el-button>
<span v-if="fileName" class="file-name ml-2">{{ fileName }}</span>
</div>
</div>
</template>
<el-input
type="textarea"
:rows="15"
v-model="storyInput"
placeholder="Enter your story idea, synopsis, or full text here..."
@change="saveStoryInput"
class="story-textarea"
resize="none"
/>
<div class="action-footer mt-4">
<el-button size="large" type="primary" @click="generateStoryboard" :disabled="isTaskRunning || !storyInput">
Generate Storyboard
</el-button>
<el-popconfirm
title="Regenerate will overwrite existing storyboard and character settings. Continue?"
confirm-button-text="Yes"
cancel-button-text="No"
@confirm="generateStoryboard"
>
<template #reference>
<el-button size="large" type="warning" plain :disabled="isTaskRunning || !storyInput">
Regenerate All
</el-button>
</template>
</el-popconfirm>
<el-button size="large" @click="saveStoryInput">Save Story Only</el-button>
</div>
</el-card>
<!-- Dialogs -->
<JsonEditorDialog
v-model:visible="showGlobalConfig"
:content="globalConfigEditor"
title="Global Configuration"
@save="handleGlobalConfigSave"
/>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import JsonEditorDialog from './JsonEditorDialog.vue'
const props = defineProps({
project: Object,
projectId: [String, Number],
isTaskRunning: Boolean
})
const emit = defineEmits(['refresh-project', 'task-started'])
const storyInput = ref('')
const fileName = ref('')
const fileInput = ref(null)
const globalConfigEditor = ref('')
const showGlobalConfig = ref(false)
// Initialize local state when project changes
watch(() => props.project, (newVal) => {
if (newVal) {
if (newVal.story_input && !storyInput.value) storyInput.value = newVal.story_input
if (newVal.global_config) {
globalConfigEditor.value = JSON.stringify(newVal.global_config.data, null, 2)
}
}
}, { immediate: true, deep: true })
const saveSettings = async () => {
try {
await axios.put(`/api/v1/projects/${props.projectId}`, {
theme: props.project.theme,
language: props.project.language,
panel_count: props.project.panel_count,
aspect_ratio: props.project.aspect_ratio,
resolution: props.project.resolution
})
ElMessage.success('Settings saved')
} catch (e) {
ElMessage.error('Failed to save settings')
}
}
const saveStoryInput = async () => {
try {
await axios.put(`/api/v1/projects/${props.projectId}`, { story_input: storyInput.value })
ElMessage.success('Story saved')
} catch (e) {
console.error("Failed to save story input", e)
}
}
const handleFileUpload = (event) => {
const file = event.target.files[0]
if (!file) return
fileName.value = file.name
const reader = new FileReader()
reader.onload = (e) => {
storyInput.value = e.target.result
saveStoryInput()
ElMessage.success('File read successfully')
}
reader.onerror = () => ElMessage.error('Failed to read file')
reader.readAsText(file)
}
const generateStoryboard = async () => {
if (!storyInput.value) return ElMessage.warning('Please enter story content')
try {
await saveStoryInput()
await axios.post(`/api/v1/generate/storyboard/${props.projectId}`, {
user_input: storyInput.value
})
emit('task-started')
ElMessage.info('Storyboard generation task started in background...')
} catch (error) {
ElMessage.error('Failed to start task: ' + (error.response?.data?.detail || error.message))
}
}
const openGlobalConfig = () => {
// Ensure editor has latest
if (props.project.global_config) {
globalConfigEditor.value = JSON.stringify(props.project.global_config.data, null, 2)
} else {
globalConfigEditor.value = '{}'
}
showGlobalConfig.value = true
}
const handleGlobalConfigSave = async (newContent) => {
try {
const data = JSON.parse(newContent)
await axios.put(`/api/v1/projects/${props.projectId}/global_config`, data)
ElMessage.success('Global config synced')
emit('refresh-project')
} catch (e) {
ElMessage.error('Save failed: ' + e.message)
}
}
</script>
<style scoped>
.story-tab-content {
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.settings-form {
/* padding: 10px 0; */
}
.w-200 { width: 200px; }
.w-150 { width: 150px; }
.story-textarea :deep(.el-textarea__inner) {
font-family: inherit;
font-size: 1.05rem;
line-height: 1.6;
padding: 16px;
}
.upload-area { display: flex; align-items: center; }
.file-name { color: #888; font-size: 0.9em; }
.action-footer {
display: flex;
gap: 16px;
justify-content: flex-start;
border-top: 1px solid #333;
padding-top: 20px;
}
.mb-4 { margin-bottom: 24px; }
.mt-4 { margin-top: 24px; }
.ml-2 { margin-left: 10px; }
</style>
@@ -0,0 +1,323 @@
<template>
<div class="storyboard-tab">
<div class="comic-actions mb-4">
<el-tooltip :disabled="hasCharacters" content="Please generate character images in Character Studio first" placement="top">
<div style="display: inline-block;">
<el-popconfirm
title="This will regenerate ALL panel images, overwriting existing ones. Continue?"
confirm-button-text="Yes, Overwrite"
cancel-button-text="Cancel"
@confirm="generateAllImages"
>
<template #reference>
<el-button type="primary" size="large" :disabled="!hasCharacters || isTaskRunning">
Generate All Panels (Overwrite)
</el-button>
</template>
</el-popconfirm>
</div>
</el-tooltip>
<div class="info-group">
<span class="tip-text ml-2">Generation will proceed in storyboard order, subsequent panels will reference previous image content.</span>
<span v-if="!hasCharacters" class="warning-text ml-2"><el-icon><Warning /></el-icon> Please generate characters first!</span>
</div>
</div>
<div v-for="item in sortedStoryboard" :key="item.id" class="comic-row">
<el-card class="panel-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="panel-title">Panel {{ item.sequence }}</span>
<div class="header-actions">
<el-button size="small" type="primary" link @click="openJsonEditor(item)">Edit JSON</el-button>
<el-tooltip :disabled="hasCharacters" content="Please generate character images in Character Studio first" placement="top">
<el-button size="small" type="primary" plain @click="generatePanel(item.id)" :disabled="!hasCharacters">Regenerate</el-button>
</el-tooltip>
</div>
</div>
</template>
<el-row :gutter="24">
<!-- Left: Panel Details -->
<el-col :xs="24" :sm="10" :md="8" :lg="8">
<div class="panel-details">
<!-- Scene Info -->
<div class="detail-group" v-if="item.data.scene">
<label>Scene:</label>
<div class="detail-content">{{ item.data.scene }}</div>
</div>
<!-- Action Info -->
<div class="detail-group" v-if="item.data.action">
<label>Action:</label>
<div class="detail-content">{{ item.data.action }}</div>
</div>
<!-- Dialogue Info -->
<div class="detail-group" v-if="item.data.dialogue">
<label>Dialogue:</label>
<div class="detail-content">{{ item.data.dialogue }}</div>
</div>
<!-- Prompt (Existing) -->
<div class="detail-group mt-3" v-if="item.data.prompt">
<label>Full Prompt:</label>
<div class="detail-content prompt-text">
{{ item.data.prompt }}
</div>
</div>
<div class="detail-group mt-3" v-if="item.data.negative_prompt">
<label>Negative Prompt:</label>
<div class="detail-content sm-text text-gray">
{{ item.data.negative_prompt }}
</div>
</div>
<div class="detail-group mt-3">
<label>Included Characters:</label>
<div class="detail-content">
<div v-if="getPanelCharacters(item.id).length" class="tags-wrapper">
<el-tag v-for="name in getPanelCharacters(item.id)" :key="name" size="small" effect="dark">{{ name }}</el-tag>
</div>
<span v-else class="text-gray sm-text">No explicit characters</span>
</div>
</div>
</div>
</el-col>
<!-- Right: Image Preview -->
<el-col :xs="24" :sm="14" :md="16" :lg="16">
<div class="image-area">
<div class="image-wrapper">
<el-image
v-if="item.image_url"
:src="`${item.image_url}?v=${imageVersion}`"
fit="contain"
class="comic-preview"
:preview-src-list="[`${item.image_url}?v=${imageVersion}`]"
>
<template #error>
<div class="image-slot">
<el-icon><icon-picture /></el-icon>
</div>
</template>
</el-image>
<div v-else class="no-image">
<span>No Image Generated</span>
</div>
</div>
<div v-if="item.image_url" class="image-actions mt-2">
<a :href="item.image_url" :download="`panel_${item.sequence}.png`" target="_blank" class="mr-2">
<el-button size="small" type="info" plain>Download</el-button>
</a>
<el-button size="small" @click="emit('open-history', 'panel', item.id)">History</el-button>
</div>
</div>
</el-col>
</el-row>
</el-card>
</div>
<JsonEditorDialog
v-model:visible="showJsonEditor"
:content="currentEditorContent"
:title="`Edit Panel ${currentEditingItem?.sequence || ''}`"
@save="handleJsonSave"
/>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { Warning, Picture as IconPicture } from '@element-plus/icons-vue'
import JsonEditorDialog from './JsonEditorDialog.vue'
const props = defineProps({
project: Object,
projectId: [String, Number],
isTaskRunning: Boolean,
imageVersion: Number
})
const emit = defineEmits(['task-started', 'refresh-project', 'open-history'])
const showJsonEditor = ref(false)
const currentEditingItem = ref(null)
const currentEditorContent = ref('')
const sortedStoryboard = computed(() => {
if (!props.project.storyboard_items) return []
return [...props.project.storyboard_items].sort((a, b) => a.sequence - b.sequence)
})
const hasCharacters = computed(() => {
if (!props.project.characters) return false
return props.project.characters.some(c => c.image_url)
})
const getPanelCharacters = (itemId) => {
const item = props.project.storyboard_items.find(i => i.id === itemId)
if (!item || !item.data.characters) return []
let chars = item.data.characters
if (typeof chars === 'string') return [chars]
if (Array.isArray(chars)) {
return chars.map(c => typeof c === 'string' ? c : c.name)
}
return []
}
const openJsonEditor = (item) => {
currentEditingItem.value = item
currentEditorContent.value = JSON.stringify(item.data, null, 2)
showJsonEditor.value = true
}
const handleJsonSave = async (newContent) => {
if (!currentEditingItem.value) return
try {
const data = JSON.parse(newContent)
await axios.put(`/api/v1/projects/${props.projectId}/storyboard/${currentEditingItem.value.id}`, data)
ElMessage.success('Storyboard content saved')
emit('refresh-project')
} catch (e) {
ElMessage.error('JSON format error or save failed: ' + e.message)
}
}
const generateAllImages = async () => {
try {
await axios.post(`/api/v1/generate/all-images/${props.projectId}`)
emit('task-started')
ElMessage.info('Full image generation task started in background...')
} catch (error) {
ElMessage.error('Failed to start task: ' + (error.response?.data?.detail || error.message))
}
}
const generatePanel = async (itemId) => {
try {
await axios.post(`/api/v1/generate/panel/${itemId}`)
emit('task-started')
ElMessage.info('Panel drawing task started in background...')
} catch (error) {
console.error(error)
ElMessage.error('Failed to start task: ' + (error.response?.data?.detail || error.message))
}
}
</script>
<style scoped>
.storyboard-tab {
padding-bottom: 40px;
}
.comic-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.info-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.comic-row {
margin-bottom: 24px;
}
.panel-card {
border: 1px solid #333;
background-color: #1e1e1e;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-title {
font-size: 1.1rem;
font-weight: 600;
color: #409EFF;
}
.header-actions {
display: flex;
gap: 10px;
}
.panel-details {
padding-right: 16px;
}
.detail-group {
margin-bottom: 12px;
}
.detail-group label {
display: block;
color: #909399;
font-size: 0.9rem;
margin-bottom: 4px;
font-weight: 500;
}
.detail-content {
color: #E5EAF3;
line-height: 1.5;
}
.prompt-text {
background: #252525;
padding: 10px;
border-radius: 4px;
font-size: 0.95rem;
max-height: 200px;
overflow-y: auto;
}
.tags-wrapper {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.image-area {
display: flex;
flex-direction: column;
align-items: center;
}
.image-wrapper {
width: 100%;
/* Flexible height */
min-height: 300px;
display: flex;
justify-content: center;
background: #1a1a1a;
border-radius: 4px;
padding: 10px;
}
.comic-preview {
width: 100%;
height: auto;
display: block;
}
.no-image {
width: 100%;
height: 300px;
display: flex;
justify-content: center;
align-items: center;
background: #2a2a2a;
color: #666;
border-radius: 4px;
}
.image-actions {
display: flex;
justify-content: center;
width: 100%;
}
.tip-text { color: #888; font-size: 0.9em; }
.warning-text { color: #E6A23C; font-size: 0.9em; display: inline-flex; align-items: center; gap: 4px; }
.text-gray { color: #888; }
.sm-text { font-size: 0.85em; }
.mt-2 { margin-top: 10px; }
.mt-3 { margin-top: 16px; }
.mb-4 { margin-bottom: 24px; }
.ml-2 { margin-left: 10px; }
.mr-2 { margin-right: 10px; }
</style>
+176
View File
@@ -0,0 +1,176 @@
<template>
<div v-if="tasks.length > 0" class="task-manager" :class="{ collapsed: isCollapsed }">
<div class="task-header" @click="toggleCollapse">
<span>Background Tasks ({{ runningCount }})</span>
<el-icon><component :is="isCollapsed ? 'ArrowUp' : 'ArrowDown'" /></el-icon>
</div>
<div v-show="!isCollapsed" class="task-list">
<div v-for="task in tasks" :key="task.id" class="task-item">
<div class="task-info">
<div class="task-name-group">
<span class="task-name" :title="task.description">{{ task.name || getTaskTypeName(task.type) }}</span>
<span class="task-desc" v-if="task.description">{{ task.description }}</span>
<span class="task-error" v-if="task.status === 'failed' && task.message" :title="task.message">
Failure Reason: {{ task.message }}
</span>
</div>
<div class="status-group">
<el-button link size="small" @click.stop="openTerminal(task.id)" title="View Logs">
<el-icon><Monitor /></el-icon>
</el-button>
<span class="task-status" :class="task.status">{{ getTaskStatusText(task.status) }}</span>
</div>
</div>
<el-progress :percentage="task.progress" :status="getTaskProgressStatus(task.status)" :stroke-width="6"></el-progress>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { ArrowUp, ArrowDown, Monitor } from '@element-plus/icons-vue'
const props = defineProps({
tasks: {
type: Array,
required: true
},
isCollapsed: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:isCollapsed', 'open-terminal'])
const runningCount = computed(() => {
return props.tasks.filter(t => ['pending', 'processing'].includes(t.status)).length
})
const toggleCollapse = () => {
emit('update:isCollapsed', !props.isCollapsed)
}
const openTerminal = (taskId) => {
emit('open-terminal', taskId)
}
const getTaskTypeName = (type) => {
const map = {
'storyboard': 'Storyboard Generation',
'image_generation': 'Full Image Generation',
'character_generation': 'Character Drawing'
}
return map[type] || type
}
const getTaskStatusText = (status) => {
const map = {
'pending': 'Pending',
'processing': 'Processing',
'completed': 'Completed',
'failed': 'Failed'
}
return map[status] || status
}
const getTaskProgressStatus = (status) => {
if (status === 'completed') return 'success'
if (status === 'failed') return 'exception'
return ''
}
</script>
<style scoped>
.task-manager {
position: fixed;
bottom: 20px;
right: 20px;
width: 320px;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 4px;
padding: 0;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
transition: all 0.3s ease;
overflow: hidden;
}
.task-manager.collapsed {
width: 200px;
}
.task-header {
font-weight: bold;
padding: 10px 15px;
background: #2b2b2b;
border-bottom: 1px solid #333;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
}
.task-header:hover {
background: #333;
}
.task-list {
max-height: 300px;
overflow-y: auto;
padding: 10px;
}
.task-item {
margin-bottom: 15px;
font-size: 0.9em;
padding-bottom: 10px;
border-bottom: 1px solid #2a2a2a;
}
.task-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.task-info {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 6px;
}
.task-name-group {
display: flex;
flex-direction: column;
max-width: 60%;
}
.status-group {
display: flex;
align-items: center;
gap: 8px;
}
.task-name {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-desc {
font-size: 0.8em;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-error {
font-size: 0.8em;
color: #F56C6C;
margin-top: 2px;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.task-status.pending { color: #909399; }
.task-status.processing { color: #409EFF; }
.task-status.completed { color: #67C23A; }
.task-status.failed { color: #F56C6C; }
</style>
@@ -0,0 +1,134 @@
<template>
<el-dialog
v-model="visible"
title="Terminal Console"
width="800px"
:before-close="handleClose"
class="terminal-dialog"
destroy-on-close
>
<div class="terminal-window" ref="terminalRef">
<div v-if="logs.length === 0" class="empty-logs">
No logs available.
</div>
<div v-for="(log, index) in logs" :key="index" class="log-line">
{{ log }}
</div>
<div v-if="isRunning" class="loading-indicator">
<span class="cursor">_</span>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import axios from 'axios'
const props = defineProps({
visible: Boolean,
taskId: String
})
const emit = defineEmits(['update:visible'])
const visible = ref(props.visible)
const logs = ref([])
const isRunning = ref(false)
const terminalRef = ref(null)
let pollingInterval = null
watch(() => props.visible, (val) => {
visible.value = val
if (val && props.taskId) {
startPolling()
} else {
stopPolling()
}
})
watch(() => props.taskId, (val) => {
if (visible.value && val) {
logs.value = []
startPolling()
}
})
const handleClose = () => {
emit('update:visible', false)
}
const fetchLogs = async () => {
if (!props.taskId) return
try {
const res = await axios.get(`/api/v1/tasks/${props.taskId}`)
const task = res.data
logs.value = task.logs || []
isRunning.value = ['pending', 'processing'].includes(task.status)
// Auto scroll to bottom
nextTick(() => {
if (terminalRef.value) {
terminalRef.value.scrollTop = terminalRef.value.scrollHeight
}
})
if (['completed', 'failed'].includes(task.status)) {
stopPolling()
}
} catch (e) {
console.error("Failed to fetch task logs", e)
}
}
const startPolling = () => {
stopPolling()
fetchLogs() // Immediate
pollingInterval = setInterval(fetchLogs, 1000)
}
const stopPolling = () => {
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
}
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped>
.terminal-window {
background-color: #1e1e1e;
color: #00ff00;
font-family: 'Courier New', Courier, monospace;
padding: 16px;
height: 400px;
overflow-y: auto;
border-radius: 4px;
font-size: 14px;
line-height: 1.4;
}
.log-line {
word-break: break-all;
white-space: pre-wrap;
margin-bottom: 4px;
}
.empty-logs {
color: #666;
font-style: italic;
}
.cursor {
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
</style>