feat: 增加操作步骤引导与错误修复入口

This commit is contained in:
GouJ
2026-09-22 21:40:58 +08:00
parent be06caaa9f
commit 1c5b1e448a
8 changed files with 374 additions and 122 deletions
@@ -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
)
+14 -4
View File
@@ -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 必须生成
+113 -20
View File
@@ -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,26 +721,40 @@ 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"
<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 }"
>
生成选角候选</NButton
><NButton
v-if="subject.module !== 'character' || !!anchor"
:disabled="!canGenerateImage"
@click="imageOpen = true"
>{{ subject.module === 'character' ? '生成辅助身份图' : '生成身份参考图' }}</NButton
<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
:key="`${selectedId}:${editorRevision}`"
@@ -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,
+11 -20
View File
@@ -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)
})
})