feat: 优化生产面板并增加图片网格加载

This commit is contained in:
GouJ
2026-09-21 10:21:22 +08:00
parent 01f274cd8e
commit a253711435
9 changed files with 501 additions and 276 deletions
+5 -7
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { NButton, NImage } from 'naive-ui'
import { NButton } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { ImageOff } from '@lucide/vue'
import { referenceImageUrl } from '../../lib/assets'
import RevealImage from './RevealImage.vue'
/** 图片统一铺满容器;原图预览按场景开启,历史选择按钮不触发预览。 */
const props = withDefaults(
@@ -28,16 +29,12 @@ watch(
<template>
<div class="asset-image">
<NImage
<RevealImage
v-if="url && !failed"
:src="url"
:preview-src="url"
:alt="alt"
lazy
:preview-disabled="!preview"
:preview="preview"
:object-fit="objectFit"
:img-props="{ referrerpolicy: 'no-referrer' }"
:previewed-img-props="{ alt, referrerpolicy: 'no-referrer' }"
@error="failed = true"
/>
<span v-else class="asset-image-empty"
@@ -63,6 +60,7 @@ watch(
.asset-image-empty {
@apply flex items-center justify-center flex-col gap-2.5 p-5 text-muted text-[11px] text-center;
}
.asset-image .reveal-image,
.asset-image .n-image {
@apply w-full h-full min-h-0 min-w-0 flex justify-center;
}
+149
View File
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { NImage } from 'naive-ui'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
/**
* 全局图片加载动效:借鉴 Grid Reveal 的网格落图方式,但只使用 16 个 CSS 单元,
* 避免列表中的每张缩略图都创建 Canvas、采样像素并持续运行 requestAnimationFrame。
*/
const props = withDefaults(
defineProps<{
src: string
alt: string
objectFit?: 'contain' | 'cover'
preview?: boolean
lazy?: boolean
}>(),
{ objectFit: 'cover', preview: false, lazy: true }
)
const emit = defineEmits<{ error: [event: Event] }>()
const loaded = ref(false)
const settled = ref(false)
let settleTimer: ReturnType<typeof setTimeout> | undefined
/** 让相邻方格错开退出,视觉上从中心和高关注区域向外展开。 */
const cells = computed(() => {
const order = [12, 8, 4, 13, 3, 0, 1, 9, 11, 2, 5, 14, 10, 6, 7, 15]
return Array.from({ length: 16 }, (_, index) => ({
index,
order: order[index] ?? index,
column: index % 4,
row: Math.floor(index / 4)
}))
})
function reset() {
clearTimeout(settleTimer)
loaded.value = false
settled.value = false
}
function reveal() {
requestAnimationFrame(() => {
loaded.value = true
settleTimer = setTimeout(() => {
settled.value = true
}, 760)
})
}
function fail(event: Event) {
clearTimeout(settleTimer)
emit('error', event)
}
function cellStyle(cell: (typeof cells.value)[number]) {
return {
'--reveal-order': cell.order,
backgroundImage: loaded.value ? `url(${JSON.stringify(props.src)})` : undefined,
backgroundPosition: `${(cell.column / 3) * 100}% ${(cell.row / 3) * 100}%`
}
}
watch(() => props.src, reset)
onBeforeUnmount(() => clearTimeout(settleTimer))
</script>
<template>
<span class="reveal-image" :class="{ 'is-loaded': loaded, 'is-settled': settled }">
<NImage
:src="src"
:preview-src="src"
:alt="alt"
:lazy="lazy"
:preview-disabled="!preview"
:object-fit="objectFit"
:img-props="{ referrerpolicy: 'no-referrer' }"
:previewed-img-props="{ alt, referrerpolicy: 'no-referrer' }"
@load="reveal"
@error="fail"
/>
<span v-if="!settled" class="reveal-image-grid" aria-hidden="true">
<span v-for="cell in cells" :key="cell.index" class="reveal-image-cell" :style="cellStyle(cell)" />
</span>
</span>
</template>
<style>
@reference "../../styles/styles.css";
.reveal-image {
@apply relative block w-full h-full min-w-0 min-h-0 overflow-hidden bg-(--app-subtle);
}
.reveal-image > .n-image,
.reveal-image > .n-image img {
@apply w-full h-full min-w-0 min-h-0;
}
.reveal-image > .n-image img {
opacity: 0;
filter: blur(9px);
transform: scale(1.025);
transition:
opacity 280ms ease,
filter 620ms cubic-bezier(0.22, 1, 0.36, 1),
transform 620ms cubic-bezier(0.22, 1, 0.36, 1);
}
.reveal-image.is-loaded > .n-image img {
opacity: 1;
filter: blur(0);
transform: scale(1);
}
.reveal-image-grid {
@apply absolute inset-0 grid grid-cols-4 grid-rows-4 pointer-events-none;
gap: 1px;
background: var(--app-body);
}
.reveal-image-cell {
background-color: var(--app-control);
background-repeat: no-repeat;
background-size: 400% 400%;
opacity: 1;
transform: scale(1.01);
transition:
opacity 420ms cubic-bezier(0.4, 0, 0.2, 1),
transform 520ms cubic-bezier(0.22, 1, 0.36, 1);
transition-delay: calc(var(--reveal-order) * 18ms);
}
.reveal-image:not(.is-loaded) .reveal-image-cell {
animation: reveal-image-wait 1.8s ease-in-out infinite alternate;
animation-delay: calc(var(--reveal-order) * -55ms);
}
.reveal-image.is-loaded .reveal-image-cell {
opacity: 0;
transform: scale(0.88);
}
@keyframes reveal-image-wait {
from {
filter: brightness(0.96);
}
to {
filter: brightness(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.reveal-image > .n-image img,
.reveal-image-cell {
animation: none;
transition: none;
}
}
</style>
+1
View File
@@ -3,5 +3,6 @@ export { default as AppDialog } from './AppDialog.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as StatusBadge } from './StatusBadge.vue'
export { default as AssetImage } from './AssetImage.vue'
export { default as RevealImage } from './RevealImage.vue'
export { default as DirectoryItem } from './DirectoryItem.vue'
export { default as WorkspaceTools } from './WorkspaceTools.vue'
@@ -167,7 +167,7 @@ onScopeDispose(() => controller?.abort())
</script>
<template>
<NCollapse class="mt-4">
<NCollapse class="production-tool-section">
<NCollapseItem name="creative-profile" title="项目画布与生成模型">
<NSpin :show="loading">
<NAlert v-if="error" type="error" :show-icon="false" class="mb-3">{{ error }}</NAlert>
+321 -253
View File
@@ -227,8 +227,9 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
label="批量生产"
:has-receipt="!!session.receipt || !!session.pipelineReceipt"
>
<div class="flex flex-wrap items-end justify-between gap-4">
<div class="production-tools-intro">
<div>
<p class="eyebrow">PRODUCTION</p>
<h2 class="text-lg font-semibold">镜头生产</h2>
<p class="mt-2 text-sm text-muted">
从可生成的提示词和首帧到异步视频任务与最终成片
@@ -238,8 +239,13 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
<ArrowLeft :size="14" />回到分镜设计
</RouterLink>
</div>
<CreativeProfileSettings :project-id="id" />
<section class="mt-3" aria-label="项目生产配置">
<section class="production-batch-config" aria-label="项目生产配置">
<div class="production-section-heading">
<div>
<h3 class="text-sm font-semibold">批量执行设置</h3>
<p class="mt-1 text-xs text-muted">控制本次手动批处理的并发与覆盖方式</p>
</div>
</div>
<AppForm
:model="batchModel"
:rules="batchRules"
@@ -265,273 +271,285 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
</NCheckbox>
</div>
</AppForm>
<DetailDisclosure title="处理范围与覆盖规则" class="mt-3"
</section>
<div class="production-tool-stack">
<CreativeProfileSettings :project-id="id" />
<DetailDisclosure title="处理范围与覆盖规则" class="production-tool-section"
><p class="text-xs leading-6 text-muted">
提示词与视频就绪检查面向全项目视频实际提交受本批镜头上限控制并使用严格质量流水线首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式处理全项目过期首帧成功后会自动接替旧主图其余新增候选图片模型视频模型与视觉校验均可能产生费用
</p></DetailDisclosure
>
</section>
<div class="workspace-overview-panel">
<NCollapse v-model:expanded-names="overview"
><NCollapseItem name="overview" title="项目生产总览与批量操作"
><div class="overview-content">
<NAlert
v-if="videoRunning"
role="status"
type="info"
:show-icon="false"
class="mt-4"
>
当前有 {{ videoRunning }} 个视频任务等待或生成中后端正在轮询
Provider同一镜头不会重复提交活动任务
</NAlert>
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
当前有 {{ identityBlocked }} 项角色身份前置问题Character 必须生成
Identity确认演员母版并锁定后首帧才能就绪
<RouterLink
:to="`/projects/${id}/subject-identity`"
class="text-button ml-2"
>前往角色选角 </RouterLink
></NAlert
>
<NAlert
v-if="staleKeyframes"
role="status"
type="info"
:show-icon="false"
class="mt-4"
>
当前有
{{ staleKeyframes }}
个主首帧已过期它们使用的主体参考资产已经变化视频阶段会被阻止可在下方逐镜重新生成或使用补齐
/ 更新主首帧批量处理
</NAlert>
<NAlert
v-if="staleVideos"
role="status"
type="info"
:show-icon="false"
class="mt-4"
>
当前有
{{ staleVideos }}
个主视频已过期它们使用的主首帧或主体参考图已经变化非覆盖模式批量生成时会只重建这些过期主视频
</NAlert>
<ProductionReceipt v-if="session.receipt" :receipt="session.receipt" />
<div v-if="query.data.value" class="production-pipeline mt-5">
<article
v-for="stage in stages"
:key="stage.key"
class="panel production-pipeline-card"
<div class="workspace-overview-panel">
<NCollapse v-model:expanded-names="overview" class="production-tool-section"
><NCollapseItem name="overview" title="项目生产总览与批量操作"
><div class="overview-content">
<NAlert
v-if="videoRunning"
role="status"
type="info"
:show-icon="false"
class="mt-4"
>
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3">
<span class="production-step"
><component :is="stage.icon" :size="17"
/></span>
<div>
<p class="eyebrow">STEP {{ stage.code }}</p>
<h3 class="mt-2 text-sm font-semibold">
{{ stage.title }}
</h3>
当前有 {{ videoRunning }} 个视频任务等待或生成中后端正在轮询
Provider同一镜头不会重复提交活动任务
</NAlert>
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
当前有 {{ identityBlocked }} 项角色身份前置问题Character 必须生成
Identity确认演员母版并锁定后首帧才能就绪
<RouterLink
:to="`/projects/${id}/subject-identity`"
class="text-button ml-2"
>前往角色选角 </RouterLink
></NAlert
>
<NAlert
v-if="staleKeyframes"
role="status"
type="info"
:show-icon="false"
class="mt-4"
>
当前有
{{ staleKeyframes }}
个主首帧已过期它们使用的主体参考资产已经变化视频阶段会被阻止可在下方逐镜重新生成或使用补齐
/ 更新主首帧批量处理
</NAlert>
<NAlert
v-if="staleVideos"
role="status"
type="info"
:show-icon="false"
class="mt-4"
>
当前有
{{ staleVideos }}
个主视频已过期它们使用的主首帧或主体参考图已经变化非覆盖模式批量生成时会只重建这些过期主视频
</NAlert>
<ProductionReceipt v-if="session.receipt" :receipt="session.receipt" />
<div v-if="query.data.value" class="production-pipeline mt-5">
<article
v-for="stage in stages"
:key="stage.key"
class="panel production-pipeline-card"
>
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3">
<span class="production-step"
><component :is="stage.icon" :size="17"
/></span>
<div>
<p class="eyebrow">STEP {{ stage.code }}</p>
<h3 class="mt-2 text-sm font-semibold">
{{ stage.title }}
</h3>
</div>
</div>
<span class="font-mono text-xs text-muted"
>{{ stage.done }} / {{ stage.data?.total ?? 0 }}</span
>
</div>
<span class="font-mono text-xs text-muted"
>{{ stage.done }} / {{ stage.data?.total ?? 0 }}</span
>
</div>
<p class="mt-4 min-h-10 text-xs leading-5 text-muted">
{{ stage.description }}
</p>
<NProgress
:aria-label="`${stage.title}完成数量`"
type="line"
:show-indicator="false"
:height="4"
:percentage="
Math.min(
100,
Math.max(
0,
(stage.done /
Math.max(1, Math.max(stage.data?.total ?? 0, 1))) *
100
<p class="mt-4 min-h-10 text-xs leading-5 text-muted">
{{ stage.description }}
</p>
<NProgress
:aria-label="`${stage.title}完成数量`"
type="line"
:show-indicator="false"
:height="4"
:percentage="
Math.min(
100,
Math.max(
0,
(stage.done /
Math.max(
1,
Math.max(stage.data?.total ?? 0, 1)
)) *
100
)
)
)
"
class="mt-4"
></NProgress>
<dl class="production-stats mt-4">
"
class="mt-4"
></NProgress>
<dl class="production-stats mt-4">
<div>
<dt>可执行</dt>
<dd>{{ stage.data?.ready ?? 0 }}</dd>
</div>
<div>
<dt>已有结果</dt>
<dd>{{ stage.data?.skipped ?? 0 }}</dd>
</div>
<div>
<dt>阻塞</dt>
<dd>{{ stage.data?.blocked ?? 0 }}</dd>
</div>
</dl>
<p
v-if="stage.key === 'keyframes' && staleKeyframes"
class="mt-3 text-[11px] leading-5"
>
已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成
</p>
<p
v-if="stage.key === 'videos' && staleVideos"
class="mt-3 text-[11px] leading-5"
>
主视频已过期 {{ staleVideos }} ·
会按当前主首帧与参考资产重新生成
</p>
<p
v-if="stage.key === 'keyframes' && identityBlocked"
class="mt-3 text-[11px] leading-5 text-danger"
>
身份缺失
{{ query.data.value?.keyframes.missingIdentity ?? 0 }} ·
母版缺失
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} ·
未锁定
{{ query.data.value?.keyframes.identityUnlocked ?? 0 }}
</p>
<ConfirmAction
class="mt-4"
:label="stage.action"
:disabled="
blocked ||
!batchValid ||
(stage.key === 'keyframes' && !keyframeValid) ||
(stage.key === 'videos' && !videoQualityValid) ||
!(stage.data?.ready ?? 0)
"
acknowledgement
:description="
stage.key === 'videos'
? staleVideos && !force
? `当前存在过期主视频,非覆盖模式只会为其中至多 ${videoLimit || '—'} 个镜头启动严格质量流水线;候选自动校验并有限修复,通过后才接替旧主视频。`
: `只为至多 ${videoLimit || '—'} 个就绪镜头启动严格质量流水线。任务创建后由后端自动校验,最多修复 ${videoRepairAttempts || '—'} 次,通过后才晋升主视频。`
: stage.key === 'keyframes'
? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。`
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
"
:primary="stage.key === 'videos'"
@confirm="run(stage.key)"
/>
<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="留空不限制"
/></NFormItem>
<div class="grid grid-cols-2 gap-3">
<NFormItem path="keyframeWidth" label="宽度(像素)"
><NInputNumber
:input-props="{ 'aria-label': '宽度像素' }"
:value="keyframeWidth === '' ? null : keyframeWidth"
@update:value="keyframeWidth = $event ?? ''"
:min="1"
:disabled="operation.pending"
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="默认"
/></NFormItem>
</div>
</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': '视频质量任务镜头上限' }"
/></NFormItem>
<NFormItem path="videoRepairAttempts" label="每镜最多自动修复"
><NInputNumber
v-model:value="videoRepairAttempts"
:min="1"
:precision="0"
:disabled="operation.pending"
:input-props="{ 'aria-label': '视频自动修复次数' }"
/></NFormItem>
</AppForm>
</article>
</div>
<div v-if="query.data.value" class="panel mt-4 p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<dt>可执行</dt>
<dd>{{ stage.data?.ready ?? 0 }}</dd>
<h3 class="text-sm font-semibold">视频任务状态</h3>
<p class="mt-2 text-xs text-muted">
状态统计取每个镜头最近一次任务不等同于主视频数量
</p>
</div>
<ConfirmAction
label="重试失败视频"
:disabled="
blocked ||
!batchValid ||
!query.data.value.videoStatus.failed
"
acknowledgement
description="仅重新提交后端判定可重试的失败视频;不可重试项会跳过,并在回执中显示数量。"
@confirm="run('retry-videos')"
/>
</div>
<dl class="production-status-grid mt-4">
<div>
<dt>完成</dt>
<dd>{{ query.data.value.videoStatus.completed }}</dd>
</div>
<div>
<dt>已有结果</dt>
<dd>{{ stage.data?.skipped ?? 0 }}</dd>
<dt>排队生成</dt>
<dd>{{ videoRunning }}</dd>
</div>
<div>
<dt>阻塞</dt>
<dd>{{ stage.data?.blocked ?? 0 }}</dd>
<dt>失败</dt>
<dd>{{ query.data.value.videoStatus.failed }}</dd>
</div>
<div>
<dt>取消</dt>
<dd>{{ query.data.value.videoStatus.cancelled }}</dd>
</div>
<div>
<dt>未开始</dt>
<dd>{{ query.data.value.videoStatus.notStarted }}</dd>
</div>
</dl>
<p
v-if="stage.key === 'keyframes' && staleKeyframes"
class="mt-3 text-[11px] leading-5"
>
已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成
</p>
<p
v-if="stage.key === 'videos' && staleVideos"
class="mt-3 text-[11px] leading-5"
>
主视频已过期 {{ staleVideos }} · 会按当前主首帧与参考资产重新生成
</p>
<p
v-if="stage.key === 'keyframes' && identityBlocked"
class="mt-3 text-[11px] leading-5 text-danger"
>
身份缺失 {{ query.data.value?.keyframes.missingIdentity ?? 0 }} ·
母版缺失
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} ·
未锁定
{{ query.data.value?.keyframes.identityUnlocked ?? 0 }}
</p>
<ConfirmAction
class="mt-4"
:label="stage.action"
:disabled="
blocked ||
!batchValid ||
(stage.key === 'keyframes' && !keyframeValid) ||
(stage.key === 'videos' && !videoQualityValid) ||
!(stage.data?.ready ?? 0)
"
acknowledgement
:description="
stage.key === 'videos'
? staleVideos && !force
? `当前存在过期主视频,非覆盖模式只会为其中至多 ${videoLimit || '—'} 个镜头启动严格质量流水线;候选自动校验并有限修复,通过后才接替旧主视频。`
: `只为至多 ${videoLimit || '—'} 个就绪镜头启动严格质量流水线。任务创建后由后端自动校验,最多修复 ${videoRepairAttempts || '—'} 次,通过后才晋升主视频。`
: stage.key === 'keyframes'
? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。`
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
"
:primary="stage.key === 'videos'"
@confirm="run(stage.key)"
/>
<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="留空不限制"
/></NFormItem>
<div class="grid grid-cols-2 gap-3">
<NFormItem path="keyframeWidth" label="宽度(像素)"
><NInputNumber
:input-props="{ 'aria-label': '宽度像素' }"
:value="keyframeWidth === '' ? null : keyframeWidth"
@update:value="keyframeWidth = $event ?? ''"
:min="1"
:disabled="operation.pending"
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="默认"
/></NFormItem>
</div>
</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': '视频质量任务镜头上限' }"
/></NFormItem>
<NFormItem path="videoRepairAttempts" label="每镜最多自动修复"
><NInputNumber
v-model:value="videoRepairAttempts"
:min="1"
:precision="0"
:disabled="operation.pending"
:input-props="{ 'aria-label': '视频自动修复次数' }"
/></NFormItem>
</AppForm>
</article>
</div>
<div v-if="query.data.value" class="panel mt-4 p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold">视频任务状态</h3>
<p class="mt-2 text-xs text-muted">
状态统计取每个镜头最近一次任务不等同于主视频数量
</p>
</div>
<ConfirmAction
label="重试失败视频"
:disabled="
blocked || !batchValid || !query.data.value.videoStatus.failed
"
acknowledgement
description="仅重新提交后端判定可重试的失败视频;不可重试项会跳过,并在回执中显示数量。"
@confirm="run('retry-videos')"
/>
</div>
<dl class="production-status-grid mt-4">
<div>
<dt>完成</dt>
<dd>{{ query.data.value.videoStatus.completed }}</dd>
</div>
<div>
<dt>排队生成</dt>
<dd>{{ videoRunning }}</dd>
</div>
<div>
<dt>失败</dt>
<dd>{{ query.data.value.videoStatus.failed }}</dd>
</div>
<div>
<dt>取消</dt>
<dd>{{ query.data.value.videoStatus.cancelled }}</dd>
</div>
<div>
<dt>未开始</dt>
<dd>{{ query.data.value.videoStatus.notStarted }}</dd>
</div>
</dl>
</div>
</div></NCollapseItem
></NCollapse
>
</div></NCollapseItem
></NCollapse
>
</div>
<EpisodeAssemblyPanel :project-id="id" />
<AdvancedProduction />
</div>
<EpisodeAssemblyPanel :project-id="id" />
<AdvancedProduction />
</WorkspaceTools>
</div></div
></template>
@@ -695,6 +713,46 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.production-tools-intro {
@apply flex flex-wrap items-end justify-between gap-4 p-[18px] bg-(--app-surface);
}
.production-tools-intro .eyebrow {
@apply mb-2;
}
.production-batch-config {
@apply mt-3 p-4 bg-(--app-surface);
}
.production-section-heading {
@apply flex items-start justify-between gap-4;
}
.workspace-tools-body .production-batch-config .production-controls {
@apply mt-4 mb-0;
}
.production-tool-stack {
@apply mt-3 px-4 bg-(--app-surface);
}
.production-tool-stack .production-tool-section.n-collapse {
@apply m-0;
box-shadow: inset 0 1px var(--app-border);
}
.production-tool-stack > :first-child.production-tool-section.n-collapse {
box-shadow: none;
}
.production-tool-stack .production-tool-section .n-collapse-item__header {
@apply min-h-12 py-0;
}
.production-tool-stack .production-tool-section .n-collapse-item__header-main {
@apply text-[13px] font-medium;
}
.production-tool-stack .production-tool-section .n-collapse-item__content-inner {
@apply pt-1 pb-4 pl-6;
}
.production-tool-stack .detail-disclosure-content {
@apply py-3 px-4 bg-(--app-subtle);
}
.production-tool-stack .workspace-overview-panel {
@apply m-0;
}
.production-controls {
@apply grid grid-cols-[minmax(220px,_1.6fr)_minmax(110px,_0.5fr)_minmax(230px,_1fr)_auto] items-end gap-[18px];
}
@@ -737,6 +795,16 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
}
}
@media (max-width: 760px) {
.production-tools-intro,
.production-section-heading {
@apply items-start;
}
.production-tool-stack {
@apply px-3;
}
.production-tool-stack .production-tool-section .n-collapse-item__content-inner {
@apply pl-4;
}
.production-controls,
.production-workspace {
@apply grid-cols-[minmax(0,_1fr)];
@@ -29,7 +29,7 @@ function exportReceipt() {
}
</script>
<template>
<NCollapse v-model:expanded-names="expanded" class="mt-5"
<NCollapse v-model:expanded-names="expanded" class="production-tool-section"
><NCollapseItem name="advanced" title="高级:串联生产(全项目)">
<NAlert type="warning" :show-icon="false"
>依次补齐形态提示词参考图首帧质量校验与修复视频提示词再提交视频任务视觉风格身份母版导演设计和即时状态仍需提前准备</NAlert
@@ -60,7 +60,7 @@ onScopeDispose(() => controller?.abort())
</script>
<template>
<NCollapse class="mt-4">
<NCollapse class="production-tool-section">
<NCollapseItem name="episode-assembly" title="单集成片拼接">
<NSpin :show="loading">
<div class="flex flex-wrap items-center justify-between gap-3">
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { NImage } from 'naive-ui'
import { Box, MapPin, UserRound } from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { referenceImageUrl } from '../../../lib/assets'
import { subjectIdentityApi } from '../api'
import { currentAnchor } from '../model'
import RevealImage from '../../../components/ui/RevealImage.vue'
/** source 为 undefined 时按需查询;null 表示上层已确认没有母版,不能用候选或形态图替代。 */
const props = defineProps<{
@@ -89,16 +89,7 @@ watch(
<template>
<span ref="target" class="identity-thumbnail" :title="label">
<NImage
v-if="url && !imageFailed"
:src="url"
:alt="label"
object-fit="cover"
preview-disabled
lazy
:img-props="{ referrerpolicy: 'no-referrer' }"
@error="imageFailed = true"
/>
<RevealImage v-if="url && !imageFailed" :src="url" :alt="label" @error="imageFailed = true" />
<span v-else class="identity-thumbnail-placeholder" role="img" :aria-label="label">
<component :is="icon" :size="22" :stroke-width="1.4" aria-hidden="true" />
<span aria-hidden="true">{{ kind }}</span>
@@ -113,7 +104,8 @@ watch(
@apply flex w-12 h-[64px] overflow-hidden bg-(--app-subtle);
}
.identity-thumbnail .n-image,
.identity-thumbnail .n-image img {
.identity-thumbnail .n-image img,
.identity-thumbnail .reveal-image {
@apply w-full h-full;
}
.identity-thumbnail-placeholder {
+18 -1
View File
@@ -1,5 +1,5 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NImage } from 'naive-ui'
import AssetImage from '@/components/ui/AssetImage.vue'
@@ -8,6 +8,8 @@ afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe('资源图片展示与原图预览', () => {
@@ -31,6 +33,21 @@ describe('资源图片展示与原图预览', () => {
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('contain')
})
it('图片加载完成后按网格逐块显现,并在动画结束后移除覆盖层', async () => {
vi.useFakeTimers()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0)
return 1
})
wrapper = mount(AssetImage, { props: { src: '/storage/reveal.png', alt: '加载动效图片' } })
expect(wrapper.findAll('.reveal-image-cell')).toHaveLength(16)
await wrapper.get('img').trigger('load')
expect(wrapper.get('.reveal-image').classes()).toContain('is-loaded')
vi.advanceTimersByTime(760)
await wrapper.vm.$nextTick()
expect(wrapper.find('.reveal-image-grid').exists()).toBe(false)
})
it('cover 仅裁切卡片,点击使用 Naive 原生预览展示同一完整资源,遮罩可关闭', async () => {
wrapper = mount(AssetImage, {
attachTo: document.body,