feat: 同步视觉风格与主体身份母版工作区
对齐后端 dev 059e597,新增项目风格编辑锁定、身份文本生成、身份参考图与母版切换。 整理六个工作区入口,接通人物及场景母版继承说明、形态图片来源追溯。 补充草稿保护、正式 ID 校验、费用确认及部分失败回执,67 项测试和静态检查、构建通过。 未修改后端,未调用真实模型;本环境未完成浏览器视觉验收。
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { visualStyleApi } from '../visual-style'
|
||||
import { subjectImagesApi } from '../subject-images/api'
|
||||
import { hasRunningImages } from '../subject-images/model'
|
||||
import { runOperation } from '../workflows/operations'
|
||||
import { useProjectMutationGuard } from '../workflows/useProjectMutationGuard'
|
||||
import { subjectIdentityApi } from './api'
|
||||
import { canBeAnchor, getIdentitySession, groupIdentitySubjects } from './model'
|
||||
import type { GenerateIdentityImageInput, IdentityImage, SaveIdentityInput, SubjectIdentity } from './types'
|
||||
|
||||
/** 单主体保存响应必须与提交时固定的正式 ID 一致。 */
|
||||
function assertIdentity(value: SubjectIdentity, id: string) {
|
||||
if (!value || value.subjectId !== id || value.images.some(image => image.identityId !== value.id))
|
||||
throw new Error('后端未返回匹配的主体身份,请刷新核对。')
|
||||
}
|
||||
|
||||
/** 主体目录、项目风格和当前身份分开查询,未创建 Identity 不误报为空图库错误。 */
|
||||
export function useSubjectIdentity() {
|
||||
const { projectId, operation, blocked: projectBlocked } = useProjectMutationGuard()
|
||||
const selectedId = ref('')
|
||||
const editorRevision = ref(0)
|
||||
const dirty = ref(false)
|
||||
const concurrency = ref(3)
|
||||
const force = ref(false)
|
||||
const catalog = usePolling(projectId, async (id, signal) => {
|
||||
const forms = await subjectImagesApi.listForms(id, signal)
|
||||
if (
|
||||
forms.some(
|
||||
form => form.subject.projectId !== id || form.images.some(image => image.subjectFormId !== form.id)
|
||||
)
|
||||
)
|
||||
throw new Error('主体目录与当前项目不匹配,请刷新后重试。')
|
||||
return { subjects: groupIdentitySubjects(forms), running: forms.some(form => hasRunningImages(form.images)) }
|
||||
})
|
||||
const styleQuery = usePolling(projectId, async (id, signal) => {
|
||||
const style = await visualStyleApi.get(id, signal)
|
||||
if (style && style.projectId !== id) throw new Error('视觉风格与当前项目不匹配。')
|
||||
return { style }
|
||||
})
|
||||
const subjects = computed(() => catalog.data.value?.subjects ?? [])
|
||||
const subject = computed(() => subjects.value.find(item => item.id === selectedId.value))
|
||||
const selectionKey = computed(() => subject.value?.id ?? '')
|
||||
const detail = usePolling(selectionKey, async (id, signal) => {
|
||||
if (!id) return null
|
||||
const identity = await subjectIdentityApi.get(id, signal)
|
||||
if (!identity) return { identity: null, images: [] as IdentityImage[] }
|
||||
assertIdentity(identity, id)
|
||||
const images = await subjectIdentityApi.listImages(id, signal)
|
||||
if (images.some(image => image.identityId !== identity.id)) throw new Error('身份图片与当前主体不匹配。')
|
||||
return { identity, images }
|
||||
})
|
||||
const identity = computed(() => detail.data.value?.identity ?? null)
|
||||
const images = computed(() => detail.data.value?.images ?? [])
|
||||
const session = computed(() => getIdentitySession(projectId.value))
|
||||
const blocked = computed(
|
||||
() =>
|
||||
projectBlocked.value ||
|
||||
!!catalog.error.value ||
|
||||
!catalog.data.value ||
|
||||
!!catalog.data.value.running ||
|
||||
!!styleQuery.error.value ||
|
||||
!styleQuery.data.value
|
||||
)
|
||||
const detailBlocked = computed(
|
||||
() =>
|
||||
blocked.value ||
|
||||
!subject.value ||
|
||||
!detail.data.value ||
|
||||
!!detail.error.value ||
|
||||
hasRunningImages(images.value)
|
||||
)
|
||||
const hasStyle = computed(() => !!styleQuery.data.value?.style)
|
||||
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
|
||||
)
|
||||
const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0)
|
||||
|
||||
watch(subjects, rows => {
|
||||
if (!rows.some(item => item.id === selectedId.value)) selectedId.value = rows[0]?.id ?? ''
|
||||
})
|
||||
watch(selectionKey, () => {
|
||||
dirty.value = false
|
||||
editorRevision.value++
|
||||
})
|
||||
|
||||
/** 长请求跨主体切换后只更新原项目的操作回执,不用旧响应覆盖新主体。 */
|
||||
async function writeIdentity(label: string, action: (id: string) => Promise<SubjectIdentity>) {
|
||||
if (detailBlocked.value || !subject.value) return
|
||||
const id = subject.value.id
|
||||
const project = projectId.value
|
||||
const ok = await runOperation(project, label, async () => {
|
||||
const result = await action(id)
|
||||
assertIdentity(result, id)
|
||||
if (selectionKey.value === id && projectId.value === project) {
|
||||
// 图片的 isAnchor 只能从专用查询获取,保存文本时暂时保留已有图片列表。
|
||||
detail.data.value = { identity: result, images: images.value }
|
||||
editorRevision.value++
|
||||
dirty.value = false
|
||||
}
|
||||
})
|
||||
if (ok) await detail.refresh()
|
||||
}
|
||||
|
||||
/** 人工保存可修改已锁定身份;锁定只阻止自动重生成。 */
|
||||
function save(input: SaveIdentityInput) {
|
||||
return writeIdentity('保存主体身份', id => subjectIdentityApi.save(id, input))
|
||||
}
|
||||
|
||||
/** 单主体 AI 生成与已存在文本的覆盖均需要页面确认。 */
|
||||
function generate() {
|
||||
if (!canGenerateText.value) return
|
||||
const overwrite = !!identity.value
|
||||
return writeIdentity('AI 生成主体身份', id => subjectIdentityApi.generate(id, overwrite))
|
||||
}
|
||||
|
||||
/** 批量仅生成身份文本,已锁定项由后端跳过,回执保留部分失败。 */
|
||||
async function generateProject() {
|
||||
if (blocked.value || !hasStyle.value || !batchValid.value || dirty.value || !subjects.value.length) return
|
||||
const id = projectId.value
|
||||
const target = getIdentitySession(id)
|
||||
const input = { concurrency: concurrency.value, force: force.value }
|
||||
target.receipt = null
|
||||
await runOperation(id, '批量生成主体身份文本', async () => {
|
||||
target.receipt = await subjectIdentityApi.generateProject(id, input)
|
||||
})
|
||||
await detail.refresh()
|
||||
}
|
||||
|
||||
/** 生图不修改文字锁定;即使有自定义 Prompt,也需已有身份 Prompt 与项目风格。 */
|
||||
async function generateImage(input: GenerateIdentityImageInput) {
|
||||
if (!canGenerateImage.value || !subject.value || !identity.value) return
|
||||
if (
|
||||
input.referenceImageId &&
|
||||
!images.value.some(
|
||||
image => image.id === input.referenceImageId && image.status === 'completed' && image.imageUrl
|
||||
)
|
||||
)
|
||||
return
|
||||
const id = subject.value.id
|
||||
const identityId = identity.value.id
|
||||
await runOperation(projectId.value, `生成 ${subject.value.name} 身份参考图`, async () => {
|
||||
const image = await subjectIdentityApi.generateImage(id, input)
|
||||
if (!image || image.identityId !== identityId || image.status !== 'completed' || !image.imageUrl)
|
||||
throw new Error(image?.error || '后端未返回已完成的身份图片,请先刷新核对,不要立即重复生图。')
|
||||
})
|
||||
await detail.refresh()
|
||||
}
|
||||
|
||||
/** 候选切换母版只允许成功的 primary 图,不能将辅助视角直接升级为母版。 */
|
||||
async function setAnchor(imageId: string) {
|
||||
const image = images.value.find(item => item.id === imageId)
|
||||
if (detailBlocked.value || !subject.value || !image || !canBeAnchor(image) || image.isAnchor) return
|
||||
const id = subject.value.id
|
||||
const identityId = image.identityId
|
||||
await runOperation(projectId.value, '切换主体身份母版', async () => {
|
||||
const result = await subjectIdentityApi.setAnchor(id, imageId)
|
||||
if (
|
||||
!result ||
|
||||
result.id !== imageId ||
|
||||
result.identityId !== identityId ||
|
||||
!result.enabled ||
|
||||
!canBeAnchor(result)
|
||||
)
|
||||
throw new Error('接口未确认身份母版切换,请刷新核对。')
|
||||
})
|
||||
await detail.refresh()
|
||||
}
|
||||
|
||||
return {
|
||||
projectId,
|
||||
operation,
|
||||
selectedId,
|
||||
subject,
|
||||
subjects,
|
||||
catalog,
|
||||
styleQuery,
|
||||
detail,
|
||||
identity,
|
||||
images,
|
||||
session,
|
||||
concurrency,
|
||||
force,
|
||||
editorRevision,
|
||||
dirty,
|
||||
blocked,
|
||||
detailBlocked,
|
||||
hasStyle,
|
||||
canGenerateText,
|
||||
canGenerateImage,
|
||||
batchValid,
|
||||
save,
|
||||
generate,
|
||||
generateProject,
|
||||
generateImage,
|
||||
setAnchor
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user