feat: 同步视觉风格与主体身份母版工作区

对齐后端 dev 059e597,新增项目风格编辑锁定、身份文本生成、身份参考图与母版切换。
整理六个工作区入口,接通人物及场景母版继承说明、形态图片来源追溯。
补充草稿保护、正式 ID 校验、费用确认及部分失败回执,67 项测试和静态检查、构建通过。
未修改后端,未调用真实模型;本环境未完成浏览器视觉验收。
This commit is contained in:
GouJ
2026-08-28 19:28:27 +08:00
parent 47443c0efe
commit 669f4d9df0
34 changed files with 2273 additions and 22 deletions
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import type { SaveIdentityInput, SubjectIdentity } from '../types'
/** 身份文本草稿不随轮询覆盖;父页面切换主体或保存成功后重建编辑器。 */
const props = defineProps<{ identity: SubjectIdentity | null; disabled: boolean; module: string }>()
const emit = defineEmits<{ save: [input: SaveIdentityInput]; dirty: [value: boolean] }>()
const form = reactive({ description: '', generationPrompt: '', isLocked: false })
const baseline = ref('')
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
watch(
() => props.identity,
value => {
if (baseline.value && dirty.value) return
Object.assign(form, {
description: value?.description ?? '',
generationPrompt: value?.generationPrompt ?? '',
isLocked: value?.isLocked ?? false
})
baseline.value = JSON.stringify(form)
},
{ immediate: true }
)
watch(dirty, value => emit('dirty', value), { immediate: true })
/** 保存用户输入;空文本允许保存,但生图入口仍会检查必要 Prompt。 */
function save() {
if (!props.disabled) emit('save', { ...form })
}
</script>
<template>
<form class="mt-5" @submit.prevent="save">
<fieldset :disabled="disabled" class="space-y-4">
<label class="block"
><span class="field-label">稳定身份描述</span
><textarea
id="identity-description"
v-model="form.description"
class="input min-h-28"
placeholder="只描述跨形态不变的面部、结构、材质等特征"
/>
</label>
<label class="block"
><span class="field-label">身份核心提示词稳定事实</span
><textarea
id="identity-prompt"
v-model="form.generationPrompt"
class="input min-h-36"
placeholder="不固定某一形态的服装、伤势或临时状态"
/>
</label>
<p class="text-xs leading-6 text-muted">
{{
module === 'prop'
? '保留道具固定文字、编号、部件位置、结构与材质组合;不要泛化原有辨识细节,也不要写临时损伤或摆放状态。'
: module === 'scene'
? '保留建筑骨架、入口、窗户、楼梯与固定布局,不写临时天气、人群或灯光。'
: '保留有依据的五官、骨相、发际线与体型,不固定某套服装、临时表情或伤势。'
}}
这里保存身份事实背景视角和参考图约束由后端另行编译不是最终整段生图 Prompt
</p>
<label class="flex items-start gap-2 text-xs leading-6"
><input
id="identity-lock"
v-model="form.isLocked"
type="checkbox"
class="mt-1.5 accent-accent"
/>确认并锁定身份文本锁定后仍可人工保存生成图片及切换母版</label
>
<div class="flex flex-wrap items-center justify-between gap-3">
<span class="text-xs text-muted">{{
dirty ? '有未保存修改,自动刷新不会覆盖草稿。' : '图片以已保存的身份提示词为准。'
}}</span
><button class="button button-primary" type="submit" :disabled="disabled">保存主体身份</button>
</div>
</fieldset>
</form>
</template>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { AssetImage, StatusBadge } from '../../../components/ui'
import { referenceImageUrl } from '../../../lib/assets'
import { formatDate } from '../../../lib/format'
import { canBeAnchor, currentAnchor, identityViewLabels, readImageProvenance } from '../model'
import type { IdentityImage } from '../types'
/** 身份图库区分权威母版、primary 候选和辅助视图,保留后端原始状态。 */
const props = defineProps<{ images: IdentityImage[]; subjectName: string; disabled: boolean }>()
const emit = defineEmits<{ anchor: [imageId: string] }>()
const selectedId = ref('')
const confirming = ref(false)
const anchor = computed(() => currentAnchor(props.images))
const selected = computed(
() => props.images.find(image => image.id === selectedId.value) ?? anchor.value ?? props.images[0]
)
const canSelect = computed(
() => !props.disabled && !!selected.value && canBeAnchor(selected.value) && !selected.value.isAnchor
)
const imageUrl = computed(() => referenceImageUrl(selected.value?.imageUrl ?? ''))
watch(
() => selected.value?.id,
() => {
confirming.value = false
}
)
/** 只发出用户确认过的目标 ID,父页面再核验图片归属。 */
function chooseAnchor() {
if (!canSelect.value || !confirming.value || !selected.value) return
emit('anchor', selected.value.id)
confirming.value = false
}
</script>
<template>
<section class="mt-5 border-t border-line pt-5" aria-label="身份参考图库">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-medium">身份参考图 · {{ images.length }}</h3>
<span class="tag">{{ anchor ? '已选母版' : '尚无母版' }}</span>
</div>
<p class="mt-2 text-xs leading-6 text-muted">
身份母版确定是谁形态主图确定该造型长什么样切换母版仅影响之后的生成不会替换已有形态图分镜参考图或提示词
</p>
<template v-if="selected">
<AssetImage
:src="selected.status === 'completed' ? selected.imageUrl : null"
:alt="`${subjectName} · ${identityViewLabels[selected.viewType]}`"
class="asset-image-preview mt-4"
:empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端图片结果'"
/>
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<StatusBadge :status="selected.status" /><span class="tag">{{
selected.isAnchor
? '当前身份母版'
: selected.viewType === 'primary'
? '母版候选'
: identityViewLabels[selected.viewType]
}}</span
><span class="text-xs text-muted"
>{{ selected.enabled ? '启用' : '停用' }} · {{ selected.width || '—' }} ×
{{ selected.height || '—' }}</span
>
</div>
<a
v-if="imageUrl && selected.status === 'completed'"
:href="imageUrl"
target="_blank"
rel="noopener noreferrer"
class="text-button"
>打开原图</a
>
</div>
<p v-if="selected.error" class="alert alert-error mt-3" role="alert">{{ selected.error }}</p>
<div class="mt-3 flex flex-wrap items-center gap-3">
<button
v-if="!selected.isAnchor"
class="button button-secondary"
:disabled="!canSelect"
@click="confirming = true"
>
设为身份母版</button
><span class="text-[11px] text-muted"
>{{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}</span
>
</div>
<p v-if="selected.viewType !== 'primary'" class="mt-2 text-xs text-muted">
辅助视图不能直接设为母版可以选它作为参考另生成一张 primary 候选
</p>
<div v-if="confirming" class="alert mt-3">
<p class="text-xs">
确认切换身份母版 primary
母版将停用并保留后续人物场景形态生图会引用新母版道具暂不自动引用此操作不调用模型
</p>
<div class="mt-3 flex gap-3">
<button class="button button-primary" :disabled="!canSelect" @click="chooseAnchor">
确认切换身份母版</button
><button class="text-button" @click="confirming = false">取消</button>
</div>
</div>
<details v-if="selected.prompt" class="mt-4 text-xs">
<summary class="cursor-pointer text-muted">本次实际提示词</summary>
<p class="mt-3 whitespace-pre-wrap leading-6">{{ selected.prompt }}</p>
</details>
<p
v-if="readImageProvenance(selected.rawJson).referenceImageId"
class="mt-3 break-all text-[11px] text-muted"
>
本次锚定图片 ID{{ readImageProvenance(selected.rawJson).referenceImageId }}
</p>
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
<div class="image-history mt-4" aria-label="身份图片历史">
<button
v-for="image in images"
:key="image.id"
class="image-history-item"
:class="{ selected: image.id === selected.id }"
:aria-label="`查看身份图片 ${image.id}`"
:aria-pressed="image.id === selected.id"
@click="selectedId = image.id"
>
<AssetImage
:src="image.status === 'completed' ? image.imageUrl : null"
:alt="identityViewLabels[image.viewType]"
:retryable="false"
:empty-text="image.status === 'failed' ? '失败' : '无图'"
/><span class="mt-1 block text-[10px]">{{
image.isAnchor
? '当前母版'
: image.viewType === 'primary'
? '母版候选'
: identityViewLabels[image.viewType]
}}</span>
</button>
</div>
</template>
<p v-else class="py-8 text-sm text-muted">尚无身份参考图先保存身份提示词再生成第一张母版</p>
</section>
</template>
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
import { validImageSize } from '../../subject-images/model'
import { currentAnchor, identityViewLabels } from '../model'
import type { GenerateIdentityImageInput, IdentityImage, IdentityViewType } from '../types'
/** 身份图生成配置;默认自动引用母版,不提供后端不存在的“忽略母版”开关。 */
const props = defineProps<{ subjectId: string; subjectName: string; images: IdentityImage[]; disabled: boolean }>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ generate: [input: GenerateIdentityImageInput] }>()
const viewType = ref<IdentityViewType>('primary')
const referenceImageId = ref('')
const prompt = ref('')
const width = ref<number | ''>('')
const height = ref<number | ''>('')
const acknowledged = ref(false)
const anchor = computed(() => currentAnchor(props.images))
const references = computed(() => props.images.filter(image => image.status === 'completed' && image.imageUrl))
const valid = computed(
() =>
validImageSize(width.value, height.value) &&
(!referenceImageId.value || references.value.some(image => image.id === referenceImageId.value))
)
watch(
() => [open.value, props.subjectId],
() => {
viewType.value = 'primary'
referenceImageId.value = ''
prompt.value = ''
width.value = ''
height.value = ''
acknowledged.value = false
}
)
/** 提交显式的 Seedream 参数;省略空参考 ID 以保留后端自动选择逻辑。 */
function submit() {
if (props.disabled || !valid.value || !acknowledged.value) return
emit('generate', {
provider: 'seedream',
viewType: viewType.value,
...(referenceImageId.value ? { referenceImageId: referenceImageId.value } : {}),
...(prompt.value.trim() ? { prompt: prompt.value.trim() } : {}),
...(width.value !== '' && height.value !== '' ? { width: width.value, height: height.value } : {})
})
open.value = false
}
</script>
<template>
<AppDialog v-model:open="open" title="生成身份参考图" :description="subjectName">
<form class="mt-5 space-y-4" @submit.prevent="submit">
<p class="alert text-xs">
调用 Seedream每次新增一张图片可能产生费用第一张成功的 primary 图自动成为母版已有母版时
primary 图仅作为停用候选保留
</p>
<label class="block"
><span class="field-label">参考视角</span
><select id="identity-view" v-model="viewType" class="input">
<option v-for="(label, value) in identityViewLabels" :key="value" :value="value">
{{ label }}{{ value }}
</option>
</select></label
>
<label class="block"
><span class="field-label">身份锚定来源</span
><select id="identity-reference" v-model="referenceImageId" class="input">
<option value="">
{{ anchor ? '自动引用当前身份母版' : '自动选择(当前无母版,将按文本生成)' }}
</option>
<option v-for="image in references" :key="image.id" :value="image.id">
{{ identityViewLabels[image.viewType] }} · {{ image.id }}
</option>
</select></label
>
<p class="text-xs leading-6 text-muted">
只能选择此主体已完成的图片后端需取得 Provider 可访问的远程地址本地预览可见不保证远程地址仍有效
</p>
<label class="block"
><span class="field-label">覆盖本次完整提示词可选</span
><textarea
id="identity-image-prompt"
v-model="prompt"
class="input min-h-24"
placeholder="建议留空,使用后端编译的身份约束和视角要求"
/>
</label>
<p v-if="prompt.trim()" class="text-xs text-danger">
自定义文本会替换后端编译的完整提示词请自行包含身份约束和视角要求
</p>
<div class="grid grid-cols-2 gap-4">
<label
><span class="field-label">宽度px</span
><input
id="identity-width"
v-model.number="width"
type="number"
min="1"
step="1"
class="input"
placeholder="后端默认" /></label
><label
><span class="field-label">高度px</span
><input
id="identity-height"
v-model.number="height"
type="number"
min="1"
step="1"
class="input"
placeholder="后端默认"
/></label>
</div>
<p v-if="!valid" class="text-xs text-danger" role="alert">尺寸须成对填写正整数指定的参考图必须仍可用</p>
<label class="flex items-start gap-2 text-xs leading-6"
><input
id="identity-image-cost"
v-model="acknowledged"
type="checkbox"
class="mt-1.5 accent-accent"
/>我已确认后台没有同项目任务了解本次会调用模型并可能产生费用</label
>
<div class="dialog-footer">
<button type="button" class="button button-secondary" @click="open = false">取消</button
><button class="button button-primary" type="submit" :disabled="disabled || !valid || !acknowledged">
确认生成身份图
</button>
</div>
</form>
</AppDialog>
</template>