feat: 增加操作步骤引导与错误修复入口
This commit is contained in:
@@ -24,8 +24,8 @@ const profile = ref<CreativeProfileInput | null>(null)
|
|||||||
let controller: AbortController | null = null
|
let controller: AbortController | null = null
|
||||||
|
|
||||||
const aspectRatios = ['9:16', '16:9', '1:1', '4:3', '3:4'].map(value => ({ label: value, value }))
|
const aspectRatios = ['9:16', '16:9', '1:1', '4:3', '3:4'].map(value => ({ label: value, value }))
|
||||||
const enabledImages = computed(() => catalog.value?.images.filter(item => item.enabled && item.model) ?? [])
|
const enabledImages = computed(() => catalog.value?.images?.filter(item => item.enabled && item.model) ?? [])
|
||||||
const enabledVideos = computed(() => catalog.value?.videos.filter(item => item.enabled && item.model) ?? [])
|
const enabledVideos = computed(() => catalog.value?.videos?.filter(item => item.enabled && item.model) ?? [])
|
||||||
const imageSelection = computed(() =>
|
const imageSelection = computed(() =>
|
||||||
profile.value ? selectionValue(profile.value.imageProvider, profile.value.imageModel) : null
|
profile.value ? selectionValue(profile.value.imageProvider, profile.value.imageModel) : null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -169,6 +169,17 @@ watch(
|
|||||||
if (receipt) toolsOpen.value = true
|
if (receipt) toolsOpen.value = true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
watch(
|
||||||
|
() => route?.query.configure,
|
||||||
|
value => {
|
||||||
|
if (value === 'profile') toolsOpen.value = true
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function profileSaved() {
|
||||||
|
operation.value.error = ''
|
||||||
|
}
|
||||||
const batchModel = computed(() => ({ concurrency: concurrency.value }))
|
const batchModel = computed(() => ({ concurrency: concurrency.value }))
|
||||||
const batchRules = { concurrency: integerRule('批量并发') }
|
const batchRules = { concurrency: integerRule('批量并发') }
|
||||||
const keyframeModel = computed(() => ({
|
const keyframeModel = computed(() => ({
|
||||||
@@ -273,7 +284,7 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
|||||||
</AppForm>
|
</AppForm>
|
||||||
</section>
|
</section>
|
||||||
<div class="production-tool-stack">
|
<div class="production-tool-stack">
|
||||||
<CreativeProfileSettings :project-id="id" />
|
<CreativeProfileSettings :project-id="id" @saved="profileSaved" />
|
||||||
<DetailDisclosure title="处理范围与覆盖规则" class="production-tool-section"
|
<DetailDisclosure title="处理范围与覆盖规则" class="production-tool-section"
|
||||||
><p class="text-xs leading-6 text-muted">
|
><p class="text-xs leading-6 text-muted">
|
||||||
提示词与视频就绪检查面向全项目;视频实际提交受本批镜头上限控制,并使用严格质量流水线。首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式处理全项目;过期首帧成功后会自动接替旧主图,其余新增候选。图片模型、视频模型与视觉校验均可能产生费用。
|
提示词与视频就绪检查面向全项目;视频实际提交受本批镜头上限控制,并使用严格质量流水线。首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式处理全项目;过期首帧成功后会自动接替旧主图,其余新增候选。图片模型、视频模型与视觉校验均可能产生费用。
|
||||||
@@ -290,9 +301,8 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
|||||||
:show-icon="false"
|
:show-icon="false"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
>
|
>
|
||||||
当前有
|
当前有 {{ videoRunning }} 个视频任务等待或生成中。后端正在轮询
|
||||||
{{ videoRunning }}
|
Provider;同一镜头不会重复提交活动任务。
|
||||||
个视频任务等待或生成中。请手动刷新查看最新状态;同一镜头不会重复提交活动任务。
|
|
||||||
</NAlert>
|
</NAlert>
|
||||||
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
|
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
|
||||||
当前有 {{ identityBlocked }} 项角色身份前置问题。Character 必须生成
|
当前有 {{ identityBlocked }} 项角色身份前置问题。Character 必须生成
|
||||||
|
|||||||
@@ -1,34 +1,80 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, onScopeDispose, provide, watchEffect } from 'vue'
|
import { computed, inject, onScopeDispose, provide, ref, watchEffect } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { NScrollbar, NAlert, NButton, NSpin } from 'naive-ui'
|
import { NScrollbar, NAlert, NButton, NPageHeader, NSpin, NEllipsis } from 'naive-ui'
|
||||||
import { EmptyState } from '../../components/ui'
|
import { EmptyState } from '../../components/ui'
|
||||||
import { buildDocumentTitle } from '../../lib/document-title'
|
|
||||||
import { projectContextKey, useProjectData } from './context'
|
import { projectContextKey, useProjectData } from './context'
|
||||||
import { getOperation } from '../workflows/operations'
|
import { getOperation } from '../workflows/operations'
|
||||||
import { isProjectComplete, projectAccessKey, type ProjectAccess } from './access'
|
import { isProjectComplete, projectAccessKey, type ProjectAccess } from './access'
|
||||||
|
|
||||||
/** 下游工作区在剧本完成前不挂载,直接链接也无法越过限制。 */
|
/** 项目标题只保留名称;下游工作区在剧本完成前不挂载,直接链接也无法越过限制。 */
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
/** 当前路由中的项目 ID。 */
|
|
||||||
const id = computed(() => String(route.params.projectId))
|
const id = computed(() => String(route.params.projectId))
|
||||||
|
/** 移动端标题只截断,不弹悬浮提示;跟随视口变化并在卸载时清理监听。 */
|
||||||
|
const mobileMedia = window.matchMedia('(max-width: 760px)')
|
||||||
|
const isMobile = ref(mobileMedia.matches)
|
||||||
|
function syncMobile() {
|
||||||
|
isMobile.value = mobileMedia.matches
|
||||||
|
}
|
||||||
|
mobileMedia.addEventListener('change', syncMobile)
|
||||||
|
onScopeDispose(() => mobileMedia.removeEventListener('change', syncMobile))
|
||||||
|
|
||||||
/** 项目数据仅在进入、切换项目或显式刷新时读取。 */
|
// 图库使用手动刷新,父布局也停止轮询,避免浏览素材时被周期性更新打断。
|
||||||
const context = useProjectData(id)
|
const isGallery = computed(() => {
|
||||||
|
const path = route.path.replace(/\/+$/, '')
|
||||||
|
return path.endsWith('/subject-images') || path.endsWith('/assets')
|
||||||
|
})
|
||||||
|
const context = useProjectData(
|
||||||
|
id,
|
||||||
|
computed(() => (isGallery.value ? false : 6000))
|
||||||
|
)
|
||||||
provide(projectContextKey, context)
|
provide(projectContextKey, context)
|
||||||
/** 当前项目名称,仅使用与路由项目 ID 匹配的数据,避免切换项目时短暂显示旧标题。 */
|
|
||||||
const projectTitle = computed(() => {
|
|
||||||
const project = context.project.value
|
|
||||||
return project?.id === id.value ? project.title || project.topic : ''
|
|
||||||
})
|
|
||||||
/** 项目数据或子页面变化时同步浏览器标签标题。 */
|
|
||||||
watchEffect(() => {
|
|
||||||
document.title = buildDocumentTitle(route.meta.title, projectTitle.value)
|
|
||||||
})
|
|
||||||
const operation = computed(() => getOperation(id.value))
|
const operation = computed(() => getOperation(id.value))
|
||||||
const complete = computed(() => isProjectComplete(context.project.value, id.value))
|
const complete = computed(() => isProjectComplete(context.project.value, id.value))
|
||||||
const isCreation = computed(() => route.path.replace(/\/+$/, '').endsWith('/create-drama'))
|
const isCreation = computed(() => route.path.replace(/\/+$/, '').endsWith('/create-drama'))
|
||||||
|
type OperationGuide = { title: string; description: string; action?: string; target?: 'profile' | 'style' | 'identity' }
|
||||||
|
const operationGuide = computed<OperationGuide | null>(() => {
|
||||||
|
const error = operation.value.error?.trim()
|
||||||
|
if (!error) return null
|
||||||
|
const normalized = error.toLowerCase()
|
||||||
|
if (normalized.includes('creative profile'))
|
||||||
|
return {
|
||||||
|
title: '缺少项目生成配置',
|
||||||
|
description: '先选择图片和视频生成模型,保存后再继续当前操作。',
|
||||||
|
action: '配置生成模型',
|
||||||
|
target: 'profile'
|
||||||
|
}
|
||||||
|
if (error.includes('视觉风格') || normalized.includes('visual style'))
|
||||||
|
return {
|
||||||
|
title: '缺少项目视觉风格',
|
||||||
|
description: '先完成视觉风格,再回来继续生成。',
|
||||||
|
action: '设置视觉风格',
|
||||||
|
target: 'style'
|
||||||
|
}
|
||||||
|
if (error.includes('身份') || normalized.includes('identity'))
|
||||||
|
return {
|
||||||
|
title: '主体身份尚未就绪',
|
||||||
|
description: '先按主体身份页的步骤补齐文本、母版并完成锁定。',
|
||||||
|
action: '处理主体身份',
|
||||||
|
target: 'identity'
|
||||||
|
}
|
||||||
|
return { title: '操作未完成', description: error }
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 已知前置条件直接送达对应工作区;保留当前页面,修复后可继续原操作。 */
|
||||||
|
function resolveOperationError() {
|
||||||
|
const target = operationGuide.value?.target
|
||||||
|
if (target === 'style') void router.push(`/projects/${id.value}/visual-style`)
|
||||||
|
else if (target === 'identity') void router.push(`/projects/${id.value}/subject-identity`)
|
||||||
|
else if (target === 'profile') {
|
||||||
|
const onIdentityPage = route.path.replace(/\/+$/, '').endsWith('/subject-identity')
|
||||||
|
void router.push({
|
||||||
|
path: onIdentityPage ? route.path : `/projects/${id.value}/production`,
|
||||||
|
query: { ...route.query, configure: 'profile' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
const access = inject(projectAccessKey, undefined)
|
const access = inject(projectAccessKey, undefined)
|
||||||
let published: ProjectAccess | null = null
|
let published: ProjectAccess | null = null
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
@@ -37,11 +83,19 @@ watchEffect(() => {
|
|||||||
})
|
})
|
||||||
onScopeDispose(() => {
|
onScopeDispose(() => {
|
||||||
if (access?.value === published) access.value = null
|
if (access?.value === published) access.value = null
|
||||||
document.title = buildDocumentTitle(route.meta.title)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section class="project-frame">
|
<section class="project-frame">
|
||||||
|
<header class="project-header">
|
||||||
|
<NPageHeader @back="router.push('/projects')">
|
||||||
|
<template #title
|
||||||
|
><NEllipsis :key="isMobile ? 'mobile' : 'desktop'" class="project-title" :tooltip="!isMobile">{{
|
||||||
|
context.project.value?.title || context.project.value?.topic || '读取项目'
|
||||||
|
}}</NEllipsis></template
|
||||||
|
>
|
||||||
|
</NPageHeader>
|
||||||
|
</header>
|
||||||
<NScrollbar
|
<NScrollbar
|
||||||
v-if="context.error.value || operation.pending || operation.error || operation.notice"
|
v-if="context.error.value || operation.pending || operation.error || operation.notice"
|
||||||
class="project-notices"
|
class="project-notices"
|
||||||
@@ -56,7 +110,17 @@ onScopeDispose(() => {
|
|||||||
<NAlert v-if="operation.pending" :show-icon="false" role="status"
|
<NAlert v-if="operation.pending" :show-icon="false" role="status"
|
||||||
>{{ operation.label }}。可切换页面查看结果,请勿重复提交。</NAlert
|
>{{ operation.label }}。可切换页面查看结果,请勿重复提交。</NAlert
|
||||||
>
|
>
|
||||||
<NAlert v-if="operation.error" type="error" :show-icon="false">{{ operation.error }}</NAlert>
|
<NAlert v-if="operationGuide" type="error" :show-icon="false" class="operation-guide">
|
||||||
|
<div class="operation-guide-content">
|
||||||
|
<div>
|
||||||
|
<strong>{{ operationGuide.title }}</strong>
|
||||||
|
<span class="ml-2">{{ operationGuide.description }}</span>
|
||||||
|
</div>
|
||||||
|
<NButton v-if="operationGuide.action" size="small" type="primary" @click="resolveOperationError">{{
|
||||||
|
operationGuide.action
|
||||||
|
}}</NButton>
|
||||||
|
</div>
|
||||||
|
</NAlert>
|
||||||
<NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert>
|
<NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert>
|
||||||
</NScrollbar>
|
</NScrollbar>
|
||||||
<div class="project-view">
|
<div class="project-view">
|
||||||
@@ -65,10 +129,12 @@ onScopeDispose(() => {
|
|||||||
v-else-if="context.project.value?.id === id"
|
v-else-if="context.project.value?.id === id"
|
||||||
class="project-access-gate"
|
class="project-access-gate"
|
||||||
title="请先完成剧本创作"
|
title="请先完成剧本创作"
|
||||||
description="剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、资产库、分镜设计与镜头生产。请手动刷新项目状态。"
|
:description="`剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、资产库、分镜设计与镜头生产。${isGallery ? '请手动刷新项目状态。' : '状态会自动更新。'}`"
|
||||||
>
|
>
|
||||||
<NButton type="primary" @click="router.push(`/projects/${id}/create-drama`)">返回剧本创作</NButton>
|
<NButton type="primary" @click="router.push(`/projects/${id}/create-drama`)">返回剧本创作</NButton>
|
||||||
<NButton class="ml-3" :loading="context.loading.value" @click="context.refresh">刷新项目状态</NButton>
|
<NButton v-if="isGallery" class="ml-3" :loading="context.loading.value" @click="context.refresh"
|
||||||
|
>刷新项目状态</NButton
|
||||||
|
>
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
<NSpin
|
<NSpin
|
||||||
v-else-if="context.loading.value"
|
v-else-if="context.loading.value"
|
||||||
@@ -85,6 +151,16 @@ onScopeDispose(() => {
|
|||||||
.project-frame {
|
.project-frame {
|
||||||
@apply flex flex-col h-full min-h-0 overflow-hidden;
|
@apply flex flex-col h-full min-h-0 overflow-hidden;
|
||||||
}
|
}
|
||||||
|
.project-header {
|
||||||
|
@apply shrink-0 py-[9px] px-5 bg-(--app-surface);
|
||||||
|
}
|
||||||
|
.project-header .n-page-header__main,
|
||||||
|
.project-header .n-page-header__title {
|
||||||
|
@apply min-w-0 overflow-hidden;
|
||||||
|
}
|
||||||
|
.project-title {
|
||||||
|
@apply max-w-full text-base font-semibold;
|
||||||
|
}
|
||||||
.project-notices.n-scrollbar {
|
.project-notices.n-scrollbar {
|
||||||
@apply shrink-0 h-auto max-h-[100px] overflow-hidden;
|
@apply shrink-0 h-auto max-h-[100px] overflow-hidden;
|
||||||
}
|
}
|
||||||
@@ -94,13 +170,30 @@ onScopeDispose(() => {
|
|||||||
.project-notices .n-alert {
|
.project-notices .n-alert {
|
||||||
@apply py-[7px] px-3 text-xs;
|
@apply py-[7px] px-3 text-xs;
|
||||||
}
|
}
|
||||||
|
.operation-guide-content {
|
||||||
|
@apply flex items-center justify-between gap-4;
|
||||||
|
}
|
||||||
.project-view {
|
.project-view {
|
||||||
@apply flex-1 min-h-0 overflow-hidden;
|
@apply flex-1 min-h-0 overflow-hidden;
|
||||||
}
|
}
|
||||||
|
.project-header .n-page-header-wrapper {
|
||||||
|
@apply min-w-0;
|
||||||
|
}
|
||||||
.project-access-gate {
|
.project-access-gate {
|
||||||
@apply h-full;
|
@apply h-full;
|
||||||
}
|
}
|
||||||
.workspace-loading {
|
.workspace-loading {
|
||||||
@apply grid place-content-center h-full;
|
@apply grid place-content-center h-full;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.project-header {
|
||||||
|
@apply py-2 px-3;
|
||||||
|
}
|
||||||
|
.project-title {
|
||||||
|
@apply text-sm;
|
||||||
|
}
|
||||||
|
.operation-guide-content {
|
||||||
|
@apply items-start flex-col gap-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
NTag
|
NTag
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { RefreshCw, UserCheck } from '@lucide/vue'
|
import { Check, Circle, RefreshCw, UserCheck } from '@lucide/vue'
|
||||||
import { AssetImage, DirectoryItem, EmptyState, StatusBadge } from '../../components/ui'
|
import { AssetImage, DirectoryItem, EmptyState, StatusBadge } from '../../components/ui'
|
||||||
import { downloadText } from '../../lib/format'
|
import { downloadText } from '../../lib/format'
|
||||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||||
@@ -29,10 +29,12 @@ import IdentityGallery from './components/IdentityGallery.vue'
|
|||||||
import IdentityImageDialog from './components/IdentityImageDialog.vue'
|
import IdentityImageDialog from './components/IdentityImageDialog.vue'
|
||||||
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
|
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
|
||||||
import IdentityThumbnail from './components/IdentityThumbnail.vue'
|
import IdentityThumbnail from './components/IdentityThumbnail.vue'
|
||||||
|
import CreativeProfileSettings from '../generation-config/components/CreativeProfileSettings.vue'
|
||||||
import type { IdentitySubject } from './types'
|
import type { IdentitySubject } from './types'
|
||||||
|
|
||||||
/** 主体身份工作区按正式主体聚合,不把同一主体的多个 Form 当成不同身份。 */
|
/** 主体身份工作区按正式主体聚合,不把同一主体的多个 Form 当成不同身份。 */
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const {
|
const {
|
||||||
projectId,
|
projectId,
|
||||||
operation,
|
operation,
|
||||||
@@ -42,10 +44,10 @@ const {
|
|||||||
catalog,
|
catalog,
|
||||||
castingQuery,
|
castingQuery,
|
||||||
styleQuery,
|
styleQuery,
|
||||||
|
profileQuery,
|
||||||
detail,
|
detail,
|
||||||
identity,
|
identity,
|
||||||
images,
|
images,
|
||||||
castingItem,
|
|
||||||
session,
|
session,
|
||||||
concurrency,
|
concurrency,
|
||||||
candidateLimit,
|
candidateLimit,
|
||||||
@@ -56,6 +58,7 @@ const {
|
|||||||
castingBlocked,
|
castingBlocked,
|
||||||
detailBlocked,
|
detailBlocked,
|
||||||
hasStyle,
|
hasStyle,
|
||||||
|
hasCreativeProfile,
|
||||||
canGenerateText,
|
canGenerateText,
|
||||||
canGenerateImage,
|
canGenerateImage,
|
||||||
canGenerateCandidate,
|
canGenerateCandidate,
|
||||||
@@ -162,6 +165,121 @@ watch(
|
|||||||
)
|
)
|
||||||
const formModel = computed(() => ({ concurrency: concurrency.value, candidateLimit: candidateLimit.value }))
|
const formModel = computed(() => ({ concurrency: concurrency.value, candidateLimit: candidateLimit.value }))
|
||||||
const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRule('本批上限') }
|
const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRule('本批上限') }
|
||||||
|
|
||||||
|
type IdentityFlowAction = 'style' | 'profile' | 'identity' | 'candidate' | 'confirm' | 'lock' | 'forms'
|
||||||
|
const primaryCandidate = computed(() =>
|
||||||
|
images.value.find(image => image.viewType === 'primary' && image.status === 'completed' && image.imageUrl)
|
||||||
|
)
|
||||||
|
const identityTextReady = computed(() => !!identity.value?.generationPrompt?.trim())
|
||||||
|
const candidateReady = computed(() => !!anchor.value || !!primaryCandidate.value)
|
||||||
|
const identityReady = computed(() => !!anchor.value && !!identity.value?.isLocked)
|
||||||
|
const identityFlow = computed(() => {
|
||||||
|
const character = subject.value?.module === 'character'
|
||||||
|
return [
|
||||||
|
{ label: '身份文本', done: identityTextReady.value },
|
||||||
|
{ label: character ? '选角候选' : '身份参考图', done: candidateReady.value },
|
||||||
|
{ label: '确认并锁定', done: identityReady.value }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const currentFlowIndex = computed(() => {
|
||||||
|
const index = identityFlow.value.findIndex(step => !step.done)
|
||||||
|
return index < 0 ? identityFlow.value.length : index
|
||||||
|
})
|
||||||
|
const nextAction = computed<{ action: IdentityFlowAction; title: string; description: string; label: string }>(() => {
|
||||||
|
if (!hasStyle.value)
|
||||||
|
return {
|
||||||
|
action: 'style',
|
||||||
|
title: '先设置视觉风格',
|
||||||
|
description: '身份生成需要项目视觉风格。',
|
||||||
|
label: '设置视觉风格'
|
||||||
|
}
|
||||||
|
if (!hasCreativeProfile.value)
|
||||||
|
return {
|
||||||
|
action: 'profile',
|
||||||
|
title: '先配置生成模型',
|
||||||
|
description: '选择项目图片与视频模型后才能生图。',
|
||||||
|
label: '配置生成模型'
|
||||||
|
}
|
||||||
|
if (!identityTextReady.value)
|
||||||
|
return {
|
||||||
|
action: 'identity',
|
||||||
|
title: '第 1 步:生成身份文本',
|
||||||
|
description: '生成稳定身份描述和核心提示词。',
|
||||||
|
label: '生成身份文本'
|
||||||
|
}
|
||||||
|
if (!candidateReady.value)
|
||||||
|
return subject.value?.module === 'character'
|
||||||
|
? {
|
||||||
|
action: 'candidate',
|
||||||
|
title: '第 2 步:生成选角候选',
|
||||||
|
description: '生成候选后再确认正式演员。',
|
||||||
|
label: '生成选角候选'
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
action: 'candidate',
|
||||||
|
title: '第 2 步:生成身份参考图',
|
||||||
|
description: '生成一张可选为母版的参考图。',
|
||||||
|
label: '生成身份参考图'
|
||||||
|
}
|
||||||
|
if (anchor.value && !identity.value?.isLocked)
|
||||||
|
return {
|
||||||
|
action: 'lock',
|
||||||
|
title: '第 3 步:锁定身份',
|
||||||
|
description: '勾选锁定并保存,后续生成才会稳定引用当前母版。',
|
||||||
|
label: '前往锁定'
|
||||||
|
}
|
||||||
|
if (!identityReady.value)
|
||||||
|
return {
|
||||||
|
action: 'confirm',
|
||||||
|
title: '第 3 步:确认并锁定',
|
||||||
|
description: '在下方选择母版;人物会同时锁定,场景和道具还需保存锁定状态。',
|
||||||
|
label: '前往确认母版'
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
action: 'forms',
|
||||||
|
title: '主体身份已完成',
|
||||||
|
description: '可以继续生成此主体的形态图片。',
|
||||||
|
label: '查看形态图片'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function openProfileSettings() {
|
||||||
|
toolsOpen.value = true
|
||||||
|
overview.value = []
|
||||||
|
if (route.query.configure !== 'profile') void router.replace({ query: { ...route.query, configure: 'profile' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function runNextAction() {
|
||||||
|
if (nextAction.value.action === 'style') void router.push(`/projects/${projectId.value}/visual-style`)
|
||||||
|
else if (nextAction.value.action === 'profile') openProfileSettings()
|
||||||
|
else if (nextAction.value.action === 'identity') void generate()
|
||||||
|
else if (nextAction.value.action === 'candidate') {
|
||||||
|
if (subject.value?.module === 'character') castingOpen.value = true
|
||||||
|
else imageOpen.value = true
|
||||||
|
} else if (nextAction.value.action === 'confirm') {
|
||||||
|
document.querySelector('#identity-reference-gallery')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
} else if (nextAction.value.action === 'lock') {
|
||||||
|
document.querySelector('#identity-lock')?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
} else if (nextAction.value.action === 'forms') {
|
||||||
|
void router.push({
|
||||||
|
path: `/projects/${projectId.value}/subject-images`,
|
||||||
|
query: { subjectRef: subject.value?.ref }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profileSaved() {
|
||||||
|
operation.value.error = ''
|
||||||
|
await profileQuery.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.configure,
|
||||||
|
value => {
|
||||||
|
if (value === 'profile') openProfileSettings()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -188,6 +306,7 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
|||||||
title="角色选角总览与批量操作"
|
title="角色选角总览与批量操作"
|
||||||
:has-receipt="!!session.receipt"
|
:has-receipt="!!session.receipt"
|
||||||
>
|
>
|
||||||
|
<CreativeProfileSettings :project-id="projectId" @saved="profileSaved" />
|
||||||
<div class="identity-tools-intro flex flex-wrap items-end justify-between gap-4">
|
<div class="identity-tools-intro flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold">主体身份</h2>
|
<h2 class="text-lg font-semibold">主体身份</h2>
|
||||||
@@ -503,6 +622,17 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
|||||||
当前相关操作已暂停,请刷新核对后端。
|
当前相关操作已暂停,请刷新核对后端。
|
||||||
</NAlert></NScrollbar
|
</NAlert></NScrollbar
|
||||||
>
|
>
|
||||||
|
<NAlert
|
||||||
|
v-if="profileQuery.data.value && !hasCreativeProfile"
|
||||||
|
type="warning"
|
||||||
|
:show-icon="false"
|
||||||
|
class="identity-prerequisite"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<span><strong>需要先配置项目生成模型</strong>,否则身份图和选角候选无法生成。</span>
|
||||||
|
<NButton type="primary" @click="openProfileSettings">立即配置</NButton>
|
||||||
|
</div>
|
||||||
|
</NAlert>
|
||||||
<div v-if="subjects.length" class="identity-workspace">
|
<div v-if="subjects.length" class="identity-workspace">
|
||||||
<!-- 以下是主体目录 -->
|
<!-- 以下是主体目录 -->
|
||||||
<aside class="panel directory-panel identity-directory">
|
<aside class="panel directory-panel identity-directory">
|
||||||
@@ -579,20 +709,6 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
|||||||
<h3 class="font-semibold">
|
<h3 class="font-semibold">
|
||||||
{{ subject.name }} <code class="subject-ref ml-2">{{ subject.ref }}</code>
|
{{ subject.name }} <code class="subject-ref ml-2">{{ subject.ref }}</code>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-2 text-xs text-muted">
|
|
||||||
<template v-if="subject.module === 'character' && castingItem">
|
|
||||||
选角状态:{{ castingStatusLabel(castingItem.status) }}
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{
|
|
||||||
identity
|
|
||||||
? identity.isLocked
|
|
||||||
? '身份文本已锁定'
|
|
||||||
: '身份文本未锁定'
|
|
||||||
: '尚未创建身份'
|
|
||||||
}}
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<NButton :disabled="detail.loading.value" @click="detail.refresh" text size="small">
|
<NButton :disabled="detail.loading.value" @click="detail.refresh" text size="small">
|
||||||
刷新身份
|
刷新身份
|
||||||
@@ -605,26 +721,40 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
|||||||
正在读取主体身份…
|
正在读取主体身份…
|
||||||
</p>
|
</p>
|
||||||
<template v-if="detail.data.value">
|
<template v-if="detail.data.value">
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="identity-flow mt-4" aria-label="主体身份操作步骤">
|
||||||
<ConfirmAction
|
<ol class="identity-flow-steps">
|
||||||
:label="identity ? 'AI 重新生成身份' : 'AI 生成身份'"
|
<li
|
||||||
:disabled="!canGenerateText"
|
v-for="(step, index) in identityFlow"
|
||||||
acknowledgement
|
:key="step.label"
|
||||||
description="调用文本模型生成稳定身份描述和提示词。旧图片不会自动重生成;已锁定身份需先解锁并保存。"
|
:class="{ done: step.done, current: index === currentFlowIndex }"
|
||||||
@confirm="generate"
|
|
||||||
/><NButton
|
|
||||||
v-if="subject.module === 'character'"
|
|
||||||
:disabled="!canGenerateCandidate"
|
|
||||||
@click="castingOpen = true"
|
|
||||||
>
|
>
|
||||||
生成选角候选</NButton
|
<span class="identity-flow-marker">
|
||||||
><NButton
|
<Check v-if="step.done" :size="14" />
|
||||||
v-if="subject.module !== 'character' || !!anchor"
|
<Circle v-else :size="12" />
|
||||||
:disabled="!canGenerateImage"
|
</span>
|
||||||
@click="imageOpen = true"
|
<span>{{ index + 1 }}. {{ step.label }}</span>
|
||||||
>{{ subject.module === 'character' ? '生成辅助身份图' : '生成身份参考图' }}</NButton
|
</li>
|
||||||
|
</ol>
|
||||||
|
<div class="identity-next-action">
|
||||||
|
<div>
|
||||||
|
<strong>{{ nextAction.title }}</strong>
|
||||||
|
<p>{{ nextAction.description }}</p>
|
||||||
|
</div>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
:disabled="
|
||||||
|
operation.pending ||
|
||||||
|
(nextAction.action === 'identity' && !canGenerateText) ||
|
||||||
|
(nextAction.action === 'candidate' &&
|
||||||
|
(subject.module === 'character'
|
||||||
|
? !canGenerateCandidate
|
||||||
|
: !canGenerateImage))
|
||||||
|
"
|
||||||
|
@click="runNextAction"
|
||||||
|
>{{ nextAction.label }}</NButton
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<p v-if="dirty" class="mt-3 text-xs text-muted">请先保存身份修改,再使用 AI 或生成图片。</p>
|
<p v-if="dirty" class="mt-3 text-xs text-muted">请先保存身份修改,再使用 AI 或生成图片。</p>
|
||||||
<IdentityEditor
|
<IdentityEditor
|
||||||
:key="`${selectedId}:${editorRevision}`"
|
:key="`${selectedId}:${editorRevision}`"
|
||||||
@@ -657,6 +787,7 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<IdentityGallery
|
<IdentityGallery
|
||||||
|
id="identity-reference-gallery"
|
||||||
:key="selectedId"
|
:key="selectedId"
|
||||||
:images="images"
|
:images="images"
|
||||||
:subject-name="subject.name"
|
:subject-name="subject.name"
|
||||||
@@ -748,6 +879,38 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
|||||||
.identity-workspace-page {
|
.identity-workspace-page {
|
||||||
container: identity-header / inline-size;
|
container: identity-header / inline-size;
|
||||||
}
|
}
|
||||||
|
.identity-prerequisite {
|
||||||
|
@apply shrink-0 mx-0 mb-3;
|
||||||
|
}
|
||||||
|
.identity-flow {
|
||||||
|
@apply p-4 bg-(--app-subtle);
|
||||||
|
}
|
||||||
|
.identity-flow-steps {
|
||||||
|
@apply flex flex-wrap items-center gap-x-6 gap-y-2;
|
||||||
|
}
|
||||||
|
.identity-flow-steps li {
|
||||||
|
@apply flex items-center gap-2 text-xs text-muted;
|
||||||
|
}
|
||||||
|
.identity-flow-steps li.current {
|
||||||
|
@apply text-accent font-semibold;
|
||||||
|
}
|
||||||
|
.identity-flow-steps li.done {
|
||||||
|
@apply text-muted;
|
||||||
|
}
|
||||||
|
.identity-flow-marker {
|
||||||
|
@apply grid place-items-center w-5 h-5 rounded-full border border-(--app-border);
|
||||||
|
}
|
||||||
|
.identity-flow-steps li.done .identity-flow-marker,
|
||||||
|
.identity-flow-steps li.current .identity-flow-marker {
|
||||||
|
@apply border-(--app-accent) text-accent;
|
||||||
|
}
|
||||||
|
.identity-next-action {
|
||||||
|
@apply mt-4 pt-4 flex items-center justify-between gap-4;
|
||||||
|
border-top: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
.identity-next-action p {
|
||||||
|
@apply mt-1 text-xs text-muted;
|
||||||
|
}
|
||||||
.toolbar-type-filter {
|
.toolbar-type-filter {
|
||||||
@apply w-[152px] min-w-0;
|
@apply w-[152px] min-w-0;
|
||||||
}
|
}
|
||||||
@@ -843,6 +1006,9 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
|||||||
@apply shrink-0;
|
@apply shrink-0;
|
||||||
}
|
}
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
.identity-next-action {
|
||||||
|
@apply items-start flex-col;
|
||||||
|
}
|
||||||
.identity-workspace {
|
.identity-workspace {
|
||||||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[auto_minmax(0,_1fr)] gap-2.5;
|
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[auto_minmax(0,_1fr)] gap-2.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppForm from '../../../components/ui/AppForm.vue'
|
import AppForm from '../../../components/ui/AppForm.vue'
|
||||||
import { NFormItem } from 'naive-ui'
|
import { NFormItem } from 'naive-ui'
|
||||||
import DetailDisclosure from '../../../components/ui/DetailDisclosure.vue'
|
|
||||||
import { NButton, NCheckbox, NInput } from 'naive-ui'
|
import { NButton, NCheckbox, NInput } from 'naive-ui'
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import type { SaveIdentityInput, SubjectIdentity } from '../types'
|
import type { SaveIdentityInput, SubjectIdentity } from '../types'
|
||||||
@@ -36,63 +35,42 @@ function save() {
|
|||||||
<template>
|
<template>
|
||||||
<AppForm :model="form" :disabled="disabled" class="mt-5" @submit="save">
|
<AppForm :model="form" :disabled="disabled" class="mt-5" @submit="save">
|
||||||
<fieldset :disabled="disabled" class="space-y-4">
|
<fieldset :disabled="disabled" class="space-y-4">
|
||||||
<NFormItem
|
<NFormItem class="block" path="description" label="身份描述" :label-props="{ for: 'identity-description' }"
|
||||||
class="block"
|
|
||||||
path="description"
|
|
||||||
label="稳定身份描述"
|
|
||||||
:label-props="{ for: 'identity-description' }"
|
|
||||||
><NInput
|
><NInput
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
placeholder="只描述跨形态不变的面部、结构、材质等特征"
|
placeholder="只描述跨形态不变的面部、结构、材质等特征"
|
||||||
:input-props="{ id: 'identity-description' }"
|
:input-props="{ id: 'identity-description' }"
|
||||||
class="min-h-28"
|
class="min-h-20"
|
||||||
v-model:value="form.description"
|
v-model:value="form.description"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
:autosize="{ minRows: 2, maxRows: 8 }"
|
||||||
></NInput>
|
></NInput>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem
|
<NFormItem
|
||||||
class="block"
|
class="block"
|
||||||
path="generationPrompt"
|
path="generationPrompt"
|
||||||
label="身份核心提示词(稳定事实)"
|
label="核心提示词"
|
||||||
:label-props="{ for: 'identity-prompt' }"
|
:label-props="{ for: 'identity-prompt' }"
|
||||||
><NInput
|
><NInput
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
placeholder="不固定某一形态的服装、伤势或临时状态"
|
placeholder="不固定某一形态的服装、伤势或临时状态"
|
||||||
:input-props="{ id: 'identity-prompt' }"
|
:input-props="{ id: 'identity-prompt' }"
|
||||||
class="min-h-36"
|
class="min-h-24"
|
||||||
v-model:value="form.generationPrompt"
|
v-model:value="form.generationPrompt"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
:autosize="{ minRows: 2, maxRows: 8 }"
|
||||||
></NInput>
|
></NInput>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<DetailDisclosure title="身份填写与锁定规则"
|
|
||||||
><p class="text-xs leading-6 text-muted">
|
|
||||||
{{
|
|
||||||
module === 'prop'
|
|
||||||
? '保留道具固定文字、编号、部件位置、结构与材质组合;不要泛化原有辨识细节,也不要写临时损伤或摆放状态。'
|
|
||||||
: module === 'scene'
|
|
||||||
? '保留建筑骨架、入口、窗户、楼梯与固定布局,不写临时天气、人群或灯光。'
|
|
||||||
: '保留有依据的五官、骨相、发际线与体型,不固定某套服装、临时表情或伤势。'
|
|
||||||
}}
|
|
||||||
这里保存身份事实;背景、视角和参考图约束由后端另行编译,不是最终整段生图 Prompt。
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 text-xs text-muted">
|
|
||||||
人物需要同时确认母版并锁定身份,首帧才能就绪。锁定后仍可人工编辑、生成图片及切换母版。
|
|
||||||
</p></DetailDisclosure
|
|
||||||
>
|
|
||||||
<NCheckbox
|
<NCheckbox
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
id="identity-lock"
|
id="identity-lock"
|
||||||
v-model:checked="form.isLocked"
|
v-model:checked="form.isLocked"
|
||||||
class="flex items-start gap-2 text-xs leading-6"
|
class="flex items-start gap-2 text-xs leading-6"
|
||||||
>{{ module === 'character' ? '确认并锁定演员身份' : '确认并锁定身份文本' }}</NCheckbox
|
>{{ module === 'character' ? '锁定演员身份' : '锁定身份' }}</NCheckbox
|
||||||
>
|
>
|
||||||
<div class="page-form-actions">
|
<div class="page-form-actions">
|
||||||
<NButton :disabled="disabled" type="primary" attr-type="submit">保存主体身份</NButton>
|
<NButton :disabled="disabled" type="primary" attr-type="submit">保存主体身份</NButton>
|
||||||
<span class="text-xs text-muted">{{
|
<span v-if="dirty" class="text-xs text-muted">有未保存修改</span>
|
||||||
dirty ? '有未保存修改,刷新不会覆盖草稿。' : '图片以已保存的身份提示词为准。'
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</AppForm>
|
</AppForm>
|
||||||
|
|||||||
@@ -52,12 +52,6 @@ function chooseAnchor() {
|
|||||||
<h3 class="text-sm font-medium">身份参考图 · {{ images.length }}</h3>
|
<h3 class="text-sm font-medium">身份参考图 · {{ images.length }}</h3>
|
||||||
<NTag size="small" :bordered="false">{{ anchor ? '已选母版' : '尚无母版' }}</NTag>
|
<NTag size="small" :bordered="false">{{ anchor ? '已选母版' : '尚无母版' }}</NTag>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs leading-6 text-muted">
|
|
||||||
身份母版确定“是谁”;形态主图确定“该造型长什么样”。切换母版仅影响之后的生成,不会替换已有形态图、分镜参考图或提示词。
|
|
||||||
</p>
|
|
||||||
<NAlert v-if="character && anchor" type="info" :show-icon="false" class="mt-3 text-xs leading-6">
|
|
||||||
如果要更换演员身份,请生成新的“选角候选”并确认选角。正面、三分之四、全身等辅助身份图会继续引用当前母版,只用于保持同一演员,不会重新选择人物。
|
|
||||||
</NAlert>
|
|
||||||
<template v-if="selected">
|
<template v-if="selected">
|
||||||
<AssetImage
|
<AssetImage
|
||||||
:key="selected.id"
|
:key="selected.id"
|
||||||
@@ -70,7 +64,6 @@ function chooseAnchor() {
|
|||||||
/>
|
/>
|
||||||
<div class="image-history-heading">
|
<div class="image-history-heading">
|
||||||
<h4 class="font-medium">历史记录 · {{ images.length }} 条</h4>
|
<h4 class="font-medium">历史记录 · {{ images.length }} 条</h4>
|
||||||
<span class="text-muted">点击缩略图切换预览,可横向滚动</span>
|
|
||||||
</div>
|
</div>
|
||||||
<NScrollbar
|
<NScrollbar
|
||||||
x-scrollable
|
x-scrollable
|
||||||
@@ -158,7 +151,6 @@ function chooseAnchor() {
|
|||||||
><NButton @click="confirming = false" text size="small">取消</NButton>
|
><NButton @click="confirming = false" text size="small">取消</NButton>
|
||||||
</div></NAlert
|
</div></NAlert
|
||||||
>
|
>
|
||||||
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
|
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="py-8 text-sm text-muted">尚无身份参考图。先保存身份提示词,再生成第一张母版。</p>
|
<p v-else class="py-8 text-sm text-muted">尚无身份参考图。先保存身份提示词,再生成第一张母版。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { usePolling } from '../../composables/usePolling'
|
import { usePolling } from '../../composables/usePolling'
|
||||||
import { visualStyleApi } from '../visual-style'
|
import { visualStyleApi } from '../visual-style'
|
||||||
|
import { generationConfigApi } from '../generation-config/api'
|
||||||
import { subjectImagesApi } from '../subject-images/api'
|
import { subjectImagesApi } from '../subject-images/api'
|
||||||
import { hasRunningImages } from '../subject-images/model'
|
import { hasRunningImages } from '../subject-images/model'
|
||||||
import { runOperation } from '../workflows/operations'
|
import { runOperation } from '../workflows/operations'
|
||||||
@@ -56,6 +57,11 @@ export function useSubjectIdentity() {
|
|||||||
},
|
},
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
const profileQuery = usePolling(
|
||||||
|
projectId,
|
||||||
|
async (id, signal) => ({ profile: await generationConfigApi.profile(id, signal) }),
|
||||||
|
false
|
||||||
|
)
|
||||||
const castingQuery = usePolling(
|
const castingQuery = usePolling(
|
||||||
projectId,
|
projectId,
|
||||||
async (id, signal) => {
|
async (id, signal) => {
|
||||||
@@ -106,11 +112,17 @@ export function useSubjectIdentity() {
|
|||||||
hasRunningImages(images.value)
|
hasRunningImages(images.value)
|
||||||
)
|
)
|
||||||
const hasStyle = computed(() => !!styleQuery.data.value?.style)
|
const hasStyle = computed(() => !!styleQuery.data.value?.style)
|
||||||
|
const hasCreativeProfile = computed(() => !!profileQuery.data.value?.profile)
|
||||||
const canGenerateText = computed(
|
const canGenerateText = computed(
|
||||||
() => !detailBlocked.value && hasStyle.value && !identity.value?.isLocked && !dirty.value
|
() => !detailBlocked.value && hasStyle.value && !identity.value?.isLocked && !dirty.value
|
||||||
)
|
)
|
||||||
const canGenerateImage = computed(
|
const canGenerateImage = computed(
|
||||||
() => !detailBlocked.value && hasStyle.value && !!identity.value?.generationPrompt?.trim() && !dirty.value
|
() =>
|
||||||
|
!detailBlocked.value &&
|
||||||
|
hasStyle.value &&
|
||||||
|
hasCreativeProfile.value &&
|
||||||
|
!!identity.value?.generationPrompt?.trim() &&
|
||||||
|
!dirty.value
|
||||||
)
|
)
|
||||||
const canGenerateCandidate = computed(
|
const canGenerateCandidate = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -189,7 +201,15 @@ export function useSubjectIdentity() {
|
|||||||
|
|
||||||
/** 场景/道具身份母版由后端独立生成、设为 Anchor 并锁定,保留逐主体失败回执。 */
|
/** 场景/道具身份母版由后端独立生成、设为 Anchor 并锁定,保留逐主体失败回执。 */
|
||||||
async function generateStableAnchors(module: 'scene' | 'prop') {
|
async function generateStableAnchors(module: 'scene' | 'prop') {
|
||||||
if (blocked.value || !hasStyle.value || !batchValid.value || !candidateLimitValid.value || dirty.value) return
|
if (
|
||||||
|
blocked.value ||
|
||||||
|
!hasStyle.value ||
|
||||||
|
!hasCreativeProfile.value ||
|
||||||
|
!batchValid.value ||
|
||||||
|
!candidateLimitValid.value ||
|
||||||
|
dirty.value
|
||||||
|
)
|
||||||
|
return
|
||||||
const id = projectId.value
|
const id = projectId.value
|
||||||
const target = getIdentitySession(id)
|
const target = getIdentitySession(id)
|
||||||
const title = `批量生成${module === 'scene' ? '场景' : '道具'}身份母版`
|
const title = `批量生成${module === 'scene' ? '场景' : '道具'}身份母版`
|
||||||
@@ -328,6 +348,7 @@ export function useSubjectIdentity() {
|
|||||||
catalog,
|
catalog,
|
||||||
castingQuery,
|
castingQuery,
|
||||||
styleQuery,
|
styleQuery,
|
||||||
|
profileQuery,
|
||||||
detail,
|
detail,
|
||||||
identity,
|
identity,
|
||||||
images,
|
images,
|
||||||
@@ -342,6 +363,7 @@ export function useSubjectIdentity() {
|
|||||||
castingBlocked,
|
castingBlocked,
|
||||||
detailBlocked,
|
detailBlocked,
|
||||||
hasStyle,
|
hasStyle,
|
||||||
|
hasCreativeProfile,
|
||||||
canGenerateText,
|
canGenerateText,
|
||||||
canGenerateImage,
|
canGenerateImage,
|
||||||
canGenerateCandidate,
|
canGenerateCandidate,
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ describe('视觉风格与主体身份工作区', () => {
|
|||||||
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
||||||
expect(put[0]).toBe('/api/subjects/subject-db-1/identity')
|
expect(put[0]).toBe('/api/subjects/subject-db-1/identity')
|
||||||
expect(JSON.parse(put[1]!.body as string)).toMatchObject({ description: '人工稳定身份', isLocked: true })
|
expect(JSON.parse(put[1]!.body as string)).toMatchObject({ description: '人工稳定身份', isLocked: true })
|
||||||
expect(button('AI 重新生成身份').disabled).toBe(true)
|
expect(button('生成选角候选').disabled).toBe(false)
|
||||||
await wrapper!.get('#identity-description').setValue('新的未保存草稿')
|
await wrapper!.get('#identity-description').setValue('新的未保存草稿')
|
||||||
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
|
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
|
||||||
expect(wrapper!.text()).toContain('切换会丢弃草稿')
|
expect(wrapper!.text()).toContain('切换会丢弃草稿')
|
||||||
@@ -538,11 +538,10 @@ describe('视觉风格与主体身份工作区', () => {
|
|||||||
expect(wrapper!.text()).toContain('2 个形态')
|
expect(wrapper!.text()).toContain('2 个形态')
|
||||||
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/subjects/subject-db-1/identity'))).toBe(true)
|
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/subjects/subject-db-1/identity'))).toBe(true)
|
||||||
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/identity/images'))).toBe(false)
|
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/identity/images'))).toBe(false)
|
||||||
expect(button('生成选角候选').disabled).toBe(true)
|
expect(button('配置生成模型').disabled).toBe(false)
|
||||||
expect(button('AI 生成身份').disabled).toBe(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('锁定阻止身份 AI 覆盖但不阻止人工编辑和生图,生图默认省略 referenceImageId', async () => {
|
it('锁定阻止身份 AI 覆盖但不阻止人工编辑,并按步骤进入后续操作', async () => {
|
||||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||||
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
||||||
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture({ isLocked: true }))
|
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture({ isLocked: true }))
|
||||||
@@ -552,18 +551,9 @@ describe('视觉风格与主体身份工作区', () => {
|
|||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetcher)
|
vi.stubGlobal('fetch', fetcher)
|
||||||
await mountAssets('identity')
|
await mountAssets('identity')
|
||||||
expect(button('AI 重新生成身份').disabled).toBe(true)
|
expect(wrapper!.text()).toContain('主体身份已完成')
|
||||||
expect(button('保存主体身份').disabled).toBe(false)
|
expect(button('保存主体身份').disabled).toBe(false)
|
||||||
button('生成辅助身份图').click()
|
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
|
||||||
await flushPromises()
|
|
||||||
expect(button('确认生成身份图').disabled).toBe(true)
|
|
||||||
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
|
|
||||||
await flushPromises()
|
|
||||||
button('确认生成身份图').click()
|
|
||||||
await flushPromises()
|
|
||||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
|
||||||
expect(post[0]).toBe('/api/subjects/subject-db-1/identity/images')
|
|
||||||
expect(JSON.parse(post[1]!.body as string)).toEqual({ viewType: 'front' })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('无项目风格时禁止生成身份和图片,但仍可人工保存身份', async () => {
|
it('无项目风格时禁止生成身份和图片,但仍可人工保存身份', async () => {
|
||||||
@@ -577,8 +567,7 @@ describe('视觉风格与主体身份工作区', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
await mountAssets('identity')
|
await mountAssets('identity')
|
||||||
expect(button('AI 重新生成身份').disabled).toBe(true)
|
expect(button('设置视觉风格').disabled).toBe(false)
|
||||||
expect(button('生成选角候选').disabled).toBe(true)
|
|
||||||
expect(button('保存主体身份').disabled).toBe(false)
|
expect(button('保存主体身份').disabled).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -758,7 +747,7 @@ describe('视觉风格与主体身份工作区', () => {
|
|||||||
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
||||||
expect(put[0]).toBe('/api/subjects/subject-db-1/identity/images/casting-1/casting')
|
expect(put[0]).toBe('/api/subjects/subject-db-1/identity/images/casting-1/casting')
|
||||||
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/anchor'))).toBe(false)
|
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/anchor'))).toBe(false)
|
||||||
expect(wrapper!.text()).toContain('选角状态:选角已完成')
|
expect(wrapper!.text()).toContain('主体身份已完成')
|
||||||
expect(wrapper!.text()).toContain('演员身份已确认并锁定')
|
expect(wrapper!.text()).toContain('演员身份已确认并锁定')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -803,17 +792,19 @@ describe('视觉风格与主体身份工作区', () => {
|
|||||||
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
||||||
if (String(url).endsWith('/identity/images')) return jsonResponse([])
|
if (String(url).endsWith('/identity/images')) return jsonResponse([])
|
||||||
if (init?.method === 'POST') throw new Error('connection lost')
|
if (init?.method === 'POST') throw new Error('connection lost')
|
||||||
|
if (String(url).endsWith('/identity')) return jsonResponse(null)
|
||||||
return jsonResponse(identityFixture())
|
return jsonResponse(identityFixture())
|
||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetcher)
|
vi.stubGlobal('fetch', fetcher)
|
||||||
const provided = await mountAssets('identity')
|
const provided = await mountAssets('identity')
|
||||||
await confirmGeneration('AI 重新生成身份')
|
button('生成身份文本').click()
|
||||||
|
await flushPromises()
|
||||||
expect(fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1)
|
expect(fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1)
|
||||||
expect(getOperation(fixture.id).error).toContain('无法连接后端')
|
expect(getOperation(fixture.id).error).toContain('无法连接后端')
|
||||||
provided.data.value!.project = { ...fixture, status: 'generating' }
|
provided.data.value!.project = { ...fixture, status: 'generating' }
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(button('保存主体身份').disabled).toBe(true)
|
expect(button('保存主体身份').disabled).toBe(true)
|
||||||
expect(button('生成选角候选').disabled).toBe(true)
|
expect(button('生成身份文本').disabled).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user