feat: 增加操作步骤引导与错误修复入口
This commit is contained in:
@@ -24,8 +24,8 @@ const profile = ref<CreativeProfileInput | 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 enabledImages = computed(() => catalog.value?.images.filter(item => item.enabled && item.model) ?? [])
|
||||
const enabledVideos = computed(() => catalog.value?.videos.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 imageSelection = computed(() =>
|
||||
profile.value ? selectionValue(profile.value.imageProvider, profile.value.imageModel) : null
|
||||
)
|
||||
|
||||
@@ -169,6 +169,17 @@ watch(
|
||||
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 batchRules = { concurrency: integerRule('批量并发') }
|
||||
const keyframeModel = computed(() => ({
|
||||
@@ -273,7 +284,7 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
</AppForm>
|
||||
</section>
|
||||
<div class="production-tool-stack">
|
||||
<CreativeProfileSettings :project-id="id" />
|
||||
<CreativeProfileSettings :project-id="id" @saved="profileSaved" />
|
||||
<DetailDisclosure title="处理范围与覆盖规则" class="production-tool-section"
|
||||
><p class="text-xs leading-6 text-muted">
|
||||
提示词与视频就绪检查面向全项目;视频实际提交受本批镜头上限控制,并使用严格质量流水线。首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式处理全项目;过期首帧成功后会自动接替旧主图,其余新增候选。图片模型、视频模型与视觉校验均可能产生费用。
|
||||
@@ -290,9 +301,8 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
:show-icon="false"
|
||||
class="mt-4"
|
||||
>
|
||||
当前有
|
||||
{{ videoRunning }}
|
||||
个视频任务等待或生成中。请手动刷新查看最新状态;同一镜头不会重复提交活动任务。
|
||||
当前有 {{ videoRunning }} 个视频任务等待或生成中。后端正在轮询
|
||||
Provider;同一镜头不会重复提交活动任务。
|
||||
</NAlert>
|
||||
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
|
||||
当前有 {{ identityBlocked }} 项角色身份前置问题。Character 必须生成
|
||||
|
||||
@@ -1,34 +1,80 @@
|
||||
<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 { NScrollbar, NAlert, NButton, NSpin } from 'naive-ui'
|
||||
import { NScrollbar, NAlert, NButton, NPageHeader, NSpin, NEllipsis } from 'naive-ui'
|
||||
import { EmptyState } from '../../components/ui'
|
||||
import { buildDocumentTitle } from '../../lib/document-title'
|
||||
import { projectContextKey, useProjectData } from './context'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
import { isProjectComplete, projectAccessKey, type ProjectAccess } from './access'
|
||||
|
||||
/** 下游工作区在剧本完成前不挂载,直接链接也无法越过限制。 */
|
||||
/** 项目标题只保留名称;下游工作区在剧本完成前不挂载,直接链接也无法越过限制。 */
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
/** 当前路由中的项目 ID。 */
|
||||
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)
|
||||
/** 当前项目名称,仅使用与路由项目 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 complete = computed(() => isProjectComplete(context.project.value, id.value))
|
||||
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)
|
||||
let published: ProjectAccess | null = null
|
||||
watchEffect(() => {
|
||||
@@ -37,11 +83,19 @@ watchEffect(() => {
|
||||
})
|
||||
onScopeDispose(() => {
|
||||
if (access?.value === published) access.value = null
|
||||
document.title = buildDocumentTitle(route.meta.title)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<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
|
||||
v-if="context.error.value || operation.pending || operation.error || operation.notice"
|
||||
class="project-notices"
|
||||
@@ -56,7 +110,17 @@ onScopeDispose(() => {
|
||||
<NAlert v-if="operation.pending" :show-icon="false" role="status"
|
||||
>{{ 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>
|
||||
</NScrollbar>
|
||||
<div class="project-view">
|
||||
@@ -65,10 +129,12 @@ onScopeDispose(() => {
|
||||
v-else-if="context.project.value?.id === id"
|
||||
class="project-access-gate"
|
||||
title="请先完成剧本创作"
|
||||
description="剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、资产库、分镜设计与镜头生产。请手动刷新项目状态。"
|
||||
:description="`剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、资产库、分镜设计与镜头生产。${isGallery ? '请手动刷新项目状态。' : '状态会自动更新。'}`"
|
||||
>
|
||||
<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>
|
||||
<NSpin
|
||||
v-else-if="context.loading.value"
|
||||
@@ -85,6 +151,16 @@ onScopeDispose(() => {
|
||||
.project-frame {
|
||||
@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 {
|
||||
@apply shrink-0 h-auto max-h-[100px] overflow-hidden;
|
||||
}
|
||||
@@ -94,13 +170,30 @@ onScopeDispose(() => {
|
||||
.project-notices .n-alert {
|
||||
@apply py-[7px] px-3 text-xs;
|
||||
}
|
||||
.operation-guide-content {
|
||||
@apply flex items-center justify-between gap-4;
|
||||
}
|
||||
.project-view {
|
||||
@apply flex-1 min-h-0 overflow-hidden;
|
||||
}
|
||||
.project-header .n-page-header-wrapper {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.project-access-gate {
|
||||
@apply h-full;
|
||||
}
|
||||
.workspace-loading {
|
||||
@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>
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
NTag
|
||||
} from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { RefreshCw, UserCheck } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Check, Circle, RefreshCw, UserCheck } from '@lucide/vue'
|
||||
import { AssetImage, DirectoryItem, EmptyState, StatusBadge } from '../../components/ui'
|
||||
import { downloadText } from '../../lib/format'
|
||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||
@@ -29,10 +29,12 @@ import IdentityGallery from './components/IdentityGallery.vue'
|
||||
import IdentityImageDialog from './components/IdentityImageDialog.vue'
|
||||
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
|
||||
import IdentityThumbnail from './components/IdentityThumbnail.vue'
|
||||
import CreativeProfileSettings from '../generation-config/components/CreativeProfileSettings.vue'
|
||||
import type { IdentitySubject } from './types'
|
||||
|
||||
/** 主体身份工作区按正式主体聚合,不把同一主体的多个 Form 当成不同身份。 */
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const {
|
||||
projectId,
|
||||
operation,
|
||||
@@ -42,10 +44,10 @@ const {
|
||||
catalog,
|
||||
castingQuery,
|
||||
styleQuery,
|
||||
profileQuery,
|
||||
detail,
|
||||
identity,
|
||||
images,
|
||||
castingItem,
|
||||
session,
|
||||
concurrency,
|
||||
candidateLimit,
|
||||
@@ -56,6 +58,7 @@ const {
|
||||
castingBlocked,
|
||||
detailBlocked,
|
||||
hasStyle,
|
||||
hasCreativeProfile,
|
||||
canGenerateText,
|
||||
canGenerateImage,
|
||||
canGenerateCandidate,
|
||||
@@ -162,6 +165,121 @@ watch(
|
||||
)
|
||||
const formModel = computed(() => ({ concurrency: concurrency.value, candidateLimit: candidateLimit.value }))
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -188,6 +306,7 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
title="角色选角总览与批量操作"
|
||||
: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>
|
||||
<h2 class="text-lg font-semibold">主体身份</h2>
|
||||
@@ -503,6 +622,17 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
当前相关操作已暂停,请刷新核对后端。
|
||||
</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">
|
||||
<!-- 以下是主体目录 -->
|
||||
<aside class="panel directory-panel identity-directory">
|
||||
@@ -579,20 +709,6 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
<h3 class="font-semibold">
|
||||
{{ subject.name }} <code class="subject-ref ml-2">{{ subject.ref }}</code>
|
||||
</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>
|
||||
<NButton :disabled="detail.loading.value" @click="detail.refresh" text size="small">
|
||||
刷新身份
|
||||
@@ -605,25 +721,39 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
正在读取主体身份…
|
||||
</p>
|
||||
<template v-if="detail.data.value">
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<ConfirmAction
|
||||
:label="identity ? 'AI 重新生成身份' : 'AI 生成身份'"
|
||||
:disabled="!canGenerateText"
|
||||
acknowledgement
|
||||
description="调用文本模型生成稳定身份描述和提示词。旧图片不会自动重生成;已锁定身份需先解锁并保存。"
|
||||
@confirm="generate"
|
||||
/><NButton
|
||||
v-if="subject.module === 'character'"
|
||||
:disabled="!canGenerateCandidate"
|
||||
@click="castingOpen = true"
|
||||
>
|
||||
生成选角候选</NButton
|
||||
><NButton
|
||||
v-if="subject.module !== 'character' || !!anchor"
|
||||
:disabled="!canGenerateImage"
|
||||
@click="imageOpen = true"
|
||||
>{{ subject.module === 'character' ? '生成辅助身份图' : '生成身份参考图' }}</NButton
|
||||
>
|
||||
<div class="identity-flow mt-4" aria-label="主体身份操作步骤">
|
||||
<ol class="identity-flow-steps">
|
||||
<li
|
||||
v-for="(step, index) in identityFlow"
|
||||
:key="step.label"
|
||||
:class="{ done: step.done, current: index === currentFlowIndex }"
|
||||
>
|
||||
<span class="identity-flow-marker">
|
||||
<Check v-if="step.done" :size="14" />
|
||||
<Circle v-else :size="12" />
|
||||
</span>
|
||||
<span>{{ index + 1 }}. {{ step.label }}</span>
|
||||
</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>
|
||||
<p v-if="dirty" class="mt-3 text-xs text-muted">请先保存身份修改,再使用 AI 或生成图片。</p>
|
||||
<IdentityEditor
|
||||
@@ -657,6 +787,7 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
</div>
|
||||
</div>
|
||||
<IdentityGallery
|
||||
id="identity-reference-gallery"
|
||||
:key="selectedId"
|
||||
:images="images"
|
||||
:subject-name="subject.name"
|
||||
@@ -748,6 +879,38 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
.identity-workspace-page {
|
||||
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 {
|
||||
@apply w-[152px] min-w-0;
|
||||
}
|
||||
@@ -843,6 +1006,9 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
@apply shrink-0;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.identity-next-action {
|
||||
@apply items-start flex-col;
|
||||
}
|
||||
.identity-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[auto_minmax(0,_1fr)] gap-2.5;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import AppForm from '../../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import DetailDisclosure from '../../../components/ui/DetailDisclosure.vue'
|
||||
import { NButton, NCheckbox, NInput } from 'naive-ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { SaveIdentityInput, SubjectIdentity } from '../types'
|
||||
@@ -36,63 +35,42 @@ function save() {
|
||||
<template>
|
||||
<AppForm :model="form" :disabled="disabled" class="mt-5" @submit="save">
|
||||
<fieldset :disabled="disabled" class="space-y-4">
|
||||
<NFormItem
|
||||
class="block"
|
||||
path="description"
|
||||
label="稳定身份描述"
|
||||
:label-props="{ for: 'identity-description' }"
|
||||
<NFormItem class="block" path="description" label="身份描述" :label-props="{ for: 'identity-description' }"
|
||||
><NInput
|
||||
:disabled="disabled"
|
||||
placeholder="只描述跨形态不变的面部、结构、材质等特征"
|
||||
:input-props="{ id: 'identity-description' }"
|
||||
class="min-h-28"
|
||||
class="min-h-20"
|
||||
v-model:value="form.description"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
:autosize="{ minRows: 2, maxRows: 8 }"
|
||||
></NInput>
|
||||
</NFormItem>
|
||||
<NFormItem
|
||||
class="block"
|
||||
path="generationPrompt"
|
||||
label="身份核心提示词(稳定事实)"
|
||||
label="核心提示词"
|
||||
:label-props="{ for: 'identity-prompt' }"
|
||||
><NInput
|
||||
:disabled="disabled"
|
||||
placeholder="不固定某一形态的服装、伤势或临时状态"
|
||||
:input-props="{ id: 'identity-prompt' }"
|
||||
class="min-h-36"
|
||||
class="min-h-24"
|
||||
v-model:value="form.generationPrompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
:autosize="{ minRows: 2, maxRows: 8 }"
|
||||
></NInput>
|
||||
</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
|
||||
:disabled="disabled"
|
||||
id="identity-lock"
|
||||
v-model:checked="form.isLocked"
|
||||
class="flex items-start gap-2 text-xs leading-6"
|
||||
>{{ module === 'character' ? '确认并锁定演员身份' : '确认并锁定身份文本' }}</NCheckbox
|
||||
>{{ module === 'character' ? '锁定演员身份' : '锁定身份' }}</NCheckbox
|
||||
>
|
||||
<div class="page-form-actions">
|
||||
<NButton :disabled="disabled" type="primary" attr-type="submit">保存主体身份</NButton>
|
||||
<span class="text-xs text-muted">{{
|
||||
dirty ? '有未保存修改,刷新不会覆盖草稿。' : '图片以已保存的身份提示词为准。'
|
||||
}}</span>
|
||||
<span v-if="dirty" class="text-xs text-muted">有未保存修改</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
</AppForm>
|
||||
|
||||
@@ -52,12 +52,6 @@ function chooseAnchor() {
|
||||
<h3 class="text-sm font-medium">身份参考图 · {{ images.length }}</h3>
|
||||
<NTag size="small" :bordered="false">{{ anchor ? '已选母版' : '尚无母版' }}</NTag>
|
||||
</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">
|
||||
<AssetImage
|
||||
:key="selected.id"
|
||||
@@ -70,7 +64,6 @@ function chooseAnchor() {
|
||||
/>
|
||||
<div class="image-history-heading">
|
||||
<h4 class="font-medium">历史记录 · {{ images.length }} 条</h4>
|
||||
<span class="text-muted">点击缩略图切换预览,可横向滚动</span>
|
||||
</div>
|
||||
<NScrollbar
|
||||
x-scrollable
|
||||
@@ -158,7 +151,6 @@ function chooseAnchor() {
|
||||
><NButton @click="confirming = false" text size="small">取消</NButton>
|
||||
</div></NAlert
|
||||
>
|
||||
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
|
||||
</template>
|
||||
<p v-else class="py-8 text-sm text-muted">尚无身份参考图。先保存身份提示词,再生成第一张母版。</p>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { visualStyleApi } from '../visual-style'
|
||||
import { generationConfigApi } from '../generation-config/api'
|
||||
import { subjectImagesApi } from '../subject-images/api'
|
||||
import { hasRunningImages } from '../subject-images/model'
|
||||
import { runOperation } from '../workflows/operations'
|
||||
@@ -56,6 +57,11 @@ export function useSubjectIdentity() {
|
||||
},
|
||||
false
|
||||
)
|
||||
const profileQuery = usePolling(
|
||||
projectId,
|
||||
async (id, signal) => ({ profile: await generationConfigApi.profile(id, signal) }),
|
||||
false
|
||||
)
|
||||
const castingQuery = usePolling(
|
||||
projectId,
|
||||
async (id, signal) => {
|
||||
@@ -106,11 +112,17 @@ export function useSubjectIdentity() {
|
||||
hasRunningImages(images.value)
|
||||
)
|
||||
const hasStyle = computed(() => !!styleQuery.data.value?.style)
|
||||
const hasCreativeProfile = computed(() => !!profileQuery.data.value?.profile)
|
||||
const canGenerateText = computed(
|
||||
() => !detailBlocked.value && hasStyle.value && !identity.value?.isLocked && !dirty.value
|
||||
)
|
||||
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(
|
||||
() =>
|
||||
@@ -189,7 +201,15 @@ export function useSubjectIdentity() {
|
||||
|
||||
/** 场景/道具身份母版由后端独立生成、设为 Anchor 并锁定,保留逐主体失败回执。 */
|
||||
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 target = getIdentitySession(id)
|
||||
const title = `批量生成${module === 'scene' ? '场景' : '道具'}身份母版`
|
||||
@@ -328,6 +348,7 @@ export function useSubjectIdentity() {
|
||||
catalog,
|
||||
castingQuery,
|
||||
styleQuery,
|
||||
profileQuery,
|
||||
detail,
|
||||
identity,
|
||||
images,
|
||||
@@ -342,6 +363,7 @@ export function useSubjectIdentity() {
|
||||
castingBlocked,
|
||||
detailBlocked,
|
||||
hasStyle,
|
||||
hasCreativeProfile,
|
||||
canGenerateText,
|
||||
canGenerateImage,
|
||||
canGenerateCandidate,
|
||||
|
||||
@@ -378,7 +378,7 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
||||
expect(put[0]).toBe('/api/subjects/subject-db-1/identity')
|
||||
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!.findAll('.identity-subject-item')[1]!.trigger('click')
|
||||
expect(wrapper!.text()).toContain('切换会丢弃草稿')
|
||||
@@ -538,11 +538,10 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
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('/identity/images'))).toBe(false)
|
||||
expect(button('生成选角候选').disabled).toBe(true)
|
||||
expect(button('AI 生成身份').disabled).toBe(false)
|
||||
expect(button('配置生成模型').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('锁定阻止身份 AI 覆盖但不阻止人工编辑和生图,生图默认省略 referenceImageId', async () => {
|
||||
it('锁定阻止身份 AI 覆盖但不阻止人工编辑,并按步骤进入后续操作', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
||||
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture({ isLocked: true }))
|
||||
@@ -552,18 +551,9 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountAssets('identity')
|
||||
expect(button('AI 重新生成身份').disabled).toBe(true)
|
||||
expect(wrapper!.text()).toContain('主体身份已完成')
|
||||
expect(button('保存主体身份').disabled).toBe(false)
|
||||
button('生成辅助身份图').click()
|
||||
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' })
|
||||
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
|
||||
})
|
||||
|
||||
it('无项目风格时禁止生成身份和图片,但仍可人工保存身份', async () => {
|
||||
@@ -577,8 +567,7 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
})
|
||||
)
|
||||
await mountAssets('identity')
|
||||
expect(button('AI 重新生成身份').disabled).toBe(true)
|
||||
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')!
|
||||
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(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('/identity/images')) return jsonResponse([])
|
||||
if (init?.method === 'POST') throw new Error('connection lost')
|
||||
if (String(url).endsWith('/identity')) return jsonResponse(null)
|
||||
return jsonResponse(identityFixture())
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const provided = await mountAssets('identity')
|
||||
await confirmGeneration('AI 重新生成身份')
|
||||
button('生成身份文本').click()
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1)
|
||||
expect(getOperation(fixture.id).error).toContain('无法连接后端')
|
||||
provided.data.value!.project = { ...fixture, status: 'generating' }
|
||||
await flushPromises()
|
||||
expect(button('保存主体身份').disabled).toBe(true)
|
||||
expect(button('生成选角候选').disabled).toBe(true)
|
||||
expect(button('生成身份文本').disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user