243 lines
8.5 KiB
Vue
243 lines
8.5 KiB
Vue
|
|
<template>
|
|||
|
|
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" @click.self="handleClose">
|
|||
|
|
<BaseCard variant="elevated" class="w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
|||
|
|
<template #header>
|
|||
|
|
<div class="flex items-center justify-between">
|
|||
|
|
<h2 class="text-xl font-semibold text-gray-900">編輯人物:{{ character?.name }}</h2>
|
|||
|
|
<button @click="handleClose" class="text-gray-500 hover:text-gray-700">
|
|||
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<div v-if="character" class="space-y-6">
|
|||
|
|
<!-- 基本資訊 -->
|
|||
|
|
<div>
|
|||
|
|
<h3 class="font-semibold text-gray-900 mb-4">基本資訊</h3>
|
|||
|
|
<div class="space-y-4">
|
|||
|
|
<BaseInput
|
|||
|
|
v-model="editedCharacter.name"
|
|||
|
|
label="角色名稱"
|
|||
|
|
:required="true"
|
|||
|
|
/>
|
|||
|
|
<BaseTextarea
|
|||
|
|
v-model="editedCharacter.description"
|
|||
|
|
label="角色描述"
|
|||
|
|
:rows="3"
|
|||
|
|
/>
|
|||
|
|
<BaseInput
|
|||
|
|
v-model="editedCharacter.role"
|
|||
|
|
label="角色定位"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 外觀設定 -->
|
|||
|
|
<div>
|
|||
|
|
<h3 class="font-semibold text-gray-900 mb-4">外觀設定</h3>
|
|||
|
|
<div class="space-y-4">
|
|||
|
|
<BaseInput
|
|||
|
|
v-model="editedCharacter.clothing"
|
|||
|
|
label="服裝"
|
|||
|
|
placeholder="例如:T恤、牛仔褲"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label class="block mb-2 text-sm font-medium text-gray-700">配件</label>
|
|||
|
|
<div class="flex flex-wrap gap-2 mb-2">
|
|||
|
|
<span
|
|||
|
|
v-for="(accessory, index) in editedCharacter.accessories"
|
|||
|
|
:key="index"
|
|||
|
|
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
|
|||
|
|
>
|
|||
|
|
{{ accessory }}
|
|||
|
|
<button
|
|||
|
|
@click="removeAccessory(index)"
|
|||
|
|
class="text-blue-600 hover:text-blue-800"
|
|||
|
|
>
|
|||
|
|
×
|
|||
|
|
</button>
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex gap-2">
|
|||
|
|
<BaseInput
|
|||
|
|
v-model="newAccessory"
|
|||
|
|
placeholder="輸入配件名稱"
|
|||
|
|
class="flex-1"
|
|||
|
|
@keyup.enter="addAccessory"
|
|||
|
|
/>
|
|||
|
|
<BaseButton variant="outline" @click="addAccessory">新增</BaseButton>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 顏色設定 -->
|
|||
|
|
<div>
|
|||
|
|
<h3 class="font-semibold text-gray-900 mb-4">顏色設定</h3>
|
|||
|
|
<div class="grid grid-cols-3 gap-4">
|
|||
|
|
<div>
|
|||
|
|
<label class="block mb-2 text-sm font-medium text-gray-700">髮色</label>
|
|||
|
|
<input
|
|||
|
|
v-model="editedCharacter.colors.hair"
|
|||
|
|
type="color"
|
|||
|
|
class="w-full h-10 rounded-lg border border-gray-200"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label class="block mb-2 text-sm font-medium text-gray-700">膚色</label>
|
|||
|
|
<input
|
|||
|
|
v-model="editedCharacter.colors.skin"
|
|||
|
|
type="color"
|
|||
|
|
class="w-full h-10 rounded-lg border border-gray-200"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label class="block mb-2 text-sm font-medium text-gray-700">服裝顏色</label>
|
|||
|
|
<input
|
|||
|
|
v-model="editedCharacter.colors.clothing"
|
|||
|
|
type="color"
|
|||
|
|
class="w-full h-10 rounded-lg border border-gray-200"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 三視圖預覽 -->
|
|||
|
|
<div>
|
|||
|
|
<h3 class="font-semibold text-gray-900 mb-4">三視圖</h3>
|
|||
|
|
<div class="grid grid-cols-3 gap-4">
|
|||
|
|
<div class="text-center">
|
|||
|
|
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
|
|||
|
|
<span v-if="!editedCharacter.frontView" class="text-gray-400 text-sm">正面</span>
|
|||
|
|
<img v-else :src="editedCharacter.frontView" alt="正面" class="w-full h-full object-contain rounded-lg" />
|
|||
|
|
</div>
|
|||
|
|
<BaseButton variant="outline" @click="generateView('front')" class="text-sm px-3 py-1 w-full">
|
|||
|
|
生成
|
|||
|
|
</BaseButton>
|
|||
|
|
</div>
|
|||
|
|
<div class="text-center">
|
|||
|
|
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
|
|||
|
|
<span v-if="!editedCharacter.sideView" class="text-gray-400 text-sm">側面</span>
|
|||
|
|
<img v-else :src="editedCharacter.sideView" alt="側面" class="w-full h-full object-contain rounded-lg" />
|
|||
|
|
</div>
|
|||
|
|
<BaseButton variant="outline" @click="generateView('side')" class="text-sm px-3 py-1 w-full">
|
|||
|
|
生成
|
|||
|
|
</BaseButton>
|
|||
|
|
</div>
|
|||
|
|
<div class="text-center">
|
|||
|
|
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
|
|||
|
|
<span v-if="!editedCharacter.backView" class="text-gray-400 text-sm">背面</span>
|
|||
|
|
<img v-else :src="editedCharacter.backView" alt="背面" class="w-full h-full object-contain rounded-lg" />
|
|||
|
|
</div>
|
|||
|
|
<BaseButton variant="outline" @click="generateView('back')" class="text-sm px-3 py-1 w-full">
|
|||
|
|
生成
|
|||
|
|
</BaseButton>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 操作按鈕 -->
|
|||
|
|
<div class="flex justify-end gap-4 pt-4 border-t border-gray-200">
|
|||
|
|
<BaseButton variant="outline" @click="handleClose">取消</BaseButton>
|
|||
|
|
<BaseButton variant="primary" @click="handleSave">儲存</BaseButton>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</BaseCard>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import type { CharacterAsset } from '~/types/storyboard'
|
|||
|
|
import { useAIImageGeneration } from '~/composables/useAIImageGeneration'
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
isOpen: boolean
|
|||
|
|
character: CharacterAsset | null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const props = defineProps<Props>()
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
close: []
|
|||
|
|
save: [character: CharacterAsset]
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const { generateCharacterView: generateCharacterViewImage, isLoading: isGenerating } = useAIImageGeneration()
|
|||
|
|
|
|||
|
|
const editedCharacter = ref<CharacterAsset | null>(null)
|
|||
|
|
const newAccessory = ref('')
|
|||
|
|
const generatingView = ref<'front' | 'side' | 'back' | null>(null)
|
|||
|
|
|
|||
|
|
watch(() => props.character, (newChar) => {
|
|||
|
|
if (newChar) {
|
|||
|
|
editedCharacter.value = {
|
|||
|
|
...newChar,
|
|||
|
|
accessories: [...(newChar.accessories || [])],
|
|||
|
|
colors: {
|
|||
|
|
hair: newChar.colors?.hair || '#000000',
|
|||
|
|
skin: newChar.colors?.skin || '#FFDBAC',
|
|||
|
|
clothing: newChar.colors?.clothing || '#000000'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, { immediate: true })
|
|||
|
|
|
|||
|
|
function handleClose() {
|
|||
|
|
emit('close')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleSave() {
|
|||
|
|
if (editedCharacter.value) {
|
|||
|
|
emit('save', editedCharacter.value)
|
|||
|
|
handleClose()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function addAccessory() {
|
|||
|
|
if (newAccessory.value.trim() && editedCharacter.value) {
|
|||
|
|
if (!editedCharacter.value.accessories) {
|
|||
|
|
editedCharacter.value.accessories = []
|
|||
|
|
}
|
|||
|
|
editedCharacter.value.accessories.push(newAccessory.value.trim())
|
|||
|
|
newAccessory.value = ''
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function removeAccessory(index: number) {
|
|||
|
|
if (editedCharacter.value?.accessories) {
|
|||
|
|
editedCharacter.value.accessories.splice(index, 1)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function generateView(view: 'front' | 'side' | 'back') {
|
|||
|
|
if (!editedCharacter.value) return
|
|||
|
|
|
|||
|
|
generatingView.value = view
|
|||
|
|
try {
|
|||
|
|
const style = '寫實風格' // 可以從 workflow 中取得
|
|||
|
|
const imageUrl = await generateCharacterViewImage(
|
|||
|
|
editedCharacter.value.name,
|
|||
|
|
editedCharacter.value.description,
|
|||
|
|
view,
|
|||
|
|
style
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if (view === 'front') {
|
|||
|
|
editedCharacter.value.frontView = imageUrl
|
|||
|
|
} else if (view === 'side') {
|
|||
|
|
editedCharacter.value.sideView = imageUrl
|
|||
|
|
} else if (view === 'back') {
|
|||
|
|
editedCharacter.value.backView = imageUrl
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('生成視圖失敗:', err)
|
|||
|
|
} finally {
|
|||
|
|
generatingView.value = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|