feat: 同步精简接口并统一表单校验
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '主首帧已过期'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 中页面使用的状态切片。 */
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
/** 主体身份模块公共入口。 */
|
||||
export { subjectIdentityApi } from './api'
|
||||
export { currentAnchor, readImageProvenance } from './model'
|
||||
export { currentAnchor } from './model'
|
||||
export type { SubjectIdentity, IdentityImage, GenerateIdentityImageInput } from './types'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('接口未确认演员母版与身份锁定,请刷新核对。')
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,6 @@ export interface VisualStyleImage {
|
||||
sortOrder: number
|
||||
provider: string | null
|
||||
model: string | null
|
||||
prompt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 字符串数组,例如 ["真人写实", "禁止二次元"]')
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user