feat: 新增项目配置选项和任务日志功能
添加分辨率配置选项,支持在项目设置中选择1K/2K/4K分辨率 为任务模型添加logs字段,记录任务执行过程中的详细日志 实现任务日志记录功能,包括时间戳和状态更新 新增多个前端对话框组件:终端日志、历史记录、JSON编辑器等 优化AI服务生成图像接口,支持传入分辨率和宽高比参数 重构任务管理界面,添加折叠功能和日志查看入口
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+138
-908
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user