Files
short-drama-agent-front/src/features/storyboard/StoryboardPage.vue
T
GouJ 769407cb14 fix: 优化移动端工作台筛选与工具栏
将形态图片状态筛选合并为下拉框,统一筛选容器内边距并调整背景。重新排列分镜与镜头生产移动端工具栏,隐藏重复统计并补充交互测试。
2026-09-08 09:59:05 +08:00

494 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import ActionMenu from '../../components/ui/ActionMenu.vue'
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
import {
NScrollbar,
NAlert,
NButton,
NCheckbox,
NCollapse,
NCollapseItem,
NInputNumber,
NProgress,
NSelect,
NTab,
NTabs
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { ArrowLeft, RefreshCw } from '@lucide/vue'
import { EmptyState } from '../../components/ui'
import { downloadText } from '../../lib/format'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import GenerationReceipt from './components/GenerationReceipt.vue'
import ShotDesign from './components/ShotDesign.vue'
import ShotTools from './components/ShotTools.vue'
import BeatShotDirectory from './components/BeatShotDirectory.vue'
import { useStoryboard } from './useStoryboard'
const detailTab = ref('design')
/** 分镜工作区按单集查看正式数据,生成配置与镜头检查分开。 */
const {
id,
selectedEpisode,
episodeNo,
episodeOptions,
selectedShot,
shots,
shot,
query,
data,
prerequisites,
session,
operation,
concurrency,
maxRepairAttempts,
force,
allowed,
batchValid,
repairsValid,
blocked,
directionComplete,
run
} = useStoryboard()
const coverage = computed(() => [
{
title: '导演设计',
count: data.value?.directions.directionCount ?? 0,
total: data.value?.directions.shotCount ?? 0
},
{
title: '即时状态',
count: data.value?.visualStates.visualStateCount ?? 0,
total: data.value?.visualStates.shotCount ?? 0
}
])
/** 导出当前剧集的正式镜头数据;标题与剧情描述来源于 Breakdown 快照。 */
function exportEpisode() {
downloadText(
`episode-${episodeNo.value}-storyboard.json`,
JSON.stringify(
{
projectId: id.value,
episodeNo: episodeNo.value,
directions: data.value?.directions,
visualStates: data.value?.visualStates,
shots: shots.value
},
null,
2
),
'application/json'
)
}
/** 回执到达后打开诊断区;平时留出镜头检查空间。 */
const toolsOpen = ref(false)
const overview = ref<string[]>(['generation'])
watch(
() => session.value.receipt,
receipt => {
if (receipt) {
overview.value = ['generation']
toolsOpen.value = true
}
}
)
</script>
<template>
<WorkspacePage split compact class="storyboard-workspace-page"
><template #header
><div class="workspace-toolbar">
<div class="toolbar-episode">
<NSelect
:value="episodeNo"
aria-label="选择分镜剧集"
@update:value="selectedEpisode = Number($event)"
:options="[
...(!episodeOptions.length ? [{ label: String('暂无剧集'), value: 0 }] : []),
...episodeOptions.map(episode => ({
label: String(' 第 ' + episode.episodeNo + ' 集 · ' + episode.title),
value: episode.episodeNo
}))
]"
class="select-control"
></NSelect>
</div>
<div class="toolbar-metrics">
<span v-for="item in coverage" :key="item.title"
>{{ item.title }} {{ item.count }} / {{ item.total }}</span
>
</div>
<div class="toolbar-actions">
<div class="flex gap-3">
<NButton
:disabled="query.loading.value"
@click="query.refresh"
quaternary
class="icon-button"
aria-label="刷新分镜"
title="刷新分镜"
><RefreshCw :size="16" :class="{ 'animate-spin': query.loading.value }"
/></NButton>
<ActionMenu
label="分镜更多操作"
:items="[{ key: 'export', label: '导出本集', disabled: !shots.length }]"
@select="exportEpisode"
/>
</div>
<WorkspaceTools
v-model:open="toolsOpen"
title="分镜生成配置与回执"
label="生成与恢复"
:has-receipt="!!session.receipt"
>
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">分镜设计</h2>
<p class="mt-2 text-sm text-muted">从镜头拆解到摄影设计再补齐每一镜的即时状态</p>
</div>
<RouterLink :to="`/projects/${id}/breakdown`" class="text-button"
><ArrowLeft :size="14" />回到拆解</RouterLink
>
</div>
<section class="mt-3" aria-label="分镜生成配置">
<div class="storyboard-controls">
<label
><span class="field-label">批量并发</span
><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
><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>
</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">
<div class="mb-3 flex items-center justify-between text-xs">
<span>{{ item.title }}</span
><span class="font-mono text-muted">{{ item.count }} / {{ item.total }} 镜头</span>
</div>
<NProgress
:aria-label="item.title + '已保存覆盖率'"
type="line"
:show-indicator="false"
:height="4"
:percentage="
Math.min(
100,
Math.max(0, (item.count / Math.max(1, Math.max(item.total, 1))) * 100)
)
"
class=""
></NProgress>
</div>
</div>
<div class="workspace-overview-panel">
<NCollapse v-model:expanded-names="overview"
><NCollapseItem name="generation" title="生成配置、批量操作与回执"
><div class="overview-content">
<div class="generation-row">
<div>
<h3 class="text-sm font-semibold">
<span class="mr-2 font-mono text-xs text-muted">01</span
>导演摄影设计
</h3>
<p class="mt-2 text-xs leading-6 text-muted">
景别机位运镜与构图正式项目生成默认跳过已完整剧集覆盖模式会重新生成全部剧集
</p>
</div>
<div class="flex flex-wrap gap-2">
<ConfirmAction
:label="force ? '重生成全部导演设计' : '补齐项目导演设计'"
:disabled="!allowed['direction-all']"
acknowledgement
:description="
force
? '重新生成项目全部剧集的导演设计,覆盖已有结果。'
: '对整个项目生成导演设计,跳过已完整保存的剧集,可用于重试失败或未完成的剧集。'
"
@confirm="run('direction-all')"
/>
</div>
</div>
<div class="generation-row">
<div>
<h3 class="text-sm font-semibold">
<span class="mr-2 font-mono text-xs text-muted">02</span
>即时视觉状态
</h3>
<p class="mt-2 text-xs leading-6 text-muted">
表情姿态位置环境与连续性每集需要完整导演设计批量中缺少前置设计的剧集会单独报错
</p>
</div>
<div class="flex flex-wrap gap-2">
<ConfirmAction
:label="force ? '重生成全部即时状态' : '补齐项目即时状态'"
:disabled="!allowed['visual-all']"
acknowledgement
:description="
force
? '重新生成整个项目的即时状态,覆盖已有结果。每集必须先有完整导演设计。'
: '补齐项目即时状态,跳过完整剧集;未完成导演设计的剧集会返回失败原因。'
"
@confirm="run('visual-all')"
/>
</div>
</div>
<p class="mt-4 text-[11px] leading-6 text-muted">
修改或覆盖上游设计后后端不会自动使旧状态旧提示词失效请按顺序重生成下游内容自动修复次数仅用于
VisualState
</p>
<NCollapse class="mt-3 pt-3"
><NCollapseItem name="details"
><template #header>视频提示词批量操作</template>
<p class="my-3 text-xs leading-6 text-muted">
后端会先校验 GenerationSpec
与主体参考图这里只生成提示词首帧视频任务就绪诊断与失败重试已整理到
<RouterLink
:to="`/projects/${id}/production`"
class="text-button mx-1"
>镜头生产</RouterLink
>
</p>
<ConfirmAction
:label="force ? '重生成全部视频提示词' : '补齐项目视频提示词'"
:disabled="!allowed.prompts"
acknowledgement
:description="
force
? '调用模型重新生成全项目镜头的视频提示词,覆盖已有正文。'
: '调用模型生成缺少视频提示词的镜头,已有提示词直接跳过。参考图缺失会返回失败。'
"
@confirm="run('prompts')" /></NCollapseItem
></NCollapse>
<GenerationReceipt
v-if="session.receipt"
:receipt="session.receipt"
/></div></NCollapseItem
></NCollapse>
</div>
</WorkspaceTools>
</div></div
></template>
<NScrollbar
v-if="
query.error.value ||
!prerequisites.directionEpisode ||
!prerequisites.visualEpisode ||
(data && !directionComplete && data.directions.shotCount)
"
class="workspace-feedback"
>
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
>{{ query.error.value }} 当前保留上次成功读取的数据,暂停生成操作。<NButton
@click="query.refresh"
text
size="small"
class="ml-3"
>
重试查询
</NButton></NAlert
><NAlert
v-if="!prerequisites.directionEpisode || !prerequisites.visualEpisode"
type="info"
:show-icon="false"
class="mt-4"
>
最新 Breakdown
缺少本集生成所需的镜头、主体或形态数据。已保存设计仍可查看;请先在拆解页完成或恢复相应阶段。 </NAlert
><NAlert
v-else-if="data && !directionComplete && data.directions.shotCount"
type="info"
:show-icon="false"
class="mt-4"
>
本集导演设计尚未补齐,请先完成第 01 步,再生成本集即时状态。
</NAlert>
</NScrollbar>
<div v-if="!data && query.loading.value" class="panel p-8 text-sm text-muted" role="status">
正在读取正式分镜数据……
</div>
<div v-else-if="shot" class="panel storyboard-workspace">
<nav class="shot-list directory-panel" aria-label="本集镜头">
<div class="directory-heading">
<h3>
镜头目录 <span>{{ shots.length }} 镜</span>
</h3>
<span>按 Beat 排列</span>
</div>
<NScrollbar
class="panel-scroll directory-scroll"
content-class="directory-list-content shot-list-content"
x-scrollable
>
<BeatShotDirectory
:shots="shots"
:active-id="shot.shotId"
item-class="shot-link"
@select="selectedShot = $event"
>
<template #meta="{ shot: item }">
<span class="directory-status">{{ item.direction ? '设计已保存' : '待设计' }}</span>
<span class="directory-status">{{ item.visualState ? '状态已保存' : '待状态' }}</span>
</template>
</BeatShotDirectory>
</NScrollbar>
</nav>
<article class="storyboard-detail min-w-0">
<NScrollbar class="panel-scroll" content-class="p-5 lg:p-7">
<header class="mb-6">
<p class="eyebrow">
BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }}
<span v-if="shot.durationSeconds" class="ml-3">{{ shot.durationSeconds }}s</span>
</p>
<h3 class="mt-2 text-lg font-semibold">{{ shot.title }}</h3>
<p v-if="shot.description" class="mt-3 whitespace-pre-wrap text-sm leading-7">
{{ shot.description }}
</p>
<p v-if="shot.visualFocus" class="mt-2 text-xs leading-6 text-muted">
视觉重点:{{ shot.visualFocus }}
</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
<p class="mt-1 text-[10px] text-muted">
标题与剧情描述取自最近可用 Breakdown 快照;以下设计与状态取自数据库。
</p>
</header>
<div>
<NTabs v-model:value="detailTab" type="line" size="small" aria-label="镜头详情"
><NTab name="design">设计与状态</NTab><NTab name="tools">参考图与生成规格</NTab></NTabs
>
<div v-if="detailTab === 'design'" class="pt-6"><ShotDesign :shot="shot" /></div>
<div v-if="detailTab === 'tools'" class="pt-6">
<ShotTools :key="shot.shotId" :project-id="id" :shot="shot" :disabled="blocked" />
</div>
</div>
</NScrollbar>
</article>
</div>
<EmptyState
v-else-if="data"
title="本集还没有正式分镜"
description="先完成剧本拆解与分镜持久化,再回来生成导演设计和即时状态。"
><RouterLink :to="`/projects/${id}/breakdown`" class="button button-secondary mt-4"
>前往拆解</RouterLink
></EmptyState
></WorkspacePage
>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.storyboard-controls {
@apply grid grid-cols-[minmax(180px,_1fr)_100px_145px_auto] gap-[18px] items-end;
}
.generation-row {
@apply flex items-center justify-between gap-5 pt-5 mt-5;
}
.generation-row > div:last-child {
@apply shrink-0;
}
.storyboard-workspace {
@apply grid grid-cols-[220px_minmax(0,_1fr)] overflow-hidden;
}
.shot-list {
@apply max-h-[1000px] overflow-hidden bg-(--app-surface) pt-0 px-2 pb-5;
}
@media (max-width: 1200px) {
.generation-row {
@apply items-start flex-col gap-3;
}
.storyboard-controls {
@apply grid-cols-[minmax(0,_1fr)_minmax(0,_1fr)];
}
}
@media (max-width: 760px) {
.storyboard-controls {
@apply grid-cols-[minmax(0,_1fr)] gap-3;
}
.storyboard-workspace {
@apply grid-cols-[minmax(0,_1fr)];
}
.shot-list {
@apply max-h-[240px];
border-right: 0;
}
}
.storyboard-workspace-page .storyboard-workspace {
@apply flex-1 h-auto min-h-0 overflow-hidden;
}
.storyboard-coverage {
@apply shrink-0;
}
.storyboard-workspace {
@apply grid-cols-[clamp(240px,_22%,_280px)_minmax(0,_1fr)];
}
.storyboard-workspace {
@apply h-[clamp(360px,65dvh,850px)] min-h-0;
}
.storyboard-workspace > article,
.storyboard-workspace .shot-list {
@apply overflow-hidden min-h-0 h-full max-h-none;
}
.shot-list-content {
@apply gap-3 pt-0 px-0 pb-3;
}
.shot-list {
@apply p-0;
}
@media (max-width: 760px) {
/* 移动端按整行排列剧集、统计和操作,避免 flex 自动换行留下不规则空白。 */
.storyboard-workspace-page .workspace-toolbar {
@apply grid grid-cols-[minmax(0,_1fr)] gap-2;
}
.storyboard-workspace-page .toolbar-episode,
.storyboard-workspace-page .toolbar-metrics,
.storyboard-workspace-page .toolbar-actions {
@apply w-full min-w-0 m-0;
}
.storyboard-workspace-page .toolbar-metrics {
@apply justify-between;
}
.storyboard-workspace-page .toolbar-actions {
@apply justify-start;
}
.storyboard-workspace {
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[184px_minmax(0,_1fr)];
}
.storyboard-controls {
@apply grid-cols-[minmax(0,_1fr)_minmax(0,_1fr)];
}
}
</style>