feat: 同步精简接口并统一表单校验

This commit is contained in:
GJ
2026-09-10 22:34:47 +08:00
parent 82d7bde631
commit 2eea0ea72e
66 changed files with 1611 additions and 1100 deletions
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { NForm, type FormInst, type FormRules, type FormValidationError } from 'naive-ui'
/** 统一使用 Naive UI 校验,屏蔽浏览器原生气泡;不改变请求和费用确认职责。 */
const props = withDefaults(
defineProps<{
model: Record<string, unknown>
rules?: FormRules
disabled?: boolean
validateOnChange?: boolean
resetKey?: unknown
}>(),
{ validateOnChange: false }
)
const emit = defineEmits<{ submit: []; invalid: [errors: FormValidationError[]] }>()
const formRef = ref<(FormInst & { $el: HTMLFormElement }) | null>(null)
let revision = 0
let validating = false
let submitted = false
onBeforeUnmount(() => {
revision++
})
watch(
() => props.resetKey,
() => {
revision++
submitted = false
formRef.value?.restoreValidation()
},
{ flush: 'post' }
)
/** 验证等待期间切换目标或修改输入不继续执行旧的提交。 */
async function validate(action?: () => unknown) {
if (props.disabled || validating || !formRef.value) return false
const version = revision
const snapshot = JSON.stringify(props.model)
submitted = true
validating = true
try {
try {
await formRef.value.validate()
} catch (errors) {
if (version === revision && Array.isArray(errors)) {
emit('invalid', errors)
// 等待调用方展开高级参数,再将焦点交给第一个错误字段。
await nextTick()
if (version === revision) {
formRef.value?.$el
.querySelector<HTMLElement>(
'.n-form-item-blank--error input, .n-form-item-blank--error textarea'
)
?.focus()
}
}
return false
}
if (version !== revision || props.disabled || snapshot !== JSON.stringify(props.model)) return false
if (action) await action()
return true
} finally {
validating = false
}
}
watch(
() => props.model,
async () => {
if (!submitted && !props.validateOnChange) return
await nextTick()
// 这里只更新字段反馈,不提交业务操作。
void formRef.value?.validate().catch(() => {})
},
{ deep: true, flush: 'post' }
)
async function submit() {
await validate(() => emit('submit'))
}
defineExpose({ validate, restoreValidation: () => formRef.value?.restoreValidation() })
</script>
<template>
<NForm
ref="formRef"
class="app-form"
:model="model"
:rules="rules"
:disabled="disabled"
label-placement="top"
require-mark-placement="right"
novalidate
@submit.prevent="submit"
>
<slot :validate="validate" />
</NForm>
</template>
<style>
/* 标签与错误由 Naive UI 排版;窄列中的反馈换行不挤压相邻字段。 */
.app-form .n-form-item {
min-width: 0;
}
.app-form .n-form-item-blank > .n-input-number,
.app-form .n-form-item-blank > .n-select {
width: 100%;
}
.app-form .n-form-item-feedback {
overflow-wrap: anywhere;
}
.app-form .form-controls {
align-items: start;
}
.app-form .form-controls > .n-checkbox,
.app-form .form-controls > .n-button,
.app-form .form-controls > .confirm-action {
align-self: start;
margin-top: 29px;
}
</style>
+5
View File
@@ -9,6 +9,11 @@ const expanded = ref<string[]>([])
function toggle() {
expanded.value = expanded.value.length ? [] : ['details']
}
defineExpose({
expand: () => {
expanded.value = ['details']
}
})
</script>
<template>
<NCollapse v-model:expanded-names="expanded" class="detail-disclosure"
+9
View File
@@ -148,6 +148,15 @@ export function useTheme() {
textColorTextSuccess: accentText,
textColorGhostSuccess: accentText
},
Form: {
labelFontSizeTopMedium: '12px',
labelFontWeight: '500',
labelTextColor: text,
labelPaddingVertical: '0 0 9px 0',
feedbackFontSizeMedium: '12px',
feedbackHeightMedium: '0px',
feedbackPadding: '4px 0 0 0'
},
Input: {
color: 'var(--app-field)',
colorHover: 'var(--app-field-hover)',
+76 -62
View File
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { integerRule, fieldRule } from '../../lib/form-rules'
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import ActionMenu from '../../components/ui/ActionMenu.vue'
import { NScrollbar, NAlert, NButton, NCheckbox, NInputNumber, NProgress, NTab, NTable, NTabs, NTag } from 'naive-ui'
@@ -132,6 +135,11 @@ function exportResult() {
'application/json'
)
}
const formModel = computed(() => ({ groupSize: groupSize.value, modules: modules.value }))
const rules = {
groupSize: integerRule('每组集数'),
modules: fieldRule(value => Array.isArray(value) && value.length > 0, '请至少选择一个抽取模块', true)
}
</script>
<template>
@@ -170,64 +178,73 @@ function exportResult() {
</div>
<NTag size="small" :bordered="false">来源正式剧集</NTag>
</div>
<div class="breakdown-config">
<div class="breakdown-group-field">
<label class="field-label" for="group-size">每组集数</label>
<div class="breakdown-group-input">
<NInputNumber
:disabled="operation.pending"
:input-props="{ id: 'group-size' }"
class="breakdown-group-number"
:value="typeof groupSize === 'number' ? groupSize : null"
@update:value="groupSize = $event ?? 0"
:min="1"
:step="1"
></NInputNumber
><span class="breakdown-group-unit"> / </span>
</div>
</div>
<fieldset class="breakdown-modules-field">
<legend class="field-label">抽取模块</legend>
<div class="breakdown-module-options">
<div
v-for="option in moduleOptions"
:key="option.value"
class="breakdown-module-option"
>
<NCheckbox
:checked="modules.includes(option.value)"
<AppForm
:model="formModel"
:rules="rules"
:disabled="operation.pending"
validate-on-change
@submit="loadPreview"
>
<div class="breakdown-config">
<NFormItem
class="breakdown-group-field"
path="groupSize"
label="每组集数"
:label-props="{ for: 'group-size' }"
>
<div class="breakdown-group-input">
<NInputNumber
:disabled="operation.pending"
:aria-label="option.label"
:aria-describedby="`breakdown-module-${option.value}-help`"
@update:checked="toggleModule(option.value, $event)"
class="control-row-checkbox"
><span class="text-sm font-medium">{{ option.label }}</span></NCheckbox
>
<p
:id="`breakdown-module-${option.value}-help`"
class="breakdown-module-description"
>
{{ option.description }}
</p>
:input-props="{ id: 'group-size' }"
class="breakdown-group-number"
:value="typeof groupSize === 'number' ? groupSize : null"
@update:value="groupSize = $event ?? 0"
:min="1"
:step="1"
></NInputNumber
><span class="breakdown-group-unit"> / </span>
</div>
</div>
</fieldset>
<NButton
:disabled="
project?.status !== 'completed' ||
!configValid ||
previewBusy ||
operation.pending ||
!!error
"
@click="loadPreview"
class="breakdown-preview-button"
><LoaderCircle v-if="previewBusy" :size="14" class="animate-spin" />预览分组
</NButton>
</div>
<p v-if="!configValid" class="mt-4 text-xs text-danger">
每组集数必须是正整数并至少选择一个抽取模块
</p>
</NFormItem>
<NFormItem path="modules" label="抽取模块" class="breakdown-modules-field">
<div class="breakdown-module-options">
<div
v-for="option in moduleOptions"
:key="option.value"
class="breakdown-module-option"
>
<NCheckbox
:checked="modules.includes(option.value)"
:disabled="operation.pending"
:aria-label="option.label"
:aria-describedby="`breakdown-module-${option.value}-help`"
@update:checked="toggleModule(option.value, $event)"
class="control-row-checkbox"
><span class="text-sm font-medium">{{
option.label
}}</span></NCheckbox
>
<p
:id="`breakdown-module-${option.value}-help`"
class="breakdown-module-description"
>
{{ option.description }}
</p>
</div>
</div>
</NFormItem>
<NButton
:disabled="
project?.status !== 'completed' ||
previewBusy ||
operation.pending ||
!!error
"
attr-type="submit"
class="breakdown-preview-button"
><LoaderCircle v-if="previewBusy" :size="14" class="animate-spin" />预览分组
</NButton>
</div>
</AppForm>
<NAlert v-if="previewError" role="alert" type="error" :show-icon="false" class="mt-4">{{
previewError
}}</NAlert>
@@ -517,10 +534,7 @@ function exportResult() {
}
.breakdown-config {
@apply grid grid-cols-[180px_minmax(0,_1fr)_auto] items-start gap-y-4 gap-x-6;
--breakdown-label-offset: 28px;
}
.breakdown-config .field-label {
@apply mt-0 mx-0 mb-2 p-0 leading-[20px];
--breakdown-label-offset: 29px;
}
.breakdown-group-field,
.breakdown-modules-field,
@@ -528,7 +542,7 @@ function exportResult() {
@apply min-w-0;
}
.breakdown-group-input {
@apply flex items-center gap-3;
@apply w-full flex items-center gap-3;
}
.breakdown-group-number.n-input-number {
@apply flex-[0_0_120px] w-[120px];
@@ -540,7 +554,7 @@ function exportResult() {
@apply m-0 p-0 border-0;
}
.breakdown-module-options {
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-y-3 gap-x-4;
@apply w-full grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-y-3 gap-x-4;
}
.breakdown-module-description {
@apply mt-1 mx-0 mb-0 pl-6 text-muted text-[11px] leading-[18px] wrap-anywhere;
+7 -18
View File
@@ -11,7 +11,7 @@ import { workflowCheckpoints } from '../workflows/selectors'
import { getOperation, runOperation } from '../workflows/operations'
import HistoryPanel from '../workflows/HistoryPanel.vue'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import { downloadText, formatDate } from '../../lib/format'
import { downloadText } from '../../lib/format'
import EpisodeReader from './EpisodeReader.vue'
/** 剧本创作工作区,正式数据库内容为准,checkpoint 只补充计划与恢复信息。 */
@@ -44,7 +44,7 @@ const stages = computed(() => [
{ label: '角色设定', done: !!project.value?.characters.length },
{ label: '世界观', done: !!project.value?.world },
{ label: '编写剧集', done: !!total.value && episodes.value.length >= total.value },
{ label: '审核与改写', done: project.value?.reviews[0]?.passed === true },
{ label: '审核与改写', done: state.value?.reviewPassed === true },
{ label: '完成', done: complete.value }
])
@@ -212,24 +212,13 @@ function exportScript() {
<EmptyState v-else title="尚无世界观" description="世界观会在角色设定之后生成。" />
</div>
<div v-if="tab === 'review'" class="p-6 lg:p-8">
<div v-if="project?.reviews.length" class="space-y-5">
<article v-for="review in project.reviews" :key="review.id" class="pb-5">
<div class="mb-3 flex justify-between gap-3">
<span
class="text-sm font-medium"
:class="review.passed ? 'text-success' : 'text-danger'"
>{{ review.passed ? '审核通过' : '需要修改' }}</span
><time class="text-xs text-muted">{{ formatDate(review.createdAt) }}</time>
</div>
<p class="whitespace-pre-wrap text-sm leading-7">
{{ review.message || '本次审核未附加说明。' }}
</p>
</article>
</div>
<p v-if="typeof state?.reviewPassed === 'boolean'" class="text-sm">
{{ state.reviewPassed ? '最近工作流审核通过' : '最近工作流审核未通过' }}
</p>
<EmptyState
v-else
title="还没有审核记录"
description="剧集生成完成后,工作流会进行内容审核与必要的改写。"
title="暂无可用审核结果"
description="项目详情不再提供审核记录;可在执行记录中查看工作流状态。"
/><NCollapse v-if="state?.rewriteSuggestion" class="mt-5 text-sm"
><NCollapseItem name="details"
><template #header>查看改写建议原文</template>
+86 -44
View File
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { integerRule, sizeRules } from '../../lib/form-rules'
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
import {
@@ -164,6 +167,21 @@ watch(
if (receipt) toolsOpen.value = true
}
)
const batchModel = computed(() => ({ concurrency: concurrency.value }))
const batchRules = { concurrency: integerRule('批量并发') }
const keyframeModel = computed(() => ({
keyframeLimit: keyframeLimit.value,
keyframeWidth: keyframeWidth.value,
keyframeHeight: keyframeHeight.value
}))
const dimensionRules = sizeRules(() => ({ width: keyframeWidth.value, height: keyframeHeight.value }))
const keyframeRules = {
keyframeLimit: integerRule('本批上限', 1, true),
keyframeWidth: dimensionRules.width,
keyframeHeight: dimensionRules.height
}
const videoModel = computed(() => ({ videoLimit: videoLimit.value, videoRepairAttempts: videoRepairAttempts.value }))
const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairAttempts: integerRule('每镜最多自动修复') }
</script>
<template>
@@ -219,25 +237,31 @@ watch(
</RouterLink>
</div>
<section class="mt-3" aria-label="项目生产配置">
<div class="production-controls">
<label>
<span class="field-label">批量并发</span>
<NInputNumber
<AppForm
:model="batchModel"
:rules="batchRules"
:disabled="operation.pending"
validate-on-change
>
<div class="form-controls production-controls">
<NFormItem path="concurrency" label="批量并发">
<NInputNumber
:input-props="{ 'aria-label': '批量并发' }"
:disabled="operation.pending"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</NFormItem>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
<span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span>
</label>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="control-row-checkbox text-xs"
>覆盖模式重写提示词新增图视频候选
</NCheckbox>
</div>
class="control-row-checkbox text-xs"
>覆盖模式重写提示词新增图视频候选
</NCheckbox>
</div>
</AppForm>
<DetailDisclosure title="处理范围与覆盖规则" class="mt-3"
><p class="text-xs leading-6 text-muted">
提示词与视频就绪检查面向全项目视频实际提交受本批镜头上限控制并使用严格质量流水线首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式处理全项目过期首帧成功后会自动接替旧主图其余新增候选图片模型视频模型与视觉校验均可能产生费用
@@ -393,63 +417,69 @@ watch(
:primary="stage.key === 'videos'"
@confirm="run(stage.key)"
/>
<div v-if="stage.key === 'keyframes'" class="mt-4 grid gap-3">
<label
><span class="field-label">本批上限可选</span
<AppForm
v-if="stage.key === 'keyframes'"
:model="keyframeModel"
:rules="keyframeRules"
:disabled="operation.pending"
validate-on-change
class="mt-4 grid gap-3"
>
<NFormItem path="keyframeLimit" label="本批上限(可选)"
><NInputNumber
:input-props="{ 'aria-label': '本批上限可选' }"
:value="keyframeLimit === '' ? null : keyframeLimit"
@update:value="keyframeLimit = $event ?? ''"
:min="1"
:precision="0"
:disabled="operation.pending"
placeholder="留空不限制"
/></label>
/></NFormItem>
<div class="grid grid-cols-2 gap-3">
<label
><span class="field-label">宽度像素</span
<NFormItem path="keyframeWidth" label="宽度(像素)"
><NInputNumber
:input-props="{ 'aria-label': '宽度像素' }"
:value="keyframeWidth === '' ? null : keyframeWidth"
@update:value="keyframeWidth = $event ?? ''"
:min="1"
:disabled="operation.pending"
placeholder="默认" /></label
><label
><span class="field-label">高度像素</span
placeholder="默认" /></NFormItem
><NFormItem path="keyframeHeight" label="高度(像素)"
><NInputNumber
:input-props="{ 'aria-label': '高度像素' }"
:value="keyframeHeight === '' ? null : keyframeHeight"
@update:value="keyframeHeight = $event ?? ''"
:min="1"
:disabled="operation.pending"
placeholder="默认"
/></label>
/></NFormItem>
</div>
<p v-if="!keyframeValid" class="text-xs text-danger">
上限须为正整数宽高须同时留空或填写正整数
</p>
</div>
<div v-if="stage.key === 'videos'" class="mt-4 grid gap-3">
<label
><span class="field-label">本批镜头上限</span
</AppForm>
<AppForm
v-if="stage.key === 'videos'"
:model="videoModel"
:rules="videoRules"
:disabled="operation.pending"
validate-on-change
class="mt-4 grid gap-3"
>
<NFormItem path="videoLimit" label="本批镜头上限"
><NInputNumber
v-model:value="videoLimit"
:min="1"
:precision="0"
:disabled="operation.pending"
:input-props="{ 'aria-label': '视频质量任务镜头上限' }"
/></label>
<label
><span class="field-label">每镜最多自动修复</span
/></NFormItem>
<NFormItem path="videoRepairAttempts" label="每镜最多自动修复"
><NInputNumber
v-model:value="videoRepairAttempts"
:min="1"
:precision="0"
:disabled="operation.pending"
:input-props="{ 'aria-label': '视频自动修复次数' }"
/></label>
<p v-if="!videoQualityValid" class="text-xs text-danger">
本批上限和自动修复次数都必须是正整数
</p>
</div>
/></NFormItem>
</AppForm>
</article>
</div>
<div v-if="query.data.value" class="panel mt-4 p-5">
@@ -466,7 +496,7 @@ watch(
blocked || !batchValid || !query.data.value.videoStatus.failed
"
acknowledgement
description="为当前最近一次状态为失败的镜头重新创建视频模型任务。成功镜头和进行中的镜头不会处理。"
description="仅重新提交后端判定可重试的失败视频;不可重试项会跳过,并在回执中显示数量。"
@confirm="run('retry-videos')"
/>
</div>
@@ -591,6 +621,18 @@ watch(
</div>
<p v-if="shot.description" class="mt-3 text-sm leading-7">{{ shot.description }}</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
<NAlert
v-if="videoStatusItem?.status === 'failed'"
type="error"
:show-icon="false"
class="mt-3"
>
{{ videoStatusItem.error || '最近一次视频任务失败' }}
<p v-if="videoStatusItem.retryable === false" class="mt-2">
此失败不支持普通重试,请先处理失败原因。
</p>
<p v-else-if="videoStatusItem.retryable === true" class="mt-2">后端允许重试此任务。</p>
</NAlert>
<StaleKeyframeNotice
v-if="selectedKeyframeStale"
:key="shot.shotId"
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { sizeRules } from '../../../lib/form-rules'
import { NAlert, NButton, NCheckbox, NInputNumber } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
@@ -42,6 +45,8 @@ function submit() {
open.value = false
reset()
}
const formModel = computed(() => ({ width: width.value, height: height.value }))
const rules = sizeRules(() => formModel.value)
</script>
<template>
@@ -50,43 +55,51 @@ function submit() {
:title="`${replacePrimary ? '重新生成主首帧' : '生成首帧'} · ${shotTitle}`"
description="依据视觉风格、GenerationSpec 和主体主参考图编译首帧提示词,并调用 图片模型。"
>
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
当前主首帧使用的主体参考资产已经变化新图成功后应设为主首帧旧图会继续保留为历史候选
</NAlert>
<div class="mt-5 grid grid-cols-2 gap-4">
<label
><span class="field-label">宽度可选</span
><NInputNumber
:value="typeof width === 'number' ? width : null"
@update:value="width = $event ?? ''"
:min="1"
></NInputNumber
></label>
<label
><span class="field-label">高度可选</span
><NInputNumber
:value="typeof height === 'number' ? height : null"
@update:value="height = $event ?? ''"
:min="1"
></NInputNumber
></label>
</div>
<p v-if="!sizeValid" class="mt-2 text-xs text-danger">宽高需要同时留空或同时填写正整数</p>
<NCheckbox v-model:checked="setPrimary" class="mt-5 flex items-start gap-3 text-sm leading-6"
>生成成功后设为当前主首帧
</NCheckbox>
<NAlert v-if="hasPrimary && setPrimary" type="info" :show-icon="false" class="mt-3 text-xs">
当前已有主首帧新图成功后将成为视频生成使用的首帧旧图仍保留为候选
</NAlert>
<NCheckbox
id="confirm-keyframe-cost"
v-model:checked="confirmed"
class="mt-5 flex items-start gap-3 text-sm leading-6"
>我已确认调用图片模型可能产生费用且当前没有相同镜头的生图任务
</NCheckbox>
<div class="dialog-footer mt-6">
<NButton @click="open = false">取消</NButton>
<NButton :disabled="!canSubmit" @click="submit" type="primary">确认生成首帧</NButton>
</div>
<AppForm
:model="formModel"
:rules="rules"
:disabled="disabled"
:reset-key="`${open}:${shotId}`"
validate-on-change
@submit="submit"
>
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
当前主首帧使用的主体参考资产已经变化新图成功后应设为主首帧旧图会继续保留为历史候选
</NAlert>
<div class="mt-5 grid grid-cols-2 gap-4">
<NFormItem path="width" label="宽度(可选)"
><NInputNumber
:input-props="{ 'aria-label': '宽度可选' }"
:value="typeof width === 'number' ? width : null"
@update:value="width = $event ?? ''"
:min="1"
></NInputNumber
></NFormItem>
<NFormItem path="height" label="高度(可选)"
><NInputNumber
:input-props="{ 'aria-label': '高度可选' }"
:value="typeof height === 'number' ? height : null"
@update:value="height = $event ?? ''"
:min="1"
></NInputNumber
></NFormItem>
</div>
<NCheckbox v-model:checked="setPrimary" class="mt-5 flex items-start gap-3 text-sm leading-6"
>生成成功后设为当前主首帧
</NCheckbox>
<NAlert v-if="hasPrimary && setPrimary" type="info" :show-icon="false" class="mt-3 text-xs">
当前已有主首帧新图成功后将成为视频生成使用的首帧旧图仍保留为候选
</NAlert>
<NCheckbox
id="confirm-keyframe-cost"
v-model:checked="confirmed"
class="mt-5 flex items-start gap-3 text-sm leading-6"
>我已确认调用图片模型可能产生费用且当前没有相同镜头的生图任务
</NCheckbox>
<div class="dialog-footer mt-6">
<NButton @click="open = false">取消</NButton>
<NButton :disabled="disabled || !confirmed" attr-type="submit" type="primary">确认生成首帧</NButton>
</div>
</AppForm>
</AppDialog>
</template>
@@ -40,7 +40,8 @@ function exportReceipt() {
{{ receipt.result.failed }}
</p>
<p v-else class="mt-3 text-xs text-muted">
检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 已重新提交 {{ receipt.result.retried }} · 提交失败
检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 可重试 {{ receipt.result.retryable }} · 跳过
{{ receipt.result.skipped }} · 已重新提交 {{ receipt.result.retried }} · 提交失败
{{ receipt.result.failed }}
</p>
<NAlert
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { integerRule, sizeRules } from '../../../lib/form-rules'
import { computed, ref, watch } from 'vue'
import {
NAlert,
@@ -12,7 +15,7 @@ import {
NRadioGroup
} from 'naive-ui'
import { AppDialog } from '../../../components/ui'
import { allowedTextLines, savedVideoValidation, validQualityInput } from '../quality'
import { allowedTextLines, validQualityInput } from '../quality'
import type { QualityAction, QualityInput, QualityTarget } from '../quality.types'
import { useQuality } from '../useQuality'
import QualityResult from './QualityResult.vue'
@@ -23,7 +26,6 @@ const props = defineProps<{
projectId: string
target: QualityTarget | null
disabled: boolean
savedRawJson?: string | null
}>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ changed: []; locate: [shotId: string] }>()
@@ -75,7 +77,7 @@ const maxImages = computed(() =>
const maxValidations = computed(() =>
action.value === 'batch' ? maxImages.value : action.value === 'repair' ? input.value.maxRepairAttempts + 1 : 1
)
const saved = computed(() => (props.target?.kind === 'video' ? savedVideoValidation(props.savedRawJson) : null))
const saved = computed(() => (props.target?.kind === 'video' ? (session.value.videoValidation ?? null) : null))
const repairAvailable = computed(
() =>
!videoRepair.value ||
@@ -109,6 +111,21 @@ async function submit() {
acknowledged.value = false
await run(action.value, input.value)
}
const formModel = computed(() => ({
attempts: attempts.value,
concurrency: concurrency.value,
limit: limit.value,
width: width.value,
height: height.value,
texts: texts.value
}))
const rules = computed(() => ({
attempts: integerRule('修复次数', videoRepair.value ? 1 : 0),
concurrency: integerRule('批量并发'),
limit: integerRule('本批镜头上限'),
...sizeRules(() => formModel.value)
}))
const optionsExpanded = ref<string[]>([])
</script>
<template>
@@ -118,126 +135,141 @@ async function submit() {
description="查看质量结果,或按需发起新的视觉检查。"
wide
>
<NRadioGroup
v-if="target && target.kind !== 'batch'"
v-model:value="action"
<AppForm
:model="formModel"
:rules="rules"
:disabled="operation.pending"
class="mt-4"
aria-label="素材质量操作"
:reset-key="`${open}:${key}:${action}`"
validate-on-change
@invalid="optionsExpanded = ['options']"
@submit="submit"
>
<NRadioButton value="validate">视觉校验</NRadioButton
><NRadioButton value="repair">{{ target?.kind === 'video' ? '生成修复候选' : '校验并修复' }}</NRadioButton>
</NRadioGroup>
<NAlert v-if="target?.kind === 'video'" type="info" :show-icon="false" class="mt-4"
>视频校验只检查三个抽样帧不含音频口型和完整运动普通候选只校验修复候选复检通过后会自动设为主视频</NAlert
>
<div v-if="generating" class="quality-fields mt-4">
<label
><span class="field-label">{{ videoRepair ? '修复链次数上限' : '每镜最多修复' }}</span
><NInputNumber
v-model:value="attempts"
:disabled="operation.pending"
:min="videoRepair ? 1 : 0"
:step="1"
:input-props="{ 'aria-label': '每镜最多修复次数' }"
/></label>
<label v-if="target?.kind === 'batch'"
><span class="field-label">本批镜头上限</span
><NInputNumber
v-model:value="limit"
:disabled="operation.pending"
:min="1"
:step="1"
:input-props="{ 'aria-label': '质量生成镜头上限' }"
/></label>
</div>
<NCheckbox v-if="target?.kind === 'batch'" v-model:checked="force" :disabled="operation.pending" class="mt-4"
>重做本集已有有效首帧通过后替换主图</NCheckbox
>
<p v-if="target?.kind === 'batch'" class="mt-3 text-xs text-muted">
仅第 {{ target.episodeNo }} 最多
{{ limit ?? '' }} 非重做模式优先处理过期项若全项目无过期项则补缺失首帧
</p>
<p v-if="videoRepair" class="mt-3 text-xs text-muted">
本次仅创建一个修复视频不自动等待或复检完成后请打开新候选的质量检查修复约束沿用上次失败校验不使用额外参数
</p>
<NAlert v-if="!repairAvailable" type="warning" :show-icon="false" class="mt-3"
>请先执行视觉校验只有未通过的视频可以创建修复候选</NAlert
>
<NCollapse v-if="!videoRepair" class="mt-4">
<NCollapseItem name="options" title="更多参数与模型限制">
<div class="quality-fields">
<label v-if="target?.kind === 'batch'"
><span class="field-label">批量并发</span
><NInputNumber
v-model:value="concurrency"
:disabled="operation.pending"
:min="1"
:step="1"
:input-props="{ 'aria-label': '质量生成并发' }"
/></label>
<label v-if="generating"
><span class="field-label">宽度可选</span
><NInputNumber
v-model:value="width"
:disabled="operation.pending"
:min="1"
:input-props="{ 'aria-label': '质量生成宽度' }"
/></label>
<label v-if="generating"
><span class="field-label">高度可选</span
><NInputNumber
v-model:value="height"
:disabled="operation.pending"
:min="1"
:input-props="{ 'aria-label': '质量生成高度' }"
/></label>
</div>
<label class="mt-4 block"
><span class="field-label">额外允许的画面文字一行一项可留空</span
><NInput
v-model:value="texts"
<NRadioGroup
v-if="target && target.kind !== 'batch'"
v-model:value="action"
:disabled="operation.pending"
class="mt-4"
aria-label="素材质量操作"
>
<NRadioButton value="validate">视觉校验</NRadioButton
><NRadioButton value="repair">{{
target?.kind === 'video' ? '生成修复候选' : '校验并修复'
}}</NRadioButton>
</NRadioGroup>
<NAlert v-if="target?.kind === 'video'" type="info" :show-icon="false" class="mt-4"
>视频校验会按镜头启用抽样帧运动及道具专项检查不含音频和口型普通候选只校验修复候选复检通过后会自动设为主视频</NAlert
>
<div v-if="generating" class="quality-fields mt-4">
<NFormItem path="attempts" :label="videoRepair ? '修复链次数上限' : '每镜最多修复'"
><NInputNumber
v-model:value="attempts"
:disabled="operation.pending"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
:input-props="{ 'aria-label': '额外允许的画面文字' }"
/></label>
<ModelCapabilities :shot-id="target && target.kind !== 'batch' ? target.shotId : undefined" />
</NCollapseItem>
</NCollapse>
<NAlert v-if="!valid" type="error" :show-icon="false" class="mt-4">{{
videoRepair
? '修复链次数上限需为正整数'
: '并发数量和集数需为正整数修复次数需为非负整数宽高同时留空或同时填写正整数'
}}</NAlert>
<NAlert type="warning" :show-icon="false" class="mt-4">
<template v-if="videoRepair"
>本次最多提交 1
个视频生成任务可能产生费用生成完成不会切换主视频需再次付费复检通过后才会晋升</template
:min="videoRepair ? 1 : 0"
:step="1"
:input-props="{ 'aria-label': '每镜最多修复次数' }"
/></NFormItem>
<NFormItem v-if="target?.kind === 'batch'" path="limit" label="本批镜头上限"
><NInputNumber
v-model:value="limit"
:disabled="operation.pending"
:min="1"
:step="1"
:input-props="{ 'aria-label': '质量生成镜头上限' }"
/></NFormItem>
</div>
<NCheckbox
v-if="target?.kind === 'batch'"
v-model:checked="force"
:disabled="operation.pending"
class="mt-4"
>重做本集已有有效首帧通过后替换主图</NCheckbox
>
<template v-else-if="valid"
>本次最多调用 {{ maxImages }} 次生图{{ maxValidations }} 次视觉校验均可能产生费用</template
<p v-if="target?.kind === 'batch'" class="mt-3 text-xs text-muted">
仅第 {{ target.episodeNo }} 最多
{{ limit ?? '' }} 非重做模式优先处理过期项若全项目无过期项则补缺失首帧
</p>
<p v-if="videoRepair" class="mt-3 text-xs text-muted">
本次仅创建一个修复视频不自动等待或复检完成后请打开新候选的质量检查修复约束沿用上次失败校验不使用额外参数
</p>
<NAlert v-if="!repairAvailable" type="warning" :show-icon="false" class="mt-3"
>请先执行视觉校验只有未通过的视频可以创建修复候选</NAlert
>
<template v-if="generating && !videoRepair"
>修复质量生成通过后可能替换主首帧失败候选仍会保留已有视频不会自动更新</template
<NCollapse
v-if="!videoRepair"
v-model:expanded-names="optionsExpanded"
display-directive="show"
class="mt-4"
>
<template v-else-if="!generating">{{
target?.kind === 'video' ? '如果当前是修复候选校验通过将替换当前主视频' : '仅校验不修改主资产'
}}</template>
</NAlert>
<NCheckbox v-model:checked="acknowledged" :disabled="operation.pending" class="mt-4"
>我已确认费用处理范围且同项目没有其他生成任务</NCheckbox
>
<div class="dialog-footer mt-4">
<NButton @click="open = false">关闭</NButton>
<NButton
type="primary"
:loading="operation.pending"
:disabled="blocked || !valid || !acknowledged || !repairAvailable"
@click="submit"
>{{ label }}</NButton
<NCollapseItem name="options" title="更多参数与模型限制">
<div class="quality-fields">
<NFormItem v-if="target?.kind === 'batch'" path="concurrency" label="批量并发"
><NInputNumber
v-model:value="concurrency"
:disabled="operation.pending"
:min="1"
:step="1"
:input-props="{ 'aria-label': '质量生成并发' }"
/></NFormItem>
<NFormItem v-if="generating" path="width" label="宽度(可选)"
><NInputNumber
v-model:value="width"
:disabled="operation.pending"
:min="1"
:input-props="{ 'aria-label': '质量生成宽度' }"
/></NFormItem>
<NFormItem v-if="generating" path="height" label="高度(可选)"
><NInputNumber
v-model:value="height"
:disabled="operation.pending"
:min="1"
:input-props="{ 'aria-label': '质量生成高度' }"
/></NFormItem>
</div>
<NFormItem class="mt-4 block" path="texts" label="额外允许的画面文字(一行一项,可留空)"
><NInput
v-model:value="texts"
:disabled="operation.pending"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
:input-props="{ 'aria-label': '额外允许的画面文字' }"
/></NFormItem>
<ModelCapabilities :shot-id="target && target.kind !== 'batch' ? target.shotId : undefined" />
</NCollapseItem>
</NCollapse>
<NAlert type="warning" :show-icon="false" class="mt-4">
<template v-if="videoRepair"
>本次最多提交 1
个视频生成任务可能产生费用生成完成不会切换主视频需再次付费复检通过后才会晋升</template
>
<template v-else-if="target?.kind === 'video'"
>本次提交一次视频校验请求后端可能调用多个视觉模型并产生费用</template
>
<template v-else-if="valid"
>本次最多调用 {{ maxImages }} 次生图{{ maxValidations }} 次视觉校验均可能产生费用</template
>
<template v-if="generating && !videoRepair"
>修复质量生成通过后可能替换主首帧失败候选仍会保留已有视频不会自动更新</template
>
<template v-else-if="!generating">{{
target?.kind === 'video'
? '如果当前是修复候选校验通过将替换当前主视频'
: '仅校验不修改主资产'
}}</template>
</NAlert>
<NCheckbox v-model:checked="acknowledged" :disabled="operation.pending" class="mt-4"
>我已确认费用处理范围且同项目没有其他生成任务</NCheckbox
>
</div>
<div class="dialog-footer mt-4">
<NButton @click="open = false">关闭</NButton>
<NButton
type="primary"
:loading="operation.pending"
:disabled="blocked || !acknowledged || !repairAvailable"
attr-type="submit"
>{{ label }}</NButton
>
</div>
</AppForm>
<p v-if="operation.pending" role="status" class="mt-3 text-xs text-muted">
{{ operation.label }}进行中请勿重复提交
</p>
@@ -246,7 +278,7 @@ async function submit() {
}}</NAlert>
<QualityResult v-if="session.receipt" :receipt="session.receipt" @locate="emit('locate', $event)" />
<NCollapse v-else-if="saved" class="mt-4"
><NCollapseItem name="saved" :title="`最近已保存校验 · ${saved.passed ? '通过' : '未通过'}`"
><NCollapseItem name="saved" :title="`本会话最近校验 · ${saved.passed ? '通过' : '未通过'}`"
><p>{{ saved.summary }}</p>
<p v-for="(issue, index) in saved.issues" :key="index" class="mt-2 text-danger">
{{ issue.description }}
@@ -254,8 +286,8 @@ async function submit() {
<p class="mt-3 text-xs text-muted">此为历史抽样结论不保证当前上游素材仍然一致</p></NCollapseItem
></NCollapse
>
<p v-if="session.receipt && target?.kind !== 'video'" class="mt-3 text-xs text-muted">
回执保留在当前会话可导出后端暂未提供首帧校验历史读取接口
<p v-if="target?.kind !== 'batch'" class="mt-3 text-xs text-muted">
校验回执保留在当前会话可导出素材列表不提供历史质量结果刷新浏览器后需重新校验
</p>
</AppDialog>
</template>
@@ -22,6 +22,16 @@ const issues = computed(() => {
? (value.result.attempts.at(-1)?.issues ?? [])
: value.result.issues
})
const videoChecks = computed(() => {
if (props.receipt.kind !== 'video') return []
const result = props.receipt.result
return [
{ label: '运动真实性', result: result.motionRealism },
{ label: '空间交互', result: result.spatialInteraction },
{ label: '物理结构', result: result.physicalStructure },
{ label: '物理连接', result: result.physicalJoint }
].filter(item => item.result)
})
/** 导出只使用后端实际回执,便于关闭页面前留存。 */
function exportResult() {
downloadText(
@@ -119,6 +129,14 @@ function exportResult() {
: '未发现'
}}
</p>
<div v-for="check in videoChecks" :key="check.label" class="mt-2 text-xs">
{{ check.label }}<template v-if="check.result?.enabled"
>{{
check.result.applicable === false ? '不适用' : check.result.passed ? '通过' : '未通过'
}}
· {{ check.result.summary }}</template
><template v-else>未执行</template>
</div>
<div
v-for="subject in receipt.result.subjects"
:key="subject.subjectRef"
@@ -27,7 +27,7 @@ import ActionMenu from '../../../components/ui/ActionMenu.vue'
import { AppDialog } from '../../../components/ui'
import ModelCapabilities from './ModelCapabilities.vue'
import type { QualityTarget } from '../quality.types'
import { savedVideoValidation, videoQualityInfo, videoRepairInfo } from '../quality'
import { sessionVideoValidation } from '../quality'
/** 单镜头生产面板只负责正式资产,不修改上游导演设计与即时状态。 */
const props = defineProps<{
@@ -64,10 +64,6 @@ const assetBlocked = computed(() => props.disabled || operation.value.pending ||
const keyframeOpen = ref(false)
const qualityOpen = ref(false)
const qualityTarget = ref<QualityTarget | null>(null)
const qualityRawJson = computed(() => {
const target = qualityTarget.value
return target?.kind === 'video' ? (videos.value.find(item => item.id === target.assetId)?.rawJson ?? null) : null
})
const specsOpen = ref(false)
/** 从实际候选打开质量面板,保留单镜头主操作,不默认展开全部校验参数。 */
@@ -132,7 +128,7 @@ async function generatePrompt() {
const overwrite = props.promptReadiness?.status === 'skipped'
await runOperation(props.projectId, `生成镜头 ${props.shot.shotNo} 视频提示词`, async () => {
const result = await storyboardApi.generatePrompt(shotId, overwrite)
if (!result || result.id !== shotId || !result.videoPrompt) throw new Error('接口未返回有效视频提示词。')
if (!result || result.shotId !== shotId || !result.videoPrompt) throw new Error('接口未返回有效视频提示词。')
})
await refreshAll()
}
@@ -151,7 +147,12 @@ async function generateKeyframe(input: GenerateKeyframeInput) {
/** 切换主首帧不调用模型,但会改变后续视频生成输入。 */
async function setPrimaryKeyframe() {
const keyframeId = confirmingKeyframe.value
if (assetBlocked.value || !keyframeId) return
if (
assetBlocked.value ||
!keyframeId ||
keyframes.value.find(item => item.id === keyframeId)?.source === 'provider_variant'
)
return
const shotId = props.shot.shotId
confirmingKeyframe.value = ''
await runOperation(props.projectId, `设置镜头 ${props.shot.shotNo} 主首帧`, async () => {
@@ -195,8 +196,9 @@ async function setPrimaryVideo() {
const video = videos.value.find(item => item.id === videoId)
if (
!video ||
((videoRepairInfo(video.rawJson) || videoQualityInfo(video.rawJson)) &&
savedVideoValidation(video.rawJson)?.passed !== true)
video.status !== 'completed' ||
!video.videoUrl ||
sessionVideoValidation(props.projectId, video.shotId, video.id)?.passed !== true
)
return
const shotId = props.shot.shotId
@@ -322,6 +324,9 @@ async function inspectSpecs() {
<div class="flex flex-wrap items-center gap-2">
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
<NTag v-if="item.isPrimary" size="small" :bordered="false">主首帧</NTag>
<NTag v-if="item.source === 'provider_variant'" size="small" :bordered="false"
>模型输入变体</NTag
>
<NTag v-if="item.isPrimary && keyframeStale" size="small" :bordered="false">已过期</NTag>
<span class="text-[10px] text-muted"
>{{ item.width || '—' }} × {{ item.height || '—' }}</span
@@ -338,7 +343,12 @@ async function inspectSpecs() {
>质量检查</NButton
>
<NButton
v-if="item.status === 'completed' && item.imageUrl && !item.isPrimary"
v-if="
item.status === 'completed' &&
item.imageUrl &&
item.source !== 'provider_variant' &&
!item.isPrimary
"
:disabled="assetBlocked"
@click="confirmingKeyframe = item.id"
text
@@ -401,22 +411,7 @@ async function inspectSpecs() {
<div class="flex flex-wrap items-center gap-2">
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
<NTag v-if="item.isPrimary" size="small" :bordered="false">主视频</NTag>
<NTag v-if="videoRepairInfo(item.rawJson) && !item.isPrimary" size="small" :bordered="false"
>修复候选 · 待复检</NTag
>
<NTag
v-else-if="videoQualityInfo(item.rawJson) && !item.isPrimary"
size="small"
:bordered="false"
>质量候选 ·
{{
savedVideoValidation(item.rawJson)
? savedVideoValidation(item.rawJson)?.passed
? '已通过'
: '未通过'
: '自动校验中'
}}</NTag
>
<NTag v-if="!item.isPrimary" size="small" :bordered="false">候选视频</NTag>
<NTag v-if="item.isPrimary && videoStale" size="small" :bordered="false">已过期</NTag>
<span class="text-[10px] text-muted"
>{{ item.durationSeconds || shot.durationSeconds || '—' }}s</span
@@ -434,8 +429,13 @@ async function inspectSpecs() {
text
size="small"
@click="openQuality('video', item.id)"
>质量检查<template v-if="savedVideoValidation(item.rawJson)">
· {{ savedVideoValidation(item.rawJson)?.passed ? '曾通过' : '未通过' }}</template
>质量检查<template v-if="sessionVideoValidation(projectId, item.shotId, item.id)">
·
{{
sessionVideoValidation(projectId, item.shotId, item.id)?.passed
? '曾通过'
: '未通过'
}}</template
></NButton
>
<a
@@ -451,8 +451,7 @@ async function inspectSpecs() {
item.status === 'completed' &&
item.videoUrl &&
!item.isPrimary &&
(!(videoRepairInfo(item.rawJson) || videoQualityInfo(item.rawJson)) ||
savedVideoValidation(item.rawJson)?.passed === true)
sessionVideoValidation(projectId, item.shotId, item.id)?.passed === true
"
:disabled="assetBlocked"
@click="confirmingVideo = item.id"
@@ -473,6 +472,9 @@ async function inspectSpecs() {
</article>
</div>
<p v-else class="mt-4 text-xs text-muted">尚无视频任务记录。</p>
<p v-if="videos.some(item => !item.isPrimary)" class="mt-3 text-xs text-muted">
候选视频的历史质量结果未公开;手动设为主视频前需在本会话完成视觉校验。自动质量任务的晋升结果以刷新后的主视频标记为准。
</p>
<div class="mt-4 flex flex-wrap gap-3">
<ConfirmAction
:label="
@@ -525,7 +527,15 @@ async function inspectSpecs() {
Negative prompt: {{ videoSpec.negativePrompt }}
</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">
Keyframe ID · {{ videoSpec.keyframe.id }}
业务主首帧 ID · {{ videoSpec.keyframe.id }}
</p>
<p v-if="videoSpec.providerInputKeyframe" class="mt-2 break-all text-[10px] text-muted">
模型输入首帧 ID · {{ videoSpec.providerInputKeyframe.id }}
{{
videoSpec.providerInputKeyframe.source === 'provider_variant'
? '(专用变体)'
: '(业务主首帧)'
}}
</p>
</div>
</div></NCollapseItem
@@ -537,7 +547,6 @@ async function inspectSpecs() {
:project-id="projectId"
:target="qualityTarget"
:disabled="assetBlocked"
:saved-raw-json="qualityRawJson"
@changed="refreshAll"
/>
<KeyframeDialog
+5 -1
View File
@@ -24,7 +24,9 @@ export function validOptionalSize(width: number | '', height: number | ''): bool
/** 返回当前成功主首帧;异常的重复主图只取最新列表中的第一条。 */
export function primaryKeyframe(items: ShotKeyframe[]): ShotKeyframe | undefined {
return items.find(item => item.isPrimary && item.status === 'completed' && item.imageUrl)
return items.find(
item => item.isPrimary && item.source !== 'provider_variant' && item.status === 'completed' && item.imageUrl
)
}
/** 返回当前成功主视频。 */
@@ -47,6 +49,8 @@ export function issueLabel(code: ProductionIssueCode): string {
missing_identity_anchor: '缺少演员母版',
identity_unlocked: '演员身份未锁定',
missing_prompt: '缺少视频提示词',
stale_prompt: '视频提示词需要更新',
provider_input_recovery: '视频模型输入需要处理',
missing_keyframe: '缺少主首帧',
stale_keyframe: '主首帧已过期'
}
+7 -48
View File
@@ -1,10 +1,12 @@
import { reactive } from 'vue'
import { validOptionalSize } from './model'
import type { KeyframeReadiness } from './types'
import type { QualityInput, QualityReceipt, QualityTarget, VisualValidation } from './quality.types'
import type { QualityInput, QualityReceipt, QualityTarget, VisualValidation, VideoValidation } from './quality.types'
/** 未返回的操作不显示上次成功结论;网络失败不会自动重发。 */
const sessions = reactive<Record<string, { receipt: QualityReceipt | null; error: string }>>({})
const sessions = reactive<
Record<string, { receipt: QualityReceipt | null; error: string; videoValidation?: VideoValidation }>
>({})
/** 正式 ID 与目标类型共同隔离会话结果,批量回执按剧集隔离。 */
export function qualityKey(projectId: string, target: QualityTarget | null) {
@@ -83,50 +85,7 @@ export function isVisualValidation(value: unknown): value is VisualValidation {
)
}
/** 读取视频已保存的抽样校验,不为查看历史再次调用视觉模型。 */
export function savedVideoValidation(rawJson?: string | null): VisualValidation | null {
try {
const value: unknown = JSON.parse(rawJson || '{}').videoValidation
return isVisualValidation(value) ? value : null
} catch {
return null
}
}
/** 识别后端修复候选标记,避免未复检候选通过普通主视频按钮绕过校验。 */
export function videoRepairInfo(rawJson?: string | null): { attempt: number } | null {
try {
const repair: unknown = JSON.parse(rawJson || '{}').repair
if (!repair || typeof repair !== 'object' || Array.isArray(repair)) return null
const attempt = (repair as { attempt?: unknown }).attempt
return { attempt: typeof attempt === 'number' && Number.isSafeInteger(attempt) && attempt >= 0 ? attempt : 0 }
} catch {
return null
}
}
/** 识别严格质量候选及其自动修复配置,防止未通过候选被手动绕过校验设为主视频。 */
export function videoQualityInfo(
rawJson?: string | null
): { allowedTexts: string[]; maxRepairAttempts: number } | null {
try {
const snapshot: unknown = JSON.parse(rawJson || '{}')
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return null
const record = snapshot as Record<string, unknown>
if (record.qualityCandidate !== true) return null
const pipeline = record.qualityPipeline
if (!pipeline || typeof pipeline !== 'object' || Array.isArray(pipeline)) return null
const config = pipeline as Record<string, unknown>
const maxRepairAttempts = config.maxRepairAttempts
if (typeof maxRepairAttempts !== 'number' || !Number.isSafeInteger(maxRepairAttempts) || maxRepairAttempts <= 0)
return null
return {
allowedTexts: Array.isArray(config.allowedTexts)
? config.allowedTexts.filter((item): item is string => typeof item === 'string')
: [],
maxRepairAttempts
}
} catch {
return null
}
/** 公开视频 DTO 不含质量历史,只使用本会话真实收到的校验回执。 */
export function sessionVideoValidation(projectId: string, shotId: string, assetId: string): VideoValidation | null {
return qualitySession(qualityKey(projectId, { kind: 'video', shotId, assetId, title: '' })).videoValidation ?? null
}
+13 -1
View File
@@ -40,7 +40,7 @@ export interface KeyframeValidation extends VisualValidation {
isPrimary: boolean
}
/** 视频校验基于三点抽样帧;修复候选通过后后端会自动晋升为主视频。 */
/** 视频校验包括抽样帧及按需专项检查;修复候选通过后后端会自动晋升为主视频。 */
export interface VideoValidation extends VisualValidation {
shotId: string
videoId: string
@@ -49,6 +49,13 @@ export interface VideoValidation extends VisualValidation {
validatedAt: string
allowedTexts: string[]
sampleFrames: { label: string; timeSeconds: number }[]
motionSampleFrames?: { label: string; timeSeconds: number }[]
physicalDetailFrames?: { label: string; timeSeconds: number; imageLabel: string }[]
propLocator?: { enabled: boolean; [key: string]: unknown }
motionRealism?: VideoValidationCheck
spatialInteraction?: VideoValidationCheck
physicalStructure?: VideoValidationCheck
physicalJoint?: VideoValidationCheck
}
/** 视频修复只创建一个异步候选任务,不自动等待视频或再次执行视觉校验。 */
@@ -148,3 +155,8 @@ export type QualityReceipt =
| { kind: 'video-repair'; result: VideoRepair }
| { kind: 'repair'; result: KeyframeRepair }
| { kind: 'batch'; result: QualityBatchResult }
/** 视频专项检查保留完整回执;缺少前置材料时后端会明确停用,不视为通过。 */
export type VideoValidationCheck =
| { enabled: false; reason: string }
| { enabled: true; passed: boolean; summary: string; applicable?: boolean; [key: string]: unknown }
@@ -8,13 +8,11 @@ export function keyframeFixture(patch: Partial<ShotKeyframe> = {}): ShotKeyframe
source: 'generated',
provider: 'seedream',
model: 'seedream-test',
prompt: '首帧提示词',
imageUrl: '/storage/keyframes/keyframe-1.png',
width: 1920,
height: 1080,
status: 'completed',
isPrimary: true,
providerTaskId: null,
error: null,
createdAt: '2026-08-31T00:00:00.000Z',
updatedAt: '2026-08-31T00:00:00.000Z',
@@ -29,9 +27,6 @@ export function videoFixture(patch: Partial<ShotVideo> = {}): ShotVideo {
shotId: 'shot-1',
provider: 'seedance',
model: 'seedance-test',
providerTaskId: 'provider-task-1',
prompt: '视频提示词',
negativePrompt: null,
status: 'completed',
videoUrl: '/storage/videos/video-1.mp4',
durationSeconds: 5,
+29 -9
View File
@@ -10,6 +10,8 @@ export type ProductionIssueCode =
| 'missing_identity_anchor'
| 'identity_unlocked'
| 'missing_prompt'
| 'stale_prompt'
| 'provider_input_recovery'
| 'missing_keyframe'
| 'stale_keyframe'
@@ -25,6 +27,7 @@ export interface PromptReadinessItem {
shotId: string
shotNo: number
status: Exclude<ProductionReadinessStatus, 'in_progress'>
stalePrompt?: boolean
issues: ProductionIssue[]
}
@@ -68,6 +71,7 @@ export interface VideoReadinessItem {
activeVideoId?: string | null
/** 当前主视频已基于旧主首帧或旧主体参考图生成。 */
primaryVideoStale?: boolean
providerInputRecovery?: ProviderInputRecoveryStatus | null
}
/** 视频任务项目级就绪汇总。 */
@@ -80,6 +84,8 @@ export interface VideoReadiness {
missingPrompt: number
missingKeyframe: number
staleKeyframe: number
stalePrompt?: number
providerInputRecovery?: number
stalePrimaryVideo: number
missingReference: number
items: VideoReadinessItem[]
@@ -89,16 +95,14 @@ export interface VideoReadiness {
export interface ShotKeyframe {
id: string
shotId: string
source: string
source: string | null
provider: string | null
model: string | null
prompt: string | null
imageUrl: string | null
width: number | null
height: number | null
status: 'pending' | 'generating' | 'completed' | 'failed'
isPrimary: boolean
providerTaskId: string | null
error: string | null
createdAt: string
updatedAt: string
@@ -110,16 +114,11 @@ export interface ShotVideo {
shotId: string
provider: string
model: string
providerTaskId: string | null
prompt: string
negativePrompt: string | null
status: 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
videoUrl: string | null
durationSeconds: number | null
isPrimary: boolean
error: string | null
/** 后端保存的抽样视觉校验与生成快照,按需读取,不直接展示整段原始数据。 */
rawJson?: string | null
createdAt: string
updatedAt: string
}
@@ -170,7 +169,12 @@ export interface VideoGenerationSpec {
durationSeconds: number
videoPrompt: string
negativePrompt?: string
keyframe: { id: string; imageUrl: string; width?: number; height?: number }
keyframe: VideoInputKeyframe
providerInputKeyframe: VideoInputKeyframe & {
source: 'business_primary' | 'provider_variant'
targetProvider?: string | null
variantReason?: string | null
}
references: VideoReference[]
}
@@ -255,6 +259,9 @@ export interface ProjectVideoStatusItem {
shotNo: number
status: ShotVideo['status'] | 'not_started'
videoId: string | null
failureCategory?: 'content_safety' | 'invalid_request' | 'rate_limit' | 'transient' | 'unknown' | null
retryable?: boolean | null
providerInputRecovery?: ProviderInputRecoveryStatus | null
videoUrl: string | null
error: string | null
}
@@ -275,6 +282,8 @@ export interface ProjectVideoStatus {
/** 失败视频批量重试回执。 */
export interface RetryVideosResult {
totalFailed: number
retryable: number
skipped: number
retried: number
failed: number
failures: { shotId: string; error: string }[]
@@ -315,3 +324,14 @@ export interface PipelineBatchResult {
skipped: number
failed: number
}
/** 视频模型最终输入和业务主首帧共用的公开图片规格。 */
export interface VideoInputKeyframe {
id: string
imageUrl: string
width?: number | null
height?: number | null
}
/** 后端公开的模型输入恢复状态,不能当作普通重试状态。 */
export type ProviderInputRecoveryStatus = 'not_required' | 'required' | 'variant_ready' | 'exhausted'
@@ -2,9 +2,9 @@ import { computed, onScopeDispose, ref, watch } from 'vue'
import { projectsApi } from '../projects/api'
import { useProjectContext } from '../projects/context'
import { subjectImagesApi } from '../subject-images/api'
import { hasRunningImages, isPrimaryIdentityStale, primaryImage } from '../subject-images/model'
import { hasRunningImages, primaryImage } from '../subject-images/model'
import { getOperation, runOperation } from '../workflows/operations'
import { workflowCheckpoints } from '../workflows/selectors'
import { hasRunningWorkflow } from '../workflows/selectors'
import { errorMessage } from '../../lib/http'
import { productionApi } from './api'
import { getProductionSession } from './model'
@@ -33,16 +33,10 @@ export async function checkPipeline(projectId: string) {
)
issues.push('各阶段镜头列表不一致,可能正在重新拆解,请刷新后重试。')
if (project.status !== 'completed') issues.push('剧本尚未完成,请先完成剧本创作。')
if (
project.tasks.some(task => ['pending', 'queued', 'running', 'generating'].includes(task.status)) ||
[...new Set(checkpoints.map(item => item.workflowName))].some(
name => workflowCheckpoints(checkpoints, name).at(-1)?.state.workflowExecution?.status === 'running'
)
)
issues.push('项目仍有后台任务或工作流进行中。')
if (hasRunningWorkflow(checkpoints)) issues.push('项目仍有后台任务或工作流进行中。')
if (!forms.length) issues.push('尚无正式形态,请先完成拆解。')
if (forms.some(form => hasRunningImages(form.images))) issues.push('形态图片仍在生成中。')
if (forms.some(form => !primaryImage(form.images)?.imageUrl || isPrimaryIdentityStale(form)))
if (forms.some(form => !primaryImage(form.images)?.imageUrl))
issues.push('请先补齐并确认所有形态主图,更新过期母版引用,再生成首帧。')
if (
!keyframes.total ||
+12 -11
View File
@@ -1,5 +1,6 @@
import { computed, onScopeDispose } from 'vue'
import { projectsApi } from '../projects/api'
import { hasRunningWorkflow } from '../workflows/selectors'
import { getOperation, runOperation } from '../workflows/operations'
import { productionApi } from './api'
import { qualityApi } from './quality-api'
@@ -9,8 +10,7 @@ import {
qualitySession,
qualityTargets,
validQualityInput,
savedVideoValidation,
videoRepairInfo
sessionVideoValidation
} from './quality'
import type { QualityAction, QualityInput, QualityReceipt, QualityTarget, RepairAttempt } from './quality.types'
@@ -58,6 +58,7 @@ export function useQuality(
const receiptTarget = qualitySession(originalKey)
receiptTarget.receipt = null
receiptTarget.error = ''
if (target.kind === 'video' && action === 'validate') delete receiptTarget.videoValidation
const stillCurrent = () => !disposed && key.value === originalKey && visible()
const ok = await runOperation(
projectId,
@@ -69,14 +70,14 @@ export function useQuality(
: '首帧自动修复'
: '视觉质量校验',
async () => {
const [project, readiness] = await Promise.all([
const [project, readiness, checkpoints] = await Promise.all([
projectsApi.detail(projectId),
productionApi.keyframeReadiness(projectId, target.kind === 'batch' ? input.force : true)
productionApi.keyframeReadiness(projectId, target.kind === 'batch' ? input.force : true),
projectsApi.checkpoints(projectId)
])
if (project.id !== projectId || project.status !== 'completed')
throw new Error('剧本未完成或项目已变化,未提交质量任务。')
if (project.tasks.some(task => ['pending', 'queued', 'running', 'generating'].includes(task.status)))
throw new Error('项目还有后台任务,请结束后再执行质量检查。')
if (hasRunningWorkflow(checkpoints)) throw new Error('项目还有后台任务,请结束后再执行质量检查。')
if (
!Array.isArray(readiness.items) ||
readiness.items.length !== readiness.total ||
@@ -142,12 +143,10 @@ export function useQuality(
)
throw new Error('视频未完成、缺少视频地址或有效时长,不能执行抽样校验。')
if (action === 'repair') {
if (savedVideoValidation(video.rawJson)?.passed !== false)
if (sessionVideoValidation(projectId, target.shotId, target.assetId)?.passed !== false)
throw new Error('请先完成视频质量校验;只有未通过的视频才能创建修复候选。')
if (videos.some(item => ['pending', 'queued', 'running'].includes(item.status)))
throw new Error('此镜头仍有活动视频任务,请结束后再修复。')
if ((videoRepairInfo(video.rawJson)?.attempt ?? 0) + 1 > input.maxRepairAttempts)
throw new Error('当前视频修复链已达到次数上限,请核对后再调整。')
if (!stillCurrent()) throw new Error('已离开原操作目标,未提交质量任务。')
const result = await qualityApi.repairVideo(
target.shotId,
@@ -179,13 +178,15 @@ export function useQuality(
!isVisualValidation(result) ||
result.shotId !== target.shotId ||
result.videoId !== target.assetId ||
!Array.isArray(result.sampleFrames)
!Array.isArray(result.sampleFrames) ||
typeof result.isPrimary !== 'boolean'
)
throw new Error('视频校验结果与目标不匹配或不完整,请核对后端记录。')
receiptTarget.videoValidation = result
receipt = {
kind: 'video',
result,
promoted: !!videoRepairInfo(video.rawJson) && result.passed && result.isPrimary
promoted: !video.isPrimary && result.passed && result.isPrimary
}
}
} else {
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { requiredTextRule, integerRule } from '../../../lib/form-rules'
import { NAlert, NButton, NInput, NInputNumber } from 'naive-ui'
import { reactive, ref } from 'vue'
import { ArrowRight, LoaderCircle, Plus } from '@lucide/vue'
@@ -17,10 +20,6 @@ const form = reactive({ topic: '', style: '爽文反转', episodeCount: 3 })
async function submit() {
if (busy.value) return
error.value = ''
if (!form.topic.trim() || !Number.isSafeInteger(form.episodeCount) || form.episodeCount <= 0) {
error.value = '请填写故事主题,并输入大于 0 的整数集数。'
return
}
busy.value = true
try {
const result = await projectsApi.create({
@@ -37,6 +36,7 @@ async function submit() {
busy.value = false
}
}
const rules = { topic: requiredTextRule('故事主题'), episodeCount: integerRule('计划集数') }
</script>
<template>
@@ -49,40 +49,44 @@ async function submit() {
<template #trigger
><NButton @click="open = true" type="primary"><Plus :size="16" />新建剧本</NButton></template
>
<form class="mt-7 space-y-5" @submit.prevent="submit">
<div>
<label class="field-label" for="topic">故事主题 <span class="text-accent">*</span></label
<AppForm
:model="form"
:rules="rules"
:disabled="busy"
:reset-key="open"
class="mt-7 space-y-4"
@submit="submit"
>
<NFormItem path="topic" label="故事主题" :label-props="{ for: 'topic' }"
><NInput
placeholder="描述主角、故事背景,以及你希望展开的核心冲突……"
:disabled="busy"
:input-props="{ id: 'topic', required: true }"
:input-props="{ id: 'topic' }"
class="min-h-32 resize-y"
v-model:value="form.topic"
type="textarea"
:autosize="{ minRows: 3, maxRows: 12 }"
></NInput>
</div>
></NInput
></NFormItem>
<div class="grid grid-cols-[1fr_110px] gap-4">
<div>
<label class="field-label" for="style">剧本风格</label
<NFormItem path="style" label="剧本风格" :label-props="{ for: 'style' }"
><NInput
placeholder="如:都市悬疑、爽文反转"
:disabled="busy"
:input-props="{ id: 'style' }"
v-model:value="form.style"
></NInput>
</div>
<div>
<label class="field-label" for="episode-count">计划集数</label
></NInput
></NFormItem>
<NFormItem path="episodeCount" label="计划集数" :label-props="{ for: 'episode-count' }"
><NInputNumber
:disabled="busy"
:input-props="{ id: 'episode-count', required: true }"
:input-props="{ id: 'episode-count' }"
:value="typeof form.episodeCount === 'number' ? form.episodeCount : null"
@update:value="form.episodeCount = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</div>
></NInputNumber
></NFormItem>
</div>
<p class="text-xs leading-5 text-muted">
建议先用 3 集验证生成效果生成会实际调用后端模型产生相应费用
@@ -96,6 +100,6 @@ async function submit() {
:size="16"
/></NButton>
</div>
</form>
</AppForm>
</AppDialog>
</template>
-10
View File
@@ -52,21 +52,11 @@ export interface World {
rules?: unknown
}
/** 后端审核记录,列表按最新在前返回。 */
export interface Review {
id: string
passed: boolean
message?: string | null
createdAt: string
}
/** 项目详情接口包含的关联数据。 */
export interface ProjectDetail extends Project {
episodes: Episode[]
characters: Character[]
world: World | null
reviews: Review[]
tasks: { id: string; type: string; status: string; error?: string | null }[]
}
/** Create Drama checkpoint 中页面使用的状态切片。 */
+40 -37
View File
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { integerRule } from '../../lib/form-rules'
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import ActionMenu from '../../components/ui/ActionMenu.vue'
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
@@ -46,8 +49,6 @@ const {
maxRepairAttempts,
force,
allowed,
batchValid,
repairsValid,
blocked,
directionComplete,
hasCurrentEpisodeShots,
@@ -96,6 +97,8 @@ watch(
}
}
)
const formModel = computed(() => ({ concurrency: concurrency.value, maxRepairAttempts: maxRepairAttempts.value }))
const rules = { concurrency: integerRule('批量并发'), maxRepairAttempts: integerRule('状态自动修复次数', 0) }
</script>
<template>
@@ -156,42 +159,42 @@ watch(
>
</div>
<section class="mt-3" aria-label="分镜生成配置">
<div class="storyboard-controls">
<label
><span class="field-label">批量并发</span
><NInputNumber
<AppForm :model="formModel" :rules="rules" :disabled="operation.pending" validate-on-change>
<div class="form-controls storyboard-controls">
<NFormItem
path="concurrency"
label="批量并发"
:label-props="{ for: 'storyboard-concurrency' }"
><NInputNumber
:disabled="operation.pending"
:input-props="{ id: 'storyboard-concurrency' }"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber
></NFormItem>
<NFormItem
path="maxRepairAttempts"
label="状态自动修复次数"
:label-props="{ for: 'storyboard-repairs' }"
><NInputNumber
:disabled="operation.pending"
:input-props="{ id: 'storyboard-repairs' }"
:value="typeof maxRepairAttempts === 'number' ? maxRepairAttempts : null"
@update:value="maxRepairAttempts = $event ?? 0"
:min="0"
:step="1"
></NInputNumber
></NFormItem>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
:input-props="{ id: 'storyboard-concurrency' }"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber
><span v-if="!batchValid" class="mt-1 block text-xs text-danger"
>请输入正整数</span
></label
>
<label
><span class="field-label">状态自动修复次数</span
><NInputNumber
:disabled="operation.pending"
:input-props="{ id: 'storyboard-repairs' }"
:value="typeof maxRepairAttempts === 'number' ? maxRepairAttempts : null"
@update:value="maxRepairAttempts = $event ?? 0"
:min="0"
:step="1"
></NInputNumber
><span v-if="!repairsValid" class="mt-1 block text-xs text-danger"
>请输入非负整数0 表示不修复</span
></label
>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="control-row-checkbox text-xs"
>批量覆盖已有结果</NCheckbox
>
</div>
class="control-row-checkbox text-xs"
>批量覆盖已有结果</NCheckbox
>
</div>
</AppForm>
</section>
<div class="storyboard-coverage grid grid-cols-2 gap-3">
<div v-for="item in coverage" :key="item.title" class="panel px-3 py-2">
@@ -77,7 +77,7 @@ async function generatePrompt() {
// 请求结果不确定时不要保留可能已被覆盖的旧正文。
delete session.prompts[shotId]
const result = await storyboardApi.generatePrompt(shotId, overwrite)
if (!result || result.id !== shotId || !result.videoPrompt)
if (!result || result.shotId !== shotId || !result.videoPrompt)
throw new Error('接口未返回此镜头的有效提示词,请检查后端记录后重试。')
session.prompts[shotId] = result
})
+2 -1
View File
@@ -145,7 +145,8 @@ export interface ShotReferences {
/** 视频提示词接口返回的 Shot 数据切片,不包含实际视频生成。 */
export interface ShotPromptResult {
id: string
shotId: string
updatedAt: string
videoPrompt: string | null
negativePrompt: string | null
status: string
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { integerRule } from '../../lib/form-rules'
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
import {
@@ -155,6 +158,8 @@ watch(
}
}
)
const formModel = computed(() => ({ concurrency: concurrency.value, candidateLimit: candidateLimit.value }))
const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRule('本批上限') }
</script>
<template>
@@ -251,64 +256,73 @@ watch(
<dd>{{ casting.ready }}</dd>
</div>
</dl>
<div class="mt-5 flex flex-wrap items-end gap-4 pt-4">
<label class="w-28">
<span class="field-label">并发数</span>
<NInputNumber
:disabled="operation.pending"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</label>
<label class="w-28">
<span class="field-label">本批上限</span>
<NInputNumber
:disabled="operation.pending"
:value="
typeof candidateLimit === 'number' ? candidateLimit : null
"
@update:value="candidateLimit = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</label>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="control-row-checkbox text-xs"
>覆盖未锁定身份文本
</NCheckbox>
<ConfirmAction
:label="force ? '重生成角色身份文本' : '补齐角色身份文本'"
:disabled="
blocked || !hasStyle || !batchValid || dirty || !casting.total
"
acknowledgement
description="只生成 Character Identity 文本,不生成图片。已锁定角色始终跳过;覆盖文本不会自动更新已有候选图。"
@confirm="generateProject(true)"
/>
<ConfirmAction
label="生成选角候选"
:disabled="
castingBlocked ||
!hasStyle ||
!batchValid ||
!candidateLimitValid ||
!casting.missingAnchor
"
acknowledgement
description="最多按本批上限为缺少 Anchor 的 Character 调用 图片模型,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
@confirm="generateCastingCandidates"
/>
</div>
<p
v-if="!batchValid || !candidateLimitValid"
class="mt-3 text-xs text-danger"
<AppForm
:model="formModel"
:rules="rules"
:disabled="operation.pending"
validate-on-change
>
并发数与本批上限都必须是正整数
</p>
<div class="form-controls mt-5 flex flex-wrap gap-4 pt-4">
<NFormItem class="w-28" path="concurrency" label="并发数">
<NInputNumber
:input-props="{ 'aria-label': '并发数' }"
:disabled="operation.pending"
:value="
typeof concurrency === 'number' ? concurrency : null
"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</NFormItem>
<NFormItem class="w-28" path="candidateLimit" label="本批上限">
<NInputNumber
:input-props="{ 'aria-label': '本批上限' }"
:disabled="operation.pending"
:value="
typeof candidateLimit === 'number'
? candidateLimit
: null
"
@update:value="candidateLimit = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</NFormItem>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="control-row-checkbox text-xs"
>覆盖未锁定身份文本
</NCheckbox>
<ConfirmAction
:label="force ? '重生成角色身份文本' : '补齐角色身份文本'"
:disabled="
blocked ||
!hasStyle ||
!batchValid ||
dirty ||
!casting.total
"
acknowledgement
description="只生成 Character Identity 文本,不生成图片。已锁定角色始终跳过;覆盖文本不会自动更新已有候选图。"
@confirm="generateProject(true)"
/>
<ConfirmAction
label="生成选角候选"
:disabled="
castingBlocked ||
!hasStyle ||
!batchValid ||
!candidateLimitValid ||
!casting.missingAnchor
"
acknowledgement
description="最多按本批上限为缺少 Anchor 的 Character 调用 图片模型,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
@confirm="generateCastingCandidates"
/>
</div>
</AppForm>
<div class="casting-list" role="group" aria-label="角色选角进度">
<NButton
v-for="item in casting.items"
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { sizeRules } from '../../../lib/form-rules'
import { NAlert, NButton, NCheckbox, NInput, NInputNumber } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
@@ -33,17 +36,31 @@ function submit() {
})
open.value = false
}
const formModel = computed(() => ({ width: width.value, height: height.value, prompt: prompt.value }))
const rules = { ...sizeRules(() => formModel.value) }
</script>
<template>
<AppDialog v-model:open="open" title="生成角色选角候选" :description="`${subjectName} · 候选不会自动成为身份母版`">
<form class="mt-5 space-y-4" @submit.prevent="submit">
<AppForm
:model="formModel"
:rules="rules"
:disabled="disabled"
:reset-key="`${open}:${subjectId}`"
validate-on-change
class="mt-5 space-y-4"
@submit="submit"
>
<NAlert type="info" :show-icon="false" class="text-xs">
后端会依据角色 Identity项目视觉风格和默认选角约束独立生成一张 primary
候选不会复用当前身份母版生成完成后仍需人工点击确认选角
</NAlert>
<label class="block">
<span class="field-label">覆盖本次完整提示词可选</span>
<NFormItem
class="block"
path="prompt"
label="覆盖本次完整提示词(可选)"
:label-props="{ for: 'casting-candidate-prompt' }"
>
<NInput
placeholder="建议留空,使用后端编译的稳定身份和选角约束;未明确人物背景时默认中国人物"
:input-props="{ id: 'casting-candidate-prompt' }"
@@ -52,31 +69,30 @@ function submit() {
type="textarea"
:autosize="{ minRows: 3, maxRows: 12 }"
></NInput>
</label>
</NFormItem>
<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
<NFormItem path="width" label="宽度(px"
><NInputNumber
:input-props="{ 'aria-label': '宽度px' }"
placeholder="后端默认"
:value="typeof width === 'number' ? width : null"
@update:value="width = $event ?? ''"
:min="1"
></NInputNumber
></label>
<label
><span class="field-label">高度px</span
></NFormItem>
<NFormItem path="height" label="高度(px"
><NInputNumber
:input-props="{ 'aria-label': '高度px' }"
placeholder="后端默认"
:value="typeof height === 'number' ? height : null"
@update:value="height = $event ?? ''"
:min="1"
></NInputNumber
></label>
></NFormItem>
</div>
<p v-if="!valid" class="text-xs text-danger">宽高需要同时留空或同时填写正整数</p>
<NCheckbox
id="casting-candidate-cost"
v-model:checked="acknowledged"
@@ -89,6 +105,6 @@ function submit() {
确认生成候选
</NButton>
</div>
</form>
</AppForm>
</AppDialog>
</template>
@@ -1,4 +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'
@@ -32,10 +34,13 @@ function save() {
</script>
<template>
<form class="mt-5" @submit.prevent="save">
<AppForm :model="form" :disabled="disabled" class="mt-5" @submit="save">
<fieldset :disabled="disabled" class="space-y-4">
<label class="block"
><span class="field-label">稳定身份描述</span
<NFormItem
class="block"
path="description"
label="稳定身份描述"
:label-props="{ for: 'identity-description' }"
><NInput
:disabled="disabled"
placeholder="只描述跨形态不变的面部、结构、材质等特征"
@@ -45,9 +50,12 @@ function save() {
type="textarea"
:autosize="{ minRows: 3, maxRows: 12 }"
></NInput>
</label>
<label class="block"
><span class="field-label">身份核心提示词稳定事实</span
</NFormItem>
<NFormItem
class="block"
path="generationPrompt"
label="身份核心提示词(稳定事实)"
:label-props="{ for: 'identity-prompt' }"
><NInput
:disabled="disabled"
placeholder="不固定某一形态的服装、伤势或临时状态"
@@ -57,7 +65,7 @@ function save() {
type="textarea"
:autosize="{ minRows: 3, maxRows: 12 }"
></NInput>
</label>
</NFormItem>
<DetailDisclosure title="身份填写与锁定规则"
><p class="text-xs leading-6 text-muted">
{{
@@ -87,5 +95,5 @@ function save() {
><NButton :disabled="disabled" type="primary" attr-type="submit">保存主体身份</NButton>
</div>
</fieldset>
</form>
</AppForm>
</template>
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { NScrollbar, NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
import { NScrollbar, NAlert, NButton, NTag } from 'naive-ui'
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 { canBeAnchor, currentAnchor, identityViewLabels } from '../model'
import type { IdentityImage } from '../types'
/** 身份图库区分权威母版、primary 候选和辅助视图,保留后端原始状态。 */
@@ -104,9 +104,7 @@ function chooseAnchor() {
? '母版候选'
: identityViewLabels[image.viewType]
}}</span>
<span class="image-history-meta"
><StatusBadge :status="image.status" /><span>{{ image.enabled ? '启用' : '停用' }}</span></span
>
<span class="image-history-meta"><StatusBadge :status="image.status" /></span>
</NButton>
</NScrollbar>
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
@@ -118,10 +116,7 @@ function chooseAnchor() {
? '母版候选'
: identityViewLabels[selected.viewType]
}}</NTag
><span class="text-xs text-muted"
>{{ selected.enabled ? '启用' : '停用' }} · {{ selected.width || '—' }} ×
{{ selected.height || '—' }}</span
>
><span class="text-xs text-muted">{{ selected.width || '—' }} × {{ selected.height || '—' }}</span>
</div>
<a
v-if="imageUrl && selected.status === 'completed'"
@@ -163,19 +158,6 @@ function chooseAnchor() {
><NButton @click="confirming = false" text size="small">取消</NButton>
</div></NAlert
>
<!-- 默认展示实际提示词用户手动收起后轮询不会强制重新展开 -->
<NCollapse v-if="selected.prompt" :default-expanded-names="['details']" class="mt-4 text-xs"
><NCollapseItem name="details"
><template #header>本次实际提示词</template>
<p class="mt-3 whitespace-pre-wrap leading-6">{{ selected.prompt }}</p></NCollapseItem
></NCollapse
>
<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>
</template>
<p v-else class="py-8 text-sm text-muted">尚无身份参考图先保存身份提示词再生成第一张母版</p>
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { sizeRules, fieldRule } from '../../../lib/form-rules'
import { NAlert, NButton, NCheckbox, NInput, NInputNumber, NSelect } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
@@ -56,26 +59,51 @@ function submit() {
})
open.value = false
}
const formModel = computed(() => ({
width: width.value,
height: height.value,
prompt: prompt.value,
viewType: viewType.value,
referenceImageId: referenceImageId.value,
availableReferenceIds: references.value.map(image => image.id)
}))
const rules = {
...sizeRules(() => formModel.value),
referenceImageId: fieldRule(
value => !value || references.value.some(image => image.id === value),
'请选择此主体当前可用的参考图'
)
}
</script>
<template>
<AppDialog v-model:open="open" title="生成身份参考图" :description="subjectName">
<form class="mt-5 space-y-4" @submit.prevent="submit">
<AppForm
:model="formModel"
:rules="rules"
:disabled="disabled"
:reset-key="`${open}:${subjectId}`"
validate-on-change
class="mt-5 space-y-4"
@submit="submit"
>
<NAlert type="info" :show-icon="false" class="text-xs">
调用 图片模型每次新增一张图片可能产生费用第一张成功的 primary 图自动成为母版已有母版时
primary 图仅作为停用候选保留
</NAlert>
<label class="block"
><span class="field-label">参考视角</span
<NFormItem class="block" path="viewType" label="参考视角" :label-props="{ for: 'identity-view' }"
><NSelect
id="identity-view"
v-model:value="viewType"
:options="allowedViews.map(([value, label]) => ({ label: label + '' + value + '', value }))"
class="select-control"
></NSelect
></label>
<label class="block"
><span class="field-label">身份锚定来源</span
></NFormItem>
<NFormItem
class="block"
path="referenceImageId"
label="身份锚定来源"
:label-props="{ for: 'identity-reference' }"
><NSelect
id="identity-reference"
v-model:value="referenceImageId"
@@ -91,12 +119,15 @@ function submit() {
]"
class="select-control"
></NSelect
></label>
></NFormItem>
<p class="text-xs leading-6 text-muted">
只能选择此主体已完成的图片后端需取得 Provider 可访问的远程地址本地预览可见不保证远程地址仍有效
</p>
<label class="block"
><span class="field-label">覆盖本次完整提示词可选</span
<NFormItem
class="block"
path="prompt"
label="覆盖本次完整提示词(可选)"
:label-props="{ for: 'identity-image-prompt' }"
><NInput
placeholder="建议留空,使用后端编译的身份约束和视角要求"
:input-props="{ id: 'identity-image-prompt' }"
@@ -105,13 +136,12 @@ function submit() {
type="textarea"
:autosize="{ minRows: 3, maxRows: 12 }"
></NInput>
</label>
</NFormItem>
<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
<NFormItem path="width" label="宽度(px" :label-props="{ for: 'identity-width' }"
><NInputNumber
placeholder="后端默认"
:input-props="{ id: 'identity-width' }"
@@ -119,9 +149,8 @@ function submit() {
@update:value="width = $event ?? ''"
:min="1"
:step="1"
></NInputNumber></label
><label
><span class="field-label">高度px</span
></NInputNumber></NFormItem
><NFormItem path="height" label="高度(px" :label-props="{ for: 'identity-height' }"
><NInputNumber
placeholder="后端默认"
:input-props="{ id: 'identity-height' }"
@@ -130,9 +159,8 @@ function submit() {
:min="1"
:step="1"
></NInputNumber
></label>
></NFormItem>
</div>
<p v-if="!valid" class="text-xs text-danger" role="alert">尺寸须成对填写正整数指定的参考图必须仍可用</p>
<NCheckbox
id="identity-image-cost"
v-model:checked="acknowledged"
@@ -145,6 +173,6 @@ function submit() {
确认生成身份图
</NButton>
</div>
</form>
</AppForm>
</AppDialog>
</template>
+1 -1
View File
@@ -1,4 +1,4 @@
/** 主体身份模块公共入口。 */
export { subjectIdentityApi } from './api'
export { currentAnchor, readImageProvenance } from './model'
export { currentAnchor } from './model'
export type { SubjectIdentity, IdentityImage, GenerateIdentityImageInput } from './types'
+2 -21
View File
@@ -67,28 +67,9 @@ export function canBeAnchor(image: IdentityImage): boolean {
return image.viewType === 'primary' && image.status === 'completed' && !!image.imageUrl
}
/** 使用后端 isAnchor,不把 front/full-body 的 enabled 错当成母版。 */
/** 使用后端明确的 isAnchor 标记,并校验图片已完成且为 primary 视角。 */
export function currentAnchor(images: IdentityImage[]): IdentityImage | undefined {
return images.find(image => image.isAnchor && image.enabled && canBeAnchor(image))
}
/** 仅读取追溯所需 ID,不把原始 Provider 数据渲染为 HTML。 */
export function readImageProvenance(rawJson?: string | null): {
referenceImageId?: string
identityAnchorImageId?: string
} {
try {
const parsed: unknown = JSON.parse(rawJson || '{}')
if (!parsed || typeof parsed !== 'object') return {}
const value = parsed as Record<string, unknown>
return {
referenceImageId: typeof value.referenceImageId === 'string' ? value.referenceImageId : undefined,
identityAnchorImageId:
typeof value.identityAnchorImageId === 'string' ? value.identityAnchorImageId : undefined
}
} catch {
return {}
}
return images.find(image => image.isAnchor && canBeAnchor(image))
}
/** 批量回执按项目保存在当前会话,切页不丢失,刷新浏览器后不伪造恢复。 */
@@ -8,7 +8,6 @@ export function identityFixture(overrides: Partial<SubjectIdentity> = {}): Subje
description: '稳定面部特征',
generationPrompt: '保持相同五官与骨相',
isLocked: false,
images: [],
createdAt: '2026-08-28T00:00:00Z',
updatedAt: '2026-08-28T00:00:00Z',
...overrides
@@ -24,12 +23,10 @@ export function identityImageFixture(overrides: Partial<IdentityImage> = {}): Id
viewType: 'primary',
provider: 'seedream',
model: 'configured-model',
prompt: '<script>不执行</script>',
imageUrl: '/storage/identity.png',
width: 2048,
height: 2048,
status: 'completed',
enabled: true,
isAnchor: true,
error: null,
createdAt: '2026-08-28T00:00:00Z',
+5 -7
View File
@@ -3,7 +3,7 @@ import type { SubjectImageStatus, SubjectFormAsset } from '../subject-images/typ
/** 只有 primary 视图可以选为身份母版,其余视图是辅助参考。 */
export type IdentityViewType = 'primary' | 'front' | 'three-quarter' | 'full-body'
/** 身份参考图;isAnchor 仅由专用图片查询接口返回,不能用 enabled 代替。 */
/** 身份参考图公开 DTO;查询与变更均返回明确的 isAnchor 标记。 */
export interface IdentityImage {
id: string
identityId: string
@@ -11,15 +11,12 @@ export interface IdentityImage {
viewType: IdentityViewType
provider: string | null
model: string | null
prompt: string | null
imageUrl: string | null
width: number | null
height: number | null
status: SubjectImageStatus
enabled: boolean
isAnchor?: boolean
isAnchor: boolean
error: string | null
rawJson?: string | null
createdAt: string
updatedAt: string
}
@@ -31,7 +28,6 @@ export interface SubjectIdentity {
description: string | null
generationPrompt: string | null
isLocked: boolean
images: IdentityImage[]
createdAt: string
updatedAt: string
}
@@ -138,7 +134,9 @@ export interface CastingBatchResult {
/** 确认选角会在同一事务中切换 Anchor 并锁定 Identity。 */
export interface ConfirmCastingResult {
identity: Omit<SubjectIdentity, 'images'>
identityId: string
subjectId: string
isLocked: boolean
anchor: IdentityImage
}
@@ -17,8 +17,7 @@ import type {
/** 单主体保存响应必须与提交时固定的正式 ID 一致。 */
function assertIdentity(value: SubjectIdentity, id: string) {
if (!value || value.subjectId !== id || value.images.some(image => image.identityId !== value.id))
throw new Error('后端未返回匹配的主体身份,请刷新核对。')
if (!value || value.subjectId !== id) throw new Error('后端未返回匹配的主体身份,请刷新核对。')
}
/** 主体目录、项目风格和当前身份分开查询,未创建 Identity 不误报为空图库错误。 */
@@ -256,7 +255,7 @@ export function useSubjectIdentity() {
!result ||
result.id !== imageId ||
result.identityId !== identityId ||
!result.enabled ||
!result.isAnchor ||
!canBeAnchor(result)
)
throw new Error('接口未确认身份母版切换,请刷新核对。')
@@ -282,10 +281,13 @@ export function useSubjectIdentity() {
const result = await subjectIdentityApi.confirmCasting(subjectId, imageId)
if (
!result ||
result.identity.id !== identityId ||
!result.identity.isLocked ||
result.identityId !== identityId ||
result.subjectId !== subjectId ||
!result.isLocked ||
result.anchor.id !== imageId ||
!result.anchor.enabled
!result.anchor.isAnchor ||
result.anchor.identityId !== identityId ||
!canBeAnchor(result.anchor)
)
throw new Error('接口未确认演员母版与身份锁定,请刷新核对。')
})
+58 -119
View File
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { integerRule } from '../../lib/form-rules'
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import ActionMenu from '../../components/ui/ActionMenu.vue'
import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
@@ -27,14 +30,13 @@ import {
ArrowRight,
LayoutGrid,
Columns3,
Settings2,
AlertTriangle
Settings2
} from '@lucide/vue'
import { AssetImage, EmptyState, StatusBadge } from '../../components/ui'
import { downloadText } from '../../lib/format'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import { useSubjectImages } from './useSubjectImages'
import { coverImage, hasRunningImages, isPrimaryIdentityStale, primaryImage } from './model'
import { coverImage, hasRunningImages, primaryImage } from './model'
import type { GenerateFormImageInput, SubjectFormAsset } from './types'
import GenerateImageDialog from './components/GenerateImageDialog.vue'
import ImageGalleryDialog from './components/ImageGalleryDialog.vue'
@@ -50,7 +52,6 @@ const router = useRouter()
const {
id,
forms,
staleForms,
query,
refreshProject,
operation,
@@ -60,15 +61,13 @@ const {
limit,
promptConcurrency,
promptForce,
concurrencyValid,
generatePrompt,
generatePrompts,
blocked,
running,
batchValid,
generate,
generateProject,
generateStale
generateProject
} = useSubjectImages()
const sourceShotId = computed(() => queryText(route.query.sourceShotId))
const targetFormId = computed(() => queryText(route.query.subjectFormId))
@@ -149,12 +148,11 @@ const search = ref('')
const layout = ref<GalleryLayout>('grid')
const module = ref('all')
/** 形态图片状态筛选;单选下拉避免移动端复选项占据两行。 */
type ImageStatusFilter = 'all' | 'missing' | 'stale'
type ImageStatusFilter = 'all' | 'missing'
const statusFilter = ref<ImageStatusFilter>('all')
const statusFilterOptions: Array<{ label: string; value: ImageStatusFilter }> = [
{ label: '全部状态', value: 'all' },
{ label: '缺少主图', value: 'missing' },
{ label: '身份过期', value: 'stale' }
{ label: '缺少主图', value: 'missing' }
]
/** 保存每张卡片的尺寸观察器,切换回网格时及时释放。 */
const masonryCardObservers = new WeakMap<HTMLElement, ResizeObserver>()
@@ -241,7 +239,6 @@ const filtered = computed(() =>
(!targetFormId.value || form.id === targetFormId.value) &&
(module.value === 'all' || form.subject.module === module.value) &&
(statusFilter.value !== 'missing' || !primaryImage(form.images)) &&
(statusFilter.value !== 'stale' || isPrimaryIdentityStale(form)) &&
`${form.subject.name} ${form.subject.ref} ${form.name}`
.toLowerCase()
.includes(search.value.trim().toLowerCase())
@@ -305,16 +302,8 @@ function selectImpactShot(shotId: string | null) {
async function refreshAssets() {
await Promise.all([refreshProject(), query.refresh(), impact.query.refresh(), impact.references.refresh()])
}
/** 全局过期筛选不沿用深链接的单个素材限制。 */
async function showStaleForms() {
await router.replace({
query: { ...route.query, subjectFormId: undefined, subjectRef: undefined, sourceShotId: undefined }
})
search.value = ''
module.value = 'all'
statusFilter.value = 'stale'
}
const formModel = computed(() => ({ concurrency: concurrency.value, limit: limit.value }))
const rules = { concurrency: integerRule('批量并发'), limit: integerRule('本批上限', 1, true) }
</script>
<template>
@@ -340,7 +329,7 @@ async function showStaleForms() {
</div>
<DetailDisclosure title="母版继承与过期处理" class="mt-4">
人物场景道具形态图均可继承已锁定 Identity
的母版并记录本次引用来源母版变化后可筛选过期重新生成候选并人工确认新的主参考不要直接沿用旧资产进入下游
的母版母版变化后请核对形态按需生成候选并确认主当前图库不提供历史母版引用无法自动判断形态图是否过期下游状态以镜头就绪检查为准
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
>管理主体身份 </RouterLink
></DetailDisclosure
@@ -358,76 +347,61 @@ async function showStaleForms() {
<div class="flex flex-wrap items-center justify-between gap-4">
<p class="text-sm">
<strong>{{ completeCount }}</strong> / {{ forms.length }} 个形态已有主图
<span v-if="staleForms.length" class="ml-3 text-xs text-danger">
{{ staleForms.length }} 个形态主图已过期
</span>
<span class="ml-3 text-xs text-muted">图片模型 · 后端配置</span>
</p>
</div>
<p class="mt-2 text-[11px] leading-6 text-muted">
图库不自动刷新可点击刷新图库读取最新记录主图供分镜引用候选图和历史失败记录均保留过期主图不会自动删除或替换
</p>
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-4"
><div class="flex flex-wrap items-center justify-between gap-3">
<span class="flex items-center gap-2 text-xs">
<AlertTriangle :size="14" />检测到 {{ staleForms.length }} 个形态 仍在使用旧 Identity
Anchor
</span>
<ConfirmAction
label="重新生成过期形态"
:disabled="blocked || !concurrencyValid || !staleForms.length"
acknowledgement
description="只为身份母版已经变化的形态调用 图片模型,各新增一张基于当前已锁定母版的候选图;不会自动替换旧主图,生成后请人工确认并设置新的主参考图。"
@confirm="generateStale"
/></div
></NAlert>
<NCollapse class="mt-4 pt-4"
><NCollapseItem name="details"
><template #header>项目批量生图</template>
<div class="mt-4 flex flex-wrap items-end gap-4">
<label class="w-28">
<span class="field-label">批量并发</span>
<NInputNumber
<AppForm :model="formModel" :rules="rules" :disabled="operation.pending" validate-on-change>
<div class="form-controls mt-4 flex flex-wrap gap-4">
<NFormItem
class="w-28"
path="concurrency"
label="批量并发"
:label-props="{ for: 'image-concurrency' }"
>
<NInputNumber
:disabled="operation.pending"
:input-props="{ id: 'image-concurrency' }"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</NFormItem>
<NFormItem class="w-36" path="limit" label="本批上限(可选)"
><NInputNumber
:disabled="operation.pending"
:value="typeof limit === 'number' ? limit : null"
@update:value="limit = $event ?? ''"
:min="1"
:step="1"
placeholder="不限制"
:input-props="{ 'aria-label': '生图本批上限' }"
/></NFormItem>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
:input-props="{ id: 'image-concurrency' }"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</label>
<label class="w-36"
><span class="field-label">本批上限可选</span
><NInputNumber
:disabled="operation.pending"
:value="typeof limit === 'number' ? limit : null"
@update:value="limit = $event ?? ''"
:min="1"
:step="1"
placeholder="不限制"
:input-props="{ 'aria-label': '生图本批上限' }"
/></label>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="control-row-checkbox text-xs"
>已有主图也新增候选图
</NCheckbox>
<ConfirmAction
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
:disabled="blocked || !batchValid || !forms.length"
acknowledgement
:description="
force
? '对项目全部形态调用图片模型新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
: '仅对没有主图的形态调用图片模型生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
"
@confirm="generateProject"
/>
</div>
<p v-if="!batchValid" class="mt-2 text-xs text-danger">
并发和填写的数量上限必须是正整数
</p>
class="control-row-checkbox text-xs"
>已有主图也新增候选图
</NCheckbox>
<ConfirmAction
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
:disabled="blocked || !batchValid || !forms.length"
acknowledgement
:description="
force
? '对项目全部形态调用图片模型新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
: '仅对没有主图的形态调用图片模型生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
"
@confirm="generateProject"
/>
</div>
</AppForm>
<p class="mt-3 text-xs leading-6 text-muted">
作用于整个项目不受下方筛选影响上限只限制本次真正生图数量其余显示为待后续处理不计入跳过默认使用后端尺寸与正式提示词不会启动视频生成
</p></NCollapseItem
@@ -457,14 +431,6 @@ async function showStaleForms() {
>
部分形态生图失败已生成的图片保留先查看失败原因再按形态重新生图
</NAlert>
<NAlert
v-else-if="session.receipt.title === '刷新过期形态图'"
type="info"
:show-icon="false"
class="mt-3 text-xs"
>
新图当前仍是候选图请进入对应形态的查看图片与记录确认人物身份和造型后再设为主参考图旧主图在此之前仍保持过期状态
</NAlert>
<ul class="mt-3 space-y-2 text-xs text-danger">
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectFormId">
<code>{{ failure.subjectFormId }}</code
@@ -477,11 +443,6 @@ async function showStaleForms() {
</section>
</WorkspaceTools>
<!-- 关联说明筛选和图片共用正文滚动区避免镜头切换时两块滚动区域高度不同步 -->
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-3" title="形态主图需要更新">
{{ staleForms.length }}
个形态主图与当前已锁定身份母版不一致请生成候选验图并切换主图再重建相关首帧
<NButton text size="small" @click="showStaleForms">筛选身份过期形态</NButton>
</NAlert>
<NAlert
v-if="impact.stale.value.length"
type="warning"
@@ -730,7 +691,6 @@ async function showStaleForms() {
:key="form.id"
class="panel form-image-card"
:class="{
'ring-1 ring-danger': isPrimaryIdentityStale(form),
'form-image-card-focused': targetFormId === form.id
}"
:data-form-id="form.id"
@@ -755,9 +715,6 @@ async function showStaleForms() {
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<StatusBadge v-if="hasRunningImages(form.images)" status="generating" />
<NTag v-if="isPrimaryIdentityStale(form)" size="small" :bordered="false"
>身份母版已变更</NTag
>
<NTag v-else-if="primaryImage(form.images)" size="small" :bordered="false">主参考图</NTag>
<NTag v-else-if="coverImage(form)" size="small" :bordered="false">候选图 · 尚未设主图</NTag>
<span v-else-if="form.images[0]?.status === 'failed'" class="text-xs text-danger"
@@ -765,15 +722,6 @@ async function showStaleForms() {
>
<span class="text-[10px] text-muted">{{ form.images.length }} 条记录</span>
</div>
<NAlert
v-if="isPrimaryIdentityStale(form)"
type="error"
:show-icon="false"
class="mt-3 text-xs leading-6"
>
当前主图未能匹配最新已锁定母版请基于当前 Identity Anchor
重新生成候选图并在确认后设置为新的主参考图
</NAlert>
<NAlert
v-if="
sourceShot?.primaryKeyframeStale &&
@@ -789,9 +737,7 @@ async function showStaleForms() {
{{
sourceShot.inconsistent
? '后端首帧与视频检查结果不一致,请先核对,不要据此重复生图。'
: isPrimaryIdentityStale(form)
? '请先更新该形态主图,再重建首帧。'
: '关联不等于本素材失效;核对形态主图与身份母版,确认无误后只需重建首帧。'
: '关联不等于本素材失效;核对形态主图与身份母版,确认无误后只需重建首帧。'
}}
<RouterLink v-if="sourceLocation" :to="sourceLocation" class="text-button mt-2"
>返回该镜头处理 →</RouterLink
@@ -818,14 +764,7 @@ async function showStaleForms() {
"
/>
<NButton :disabled="blocked" @click="openGenerate(form)"
><ImagePlus :size="14" />
{{
isPrimaryIdentityStale(form)
? '基于当前母版重新生成'
: coverImage(form)
? '再生成一张'
: '生成图片'
}}</NButton
><ImagePlus :size="14" /> {{ coverImage(form) ? '再生成一张' : '生成图片' }}</NButton
>
</div>
</div>
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { integerRule } from '../../../lib/form-rules'
import { computed } from 'vue'
import { NAlert, NButton, NCheckbox, NInputNumber } from 'naive-ui'
import { downloadText } from '../../../lib/format'
@@ -22,6 +25,8 @@ const valid = computed(() => Number.isSafeInteger(concurrency.value) && concurre
function exportReceipt() {
downloadText('form-prompts-receipt.json', JSON.stringify(props.receipt, null, 2), 'application/json')
}
const formModel = computed(() => ({ concurrency: concurrency.value }))
const rules = { concurrency: integerRule('提示词并发') }
</script>
<template>
<section class="panel mt-5 p-5" aria-label="形态提示词批量管理">
@@ -30,34 +35,34 @@ function exportReceipt() {
正式提示词 {{ complete }} /
{{ forms.length }}结合项目视觉风格生成只保存文本不自动生图批量面向全项目不受搜索筛选影响
</p>
<div class="mt-4 flex flex-wrap items-end gap-4">
<label class="w-28"
><span class="field-label">提示词并发</span>
<NInputNumber
:disabled="pending"
:value="concurrency"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
:input-props="{ 'aria-label': '提示词并发' }"
<AppForm :model="formModel" :rules="rules" :disabled="pending" validate-on-change v-slot="{ validate }">
<div class="form-controls mt-4 flex flex-wrap gap-4">
<NFormItem class="w-28" path="concurrency" label="提示词并发">
<NInputNumber
:disabled="pending"
:value="concurrency"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
:input-props="{ 'aria-label': '提示词并发' }"
/>
</NFormItem>
<NCheckbox v-model:checked="force" :disabled="pending" class="control-row-checkbox text-xs"
>覆盖已有正式提示词</NCheckbox
>
<ConfirmAction
:label="force ? '重生成全部形态提示词' : '补齐形态提示词'"
:disabled="disabled || !valid || !forms.length || (!force && complete === forms.length)"
acknowledgement
:description="
force
? '调用文本模型重新生成全项目形态的正式提示词,覆盖原有文本;已有图片和主图不变。'
: '调用文本模型补齐缺少正式提示词的形态,已有正式提示词跳过;不会自动生成图片。'
"
@confirm="validate(() => emit('generate'))"
/>
</label>
<NCheckbox v-model:checked="force" :disabled="pending" class="control-row-checkbox text-xs"
>覆盖已有正式提示词</NCheckbox
>
<ConfirmAction
:label="force ? '重生成全部形态提示词' : '补齐形态提示词'"
:disabled="disabled || !valid || !forms.length || (!force && complete === forms.length)"
acknowledgement
:description="
force
? '调用文本模型重新生成全项目形态的正式提示词,覆盖原有文本;已有图片和主图不变。'
: '调用文本模型补齐缺少正式提示词的形态,已有正式提示词跳过;不会自动生成图片。'
"
@confirm="emit('generate')"
/>
</div>
<p v-if="!valid" class="mt-2 text-xs text-danger">提示词并发必须是正整数</p>
</div>
</AppForm>
<section v-if="receipt" class="surface-inset mt-4 p-4" aria-label="提示词批量回执">
<div class="flex flex-wrap items-center justify-between gap-3">
<h4>{{ receipt.title }}</h4>
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { sizeRules } from '../../../lib/form-rules'
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
@@ -39,6 +42,8 @@ function submit() {
})
open.value = false
}
const formModel = computed(() => ({ width: width.value, height: height.value, prompt: prompt.value }))
const rules = { ...sizeRules(() => formModel.value) }
</script>
<template>
@@ -47,7 +52,15 @@ function submit() {
title="生成形态图片"
:description="`${form?.subject.name || ''} · ${form?.name || ''}`"
>
<form class="mt-6 space-y-5" @submit.prevent="submit">
<AppForm
:model="formModel"
:rules="rules"
:disabled="disabled"
:reset-key="`${open}:${form?.id}`"
validate-on-change
class="mt-6 space-y-5"
@submit="submit"
>
<NAlert type="info" :show-icon="false" class="text-xs"
>使用后端配置的图片模型可能产生费用每次新增一张图片不删除历史结果</NAlert
>
@@ -55,8 +68,11 @@ function submit() {
人物场景道具均会引用已锁定身份的当前母版保持身份空间骨架或物件结构身份未锁定或没有母版时不会继承该图片更换母版不会自动更新已有形态图
身份母版与此处的形态主参考图是两种不同用途的图片
</p>
<label class="block"
><span class="field-label">自定义提示词可选仅用于本次</span
<NFormItem
class="block"
path="prompt"
label="自定义提示词(可选,仅用于本次)"
:label-props="{ for: 'image-prompt' }"
><NInput
placeholder="留空使用正式 generationPrompt;缺失时后端先自动生成正式提示词"
:input-props="{ id: 'image-prompt' }"
@@ -65,7 +81,7 @@ function submit() {
type="textarea"
:autosize="{ minRows: 3, maxRows: 12 }"
></NInput>
</label>
</NFormItem>
<NCollapse v-if="form?.generationPrompt || form?.appearancePrompt" class="text-xs"
><NCollapseItem name="details"
><template #header>{{
@@ -77,8 +93,7 @@ function submit() {
></NCollapse
>
<div class="grid grid-cols-2 gap-4">
<label
><span class="field-label">宽度px</span
<NFormItem path="width" label="宽度(px" :label-props="{ for: 'image-width' }"
><NInputNumber
placeholder="后端默认"
:input-props="{ id: 'image-width' }"
@@ -86,9 +101,8 @@ function submit() {
@update:value="width = $event ?? ''"
:min="1"
:step="1"
></NInputNumber></label
><label
><span class="field-label">高度px</span
></NInputNumber></NFormItem
><NFormItem path="height" label="高度(px" :label-props="{ for: 'image-height' }"
><NInputNumber
placeholder="后端默认"
:input-props="{ id: 'image-height' }"
@@ -97,10 +111,9 @@ function submit() {
:min="1"
:step="1"
></NInputNumber
></label>
></NFormItem>
</div>
<p class="text-xs text-muted">宽高同时留空时使用后端默认 2K自定义尺寸须满足模型限制</p>
<p v-if="!dimensionsValid" class="text-xs text-danger" role="alert">宽高需要同时填写正整数或同时留空</p>
<p v-if="!form?.generationPrompt" class="text-xs leading-6 text-muted">
未填写自定义提示词时后端先调用文本模型补齐正式提示词再生图可能产生两类模型费用原始外观素材不会直接作为最终生图提示词
</p>
@@ -124,6 +137,6 @@ function submit() {
确认生成图片
</NButton>
</div>
</form>
</AppForm>
</AppDialog>
</template>
@@ -1,15 +1,14 @@
<script setup lang="ts">
import { NScrollbar, NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
import { NScrollbar, NAlert, NButton, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { ExternalLink, RefreshCw } from '@lucide/vue'
import { AppDialog, AssetImage, StatusBadge } from '../../../components/ui'
import { usePolling } from '../../../composables/usePolling'
import { referenceImageUrl } from '../../../lib/assets'
import { formatDate } from '../../../lib/format'
import { readImageProvenance } from '../../subject-identity'
import { getOperation, runOperation } from '../../workflows/operations'
import { subjectImagesApi } from '../api'
import { hasRunningImages, primaryImage } from '../model'
import { hasRunningImages, primaryImage, currentIdentityAnchorId } from '../model'
import type { SubjectFormAsset } from '../types'
/** 形态图片历史与主图选择;历史查询只在弹窗打开时进行。 */
@@ -186,22 +185,9 @@ async function choosePrimary() {
><NButton @click="confirming = false" text size="small">取消</NButton>
</div></NAlert
>
<NCollapse v-if="selected.prompt" class="mt-4 text-xs"
><NCollapseItem name="details"
><template #header>查看本次实际提示词</template>
<p class="mt-3 whitespace-pre-wrap leading-6">{{ selected.prompt }}</p>
<p v-if="selected.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6">
Negative prompt: {{ selected.negativePrompt }}
</p></NCollapseItem
></NCollapse
>
<p class="mt-3 break-all font-mono text-[10px] text-muted">Image ID · {{ selected.id }}</p>
<p class="mt-2 break-all text-[11px] text-muted">
{{
readImageProvenance(selected.rawJson).identityAnchorImageId
? `本次引用身份母版 ID${readImageProvenance(selected.rawJson).identityAnchorImageId}`
: '此记录未提供身份母版来源,不能据此判断是否使用了当前母版。'
}}
<p v-if="form && currentIdentityAnchorId(form)" class="mt-2 break-all text-[11px] text-muted">
当前已锁定身份母版 ID{{ currentIdentityAnchorId(form) }}
</p>
</div>
<p v-else class="py-10 text-center text-sm text-muted">
+1 -23
View File
@@ -21,29 +21,7 @@ export function coverImage(form: SubjectFormAsset): SubjectImage | undefined {
/** 当前主体正在使用的 Identity Anchor ID。 */
export function currentIdentityAnchorId(form: SubjectFormAsset): string | undefined {
return form.subject.identity?.isLocked ? form.subject.identity.images[0]?.id : undefined
}
/** 读取形态图生成时实际使用的 Identity Anchor ID。 */
export function imageIdentityAnchorId(image: SubjectImage | undefined): string | undefined {
if (!image?.rawJson) return undefined
try {
const raw = JSON.parse(image.rawJson) as { identityAnchorImageId?: unknown }
return typeof raw.identityAnchorImageId === 'string' ? raw.identityAnchorImageId : undefined
} catch {
return undefined
}
}
/**
* 当前主形态图是否由旧 Identity Anchor 生成。
* 有当前 Anchor 但旧图没有追溯 ID,也按过期处理,避免历史图片继续进入后续 Keyframe。
*/
export function isPrimaryIdentityStale(form: SubjectFormAsset): boolean {
const anchorId = currentIdentityAnchorId(form)
const primary = primaryImage(form.images)
if (!anchorId || !primary) return false
return imageIdentityAnchorId(primary) !== anchorId
return form.subject.identity?.isLocked ? (form.subject.identity.anchorImageId ?? undefined) : undefined
}
/** 查询发现后台仍在运行时也阻止重复提交,不只依赖本地 loading。 */
@@ -15,7 +15,6 @@ export function imageFixture(overrides: Partial<SubjectImage> = {}): SubjectImag
error: null,
createdAt: '2026-08-28T08:00:00Z',
updatedAt: '2026-08-28T08:00:00Z',
prompt: '<script>模型提示词</script>',
...overrides
}
}
+6 -8
View File
@@ -15,18 +15,13 @@ export interface SubjectImage {
error: string | null
createdAt: string
updatedAt: string
/** 详情查询才返回实际使用的 Prompt;列表不包含长文本。 */
prompt?: string
negativePrompt?: string | null
/** 形态图生成追溯信息,包含当时使用的 Identity Anchor ID。 */
rawJson?: string | null
}
/** 当前主体 Identity 只暴露形态图库判断新旧母版所需的轻量信息。 */
/** 当前主体 Identity 的公开摘要;只说明当前母版,不包含图片的历史引用。 */
export interface SubjectFormIdentitySummary {
id: string
isLocked: boolean
images: { id: string }[]
anchorImageId: string | null
}
/** 正式 SubjectForm 及其所属主体、图片,不使用 checkpoint 的领域 formId。 */
@@ -79,7 +74,10 @@ export interface ImageBatchResult {
}
/** 正式提示词生成只返回形态字段,不包含图库列表的 images 关联。 */
export type FormPromptResult = Pick<SubjectFormAsset, 'id' | 'subjectId' | 'generationPrompt'>
export type FormPromptResult = Omit<SubjectFormAsset, 'subject' | 'images'> & {
createdAt: string
updatedAt: string
}
/** 项目提示词增强的批量参数,与图片生成的 force、limit 独立。 */
export interface GenerateFormPromptsInput {
@@ -5,8 +5,8 @@ import { useProjectContext } from '../projects/context'
import { getOperation, runOperation } from '../workflows/operations'
import { workflowCheckpoints } from '../workflows/selectors'
import { subjectImagesApi } from './api'
import { getImageSession, hasRunningImages, isPrimaryIdentityStale } from './model'
import type { GenerateFormImageInput, SubjectFormAsset } from './types'
import { getImageSession, hasRunningImages } from './model'
import type { GenerateFormImageInput } from './types'
/** 形态图库统一读取数据库记录并管理单图/批量长请求,不调用生产流程。 */
export function useSubjectImages() {
@@ -44,7 +44,6 @@ export function useSubjectImages() {
false
)
const forms = computed(() => query.data.value ?? [])
const staleForms = computed(() => forms.value.filter(form => isPrimaryIdentityStale(form)))
const operation = computed(() => getOperation(id.value))
const session = computed(() => getImageSession(id.value))
const running = computed(() => forms.value.some(form => hasRunningImages(form.images)))
@@ -138,59 +137,9 @@ export function useSubjectImages() {
await query.refresh()
}
/**
* 只刷新 Identity Anchor 已变化的形态,覆盖人物、场景与道具。
* 与确认文案一致:仅生成候选,用户验图后再显式切换主图。
*/
async function generateStale() {
if (blocked.value || !concurrencyValid.value || !staleForms.value.length) return
const projectId = id.value
const targets = [...staleForms.value]
const total = forms.value.length
const receipt = getImageSession(projectId)
receipt.receipt = null
await runOperation(projectId, '刷新过期形态图', async () => {
const results = await runWithConcurrency(targets, concurrency.value, async form => {
try {
const image = await subjectImagesApi.generate(form.id, {
setPrimary: false
})
if (!image || image.subjectFormId !== form.id || image.status !== 'completed' || !image.imageUrl) {
throw new Error(image?.error || '接口未返回已完成的候选图')
}
return { subjectFormId: form.id, success: true as const }
} catch (error) {
return {
subjectFormId: form.id,
success: false as const,
error: error instanceof Error ? error.message : String(error)
}
}
})
const failures = results
.filter(item => !item.success)
.map(item => ({ subjectFormId: item.subjectFormId, error: item.error }))
receipt.receipt = {
title: '刷新过期形态图',
result: {
total,
targetCount: targets.length,
generated: results.filter(item => item.success).length,
skipped: total - targets.length,
failed: failures.length,
failures
}
}
})
await query.refresh()
}
return {
id,
forms,
staleForms,
query,
refreshProject: context.refresh,
operation,
@@ -208,31 +157,6 @@ export function useSubjectImages() {
running,
batchValid,
generate,
generateProject,
generateStale
generateProject
}
}
/** 使用固定并发数执行需要付费的单形态生图请求。 */
async function runWithConcurrency<TResult>(
items: SubjectFormAsset[],
concurrency: number,
handler: (item: SubjectFormAsset) => Promise<TResult>
): Promise<TResult[]> {
const results: TResult[] = []
let currentIndex = 0
async function worker() {
while (currentIndex < items.length) {
const index = currentIndex
currentIndex += 1
const item = items[index]
if (!item) continue
results[index] = await handler(item)
}
}
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
await Promise.all(workers)
return results
}
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { stringArrayJsonRule } from '../../../lib/form-rules'
import DetailDisclosure from '../../../components/ui/DetailDisclosure.vue'
import { NButton, NCheckbox, NInput, NTag } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
@@ -18,16 +21,6 @@ const form = reactive({
})
const baseline = ref('')
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
const constraintsError = computed(() => {
try {
const value: unknown = JSON.parse(form.constraints)
return Array.isArray(value) && value.every(item => typeof item === 'string')
? ''
: '硬约束须为字符串数组,例如 ["真人写实", "禁止二次元"]。'
} catch {
return '硬约束 JSON 格式不正确,请修正后保存。'
}
})
watch(
() => props.visualStyle,
value => {
@@ -49,7 +42,7 @@ watch(dirty, value => emit('dirty', value), { immediate: true })
/** 显式校验 JSON 后保存结构化约束;不对错误结构做静默转换。 */
function save() {
if (props.disabled || constraintsError.value) return
if (props.disabled) return
emit('save', {
name: form.name.trim(),
prompt: form.prompt,
@@ -60,21 +53,28 @@ function save() {
isLocked: form.isLocked
})
}
const rules = { constraints: stringArrayJsonRule }
const constraintsDetails = ref<InstanceType<typeof DetailDisclosure> | null>(null)
</script>
<template>
<form class="panel p-5 sm:p-6" @submit.prevent="save">
<AppForm
:model="form"
:rules="rules"
:disabled="disabled"
class="panel p-5 sm:p-6"
@invalid="constraintsDetails?.expand()"
@submit="save"
>
<fieldset :disabled="disabled" class="space-y-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="font-medium">风格说明</h3>
<NTag v-if="dirty" size="small" :bordered="false">有未保存修改</NTag>
</div>
<label class="block"
><span class="field-label">风格名称</span
<NFormItem class="block" path="name" label="风格名称" :label-props="{ for: 'style-name' }"
><NInput :disabled="disabled" :input-props="{ id: 'style-name' }" v-model:value="form.name"></NInput
></label>
<label class="block"
><span class="field-label">整体视觉语言</span
></NFormItem>
<NFormItem class="block" path="prompt" label="整体视觉语言" :label-props="{ for: 'style-prompt' }"
><NInput
:disabled="disabled"
placeholder="媒介、真实感、色彩和整体美术方向"
@@ -84,12 +84,12 @@ function save() {
type="textarea"
:autosize="{ minRows: 3, maxRows: 12 }"
></NInput>
</label>
<DetailDisclosure title="分类风格与硬约束"
</NFormItem>
<DetailDisclosure ref="constraintsDetails" display-directive="show" title="分类风格与硬约束"
><div class="grid gap-5 lg:grid-cols-3">
<label
><span class="field-label">人物风格与选角背景</span
<NFormItem path="characterPrompt" label="人物风格与选角背景"
><NInput
:input-props="{ 'aria-label': '人物风格与选角背景' }"
:disabled="disabled"
placeholder="人物质感、妆造与默认选角背景;留空时 AI 默认采用中国人物基线"
class="min-h-32"
@@ -97,30 +97,33 @@ function save() {
type="textarea"
:autosize="{ minRows: 3, maxRows: 12 }"
></NInput>
</label>
<label
><span class="field-label">场景风格补充</span
</NFormItem>
<NFormItem path="scenePrompt" label="场景风格补充"
><NInput
:input-props="{ 'aria-label': '场景风格补充' }"
:disabled="disabled"
class="min-h-32"
v-model:value="form.scenePrompt"
type="textarea"
:autosize="{ minRows: 3, maxRows: 12 }"
></NInput>
</label>
<label
><span class="field-label">道具风格补充</span
</NFormItem>
<NFormItem path="propPrompt" label="道具风格补充"
><NInput
:input-props="{ 'aria-label': '道具风格补充' }"
:disabled="disabled"
class="min-h-32"
v-model:value="form.propPrompt"
type="textarea"
:autosize="{ minRows: 3, maxRows: 12 }"
></NInput>
</label>
</NFormItem>
</div>
<label class="mt-4 block"
><span class="field-label">硬约束 · JSON 字符串数组</span
<NFormItem
class="mt-4 block"
path="constraints"
label="硬约束 · JSON 字符串数组"
:label-props="{ for: 'style-constraints' }"
><NInput
:disabled="disabled"
spellcheck="false"
@@ -130,9 +133,8 @@ function save() {
type="textarea"
:autosize="{ minRows: 3, maxRows: 12 }"
></NInput>
</label>
</NFormItem>
</DetailDisclosure>
<p v-if="constraintsError" class="text-xs text-danger" role="alert">{{ constraintsError }}</p>
<NCheckbox
:disabled="disabled"
id="style-lock"
@@ -144,10 +146,8 @@ function save() {
锁定后仍可人工编辑并保存取消勾选并保存后才能再次使用 AI 重生成
</p>
<div class="flex justify-end">
<NButton :disabled="disabled || !!constraintsError" type="primary" attr-type="submit">
保存视觉风格
</NButton>
<NButton :disabled="disabled" type="primary" attr-type="submit"> 保存视觉风格 </NButton>
</div>
</fieldset>
</form>
</AppForm>
</template>
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect } from 'naive-ui'
import { computed, reactive, ref } from 'vue'
import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { fieldRule, imageUrlRule } from '../../../lib/form-rules'
import { NButton, NCheckbox, NInput, NInputNumber, NSelect } from 'naive-ui'
import { reactive, ref } from 'vue'
import { AssetImage } from '../../../components/ui'
import { referenceImageUrl } from '../../../lib/assets'
import type { AddStyleImageInput, StyleCategory, VisualStyleImage } from '../types'
@@ -15,11 +18,10 @@ const emit = defineEmits<{
const form = reactive({ imageUrl: '', category: 'overall' as StyleCategory, sortOrder: 0, enabled: true })
const confirmingId = ref('')
const categories = { overall: '整体', character: '人物', scene: '场景', prop: '道具' } as const
const valid = computed(() => !!referenceImageUrl(form.imageUrl) && Number.isSafeInteger(form.sortOrder))
/** 仅通过校验后发出新增请求,输入框在失败时保留供修正。 */
function add() {
if (props.disabled || !valid.value) return
if (props.disabled) return
emit('add', { ...form, imageUrl: form.imageUrl.trim(), source: 'upload' })
}
@@ -29,6 +31,7 @@ function remove(image: VisualStyleImage) {
confirmingId.value = ''
emit('remove', image)
}
const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeInteger, '排序须为整数') }
</script>
<template>
@@ -40,19 +43,18 @@ function remove(image: VisualStyleImage) {
登记已有 HTTP(S) /storage/
图片地址后端尚无文件上传接口这些图片目前仅管理记录现有身份形态生图不会自动将风格图片传给模型
</p>
<form class="mt-4" @submit.prevent="add">
<fieldset :disabled="disabled" class="flex flex-wrap items-end gap-3">
<label class="min-w-48 flex-1"
><span class="field-label">图片地址</span
<AppForm :model="form" :rules="rules" :disabled="disabled" class="mt-4" @submit="add">
<fieldset :disabled="disabled" class="form-controls flex flex-wrap gap-3">
<NFormItem class="min-w-48 flex-1" path="imageUrl" label="图片地址"
><NInput
placeholder="https://… 或 /storage/…"
:input-props="{ 'aria-label': '风格图片地址' }"
v-model:value="form.imageUrl"
></NInput
></label>
<label
><span class="field-label">分类</span
></NFormItem>
<NFormItem path="category" label="分类" :label-props="{ for: 'style-image-category' }"
><NSelect
id="style-image-category"
v-model:value="form.category"
:options="[
...Object.entries(categories).map(([value, label]) => ({
@@ -62,23 +64,19 @@ function remove(image: VisualStyleImage) {
]"
class="select-control"
></NSelect
></label>
<label class="w-24"
><span class="field-label">排序</span
></NFormItem>
<NFormItem class="w-24" path="sortOrder" label="排序"
><NInputNumber
:input-props="{ 'aria-label': '风格图片排序' }"
:value="typeof form.sortOrder === 'number' ? form.sortOrder : null"
@update:value="form.sortOrder = $event ?? 0"
:step="1"
></NInputNumber
></label>
<NCheckbox v-model:checked="form.enabled" class="flex gap-2 pb-2 text-xs">启用</NCheckbox>
<NButton :disabled="disabled || !valid" attr-type="submit">登记参考图</NButton>
></NFormItem>
<NCheckbox v-model:checked="form.enabled" class="control-row-checkbox text-xs">启用</NCheckbox>
<NButton :disabled="disabled" attr-type="submit">登记参考图</NButton>
</fieldset>
</form>
<p v-if="form.imageUrl && !valid" class="mt-2 text-xs text-danger" role="alert">
请填写有效图片地址和整数排序
</p>
</AppForm>
<div v-if="images.length" class="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<article v-for="image in images" :key="image.id" class="surface-inset min-w-0 p-3">
<AssetImage
@@ -94,12 +92,6 @@ function remove(image: VisualStyleImage) {
<p v-if="image.provider || image.model" class="mt-2 break-words text-[11px] text-muted">
{{ image.provider }} {{ image.model }}
</p>
<NCollapse v-if="image.prompt" class="mt-2 text-xs"
><NCollapseItem name="details"
><template #header>实际提示词</template>
<p class="mt-2 whitespace-pre-wrap">{{ image.prompt }}</p></NCollapseItem
></NCollapse
>
<div class="mt-3 flex flex-wrap gap-3">
<a
v-if="referenceImageUrl(image.imageUrl)"
@@ -31,7 +31,6 @@ export function styleImageFixture(overrides: Partial<VisualStyleImage> = {}): Vi
sortOrder: 0,
provider: null,
model: null,
prompt: null,
createdAt: '2026-08-28T00:00:00Z',
updatedAt: '2026-08-28T00:00:00Z',
...overrides
-1
View File
@@ -12,7 +12,6 @@ export interface VisualStyleImage {
sortOrder: number
provider: string | null
model: string | null
prompt: string | null
createdAt: string
updatedAt: string
}
+7
View File
@@ -43,3 +43,10 @@ export function recoveryOptions(checkpoints: Checkpoint[]) {
)
return { retry, shots, storyboard }
}
/** 项目公开详情不含内部任务;按每条工作流的最新 checkpoint 检查活动执行。 */
export function hasRunningWorkflow(checkpoints: Checkpoint[]): boolean {
return [...new Set(checkpoints.map(item => item.workflowName))].some(
name => workflowCheckpoints(checkpoints, name).at(-1)?.state.workflowExecution?.status === 'running'
)
}
+52
View File
@@ -0,0 +1,52 @@
import type { FormItemRule } from 'naive-ui'
import { referenceImageUrl } from './assets'
const trigger = ['input', 'change', 'blur']
/** 字段错误交给 Naive FormItem 呈现;业务层仍可独立检查接口参数。 */
export function fieldRule(check: (value: unknown) => boolean, message: string, required = false): FormItemRule {
return { trigger, required, validator: (_rule, value) => check(value) || new Error(message) }
}
export const requiredTextRule = (label: string) =>
fieldRule(value => typeof value === 'string' && !!value.trim(), `请填写${label}`, true)
/** 可选数字仅将真正的空值视为空,不把 0 或小数悄悄转换成默认值。 */
export function integerRule(label: string, min = 1, optional = false): FormItemRule {
return fieldRule(
value => (optional && isEmpty(value)) || (Number.isSafeInteger(value) && Number(value) >= min),
`${label}须为${min === 0 ? '非负' : '正'}整数${optional ? ',或留空' : ''}`,
!optional
)
}
export function isEmpty(value: unknown): boolean {
return value === '' || value === null || value === undefined
}
/** 两个尺寸字段共同校验,修改任何一侧都能更新另一侧的错误。 */
export function sizeRules(values: () => { width: unknown; height: unknown }) {
const rule = fieldRule(() => {
const { width, height } = values()
return (
(isEmpty(width) && isEmpty(height)) ||
(Number.isSafeInteger(width) && Number(width) > 0 && Number.isSafeInteger(height) && Number(height) > 0)
)
}, '宽高须同时填写正整数,或同时留空')
return { width: rule, height: rule }
}
export const imageUrlRule = fieldRule(
value => typeof value === 'string' && !!referenceImageUrl(value),
'请填写有效的 HTTP(S) 或 /storage/ 图片地址',
true
)
export const stringArrayJsonRule = fieldRule(value => {
try {
const parsed: unknown = JSON.parse(String(value))
return Array.isArray(parsed) && parsed.every(item => typeof item === 'string')
} catch {
return false
}
}, '请输入 JSON 字符串数组,例如 ["真人写实", "禁止二次元"]')
+1 -3
View File
@@ -16,9 +16,7 @@ export function testProjectContext(id = 'capability-test'): ReturnType<typeof us
updatedAt: '2026-09-01T00:00:00Z',
episodes: [{ episode: 1, title: '第一集', content: '内容' }],
characters: [],
world: null,
reviews: [],
tasks: []
world: null
}
const data = ref({ project, checkpoints: [] as Checkpoint[] })
return {