refactor: 使用 Naive UI 重构后台布局与黑白双主题
This commit is contained in:
@@ -1,16 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import { NAlert, NButton, NCheckbox, NInputNumber, NProgress, NTab, NTable, NTabs, NTag } from 'naive-ui'
|
||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||
import {
|
||||
TabsRoot,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
CheckboxRoot,
|
||||
CheckboxIndicator,
|
||||
ProgressRoot,
|
||||
ProgressIndicator
|
||||
} from 'reka-ui'
|
||||
import { Check, Download, Layers, LoaderCircle, ArrowRight } from '@lucide/vue'
|
||||
import { Download, Layers, LoaderCircle, ArrowRight } from '@lucide/vue'
|
||||
import { EmptyState, StatusBadge } from '../../components/ui'
|
||||
import { useProjectContext } from '../projects/context'
|
||||
import { breakdownApi } from './api'
|
||||
@@ -88,7 +80,7 @@ watch(
|
||||
)
|
||||
onScopeDispose(invalidatePreview)
|
||||
|
||||
/** Reka Checkbox 的不确定值不视为选择。 */
|
||||
/** 只接受明确勾选状态,不把不确定值作为模块选择。 */
|
||||
function toggleModule(module: BreakdownModule, checked: boolean | 'indeterminate') {
|
||||
if (checked === true && !modules.value.includes(module)) modules.value.push(module)
|
||||
else modules.value = modules.value.filter(item => item !== module)
|
||||
@@ -140,7 +132,11 @@ function exportResult() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6">
|
||||
<WorkspacePage
|
||||
><template #header
|
||||
><h2 class="text-lg font-semibold">剧本拆解</h2>
|
||||
<p class="text-xs text-muted">分组抽取、主体整理与分镜结果</p></template
|
||||
>
|
||||
<div v-if="!project?.episodes.length" class="panel">
|
||||
<EmptyState
|
||||
title="还没有可拆解的剧集"
|
||||
@@ -159,54 +155,53 @@ function exportResult() {
|
||||
{{ project.episodes.length }} 集剧本。每个分组分别抽取所选模块,再生成主体与分镜。
|
||||
</p>
|
||||
</div>
|
||||
<span class="tag">来源:正式剧集</span>
|
||||
<NTag size="small" :bordered="false">来源:正式剧集</NTag>
|
||||
</div>
|
||||
<div class="breakdown-config">
|
||||
<div>
|
||||
<label class="field-label" for="group-size">每组集数</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
id="group-size"
|
||||
v-model.number="groupSize"
|
||||
type="number"
|
||||
class="input w-24"
|
||||
min="1"
|
||||
step="1"
|
||||
<NInputNumber
|
||||
:disabled="operation.pending"
|
||||
/><span class="text-sm text-muted">集 / 组</span>
|
||||
:input-props="{ id: 'group-size' }"
|
||||
class="w-24"
|
||||
:value="typeof groupSize === 'number' ? groupSize : null"
|
||||
@update:value="groupSize = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
><span class="text-sm text-muted">集 / 组</span>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="flex flex-wrap gap-5 border-0 p-0">
|
||||
<legend class="field-label mb-3">抽取模块</legend>
|
||||
<label
|
||||
<NCheckbox
|
||||
:checked="modules.includes(option.value)"
|
||||
:disabled="operation.pending"
|
||||
:aria-label="option.label"
|
||||
@update:checked="toggleModule(option.value, $event)"
|
||||
v-for="option in moduleOptions"
|
||||
:key="option.value"
|
||||
class="flex cursor-pointer items-start gap-2.5"
|
||||
><CheckboxRoot
|
||||
:model-value="modules.includes(option.value)"
|
||||
:disabled="operation.pending"
|
||||
class="checkbox mt-0.5"
|
||||
:aria-label="option.label"
|
||||
@update:model-value="toggleModule(option.value, $event)"
|
||||
><CheckboxIndicator><Check :size="12" /></CheckboxIndicator></CheckboxRoot
|
||||
><span
|
||||
><span class="block text-sm font-medium">{{ option.label }}</span
|
||||
><span class="mt-1 block text-[11px] text-muted">{{ option.description }}</span></span
|
||||
></label
|
||||
></NCheckbox
|
||||
>
|
||||
</fieldset>
|
||||
<button
|
||||
class="button button-secondary self-end"
|
||||
<NButton
|
||||
:disabled="!configValid || previewBusy || operation.pending || !!error"
|
||||
@click="loadPreview"
|
||||
>
|
||||
<LoaderCircle v-if="previewBusy" :size="14" class="animate-spin" />预览分组
|
||||
</button>
|
||||
class="self-end"
|
||||
><LoaderCircle v-if="previewBusy" :size="14" class="animate-spin" />预览分组
|
||||
</NButton>
|
||||
</div>
|
||||
<p v-if="!configValid" class="mt-4 text-xs text-danger">
|
||||
每组集数必须是正整数,并至少选择一个抽取模块。
|
||||
</p>
|
||||
<p v-if="previewError" class="alert alert-error mt-4" role="alert">{{ previewError }}</p>
|
||||
<NAlert v-if="previewError" role="alert" type="error" :show-icon="false" class="mt-4">{{
|
||||
previewError
|
||||
}}</NAlert>
|
||||
<div v-if="preview" class="group-preview">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm">
|
||||
@@ -266,29 +261,28 @@ function exportResult() {
|
||||
><span>主体 {{ subjects.length }}</span
|
||||
><span>分镜剧集 {{ shots.length }} / {{ plans.length }}</span>
|
||||
</div>
|
||||
<ProgressRoot
|
||||
<NProgress
|
||||
v-if="summary?.total"
|
||||
class="progress-track mt-3"
|
||||
:model-value="summary.completed"
|
||||
:max="summary.total"
|
||||
aria-label="已完成抽取任务"
|
||||
><ProgressIndicator
|
||||
class="progress-fill"
|
||||
:style="{ width: Math.min(100, (summary.completed / summary.total) * 100) + '%' }"
|
||||
/></ProgressRoot>
|
||||
<p v-if="execution?.errorMessage" class="alert alert-error mt-4" role="alert">
|
||||
{{ execution.errorMessage }}
|
||||
</p>
|
||||
<div v-if="validation && !validation.valid" class="alert alert-error mt-4">
|
||||
<p class="font-medium">分镜校验未通过</p>
|
||||
type="line"
|
||||
:show-indicator="false"
|
||||
:height="4"
|
||||
:percentage="Math.min(100, Math.max(0, (summary.completed / Math.max(1, summary.total)) * 100))"
|
||||
class="mt-3"
|
||||
></NProgress>
|
||||
<NAlert v-if="execution?.errorMessage" role="alert" type="error" :show-icon="false" class="mt-4">{{
|
||||
execution.errorMessage
|
||||
}}</NAlert>
|
||||
<NAlert v-if="validation && !validation.valid" type="error" :show-icon="false" class="mt-4"
|
||||
><p class="font-medium">分镜校验未通过</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1">
|
||||
<li v-for="(issue, index) in validation.issues" :key="index">
|
||||
第 {{ issue.episodeNo }} 集<span v-if="issue.beatNo"> / Beat {{ issue.beatNo }}</span
|
||||
><span v-if="issue.shotNo"> / Shot {{ issue.shotNo }}</span
|
||||
>:{{ issue.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ul></NAlert
|
||||
>
|
||||
<div v-if="showRecovery" class="recovery-strip mt-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium">从中断处继续</p>
|
||||
@@ -319,42 +313,39 @@ function exportResult() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<TabsRoot v-model="tab" class="mt-7">
|
||||
<div class="mt-7">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<TabsList class="tabs-list" aria-label="拆解结果"
|
||||
><TabsTrigger
|
||||
v-for="option in moduleOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="tab-trigger"
|
||||
<NTabs v-model:value="tab" type="line" size="small" aria-label="拆解结果"
|
||||
><NTab v-for="option in moduleOptions" :key="option.value" :name="option.value"
|
||||
>{{ option.label
|
||||
}}<span>{{
|
||||
subjects.filter(item => item.module === option.value).length
|
||||
}}</span></TabsTrigger
|
||||
><TabsTrigger value="storyboard" class="tab-trigger">分镜</TabsTrigger
|
||||
><TabsTrigger value="tasks" class="tab-trigger">任务明细</TabsTrigger></TabsList
|
||||
><button class="text-button" :disabled="!snapshot" @click="exportResult">
|
||||
<Download :size="14" />导出 JSON
|
||||
</button>
|
||||
}}<span>{{ subjects.filter(item => item.module === option.value).length }}</span></NTab
|
||||
><NTab name="storyboard">分镜</NTab><NTab name="tasks">任务明细</NTab></NTabs
|
||||
><NButton :disabled="!snapshot" @click="exportResult" text size="small"
|
||||
><Download :size="14" />导出 JSON
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="content-with-history panel mt-4">
|
||||
<main class="min-w-0">
|
||||
<TabsContent v-for="option in moduleOptions" :key="option.value" :value="option.value"
|
||||
><SubjectList
|
||||
:project-id="project?.id"
|
||||
:subjects="subjects.filter(item => item.module === option.value)"
|
||||
:forms="forms" /></TabsContent
|
||||
><TabsContent value="storyboard"
|
||||
><div class="mb-4 flex justify-end">
|
||||
<template v-for="option in moduleOptions" :key="option.value"
|
||||
><div v-if="tab === option.value">
|
||||
<SubjectList
|
||||
:project-id="project?.id"
|
||||
:subjects="subjects.filter(item => item.module === option.value)"
|
||||
:forms="forms"
|
||||
/></div
|
||||
></template>
|
||||
<div v-if="tab === 'storyboard'">
|
||||
<div class="mb-4 flex justify-end">
|
||||
<RouterLink :to="`/projects/${project?.id}/storyboard`" class="text-button"
|
||||
>进入分镜设计<ArrowRight :size="14"
|
||||
/></RouterLink>
|
||||
</div>
|
||||
<StoryboardList :plans="plans" :episodes="shots" /></TabsContent
|
||||
><TabsContent value="tasks" class="p-5"
|
||||
><div v-if="snapshot?.tasks?.length" class="table-scroll">
|
||||
<table class="project-table">
|
||||
<thead>
|
||||
<StoryboardList :plans="plans" :episodes="shots" />
|
||||
</div>
|
||||
<div v-if="tab === 'tasks'" class="p-5">
|
||||
<div v-if="snapshot?.tasks?.length" class="table-scroll">
|
||||
<NTable :single-line="false" class="project-table"
|
||||
><thead>
|
||||
<tr>
|
||||
<th>分组</th>
|
||||
<th>模块</th>
|
||||
@@ -390,18 +381,18 @@ function exportResult() {
|
||||
<td colspan="4" class="text-danger">{{ task.errorMessage }}</td>
|
||||
</tr></template
|
||||
>
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody></NTable
|
||||
>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
title="尚无抽取任务记录"
|
||||
description="启动拆解后,任务会在后端保存 checkpoint 时更新。预览中的任务尚未执行。"
|
||||
/></TabsContent>
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<HistoryPanel :checkpoints="checkpoints" workflow="breakdown" />
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</template>
|
||||
</div>
|
||||
</div> </template
|
||||
></WorkspacePage>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NSelect, NTag } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Clock3 } from '@lucide/vue'
|
||||
import { EmptyState } from '../../../components/ui'
|
||||
@@ -28,14 +29,25 @@ const purposeLabels: Record<string, string> = {
|
||||
<div v-if="plan" class="p-5 lg:p-7">
|
||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<label class="flex items-center gap-3 text-sm"
|
||||
>选择剧集<select v-model="selected" class="input w-auto" aria-label="选择分镜剧集">
|
||||
<option v-if="selected === undefined" :value="undefined">
|
||||
第 {{ plan.episodeNo }} 集 · {{ plan.episodeTitle }}
|
||||
</option>
|
||||
<option v-for="item in plans" :key="item.episodeNo" :value="item.episodeNo">
|
||||
第 {{ item.episodeNo }} 集 · {{ item.episodeTitle }}
|
||||
</option>
|
||||
</select></label
|
||||
>选择剧集<NSelect
|
||||
aria-label="选择分镜剧集"
|
||||
v-model:value="selected"
|
||||
:options="[
|
||||
...(selected === undefined
|
||||
? [
|
||||
{
|
||||
label: String(' 第 ' + plan.episodeNo + ' 集 · ' + plan.episodeTitle),
|
||||
value: undefined
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...plans.map(item => ({
|
||||
label: String(' 第 ' + item.episodeNo + ' 集 · ' + item.episodeTitle),
|
||||
value: item.episodeNo
|
||||
}))
|
||||
]"
|
||||
class="select-control"
|
||||
></NSelect></label
|
||||
><span class="text-xs text-muted"
|
||||
>{{ plan.beats.length }} 个节拍 ·
|
||||
{{ episode?.beatShots.reduce((sum, beat) => sum + beat.shots.length, 0) ?? 0 }} 个镜头</span
|
||||
@@ -45,14 +57,14 @@ const purposeLabels: Record<string, string> = {
|
||||
<p class="font-medium">{{ plan.storyGoal }}</p>
|
||||
<p class="mt-2 text-sm leading-6 text-muted">{{ plan.emotionalArc }}</p>
|
||||
</div>
|
||||
<p v-if="!episode" class="alert mb-5">
|
||||
<NAlert v-if="!episode" type="info" :show-icon="false" class="mb-5">
|
||||
本集已完成节拍规划,镜头尚未生成。请等待进度更新,或在确认中断后使用“补齐缺失镜头”。
|
||||
</p>
|
||||
</NAlert>
|
||||
<section v-for="beat in plan.beats" :key="beat.beatNo" class="beat-section">
|
||||
<div class="beat-heading">
|
||||
<span class="beat-number">{{ String(beat.beatNo).padStart(2, '0') }}</span>
|
||||
<h3 class="text-sm font-semibold">{{ beat.title }}</h3>
|
||||
<span class="tag ml-auto">{{ purposeLabels[beat.purpose] || beat.purpose }}</span>
|
||||
<NTag size="small" :bordered="false">{{ purposeLabels[beat.purpose] || beat.purpose }}</NTag>
|
||||
</div>
|
||||
<p class="mb-4 mt-3 text-sm leading-6 text-muted">{{ beat.description }}</p>
|
||||
<div class="space-y-3">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NCollapse, NCollapseItem, NInput, NTag } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Search, ChevronDown } from '@lucide/vue'
|
||||
import { EmptyState } from '../../../components/ui'
|
||||
@@ -21,7 +22,12 @@ const filtered = computed(() =>
|
||||
<div class="mb-5 flex items-center justify-between gap-3">
|
||||
<p class="text-sm text-muted">{{ subjects.length }} 个主体</p>
|
||||
<div class="search-field">
|
||||
<Search :size="14" /><input v-model="search" placeholder="搜索名称或引用" aria-label="搜索主体" />
|
||||
<Search :size="14" /><NInput
|
||||
placeholder="搜索名称或引用"
|
||||
aria-label="搜索主体"
|
||||
:input-props="{ 'aria-label': '搜索主体' }"
|
||||
v-model:value="search"
|
||||
></NInput>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filtered.length" class="divide-y divide-line">
|
||||
@@ -46,28 +52,31 @@ const filtered = computed(() =>
|
||||
class="text-button mt-3"
|
||||
>查看形态图片/生图</RouterLink
|
||||
>
|
||||
<details class="subject-details mt-4">
|
||||
<summary>
|
||||
<ChevronDown :size="14" />外观描述与形态
|
||||
<span class="ml-1 text-muted">{{
|
||||
forms.filter(form => form.profileId === subject.profileId).length
|
||||
}}</span>
|
||||
</summary>
|
||||
<p class="mt-3 whitespace-pre-wrap text-sm leading-7 text-muted">
|
||||
{{ subject.appearance_prompt || '暂无外观描述' }}
|
||||
</p>
|
||||
<div
|
||||
v-for="form in forms.filter(item => item.profileId === subject.profileId)"
|
||||
:key="form.formId"
|
||||
class="mt-3 border-l-2 border-line pl-4"
|
||||
>
|
||||
<p class="text-sm font-medium">
|
||||
{{ form.formName || form.name }}<span v-if="form.isDefault" class="tag ml-2">默认</span>
|
||||
<NCollapse class="subject-details mt-4"
|
||||
><NCollapseItem name="details"
|
||||
><template #header
|
||||
><ChevronDown :size="14" />外观描述与形态
|
||||
<span class="ml-1 text-muted">{{
|
||||
forms.filter(form => form.profileId === subject.profileId).length
|
||||
}}</span></template
|
||||
>
|
||||
<p class="mt-3 whitespace-pre-wrap text-sm leading-7 text-muted">
|
||||
{{ subject.appearance_prompt || '暂无外观描述' }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm leading-6 text-muted">{{ form.description }}</p>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">{{ form.appearancePrompt }}</p>
|
||||
</div>
|
||||
</details>
|
||||
<div
|
||||
v-for="form in forms.filter(item => item.profileId === subject.profileId)"
|
||||
:key="form.formId"
|
||||
class="mt-3 border-l-2 border-line pl-4"
|
||||
>
|
||||
<p class="text-sm font-medium">
|
||||
{{ form.formName || form.name
|
||||
}}<NTag v-if="form.isDefault" size="small" :bordered="false">默认</NTag>
|
||||
</p>
|
||||
<p class="mt-2 text-sm leading-6 text-muted">{{ form.description }}</p>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">{{ form.appearancePrompt }}</p>
|
||||
</div></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
</article>
|
||||
</div>
|
||||
<EmptyState
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import { NButton, NCollapse, NCollapseItem, NProgress, NTab, NTabs, NTag } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { TabsRoot, TabsList, TabsTrigger, TabsContent, ProgressRoot, ProgressIndicator } from 'reka-ui'
|
||||
import { ArrowRight, Download, Check, Circle } from '@lucide/vue'
|
||||
import { EmptyState } from '../../components/ui'
|
||||
import { useProjectContext } from '../projects/context'
|
||||
@@ -67,13 +68,19 @@ function exportScript() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6">
|
||||
<div class="stage-strip">
|
||||
<div v-for="(stage, index) in stages" :key="stage.label" class="stage-item" :class="{ done: stage.done }">
|
||||
<Check v-if="stage.done" :size="15" /><Circle v-else :size="13" /><span>{{ stage.label }}</span
|
||||
><ArrowRight v-if="index < stages.length - 1" :size="13" class="stage-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
<WorkspacePage split class="script-workspace-page"
|
||||
><template #header
|
||||
><div class="stage-strip">
|
||||
<div
|
||||
v-for="(stage, index) in stages"
|
||||
:key="stage.label"
|
||||
class="stage-item"
|
||||
:class="{ done: stage.done }"
|
||||
>
|
||||
<Check v-if="stage.done" :size="15" /><Circle v-else :size="13" /><span>{{ stage.label }}</span
|
||||
><ArrowRight v-if="index < stages.length - 1" :size="13" class="stage-arrow" />
|
||||
</div></div
|
||||
></template>
|
||||
<div v-if="!complete" class="mt-5 flex flex-wrap items-center justify-between gap-4">
|
||||
<p class="text-sm text-muted">
|
||||
已写入 {{ episodes.length }}<span v-if="total"> / {{ total }}</span> 集<span v-if="!total">
|
||||
@@ -98,28 +105,26 @@ function exportScript() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ProgressRoot
|
||||
<NProgress
|
||||
v-if="total && !complete"
|
||||
class="progress-track mt-4"
|
||||
:model-value="Math.min(episodes.length, total)"
|
||||
:max="total"
|
||||
aria-label="已写入剧集数量"
|
||||
><ProgressIndicator
|
||||
class="progress-fill"
|
||||
:style="{ width: Math.min(100, (episodes.length / total) * 100) + '%' }"
|
||||
/></ProgressRoot>
|
||||
<TabsRoot v-model="tab" class="mt-7">
|
||||
type="line"
|
||||
:show-indicator="false"
|
||||
:height="4"
|
||||
:percentage="Math.min(100, Math.max(0, (Math.min(episodes.length, total) / Math.max(1, total)) * 100))"
|
||||
class="mt-4"
|
||||
></NProgress>
|
||||
<div class="script-section">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<TabsList class="tabs-list" aria-label="剧本内容"
|
||||
><TabsTrigger class="tab-trigger" value="episodes"
|
||||
>剧集正文 <span>{{ episodes.length }}</span></TabsTrigger
|
||||
><TabsTrigger class="tab-trigger" value="characters">角色设定</TabsTrigger
|
||||
><TabsTrigger class="tab-trigger" value="world">世界观</TabsTrigger
|
||||
><TabsTrigger class="tab-trigger" value="review">审核记录</TabsTrigger></TabsList
|
||||
<NTabs v-model:value="tab" type="line" size="small" aria-label="剧本内容"
|
||||
><NTab name="episodes"
|
||||
>剧集正文 <span>{{ episodes.length }}</span></NTab
|
||||
><NTab name="characters">角色设定</NTab><NTab name="world">世界观</NTab
|
||||
><NTab name="review">审核记录</NTab></NTabs
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="text-button" :disabled="!episodes.length" @click="exportScript">
|
||||
<Download :size="14" />导出剧本</button
|
||||
<NButton :disabled="!episodes.length" @click="exportScript" text size="small"
|
||||
><Download :size="14" />导出剧本</NButton
|
||||
><RouterLink
|
||||
v-if="complete"
|
||||
class="text-button text-accent"
|
||||
@@ -130,21 +135,20 @@ function exportScript() {
|
||||
</div>
|
||||
<div class="content-with-history panel mt-4">
|
||||
<main class="min-w-0">
|
||||
<TabsContent value="episodes" class="h-full">
|
||||
<div v-if="tab === 'episodes'" class="h-full">
|
||||
<div v-if="episode" class="script-workspace">
|
||||
<aside class="episode-list">
|
||||
<p class="px-4 pb-3 pt-5 text-[11px] font-medium tracking-wider text-muted">剧集目录</p>
|
||||
<button
|
||||
<NButton
|
||||
v-for="item in episodes"
|
||||
:key="item.episode"
|
||||
class="episode-link"
|
||||
:class="{ active: episode.episode === item.episode }"
|
||||
:aria-pressed="episode.episode === item.episode"
|
||||
@click="selected = item.episode"
|
||||
class="episode-link"
|
||||
:class="{ active: episode.episode === item.episode }"
|
||||
><span class="episode-number">{{ String(item.episode).padStart(2, '0') }}</span
|
||||
><span class="truncate">{{ item.title }}</span></NButton
|
||||
>
|
||||
<span class="episode-number">{{ String(item.episode).padStart(2, '0') }}</span
|
||||
><span class="truncate">{{ item.title }}</span>
|
||||
</button>
|
||||
</aside>
|
||||
<article class="script-page">
|
||||
<p class="eyebrow">第 {{ String(episode.episode).padStart(2, '0') }} 集</p>
|
||||
@@ -167,9 +171,9 @@ function exportScript() {
|
||||
title="剧本正在酝酿"
|
||||
description="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可以在右侧查看已保存的执行记录。"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="characters" class="p-6 lg:p-8"
|
||||
><div v-if="project?.characters.length" class="divide-y divide-line">
|
||||
</div>
|
||||
<div v-if="tab === 'characters'" class="p-6 lg:p-8">
|
||||
<div v-if="project?.characters.length" class="divide-y divide-line">
|
||||
<article
|
||||
v-for="character in project.characters"
|
||||
:key="character.id"
|
||||
@@ -177,7 +181,7 @@ function exportScript() {
|
||||
>
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold">{{ character.name }}</h3>
|
||||
<span class="tag">{{ character.role || '角色' }}</span
|
||||
<NTag size="small" :bordered="false">{{ character.role || '角色' }}</NTag
|
||||
><span class="text-xs text-muted">{{ character.occupation }}</span>
|
||||
</div>
|
||||
<dl class="detail-grid">
|
||||
@@ -194,10 +198,10 @@ function exportScript() {
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
<EmptyState v-else title="尚无角色设定" description="角色生成结束后会在这里显示。"
|
||||
/></TabsContent>
|
||||
<TabsContent value="world" class="p-6 lg:p-8"
|
||||
><div v-if="project?.world">
|
||||
<EmptyState v-else title="尚无角色设定" description="角色生成结束后会在这里显示。" />
|
||||
</div>
|
||||
<div v-if="tab === 'world'" class="p-6 lg:p-8">
|
||||
<div v-if="project?.world">
|
||||
<p class="eyebrow">故事的发生之地</p>
|
||||
<h2 class="mb-7 mt-3 text-xl font-semibold">
|
||||
{{ project.world.era }} · {{ project.world.location }}
|
||||
@@ -214,15 +218,17 @@ function exportScript() {
|
||||
<dd>{{ project.world[field.key] || '未提供' }}</dd></template
|
||||
>
|
||||
</dl>
|
||||
<details v-if="project.world.rules" class="mt-6 text-sm">
|
||||
<summary class="cursor-pointer text-muted">世界规则</summary>
|
||||
<pre class="json-view mt-3">{{ JSON.stringify(project.world.rules, null, 2) }}</pre>
|
||||
</details>
|
||||
<NCollapse v-if="project.world.rules" class="mt-6 text-sm"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>世界规则</template>
|
||||
<pre class="json-view mt-3">{{ JSON.stringify(project.world.rules, null, 2) }}</pre>
|
||||
</NCollapseItem></NCollapse
|
||||
>
|
||||
</div>
|
||||
<EmptyState v-else title="尚无世界观" description="世界观会在角色设定之后生成。"
|
||||
/></TabsContent>
|
||||
<TabsContent value="review" class="p-6 lg:p-8"
|
||||
><div v-if="project?.reviews.length" class="space-y-5">
|
||||
<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"
|
||||
@@ -244,15 +250,15 @@ function exportScript() {
|
||||
v-else
|
||||
title="还没有审核记录"
|
||||
description="剧集生成完成后,工作流会进行内容审核与必要的改写。"
|
||||
/>
|
||||
<details v-if="state?.rewriteSuggestion" class="mt-5 text-sm">
|
||||
<summary class="cursor-pointer text-muted">查看改写建议原文</summary>
|
||||
<pre class="json-view mt-3">{{ JSON.stringify(state.rewriteSuggestion, null, 2) }}</pre>
|
||||
</details></TabsContent
|
||||
>
|
||||
/><NCollapse v-if="state?.rewriteSuggestion" class="mt-5 text-sm"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>查看改写建议原文</template>
|
||||
<pre class="json-view mt-3">{{ JSON.stringify(state.rewriteSuggestion, null, 2) }}</pre>
|
||||
</NCollapseItem></NCollapse
|
||||
>
|
||||
</div>
|
||||
</main>
|
||||
<HistoryPanel :checkpoints="checkpoints" workflow="create-drama" />
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</div></div
|
||||
></WorkspacePage>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import { NCollapse, NCollapseItem, NAlert, NButton, NCheckbox, NInputNumber, NProgress, NSelect } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue'
|
||||
import { ProgressRoot, ProgressIndicator } from 'reka-ui'
|
||||
import { EmptyState, StatusBadge } from '../../components/ui'
|
||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||
import { issueLabel, productionStatusLabel } from './model'
|
||||
@@ -31,7 +32,6 @@ const {
|
||||
blocked,
|
||||
run
|
||||
} = useProduction()
|
||||
const staleKeyframes = computed(() => query.data.value?.keyframes.stalePrimaryKeyframe ?? 0)
|
||||
const staleVideos = computed(() => query.data.value?.videos.stalePrimaryVideo ?? 0)
|
||||
const stages = computed(() => {
|
||||
const data = query.data.value
|
||||
@@ -87,186 +87,238 @@ const identityBlocked = computed(
|
||||
(query.data.value?.keyframes.missingIdentityAnchor ?? 0) +
|
||||
(query.data.value?.keyframes.identityUnlocked ?? 0)
|
||||
)
|
||||
const staleKeyframes = computed(() => query.data.value?.keyframes.stalePrimaryKeyframe ?? 0)
|
||||
|
||||
/** 父页查询包含整个项目的就绪统计,单镜头变更后立即刷新。 */
|
||||
function refreshProject() {
|
||||
void query.refresh()
|
||||
}
|
||||
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
|
||||
const overview = ref<string[]>([])
|
||||
watch(
|
||||
() => session.value.receipt,
|
||||
receipt => {
|
||||
if (receipt) overview.value = ['overview']
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mt-7">
|
||||
<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}/storyboard`" class="text-button">
|
||||
<ArrowLeft :size="14" />回到分镜设计
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p class="alert mt-5 text-xs">
|
||||
每一步都按后端就绪检查执行。覆盖只会新增首帧/视频候选,不会替换当前主资产;上游参考资产变化后,下游过期结果会被标记并阻止继续使用,需要重新生成后才能恢复就绪。
|
||||
</p>
|
||||
|
||||
<section class="panel mt-5 p-5" aria-label="项目生产配置">
|
||||
<div class="production-controls">
|
||||
<label>
|
||||
<span class="field-label">当前剧集</span>
|
||||
<select
|
||||
:value="episodeNo"
|
||||
class="input"
|
||||
aria-label="选择生产剧集"
|
||||
@change="selectedEpisode = Number(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-if="!episodeOptions.length" :value="0">暂无剧集</option>
|
||||
<option v-for="episode in episodeOptions" :key="episode.episodeNo" :value="episode.episodeNo">
|
||||
第 {{ episode.episodeNo }} 集 · {{ episode.title }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="field-label">批量并发</span>
|
||||
<input
|
||||
v-model.number="concurrency"
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
:disabled="operation.pending"
|
||||
/>
|
||||
<span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 pb-3 text-xs">
|
||||
<input
|
||||
v-model="force"
|
||||
type="checkbox"
|
||||
class="accent-accent"
|
||||
:disabled="operation.pending"
|
||||
/>覆盖模式:为已有结果新增候选
|
||||
</label>
|
||||
<button class="text-button mb-2 ml-auto" :disabled="query.loading.value" @click="query.refresh">
|
||||
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新就绪状态
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-[11px] leading-6 text-muted">
|
||||
批量操作始终面向整个项目,不受当前剧集选择影响;当前剧集只控制下方逐镜检查。Seedream 与 Seedance
|
||||
均可能产生费用。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
|
||||
{{ query.error.value }}<span v-if="query.data.value"> 当前保留上次读取的数据,生产操作已暂停。</span>
|
||||
</p>
|
||||
<p v-if="videoRunning" class="alert mt-4" role="status">
|
||||
当前有 {{ videoRunning }} 个视频任务等待或生成中。后端正在轮询 Provider;同一镜头不会重复提交活动任务。
|
||||
</p>
|
||||
<p v-if="identityBlocked" class="alert mt-4">
|
||||
当前有 {{ identityBlocked }} 项角色身份前置问题。Character 必须生成
|
||||
Identity、确认演员母版并锁定后,首帧才能就绪。
|
||||
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2">前往角色选角 →</RouterLink>
|
||||
</p>
|
||||
<p v-if="staleKeyframes" class="alert mt-4" role="status">
|
||||
当前有 {{ staleKeyframes }} 个主首帧已过期。它们使用的主体参考资产已经变化,视频阶段会被阻止;可在下方逐镜重新生成,或使用“补齐 / 更新主首帧”批量处理。
|
||||
</p>
|
||||
<p v-if="staleVideos" class="alert mt-4" role="status">
|
||||
当前有 {{ staleVideos }} 个主视频已过期。它们使用的主首帧或主体参考图已经变化;非覆盖模式批量生成时会只重建这些过期主视频。
|
||||
</p>
|
||||
<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>
|
||||
<p class="mt-4 min-h-10 text-xs leading-5 text-muted">{{ stage.description }}</p>
|
||||
<ProgressRoot
|
||||
:model-value="stage.done"
|
||||
:max="Math.max(stage.data?.total ?? 0, 1)"
|
||||
:aria-label="`${stage.title}完成数量`"
|
||||
class="progress-track mt-4"
|
||||
>
|
||||
<ProgressIndicator
|
||||
class="progress-fill"
|
||||
:style="{
|
||||
width: `${stage.data?.total ? Math.min(100, (stage.done / stage.data.total) * 100) : 0}%`
|
||||
}"
|
||||
/>
|
||||
</ProgressRoot>
|
||||
<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.data?.ready ?? 0)"
|
||||
acknowledgement
|
||||
:description="
|
||||
stage.key === 'videos'
|
||||
? staleVideos
|
||||
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的 Seedance 任务;新视频完成后会自动接替过期主视频。'
|
||||
: '只为当前就绪镜头创建 Seedance 异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
|
||||
: stage.key === 'keyframes'
|
||||
? '处理缺失主首帧和参考资产已变化的过期主首帧。过期镜头会按当前主体参考资产重新生成并恢复为可用于视频的主首帧;覆盖模式只新增候选。'
|
||||
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
|
||||
"
|
||||
:primary="stage.key === 'videos'"
|
||||
@confirm="run(stage.key)"
|
||||
/>
|
||||
</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">
|
||||
<WorkspacePage split
|
||||
><template #header
|
||||
><div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">视频任务状态</h3>
|
||||
<p class="mt-2 text-xs text-muted">状态统计取每个镜头最近一次任务,不等同于主视频数量。</p>
|
||||
<h2 class="text-lg font-semibold">镜头生产</h2>
|
||||
<p class="mt-2 text-sm text-muted">从可生成的提示词和首帧,到异步视频任务与最终成片。</p>
|
||||
</div>
|
||||
<ConfirmAction
|
||||
label="重试失败视频"
|
||||
:disabled="blocked || !batchValid || !query.data.value.videoStatus.failed"
|
||||
acknowledgement
|
||||
description="为当前最近一次状态为失败的镜头重新创建 Seedance 任务。成功镜头和进行中的镜头不会处理。"
|
||||
@confirm="run('retry-videos')"
|
||||
/>
|
||||
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button">
|
||||
<ArrowLeft :size="14" />回到分镜设计
|
||||
</RouterLink>
|
||||
</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>
|
||||
|
||||
<section class="mt-3" aria-label="项目生产配置">
|
||||
<div class="production-controls">
|
||||
<label>
|
||||
<span class="field-label">当前剧集</span>
|
||||
<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>
|
||||
</label>
|
||||
<label>
|
||||
<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>
|
||||
<span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span>
|
||||
</label>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="flex items-center gap-2 pb-3 text-xs"
|
||||
>覆盖模式:为已有结果新增候选
|
||||
</NCheckbox>
|
||||
<NButton
|
||||
:disabled="query.loading.value"
|
||||
@click="query.refresh"
|
||||
text
|
||||
size="small"
|
||||
class="mb-2 ml-auto"
|
||||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新就绪状态
|
||||
</NButton>
|
||||
</div>
|
||||
<p class="mt-3 text-[11px] leading-6 text-muted">
|
||||
提示词与视频批量操作面向全项目;首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式新增全项目候选。Seedream
|
||||
与 Seedance 均可能产生费用。
|
||||
</p>
|
||||
</section></template
|
||||
>
|
||||
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||||
>{{ query.error.value
|
||||
}}<span v-if="query.data.value"> 当前保留上次读取的数据,生产操作已暂停。</span></NAlert
|
||||
>
|
||||
<NCollapse v-model:expanded-names="overview" class="workspace-overview"
|
||||
><NCollapseItem name="overview" title="项目生产总览与批量操作"
|
||||
><div class="overview-content">
|
||||
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
||||
每一步都按后端就绪检查执行。覆盖只会新增首帧/视频候选,不会替换当前主资产;上游参考资产变化后,下游过期结果会被标记并阻止继续使用,需要重新生成后才能恢复就绪。
|
||||
</NAlert>
|
||||
<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="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>
|
||||
<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">
|
||||
<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.data?.ready ?? 0)"
|
||||
acknowledgement
|
||||
:description="
|
||||
stage.key === 'videos'
|
||||
? staleVideos && !force
|
||||
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的 Seedance 任务;新视频完成后会自动接替过期主视频。'
|
||||
: '只为当前就绪镜头创建 Seedance 异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
|
||||
: stage.key === 'keyframes'
|
||||
? '处理缺失主首帧和参考资产已变化的过期主首帧。过期镜头会按当前主体参考资产重新生成并恢复为可用于视频的主首帧;覆盖模式只新增候选。'
|
||||
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
|
||||
"
|
||||
:primary="stage.key === 'videos'"
|
||||
@confirm="run(stage.key)"
|
||||
/>
|
||||
</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="为当前最近一次状态为失败的镜头重新创建 Seedance 任务。成功镜头和进行中的镜头不会处理。"
|
||||
@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 class="my-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">第 {{ episodeNo || '—' }} 集 · 逐镜生产</h3>
|
||||
@@ -276,35 +328,43 @@ function refreshProject() {
|
||||
</div>
|
||||
<div v-if="shot" class="panel production-workspace">
|
||||
<nav class="production-shot-list" aria-label="本集生产镜头">
|
||||
<button
|
||||
<NButton
|
||||
v-for="item in shots"
|
||||
:key="item.shotId"
|
||||
class="production-shot-link"
|
||||
:class="{ selected: item.shotId === shot.shotId }"
|
||||
:aria-current="item.shotId === shot.shotId ? 'true' : undefined"
|
||||
@click="selectedShot = item.shotId"
|
||||
>
|
||||
<span class="font-mono text-[10px] text-muted">B{{ item.beatNo }} / S{{ item.shotNo }}</span>
|
||||
<span class="mt-1 block truncate text-xs font-medium">{{ item.title }}</span>
|
||||
<span
|
||||
v-if="query.data.value?.keyframes.items.find(row => row.shotId === item.shotId)?.primaryKeyframeStale"
|
||||
class="production-shot-link"
|
||||
:class="{ selected: item.shotId === shot.shotId }"
|
||||
><span class="font-mono text-[10px] text-muted">B{{ item.beatNo }} / S{{ item.shotNo }}</span
|
||||
><span class="mt-1 block truncate text-xs font-medium">{{ item.title }}</span
|
||||
><span
|
||||
v-if="
|
||||
query.data.value?.keyframes.items.find(row => row.shotId === item.shotId)
|
||||
?.primaryKeyframeStale
|
||||
"
|
||||
class="mt-2 flex items-center gap-2 text-[10px]"
|
||||
>
|
||||
<CircleAlert :size="12" />主首帧已过期
|
||||
</span>
|
||||
<span
|
||||
v-else-if="query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.primaryVideoStale"
|
||||
<CircleAlert :size="12" />主首帧已过期 </span
|
||||
><span
|
||||
v-else-if="
|
||||
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.primaryVideoStale
|
||||
"
|
||||
class="mt-2 flex items-center gap-2 text-[10px]"
|
||||
>
|
||||
<CircleAlert :size="12" />主视频已过期
|
||||
</span>
|
||||
<span v-else class="mt-2 flex items-center gap-2 text-[10px] text-muted">
|
||||
<CircleAlert :size="12" />主视频已过期 </span
|
||||
><span v-else class="mt-2 flex items-center gap-2 text-[10px] text-muted">
|
||||
<CheckCircle2
|
||||
v-if="query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status === 'skipped'"
|
||||
v-if="
|
||||
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
|
||||
'skipped'
|
||||
"
|
||||
:size="12"
|
||||
/>
|
||||
<CircleAlert
|
||||
v-else-if="query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status === 'blocked'"
|
||||
v-else-if="
|
||||
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
|
||||
'blocked'
|
||||
"
|
||||
:size="12"
|
||||
/>
|
||||
{{
|
||||
@@ -313,8 +373,8 @@ function refreshProject() {
|
||||
'not_started'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</span></NButton
|
||||
>
|
||||
</nav>
|
||||
<article class="min-w-0 p-5 lg:p-7">
|
||||
<header class="mb-5 border-b border-line pb-5">
|
||||
@@ -331,12 +391,24 @@ function refreshProject() {
|
||||
</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>
|
||||
<p v-if="keyframeItem?.primaryKeyframeStale" class="alert mt-4 text-xs" role="status">
|
||||
<NAlert
|
||||
v-if="keyframeItem?.primaryKeyframeStale"
|
||||
role="status"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-4 text-xs"
|
||||
>
|
||||
主首帧已过期:当前主体参考资产与生成该首帧时不一致。旧首帧会保留,但不能继续用于视频生成。
|
||||
</p>
|
||||
<p v-if="videoItem?.primaryVideoStale" class="alert mt-4 text-xs" role="status">
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-if="videoItem?.primaryVideoStale"
|
||||
role="status"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-4 text-xs"
|
||||
>
|
||||
主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。
|
||||
</p>
|
||||
</NAlert>
|
||||
<ul v-if="selectedIssues.length" class="alert alert-error mt-4 space-y-1" role="alert">
|
||||
<li v-for="(issue, index) in selectedIssues" :key="`${issue.code}-${index}`">
|
||||
{{ issueLabel(issue.code) }}:{{ issue.reason }}
|
||||
@@ -364,6 +436,6 @@ function refreshProject() {
|
||||
</EmptyState>
|
||||
<p v-else-if="query.loading.value" class="panel mt-5 p-8 text-sm text-muted" role="status">
|
||||
正在检查全项目生产就绪状态……
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
</p></WorkspacePage
|
||||
>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NCheckbox, NInputNumber } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { AppDialog } from '../../../components/ui'
|
||||
import { validOptionalSize } from '../model'
|
||||
@@ -50,37 +51,43 @@ function submit() {
|
||||
:title="`${replacePrimary ? '重新生成主首帧' : '生成首帧'} · ${shotTitle}`"
|
||||
description="依据视觉风格、GenerationSpec 和主体主参考图编译首帧提示词,并调用 Seedream。"
|
||||
>
|
||||
<p v-if="replacePrimary" class="alert mt-5 text-xs">
|
||||
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
|
||||
当前主首帧使用的主体参考资产已经变化。新图成功后应设为主首帧,旧图会继续保留为历史候选。
|
||||
</p>
|
||||
</NAlert>
|
||||
<div class="mt-5 grid grid-cols-2 gap-4">
|
||||
<label
|
||||
><span class="field-label">宽度(可选)</span
|
||||
><input v-model.number="width" class="input" type="number" min="1"
|
||||
/></label>
|
||||
><NInputNumber
|
||||
:value="typeof width === 'number' ? width : null"
|
||||
@update:value="width = $event ?? ''"
|
||||
:min="1"
|
||||
></NInputNumber
|
||||
></label>
|
||||
<label
|
||||
><span class="field-label">高度(可选)</span
|
||||
><input v-model.number="height" class="input" type="number" min="1"
|
||||
/></label>
|
||||
><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>
|
||||
<label class="mt-5 flex items-start gap-3 text-sm leading-6">
|
||||
<input v-model="setPrimary" class="mt-1 accent-accent" type="checkbox" />生成成功后设为当前主首帧
|
||||
</label>
|
||||
<p v-if="hasPrimary && setPrimary" class="alert mt-3 text-xs">
|
||||
<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">
|
||||
当前已有主首帧。新图成功后将成为视频生成使用的首帧,旧图仍保留为候选。
|
||||
</p>
|
||||
<label class="mt-5 flex items-start gap-3 text-sm leading-6">
|
||||
<input
|
||||
id="confirm-keyframe-cost"
|
||||
v-model="confirmed"
|
||||
class="mt-1 accent-accent"
|
||||
type="checkbox"
|
||||
/>我已确认调用 Seedream 可能产生费用,且当前没有相同镜头的生图任务。
|
||||
</label>
|
||||
</NAlert>
|
||||
<NCheckbox
|
||||
id="confirm-keyframe-cost"
|
||||
v-model:checked="confirmed"
|
||||
class="mt-5 flex items-start gap-3 text-sm leading-6"
|
||||
>我已确认调用 Seedream 可能产生费用,且当前没有相同镜头的生图任务。
|
||||
</NCheckbox>
|
||||
<div class="dialog-footer mt-6">
|
||||
<button class="button button-secondary" @click="open = false">取消</button>
|
||||
<button class="button button-primary" :disabled="!canSubmit" @click="submit">确认生成首帧</button>
|
||||
<NButton @click="open = false">取消</NButton>
|
||||
<NButton :disabled="!canSubmit" @click="submit" type="primary">确认生成首帧</NButton>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton } from 'naive-ui'
|
||||
import { Download } from '@lucide/vue'
|
||||
import { downloadText } from '../../../lib/format'
|
||||
import type { ProductionReceipt } from '../types'
|
||||
@@ -16,7 +17,7 @@ function exportReceipt() {
|
||||
<section class="panel mt-4 p-5" aria-label="镜头生产回执">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-medium">{{ receipt.title }} · 本次回执</h3>
|
||||
<button class="text-button" @click="exportReceipt"><Download :size="13" />导出诊断</button>
|
||||
<NButton @click="exportReceipt" text size="small"><Download :size="13" />导出诊断</NButton>
|
||||
</div>
|
||||
<p v-if="receipt.kind === 'prompts'" class="mt-3 text-xs text-muted">
|
||||
全项目 {{ receipt.result.total }} 镜 · 本次目标 {{ receipt.result.targetCount }} · 已生成
|
||||
@@ -36,12 +37,18 @@ function exportReceipt() {
|
||||
检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 已重新提交 {{ receipt.result.retried }} · 提交失败
|
||||
{{ receipt.result.failed }}
|
||||
</p>
|
||||
<p v-if="receipt.kind === 'videos' && receipt.result.created" class="alert mt-3" role="status">
|
||||
<NAlert
|
||||
v-if="receipt.kind === 'videos' && receipt.result.created"
|
||||
role="status"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-3"
|
||||
>
|
||||
视频任务已经创建,但尚未代表成片完成。后台会继续查询 Seedance,以下方任务状态和视频资产为准。
|
||||
</p>
|
||||
<p v-if="receipt.result.failed" class="alert alert-error mt-3" role="alert">
|
||||
</NAlert>
|
||||
<NAlert v-if="receipt.result.failed" role="alert" type="error" :show-icon="false" class="mt-3">
|
||||
本次存在部分失败,成功提交或生成的结果不会回滚。请查看逐镜错误后再重试。
|
||||
</p>
|
||||
</NAlert>
|
||||
<ul v-if="receipt.result.failures.length" class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li v-for="failure in receipt.result.failures" :key="failure.shotId">
|
||||
<code>{{ failure.shotId }}</code
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ExternalLink, FileSearch, ImagePlus, RefreshCw } from '@lucide/vue'
|
||||
import { AssetImage, StatusBadge } from '../../../components/ui'
|
||||
@@ -189,18 +190,18 @@ async function inspectSpecs() {
|
||||
<p class="mt-1 text-[11px] text-muted">记录每次候选与失败,主资产决定下一阶段实际输入。</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="text-button" :disabled="inspecting" @click="inspectSpecs">
|
||||
<FileSearch :size="13" />{{ inspecting ? '检查中…' : '检查生成规格' }}
|
||||
</button>
|
||||
<button class="text-button" :disabled="query.loading.value" @click="refreshAll">
|
||||
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新资产
|
||||
</button>
|
||||
<NButton :disabled="inspecting" @click="inspectSpecs" text size="small"
|
||||
><FileSearch :size="13" />{{ inspecting ? '检查中…' : '检查生成规格' }}</NButton
|
||||
>
|
||||
<NButton :disabled="query.loading.value" @click="refreshAll" text size="small"
|
||||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新资产
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
|
||||
{{ query.error.value }} 当前保留上次成功读取的资产,操作已暂停。
|
||||
</p>
|
||||
<p v-if="specError" class="alert alert-error mt-4" role="alert">{{ specError }}</p>
|
||||
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||||
>{{ query.error.value }} 当前保留上次成功读取的资产,操作已暂停。
|
||||
</NAlert>
|
||||
<NAlert v-if="specError" role="alert" type="error" :show-icon="false" class="mt-4">{{ specError }}</NAlert>
|
||||
|
||||
<div class="production-stage mt-5">
|
||||
<div class="production-stage-heading">
|
||||
@@ -246,9 +247,9 @@ async function inspectSpecs() {
|
||||
:label="keyframeStale ? '主首帧已过期' : productionStatusLabel(keyframeReadiness.status)"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="keyframeStale" class="alert mt-3 text-xs" role="status">
|
||||
<NAlert v-if="keyframeStale" role="status" type="info" :show-icon="false" class="mt-3 text-xs">
|
||||
当前主首帧使用的主体参考资产已经变化,旧图仍保留但不会继续进入视频生成。请重新生成主首帧。
|
||||
</p>
|
||||
</NAlert>
|
||||
<ul v-if="keyframeReadiness?.issues.length" class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li v-for="issue in keyframeReadiness.issues" :key="issue.code">
|
||||
{{ issueLabel(issue.code) }}:{{ issue.reason }}
|
||||
@@ -264,34 +265,44 @@ async function inspectSpecs() {
|
||||
<div class="p-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
|
||||
<span v-if="item.isPrimary" class="tag">主首帧</span>
|
||||
<span v-if="item.isPrimary && keyframeStale" class="tag">已过期</span>
|
||||
<span class="text-[10px] text-muted">{{ item.width || '—' }} × {{ item.height || '—' }}</span>
|
||||
<NTag v-if="item.isPrimary" 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
|
||||
>
|
||||
</div>
|
||||
<p v-if="item.error" class="mt-2 line-clamp-3 text-xs text-danger">{{ item.error }}</p>
|
||||
<p class="mt-2 text-[10px] text-muted">{{ formatDate(item.createdAt) }}</p>
|
||||
<button
|
||||
<NButton
|
||||
v-if="item.status === 'completed' && item.imageUrl && !item.isPrimary"
|
||||
class="text-button mt-2"
|
||||
:disabled="assetBlocked"
|
||||
@click="confirmingKeyframe = item.id"
|
||||
text
|
||||
size="small"
|
||||
class="mt-2"
|
||||
>
|
||||
设为主首帧
|
||||
</button>
|
||||
<div v-if="confirmingKeyframe === item.id" class="alert mt-2 text-xs">
|
||||
<p>确认让后续视频使用此首帧?已有视频不会自动重生成。</p>
|
||||
</NButton>
|
||||
<NAlert
|
||||
v-if="confirmingKeyframe === item.id"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-2 text-xs"
|
||||
><p>确认让后续视频使用此首帧?已有视频不会自动重生成。</p>
|
||||
<div class="mt-2 flex gap-3">
|
||||
<button class="button button-primary" @click="setPrimaryKeyframe">确认切换</button>
|
||||
<button class="text-button" @click="confirmingKeyframe = ''">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<NButton @click="setPrimaryKeyframe" type="primary">确认切换</NButton>
|
||||
<NButton @click="confirmingKeyframe = ''" text size="small">取消</NButton>
|
||||
</div></NAlert
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="mt-4 text-xs text-muted">尚无首帧记录。</p>
|
||||
<button class="button button-secondary mt-4" :disabled="!canGenerateKeyframe" @click="keyframeOpen = true">
|
||||
<ImagePlus :size="14" />{{ keyframeStale ? '重新生成主首帧' : currentKeyframe ? '再生成一张候选' : '生成首帧' }}
|
||||
</button>
|
||||
<NButton :disabled="!canGenerateKeyframe" @click="keyframeOpen = true" class="mt-4"
|
||||
><ImagePlus :size="14" />{{
|
||||
keyframeStale ? '重新生成主首帧' : currentKeyframe ? '再生成一张候选' : '生成首帧'
|
||||
}}</NButton
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="production-stage">
|
||||
@@ -306,9 +317,9 @@ async function inspectSpecs() {
|
||||
:label="videoStale ? '主视频已过期' : productionStatusLabel(videoReadiness.status)"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="videoStale" class="alert mt-3 text-xs" role="status">
|
||||
<NAlert v-if="videoStale" role="status" type="info" :show-icon="false" class="mt-3 text-xs">
|
||||
当前主视频使用的主首帧或主体参考图已经变化。旧视频仍保留;重新生成完成后,新视频会自动接替过期主视频。
|
||||
</p>
|
||||
</NAlert>
|
||||
<ul v-if="videoReadiness?.issues.length" class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li v-for="issue in videoReadiness.issues" :key="issue.code">
|
||||
{{ issueLabel(issue.code) }}:{{ issue.reason }}
|
||||
@@ -326,11 +337,15 @@ async function inspectSpecs() {
|
||||
<div class="min-w-0 flex-1 p-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
|
||||
<span v-if="item.isPrimary" class="tag">主视频</span>
|
||||
<span v-if="item.isPrimary && videoStale" class="tag">已过期</span>
|
||||
<span class="text-[10px] text-muted">{{ item.durationSeconds || shot.durationSeconds || '—' }}s</span>
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
<p v-if="item.error" class="alert alert-error mt-3" role="alert">{{ item.error }}</p>
|
||||
<NAlert v-if="item.error" role="alert" type="error" :show-icon="false" class="mt-3">{{
|
||||
item.error
|
||||
}}</NAlert>
|
||||
<p class="mt-3 break-all text-[10px] text-muted">
|
||||
{{ item.provider }} · {{ item.model }} · {{ formatDate(item.createdAt) }}
|
||||
</p>
|
||||
@@ -341,24 +356,25 @@ async function inspectSpecs() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-button"
|
||||
>打开视频<ExternalLink :size="12" /></a
|
||||
>
|
||||
<button
|
||||
>打开视频<ExternalLink :size="12"
|
||||
/></a>
|
||||
<NButton
|
||||
v-if="item.status === 'completed' && item.videoUrl && !item.isPrimary"
|
||||
class="text-button"
|
||||
:disabled="assetBlocked"
|
||||
@click="confirmingVideo = item.id"
|
||||
text
|
||||
size="small"
|
||||
>
|
||||
设为主视频
|
||||
</button>
|
||||
</NButton>
|
||||
</div>
|
||||
<div v-if="confirmingVideo === item.id" class="alert mt-3 text-xs">
|
||||
<p>确认把此候选设为当前镜头主视频?旧成片仍会保留。</p>
|
||||
<NAlert v-if="confirmingVideo === item.id" type="info" :show-icon="false" class="mt-3 text-xs"
|
||||
><p>确认把此候选设为当前镜头主视频?旧成片仍会保留。</p>
|
||||
<div class="mt-2 flex gap-3">
|
||||
<button class="button button-primary" @click="setPrimaryVideo">确认切换</button>
|
||||
<button class="text-button" @click="confirmingVideo = ''">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<NButton @click="setPrimaryVideo" type="primary">确认切换</NButton>
|
||||
<NButton @click="confirmingVideo = ''" text size="small">取消</NButton>
|
||||
</div></NAlert
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -378,46 +394,43 @@ async function inspectSpecs() {
|
||||
primary
|
||||
@confirm="generateVideo"
|
||||
/>
|
||||
<button
|
||||
v-if="activeVideo"
|
||||
class="button button-secondary"
|
||||
:disabled="assetBlocked"
|
||||
@click="refreshActiveVideo"
|
||||
>
|
||||
<RefreshCw :size="14" />刷新任务状态
|
||||
</button>
|
||||
<NButton v-if="activeVideo" :disabled="assetBlocked" @click="refreshActiveVideo"
|
||||
><RefreshCw :size="14" />刷新任务状态
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details v-if="keyframeSpec || videoSpec" class="mt-5 border-t border-line pt-4 text-xs">
|
||||
<summary class="cursor-pointer font-medium">本镜确定性生成规格</summary>
|
||||
<div class="mt-4 grid gap-5 lg:grid-cols-2">
|
||||
<div v-if="keyframeSpec">
|
||||
<h5 class="font-medium">首帧 Prompt · {{ keyframeSpec.references.length }} 张参考图</h5>
|
||||
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ keyframeSpec.prompt }}</p>
|
||||
<ul v-if="keyframeSpec.references.length" class="mt-3 space-y-1 text-[11px] text-muted">
|
||||
<li
|
||||
v-for="reference in keyframeSpec.references"
|
||||
:key="`${reference.role}:${reference.imageId}`"
|
||||
>
|
||||
{{ reference.subjectRef }} ·
|
||||
{{ reference.role === 'identity-anchor' ? '演员身份母版' : '形态主图' }} ·
|
||||
<code>{{ reference.imageId }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="videoSpec">
|
||||
<h5 class="font-medium">视频 Prompt · {{ videoSpec.durationSeconds }}s</h5>
|
||||
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ videoSpec.videoPrompt }}</p>
|
||||
<p v-if="videoSpec.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6 text-muted">
|
||||
Negative prompt: {{ videoSpec.negativePrompt }}
|
||||
</p>
|
||||
<p class="mt-3 break-all font-mono text-[10px] text-muted">
|
||||
Keyframe ID · {{ videoSpec.keyframe.id }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<NCollapse v-if="keyframeSpec || videoSpec" class="mt-5 border-t border-line pt-4 text-xs"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>本镜确定性生成规格</template>
|
||||
<div class="mt-4 grid gap-5 lg:grid-cols-2">
|
||||
<div v-if="keyframeSpec">
|
||||
<h5 class="font-medium">首帧 Prompt · {{ keyframeSpec.references.length }} 张参考图</h5>
|
||||
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ keyframeSpec.prompt }}</p>
|
||||
<ul v-if="keyframeSpec.references.length" class="mt-3 space-y-1 text-[11px] text-muted">
|
||||
<li
|
||||
v-for="reference in keyframeSpec.references"
|
||||
:key="`${reference.role}:${reference.imageId}`"
|
||||
>
|
||||
{{ reference.subjectRef }} ·
|
||||
{{ reference.role === 'identity-anchor' ? '演员身份母版' : '形态主图' }} ·
|
||||
<code>{{ reference.imageId }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="videoSpec">
|
||||
<h5 class="font-medium">视频 Prompt · {{ videoSpec.durationSeconds }}s</h5>
|
||||
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ videoSpec.videoPrompt }}</p>
|
||||
<p v-if="videoSpec.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6 text-muted">
|
||||
Negative prompt: {{ videoSpec.negativePrompt }}
|
||||
</p>
|
||||
<p class="mt-3 break-all font-mono text-[10px] text-muted">
|
||||
Keyframe ID · {{ videoSpec.keyframe.id }}
|
||||
</p>
|
||||
</div>
|
||||
</div></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
<KeyframeDialog
|
||||
v-model:open="keyframeOpen"
|
||||
:shot-id="shot.shotId"
|
||||
@@ -428,4 +441,4 @@ async function inspectSpecs() {
|
||||
@generate="generateKeyframe"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -21,7 +21,7 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 从 Reka Portal 中查找精确按钮。 */
|
||||
/** 从 Naive 弹窗中查找精确按钮。 */
|
||||
function button(label: string): HTMLButtonElement {
|
||||
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
|
||||
if (!item) throw new Error(`缺少按钮 ${label}`)
|
||||
|
||||
@@ -1,76 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
FileText,
|
||||
Layers,
|
||||
Camera,
|
||||
Images,
|
||||
Palette,
|
||||
Fingerprint,
|
||||
LoaderCircle,
|
||||
Clapperboard
|
||||
} from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NAlert, NButton, NPageHeader, NSpin, NTab, NTabs, NEllipsis } from 'naive-ui'
|
||||
import { RefreshCw } from '@lucide/vue'
|
||||
import { StatusBadge } from '../../components/ui'
|
||||
import { projectContextKey, useProjectData } from './context'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
|
||||
/** 项目级数据与操作状态跨 graph 页面共享。 */
|
||||
/** 项目共享查询保持不变,标题与导航固定,子工作区负责内容滚动。 */
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const id = computed(() => String(route.params.projectId))
|
||||
const context = useProjectData(id)
|
||||
provide(projectContextKey, context)
|
||||
const operation = computed(() => getOperation(id.value))
|
||||
const tabs = [
|
||||
['create-drama', '剧本创作'],
|
||||
['breakdown', '剧本拆解'],
|
||||
['visual-style', '视觉风格'],
|
||||
['subject-identity', '主体身份'],
|
||||
['subject-images', '形态图片'],
|
||||
['storyboard', '分镜设计'],
|
||||
['production', '镜头生产']
|
||||
] as const
|
||||
/** 路由控制当前工作流,不额外缓存一份可能失步的标签状态。 */
|
||||
function switchWorkflow(path: string) {
|
||||
void router.push('/projects/' + id.value + '/' + path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-container">
|
||||
<RouterLink to="/projects" class="back-link"><ArrowLeft :size="14" />全部剧本</RouterLink>
|
||||
<div class="page-heading mt-5">
|
||||
<div class="min-w-0">
|
||||
<p class="eyebrow">项目工作台</p>
|
||||
<h1 class="break-words">
|
||||
{{ context.project.value?.title || context.project.value?.topic || '读取项目' }}
|
||||
</h1>
|
||||
<p class="page-description">{{ context.project.value?.style || '剧本与拆解结果' }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<StatusBadge v-if="context.project.value" :status="context.project.value.status" /><button
|
||||
class="button button-secondary"
|
||||
:disabled="context.loading.value"
|
||||
@click="context.refresh"
|
||||
<section class="project-frame">
|
||||
<header class="project-header">
|
||||
<NPageHeader @back="router.push('/projects')">
|
||||
<template #title
|
||||
><NEllipsis class="project-title">{{
|
||||
context.project.value?.title || context.project.value?.topic || '读取项目'
|
||||
}}</NEllipsis></template
|
||||
>
|
||||
<RefreshCw :size="14" :class="{ 'animate-spin': context.loading.value }" /><span
|
||||
class="hidden sm:inline"
|
||||
>刷新</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<template #extra
|
||||
><div class="flex items-center gap-3">
|
||||
<StatusBadge v-if="context.project.value" :status="context.project.value.status" /><NButton
|
||||
size="small"
|
||||
:loading="context.loading.value"
|
||||
@click="context.refresh"
|
||||
><template #icon><RefreshCw :size="14" /></template>刷新</NButton
|
||||
>
|
||||
</div></template
|
||||
>
|
||||
</NPageHeader>
|
||||
<NTabs
|
||||
:value="String(route.path.split('/').at(-1))"
|
||||
type="line"
|
||||
size="small"
|
||||
class="project-tabs"
|
||||
aria-label="项目工作流"
|
||||
@update:value="switchWorkflow"
|
||||
>
|
||||
<NTab v-for="[path, label] in tabs" :key="path" :name="path">{{ label }}</NTab>
|
||||
</NTabs>
|
||||
</header>
|
||||
<div
|
||||
v-if="context.error.value || operation.pending || operation.error || operation.notice"
|
||||
class="project-notices"
|
||||
>
|
||||
<NAlert v-if="context.error.value" type="error" :show-icon="false"
|
||||
>{{ context.error.value
|
||||
}}<span v-if="context.project.value"> 当前保留上次成功读取的数据。</span></NAlert
|
||||
>
|
||||
<NAlert v-if="operation.pending" :show-icon="false" role="status"
|
||||
>{{ operation.label }}。可切换页面查看结果,请勿重复提交;关闭页面不会取消后端任务。</NAlert
|
||||
>
|
||||
<NAlert v-if="operation.error" type="error" :show-icon="false">{{ operation.error }}</NAlert>
|
||||
<NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert>
|
||||
</div>
|
||||
<nav class="workflow-nav" aria-label="项目工作流">
|
||||
<RouterLink :to="`/projects/${id}/create-drama`"><FileText :size="17" />剧本创作</RouterLink>
|
||||
<RouterLink :to="`/projects/${id}/breakdown`"><Layers :size="17" />剧本拆解</RouterLink>
|
||||
<RouterLink :to="`/projects/${id}/visual-style`"><Palette :size="17" />视觉风格</RouterLink>
|
||||
<RouterLink :to="`/projects/${id}/subject-identity`"><Fingerprint :size="17" />主体身份</RouterLink>
|
||||
<RouterLink :to="`/projects/${id}/subject-images`"><Images :size="17" />形态图片</RouterLink>
|
||||
<RouterLink :to="`/projects/${id}/storyboard`"><Camera :size="17" />分镜设计</RouterLink>
|
||||
<RouterLink :to="`/projects/${id}/production`"><Clapperboard :size="17" />镜头生产</RouterLink>
|
||||
</nav>
|
||||
<p v-if="context.error.value" class="alert alert-error mt-4" role="alert">
|
||||
{{ context.error.value }}<span v-if="context.project.value"> 当前保留上次成功读取的数据。</span>
|
||||
</p>
|
||||
<p v-if="operation.pending" class="alert mt-4 flex items-center gap-2" role="status">
|
||||
<LoaderCircle :size="16" class="shrink-0 animate-spin" />{{
|
||||
operation.label
|
||||
}}。可切换页面查看结果,请勿重复提交或关闭浏览器;关闭页面不会取消后端任务。
|
||||
</p>
|
||||
<p v-if="operation.error" class="alert alert-error mt-4" role="alert">{{ operation.error }}</p>
|
||||
<p v-if="operation.notice" class="alert mt-4" role="status">{{ operation.notice }}</p>
|
||||
<RouterView v-if="context.project.value" :key="id" />
|
||||
<div v-else-if="context.loading.value" class="py-12 text-sm text-muted" role="status">
|
||||
正在读取项目和工作流记录……
|
||||
<div class="project-view">
|
||||
<RouterView v-if="context.project.value" :key="id" />
|
||||
<NSpin
|
||||
v-else-if="context.loading.value"
|
||||
class="workspace-loading"
|
||||
description="正在读取项目和工作流记录…"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,162 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowUpRight, Search, RefreshCw, Clapperboard } from '@lucide/vue'
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { NAlert, NButton, NDataTable, NInput, NRadioButton, NRadioGroup, type DataTableColumns } from 'naive-ui'
|
||||
import { RefreshCw, Search } from '@lucide/vue'
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { EmptyState, StatusBadge } from '../../components/ui'
|
||||
import { StatusBadge } from '../../components/ui'
|
||||
import { formatDate } from '../../lib/format'
|
||||
import { projectsApi } from './api'
|
||||
import type { Project } from './types'
|
||||
import CreateProjectDialog from './components/CreateProjectDialog.vue'
|
||||
|
||||
/** 项目索引:真实查询、客户端筛选,以及进入两条 graph 的入口。 */
|
||||
/** 项目表格使用固定表头与独立滚动;搜索筛选仍作用于真实接口数据。 */
|
||||
const router = useRouter()
|
||||
const query = usePolling(ref('projects'), (_, signal) => projectsApi.list(signal), 12_000)
|
||||
const search = ref('')
|
||||
const filter = ref('all')
|
||||
const page = ref(1)
|
||||
const projects = computed(() => query.data.value ?? [])
|
||||
const filtered = computed(() =>
|
||||
projects.value.filter(item => {
|
||||
const matches = `${item.title ?? ''} ${item.topic} ${item.style ?? ''}`
|
||||
const matches = ((item.title ?? '') + ' ' + item.topic + ' ' + (item.style ?? ''))
|
||||
.toLowerCase()
|
||||
.includes(search.value.toLowerCase().trim())
|
||||
.includes(search.value.trim().toLowerCase())
|
||||
return matches && (filter.value === 'all' || item.status === filter.value)
|
||||
})
|
||||
)
|
||||
|
||||
/** 202 后直接打开工作流,后续刷新由项目布局负责。 */
|
||||
const filters = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'generating', label: '生成中' },
|
||||
{ value: 'completed', label: '已完成' },
|
||||
{ value: 'need_review', label: '待审核' },
|
||||
{ value: 'failed', label: '失败' }
|
||||
]
|
||||
watch([search, filter], () => {
|
||||
page.value = 1
|
||||
})
|
||||
/** 表格列通过正式项目 ID 跳转,不依赖列表索引。 */
|
||||
const columns: DataTableColumns<Project> = [
|
||||
{
|
||||
title: '剧本名称',
|
||||
key: 'title',
|
||||
width: 300,
|
||||
render: row =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: '/projects/' + encodeURIComponent(row.id) + '/create-drama', class: 'project-name-cell' },
|
||||
() => [h('strong', row.title || row.topic), h('small', row.topic)]
|
||||
)
|
||||
},
|
||||
{ title: '风格', key: 'style', width: 200, ellipsis: { tooltip: true }, render: row => row.style || '未设置' },
|
||||
{ title: '状态', key: 'status', width: 120, render: row => h(StatusBadge, { status: row.status }) },
|
||||
{ title: '最近更新', key: 'updatedAt', width: 165, render: row => formatDate(row.updatedAt) }
|
||||
]
|
||||
/** 创建回执只用于跳转,生成进度以后端查询为准。 */
|
||||
function openProject(id: string) {
|
||||
void router.push(`/projects/${encodeURIComponent(id)}/create-drama`)
|
||||
void router.push('/projects/' + encodeURIComponent(id) + '/create-drama')
|
||||
}
|
||||
|
||||
/** 多项筛选的重置放在函数中,避免格式化后产生无效模板表达式。 */
|
||||
/** 清空条件后返回第一页,不改变后端数据。 */
|
||||
function clearFilters() {
|
||||
search.value = ''
|
||||
filter.value = 'all'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-container">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">工作空间 / 项目</p>
|
||||
<h1>我的剧本</h1>
|
||||
<p class="page-description">从故事到镜头,在这里继续你的创作。</p>
|
||||
</div>
|
||||
<CreateProjectDialog @created="openProject" />
|
||||
</div>
|
||||
<div class="workspace-note">
|
||||
<Clapperboard :size="20" :stroke-width="1.5" /><span
|
||||
>剧本创作 <span class="mx-3 text-faint">/</span> 主体拆解
|
||||
<span class="mx-3 text-faint">/</span> 分镜规划</span
|
||||
><span class="ml-auto hidden text-xs text-muted sm:block">两个工作流,一个项目</span>
|
||||
</div>
|
||||
<div class="toolbar mt-7">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="item in [
|
||||
{ value: 'all', label: '全部项目' },
|
||||
{ value: 'generating', label: '生成中' },
|
||||
{ value: 'completed', label: '已完成' },
|
||||
{ value: 'need_review', label: '待审核' },
|
||||
{ value: 'failed', label: '失败' }
|
||||
]"
|
||||
:key="item.value"
|
||||
class="filter-button"
|
||||
:class="{ active: filter === item.value }"
|
||||
:aria-pressed="filter === item.value"
|
||||
@click="filter = item.value"
|
||||
>
|
||||
{{ item.label
|
||||
}}<span v-if="item.value === 'all'" class="ml-2 text-muted">{{ projects.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="search-field">
|
||||
<Search :size="15" /><input v-model="search" aria-label="搜索项目" placeholder="搜索剧本" />
|
||||
<WorkspacePage split>
|
||||
<template #header>
|
||||
<div class="page-heading project-index-heading">
|
||||
<div>
|
||||
<h1>我的剧本</h1>
|
||||
<p class="page-description">从故事到镜头,继续你的创作。</p>
|
||||
</div>
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="刷新项目"
|
||||
:disabled="query.loading.value"
|
||||
@click="query.refresh"
|
||||
>
|
||||
<RefreshCw :size="16" :class="{ 'animate-spin': query.loading.value }" />
|
||||
</button>
|
||||
<CreateProjectDialog @created="openProject" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="query.error.value" class="alert alert-error mt-4" role="alert">
|
||||
{{ query.error.value }}<button class="ml-3 underline" @click="query.refresh">重新连接</button>
|
||||
</div>
|
||||
<div class="panel mt-4 overflow-hidden" :aria-busy="query.loading.value">
|
||||
<div v-if="!query.data.value && query.loading.value" class="p-10 text-sm text-muted" role="status">
|
||||
正在读取项目……
|
||||
</div>
|
||||
<div v-else-if="filtered.length" class="table-scroll">
|
||||
<table class="project-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>剧本名称</th>
|
||||
<th>风格</th>
|
||||
<th>创作状态</th>
|
||||
<th>最近更新</th>
|
||||
<th><span class="sr-only">打开项目</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="project in filtered" :key="project.id">
|
||||
<td>
|
||||
<RouterLink :to="`/projects/${project.id}/create-drama`" class="project-name"
|
||||
><span class="project-monogram">{{
|
||||
(project.title || project.topic).slice(0, 1)
|
||||
}}</span
|
||||
><span class="min-w-0"
|
||||
><strong class="block truncate font-medium">{{
|
||||
project.title || project.topic
|
||||
}}</strong
|
||||
><span class="mt-1 block max-w-md truncate text-xs text-muted">{{
|
||||
project.topic
|
||||
}}</span></span
|
||||
></RouterLink
|
||||
>
|
||||
</td>
|
||||
<td class="text-muted">{{ project.style || '未设置' }}</td>
|
||||
<td><StatusBadge :status="project.status" /></td>
|
||||
<td class="whitespace-nowrap text-xs text-muted">{{ formatDate(project.updatedAt) }}</td>
|
||||
<td>
|
||||
<RouterLink
|
||||
:to="`/projects/${project.id}/create-drama`"
|
||||
class="icon-button"
|
||||
:aria-label="`打开 ${project.title || project.topic}`"
|
||||
><ArrowUpRight :size="17"
|
||||
/></RouterLink>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else-if="query.data.value"
|
||||
:title="projects.length ? '没有匹配的剧本' : '第一部故事,从这里开始'"
|
||||
:description="
|
||||
projects.length
|
||||
? '试试其他关键词,或切换项目状态。'
|
||||
: '新建一个剧本,生成角色与剧集;完成后,再将故事拆解为主体和分镜。'
|
||||
"
|
||||
><button v-if="projects.length" class="button button-secondary" @click="clearFilters">
|
||||
清除筛选
|
||||
</button></EmptyState
|
||||
</template>
|
||||
<div class="project-index-toolbar">
|
||||
<NRadioGroup v-model:value="filter" size="small" aria-label="项目状态筛选"
|
||||
><NRadioButton v-for="item in filters" :key="item.value" :value="item.value">{{
|
||||
item.label
|
||||
}}</NRadioButton></NRadioGroup
|
||||
>
|
||||
<EmptyState
|
||||
v-else
|
||||
title="等待连接后端"
|
||||
description="启动后端服务并检查 API_PROXY_TARGET,连接成功后会显示你已有的项目。"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<NInput
|
||||
v-model:value="search"
|
||||
clearable
|
||||
placeholder="搜索剧本"
|
||||
class="project-search"
|
||||
:input-props="{ 'aria-label': '搜索项目' }"
|
||||
><template #prefix><Search :size="15" /></template
|
||||
></NInput>
|
||||
<NButton :loading="query.loading.value" aria-label="刷新项目" @click="query.refresh"
|
||||
><RefreshCw :size="16"
|
||||
/></NButton>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-muted">
|
||||
项目数据来自后端数据库<span v-if="query.updatedAt.value">
|
||||
<NAlert v-if="query.error.value" type="error" :show-icon="false"
|
||||
>{{ query.error.value }}<NButton text class="ml-3" @click="query.refresh">重新连接</NButton></NAlert
|
||||
>
|
||||
<div class="project-table-area">
|
||||
<NDataTable
|
||||
class="project-data-table"
|
||||
:columns="columns"
|
||||
:data="filtered"
|
||||
:row-key="row => row.id"
|
||||
:loading="query.loading.value"
|
||||
flex-height
|
||||
table-layout="fixed"
|
||||
:scroll-x="785"
|
||||
:pagination="{ page, pageSize: 20, onUpdatePage: (value: number) => (page = value) }"
|
||||
>
|
||||
<template #empty
|
||||
><div>
|
||||
{{
|
||||
query.error.value
|
||||
? '等待连接后端'
|
||||
: projects.length
|
||||
? '没有匹配的剧本'
|
||||
: '还没有剧本,点击右上角新建'
|
||||
}}
|
||||
</div>
|
||||
<NButton v-if="search || filter !== 'all'" text class="mt-3" @click="clearFilters"
|
||||
>清除筛选</NButton
|
||||
></template
|
||||
>
|
||||
</NDataTable>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
共 {{ filtered.length }} 个项目<span v-if="query.updatedAt.value">
|
||||
· 更新于 {{ formatDate(query.updatedAt.value) }}</span
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
</WorkspacePage>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NInput, NInputNumber } from 'naive-ui'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { DialogTrigger } from 'reka-ui'
|
||||
import { ArrowRight, LoaderCircle, Plus } from '@lucide/vue'
|
||||
import { AppDialog } from '../../../components/ui'
|
||||
import { projectsApi } from '../api'
|
||||
@@ -47,58 +47,54 @@ async function submit() {
|
||||
:busy="busy"
|
||||
>
|
||||
<template #trigger
|
||||
><DialogTrigger class="button button-primary"><Plus :size="16" />新建剧本</DialogTrigger></template
|
||||
><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
|
||||
><textarea
|
||||
id="topic"
|
||||
v-model="form.topic"
|
||||
class="input min-h-32 resize-y"
|
||||
><NInput
|
||||
placeholder="描述主角、故事背景,以及你希望展开的核心冲突……"
|
||||
required
|
||||
:disabled="busy"
|
||||
></textarea>
|
||||
:input-props="{ id: 'topic', required: true }"
|
||||
class="min-h-32 resize-y"
|
||||
v-model:value="form.topic"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</div>
|
||||
<div class="grid grid-cols-[1fr_110px] gap-4">
|
||||
<div>
|
||||
<label class="field-label" for="style">剧本风格</label
|
||||
><input
|
||||
id="style"
|
||||
v-model="form.style"
|
||||
class="input"
|
||||
><NInput
|
||||
placeholder="如:都市悬疑、爽文反转"
|
||||
:disabled="busy"
|
||||
/>
|
||||
:input-props="{ id: 'style' }"
|
||||
v-model:value="form.style"
|
||||
></NInput>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="episode-count">计划集数</label
|
||||
><input
|
||||
id="episode-count"
|
||||
v-model.number="form.episodeCount"
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
required
|
||||
><NInputNumber
|
||||
:disabled="busy"
|
||||
/>
|
||||
:input-props="{ id: 'episode-count', required: true }"
|
||||
:value="typeof form.episodeCount === 'number' ? form.episodeCount : null"
|
||||
@update:value="form.episodeCount = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted">
|
||||
建议先用 3 集验证生成效果。生成会实际调用后端模型,产生相应费用。
|
||||
</p>
|
||||
<p v-if="error" class="alert alert-error" role="alert">{{ error }}</p>
|
||||
<NAlert v-if="error" role="alert" type="error" :show-icon="false" class=" ">{{ error }}</NAlert>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="button button-secondary" :disabled="busy" @click="open = false">
|
||||
取消</button
|
||||
><button type="submit" class="button button-primary" :disabled="busy">
|
||||
<LoaderCircle v-if="busy" :size="16" class="animate-spin" />开始生成<ArrowRight
|
||||
<NButton :disabled="busy" @click="open = false"> 取消</NButton
|
||||
><NButton :disabled="busy" type="primary" attr-type="submit"
|
||||
><LoaderCircle v-if="busy" :size="16" class="animate-spin" />开始生成<ArrowRight
|
||||
v-if="!busy"
|
||||
:size="16"
|
||||
/>
|
||||
</button>
|
||||
/></NButton>
|
||||
</div>
|
||||
</form>
|
||||
</AppDialog>
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { TabsRoot, TabsList, TabsTrigger, TabsContent, ProgressRoot, ProgressIndicator } from 'reka-ui'
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCheckbox,
|
||||
NCollapse,
|
||||
NCollapseItem,
|
||||
NInputNumber,
|
||||
NProgress,
|
||||
NSelect,
|
||||
NTab,
|
||||
NTabs
|
||||
} from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ArrowLeft, Download, RefreshCw } from '@lucide/vue'
|
||||
import { EmptyState } from '../../components/ui'
|
||||
import { downloadText } from '../../lib/format'
|
||||
@@ -10,6 +22,8 @@ import ShotDesign from './components/ShotDesign.vue'
|
||||
import ShotTools from './components/ShotTools.vue'
|
||||
import { useStoryboard } from './useStoryboard'
|
||||
|
||||
const detailTab = ref('design')
|
||||
|
||||
/** 分镜工作区按单集查看正式数据,生成配置与镜头检查分开。 */
|
||||
const {
|
||||
id,
|
||||
@@ -65,167 +79,197 @@ function exportEpisode() {
|
||||
'application/json'
|
||||
)
|
||||
}
|
||||
/** 回执到达后打开诊断区;平时留出镜头检查空间。 */
|
||||
const overview = ref<string[]>([])
|
||||
watch(
|
||||
() => session.value.receipt,
|
||||
receipt => {
|
||||
if (receipt) overview.value = ['generation']
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-7">
|
||||
<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>
|
||||
<WorkspacePage split class="storyboard-workspace-page"
|
||||
><template #header
|
||||
><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>
|
||||
<RouterLink :to="`/projects/${id}/breakdown`" class="text-button"
|
||||
><ArrowLeft :size="14" />回到拆解</RouterLink
|
||||
>
|
||||
</div>
|
||||
<section class="panel mt-5 p-5" aria-label="分镜生成配置">
|
||||
<div class="storyboard-controls">
|
||||
<label
|
||||
><span class="field-label">当前剧集</span
|
||||
><select
|
||||
:value="episodeNo"
|
||||
class="input"
|
||||
aria-label="选择分镜剧集"
|
||||
@change="selectedEpisode = Number(($event.target as HTMLSelectElement).value)"
|
||||
<section class="mt-3" aria-label="分镜生成配置">
|
||||
<div class="storyboard-controls">
|
||||
<label
|
||||
><span class="field-label">当前剧集</span
|
||||
><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
|
||||
></label>
|
||||
<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
|
||||
>
|
||||
<option v-if="!episodeOptions.length" :value="0">暂无剧集</option>
|
||||
<option v-for="episode in episodeOptions" :key="episode.episodeNo" :value="episode.episodeNo">
|
||||
第 {{ episode.episodeNo }} 集 · {{ episode.title }}
|
||||
</option>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
><span class="field-label">批量并发</span
|
||||
><input
|
||||
id="storyboard-concurrency"
|
||||
v-model.number="concurrency"
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
<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"
|
||||
/><span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span></label
|
||||
>
|
||||
<label
|
||||
><span class="field-label">状态自动修复次数</span
|
||||
><input
|
||||
id="storyboard-repairs"
|
||||
v-model.number="maxRepairAttempts"
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
:disabled="operation.pending"
|
||||
/><span v-if="!repairsValid" class="mt-1 block text-xs text-danger"
|
||||
>请输入非负整数,0 表示不修复</span
|
||||
></label
|
||||
>
|
||||
<label class="flex items-center gap-2 pb-3 text-xs"
|
||||
><input
|
||||
v-model="force"
|
||||
type="checkbox"
|
||||
class="accent-accent"
|
||||
:disabled="operation.pending"
|
||||
/>批量覆盖已有结果</label
|
||||
>
|
||||
</div>
|
||||
<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">
|
||||
景别、机位、运镜与构图。单集操作会重新生成并覆盖;批量默认跳过完整剧集。
|
||||
class="flex items-center gap-2 pb-3 text-xs"
|
||||
>批量覆盖已有结果</NCheckbox
|
||||
>
|
||||
</div>
|
||||
</section></template
|
||||
>
|
||||
<NCollapse v-model:expanded-names="overview" class="workspace-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="生成本集导演设计"
|
||||
:disabled="!allowed['direction-one']"
|
||||
acknowledgement
|
||||
description="调用模型生成当前整集的导演设计,校验通过后保存。如果此集已有设计,将覆盖原结果。"
|
||||
@confirm="run('direction-one')"
|
||||
/><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="生成本集即时状态"
|
||||
:disabled="!allowed['visual-one']"
|
||||
acknowledgement
|
||||
description="依据本集导演设计和主体形态生成即时视觉状态,按配置尝试自动修复。校验通过才保存,已有状态将被覆盖。"
|
||||
@confirm="run('visual-one')"
|
||||
/><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>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ConfirmAction
|
||||
label="生成本集导演设计"
|
||||
:disabled="!allowed['direction-one']"
|
||||
acknowledgement
|
||||
description="调用模型生成当前整集的导演设计,校验通过后保存。如果此集已有设计,将覆盖原结果。"
|
||||
@confirm="run('direction-one')"
|
||||
/><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="生成本集即时状态"
|
||||
:disabled="!allowed['visual-one']"
|
||||
acknowledgement
|
||||
description="依据本集导演设计和主体形态生成即时视觉状态,按配置尝试自动修复。校验通过才保存,已有状态将被覆盖。"
|
||||
@confirm="run('visual-one')"
|
||||
/><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>
|
||||
<details class="mt-3 border-t border-line pt-3">
|
||||
<summary class="cursor-pointer text-xs font-medium">视频提示词批量操作</summary>
|
||||
<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')"
|
||||
/>
|
||||
</details>
|
||||
</section>
|
||||
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
|
||||
{{ query.error.value }} 当前保留上次成功读取的数据,暂停生成操作。<button
|
||||
class="text-button ml-3"
|
||||
@click="query.refresh"
|
||||
<NCollapse class="mt-3 border-t border-line 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 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"
|
||||
>
|
||||
重试查询
|
||||
</button>
|
||||
</p>
|
||||
<p v-if="!prerequisites.directionEpisode || !prerequisites.visualEpisode" class="alert mt-4">
|
||||
最新 Breakdown 缺少本集生成所需的镜头、主体或形态数据。已保存设计仍可查看;请先在拆解页完成或恢复相应阶段。
|
||||
</p>
|
||||
<p v-else-if="data && !directionComplete && data.directions.shotCount" class="alert mt-4">
|
||||
本集导演设计尚未补齐,请先完成第 01 步,再生成本集即时状态。
|
||||
</p>
|
||||
<GenerationReceipt v-if="session.receipt" :receipt="session.receipt" />
|
||||
最新 Breakdown
|
||||
缺少本集生成所需的镜头、主体或形态数据。已保存设计仍可查看;请先在拆解页完成或恢复相应阶段。 </NAlert
|
||||
><NAlert
|
||||
v-else-if="data && !directionComplete && data.directions.shotCount"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-4"
|
||||
>
|
||||
本集导演设计尚未补齐,请先完成第 01 步,再生成本集即时状态。
|
||||
</NAlert>
|
||||
</div>
|
||||
<div class="my-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">第 {{ episodeNo || '—' }} 集 · 镜头检查</h3>
|
||||
@@ -234,28 +278,27 @@ function exportEpisode() {
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="text-button" :disabled="query.loading.value" @click="query.refresh">
|
||||
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新分镜</button
|
||||
><button class="text-button" :disabled="!shots.length" @click="exportEpisode">
|
||||
<Download :size="13" />导出本集
|
||||
</button>
|
||||
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
|
||||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新分镜</NButton
|
||||
><NButton :disabled="!shots.length" @click="exportEpisode" text size="small"
|
||||
><Download :size="13" />导出本集
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-5 grid gap-4 sm:grid-cols-2">
|
||||
<div v-for="item in coverage" :key="item.title" class="panel px-5 py-4">
|
||||
<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>
|
||||
<ProgressRoot
|
||||
:model-value="item.count"
|
||||
:max="Math.max(item.total, 1)"
|
||||
<NProgress
|
||||
:aria-label="item.title + '已保存覆盖率'"
|
||||
class="progress-track"
|
||||
><ProgressIndicator
|
||||
class="progress-fill"
|
||||
:style="{ width: (item.total ? Math.min(100, (item.count / item.total) * 100) : 0) + '%' }"
|
||||
/></ProgressRoot>
|
||||
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 v-if="!data && query.loading.value" class="panel p-8 text-sm text-muted" role="status">
|
||||
@@ -271,21 +314,20 @@ function exportEpisode() {
|
||||
>
|
||||
BEAT {{ String(item.beatNo).padStart(2, '0') }}
|
||||
</h4>
|
||||
<button
|
||||
class="shot-link"
|
||||
:class="{ selected: shot.shotId === item.shotId }"
|
||||
<NButton
|
||||
:aria-current="shot.shotId === item.shotId ? 'true' : undefined"
|
||||
@click="selectedShot = item.shotId"
|
||||
>
|
||||
<span class="font-mono text-[10px] text-muted">{{ String(item.shotNo).padStart(2, '0') }}</span
|
||||
class="shot-link"
|
||||
:class="{ selected: shot.shotId === item.shotId }"
|
||||
><span class="font-mono text-[10px] text-muted">{{ String(item.shotNo).padStart(2, '0') }}</span
|
||||
><span class="min-w-0"
|
||||
><span class="block truncate text-xs font-medium">{{ item.title }}</span
|
||||
><span class="mt-1 block text-[10px] text-muted"
|
||||
>{{ item.direction ? '设计已保存' : '待设计' }} ·
|
||||
{{ item.visualState ? '状态已保存' : '待状态' }}</span
|
||||
></span
|
||||
>
|
||||
</button></template
|
||||
></NButton
|
||||
></template
|
||||
>
|
||||
</nav>
|
||||
<article class="min-w-0 p-5 lg:p-7">
|
||||
@@ -306,14 +348,15 @@ function exportEpisode() {
|
||||
标题与剧情描述取自最近可用 Breakdown 快照;以下设计与状态取自数据库。
|
||||
</p>
|
||||
</header>
|
||||
<TabsRoot default-value="design"
|
||||
><TabsList class="tabs-list" aria-label="镜头详情"
|
||||
><TabsTrigger value="design" class="tab-trigger">设计与状态</TabsTrigger
|
||||
><TabsTrigger value="tools" class="tab-trigger">参考图与生成规格</TabsTrigger></TabsList
|
||||
><TabsContent value="design" class="pt-6"><ShotDesign :shot="shot" /></TabsContent
|
||||
><TabsContent value="tools" class="pt-6"
|
||||
><ShotTools :key="shot.shotId" :project-id="id" :shot="shot" :disabled="blocked" /></TabsContent
|
||||
></TabsRoot>
|
||||
<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>
|
||||
</article>
|
||||
</div>
|
||||
<EmptyState
|
||||
@@ -323,6 +366,6 @@ function exportEpisode() {
|
||||
><RouterLink :to="`/projects/${id}/breakdown`" class="button button-secondary mt-4"
|
||||
>前往拆解</RouterLink
|
||||
></EmptyState
|
||||
>
|
||||
</div>
|
||||
></WorkspacePage
|
||||
>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NTable } from 'naive-ui'
|
||||
import { computed } from 'vue'
|
||||
import { Download } from '@lucide/vue'
|
||||
import { downloadText } from '../../../lib/format'
|
||||
@@ -31,19 +32,19 @@ function exportReceipt() {
|
||||
<section class="panel mt-5 p-5" aria-label="生成回执">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold">{{ receipt.title }} · 本次回执</h3>
|
||||
<button class="text-button" @click="exportReceipt"><Download :size="13" />导出诊断</button>
|
||||
<NButton @click="exportReceipt" text size="small"><Download :size="13" />导出诊断</NButton>
|
||||
</div>
|
||||
<p v-if="failed" class="alert alert-error mt-3" role="alert">
|
||||
<NAlert v-if="failed" role="alert" type="error" :show-icon="false" class="mt-3">
|
||||
本次存在失败、校验未通过或未保存项。关闭“批量覆盖已有结果”后再补齐,可跳过已完整保存项。
|
||||
</p>
|
||||
</NAlert>
|
||||
<template v-if="receipt.kind === 'batch'">
|
||||
<p class="my-4 text-xs text-muted">
|
||||
共 {{ receipt.result.episodeCount }} 集 · 完成 {{ receipt.result.completedEpisodes }} · 跳过
|
||||
{{ receipt.result.skippedEpisodes }} · 失败 {{ receipt.result.failedEpisodes }}
|
||||
</p>
|
||||
<div class="table-scroll">
|
||||
<table class="project-table">
|
||||
<thead>
|
||||
<NTable :single-line="false" class="project-table"
|
||||
><thead>
|
||||
<tr>
|
||||
<th>剧集</th>
|
||||
<th>结果</th>
|
||||
@@ -71,8 +72,8 @@ function exportReceipt() {
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody></NTable
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="receipt.kind === 'prompts'">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NTag } from 'naive-ui'
|
||||
import { directionLabel } from '../model'
|
||||
import type { DesignedShot } from '../types'
|
||||
|
||||
@@ -14,7 +15,7 @@ defineProps<{ shot: DesignedShot }>()
|
||||
</h3>
|
||||
<template v-if="shot.direction"
|
||||
><div class="mb-5 flex flex-wrap gap-2">
|
||||
<span
|
||||
<NTag
|
||||
v-for="field in [
|
||||
{ key: 'shotSize', label: '景别' },
|
||||
{ key: 'cameraAngle', label: '角度' },
|
||||
@@ -23,8 +24,9 @@ defineProps<{ shot: DesignedShot }>()
|
||||
{ key: 'depthOfField', label: '景深' }
|
||||
] as const"
|
||||
:key="field.key"
|
||||
class="tag"
|
||||
>{{ field.label }} · {{ directionLabel(shot.direction[field.key], field.key) }}</span
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>{{ field.label }} · {{ directionLabel(shot.direction[field.key], field.key) }}</NTag
|
||||
>
|
||||
</div>
|
||||
<dl class="detail-grid">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NCheckbox } from 'naive-ui'
|
||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||
import { Download, ExternalLink, RefreshCw } from '@lucide/vue'
|
||||
import { storyboardApi } from '../api'
|
||||
@@ -101,20 +102,18 @@ function exportResult(kind: 'spec' | 'prompt') {
|
||||
<section>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold">主体参考图</h3>
|
||||
<button
|
||||
class="button button-secondary"
|
||||
:disabled="busy.references || locked"
|
||||
@click="load('references')"
|
||||
>
|
||||
<RefreshCw :size="13" :class="{ 'animate-spin': busy.references }" />{{
|
||||
<NButton :disabled="busy.references || locked" @click="load('references')"
|
||||
><RefreshCw :size="13" :class="{ 'animate-spin': busy.references }" />{{
|
||||
references ? '刷新参考图' : '读取参考图'
|
||||
}}
|
||||
</button>
|
||||
}}</NButton
|
||||
>
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
读取镜头已绑定形态的图片,不生成新图。参考图缺失会影响视频提示词生成。
|
||||
</p>
|
||||
<p v-if="errors.references" class="alert alert-error mt-3" role="alert">{{ errors.references }}</p>
|
||||
<NAlert v-if="errors.references" role="alert" type="error" :show-icon="false" class="mt-3">{{
|
||||
errors.references
|
||||
}}</NAlert>
|
||||
<template v-if="references">
|
||||
<div class="reference-grid mt-4">
|
||||
<article v-for="item in references.references" :key="item.shotSubjectId" class="reference-card">
|
||||
@@ -165,24 +164,24 @@ function exportResult(kind: 'spec' | 'prompt') {
|
||||
<section class="border-t border-line pt-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold">镜头生成规格</h3>
|
||||
<button
|
||||
class="button button-secondary"
|
||||
<NButton
|
||||
:disabled="locked || busy.spec || !shot.direction || !shot.visualState"
|
||||
@click="load('spec')"
|
||||
>{{ busy.spec ? '编译中…' : '读取生成规格' }}</NButton
|
||||
>
|
||||
{{ busy.spec ? '编译中…' : '读取生成规格' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
后端合并主体稳定身份、具体形态、摄影设计与即时状态,编译为 GenerationSpec,不调用模型。需要本镜头已有
|
||||
Direction 和 VisualState。
|
||||
</p>
|
||||
<p v-if="errors.spec" class="alert alert-error mt-3" role="alert">{{ errors.spec }}</p>
|
||||
<NAlert v-if="errors.spec" role="alert" type="error" :show-icon="false" class="mt-3">{{
|
||||
errors.spec
|
||||
}}</NAlert>
|
||||
<template v-if="spec"
|
||||
><div class="mt-4 flex items-center justify-between">
|
||||
<span class="text-xs text-muted"
|
||||
>{{ spec.subjects.length }} 个主体 · {{ spec.durationSeconds }} 秒</span
|
||||
><button class="text-button" @click="exportResult('spec')"><Download :size="13" />导出规格</button>
|
||||
><NButton @click="exportResult('spec')" text size="small"><Download :size="13" />导出规格</NButton>
|
||||
</div>
|
||||
<pre class="storyboard-json mt-3">{{ JSON.stringify(spec, null, 2) }}</pre>
|
||||
</template>
|
||||
@@ -204,19 +203,14 @@ function exportResult(kind: 'spec' | 'prompt') {
|
||||
: '读取此镜头已有提示词;若不存在,将调用模型并保存结果。后端会检查参考图是否齐全。'
|
||||
"
|
||||
@confirm="generatePrompt"
|
||||
/><label class="flex items-center gap-2 text-xs"
|
||||
><input
|
||||
v-model="force"
|
||||
type="checkbox"
|
||||
class="accent-accent"
|
||||
:disabled="locked"
|
||||
/>覆盖此镜头提示词</label
|
||||
/><NCheckbox v-model:checked="force" :disabled="locked" class="flex items-center gap-2 text-xs"
|
||||
>覆盖此镜头提示词</NCheckbox
|
||||
>
|
||||
</div>
|
||||
<div v-if="prompt" class="mt-5">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-xs font-medium">本会话已读取的正文</span
|
||||
><button class="text-button" @click="exportResult('prompt')"><Download :size="13" />导出</button>
|
||||
><NButton @click="exportResult('prompt')" text size="small"><Download :size="13" />导出</NButton>
|
||||
</div>
|
||||
<p class="whitespace-pre-wrap text-sm leading-7">{{ prompt.videoPrompt }}</p>
|
||||
<template v-if="prompt.negativePrompt"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect, NTag } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { UserCheck } from '@lucide/vue'
|
||||
@@ -115,198 +117,244 @@ function chooseAnchor(imageId: string) {
|
||||
if (subject.value?.module === 'character') void confirmCasting(imageId)
|
||||
else void setAnchor(imageId)
|
||||
}
|
||||
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
|
||||
const overview = ref<string[]>([])
|
||||
watch(
|
||||
() => session.value.receipt,
|
||||
receipt => {
|
||||
if (receipt) overview.value = ['casting']
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mt-7">
|
||||
<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/${projectId}/subject-images`" class="text-button"
|
||||
>下一步:形态图片 →</RouterLink
|
||||
>
|
||||
</div>
|
||||
<p class="alert mt-5 text-xs">
|
||||
Character 现在需要依次完成身份文本、选角候选、确认演员并锁定
|
||||
Identity,之后首帧才能就绪。未明确人物背景时,AI
|
||||
默认以中国人物作为选角基线;项目和角色的明确事实始终优先。场景和道具仍使用普通身份母版流程。
|
||||
</p>
|
||||
<p
|
||||
v-if="catalog.error.value || styleQuery.error.value || castingQuery.error.value"
|
||||
class="alert alert-error mt-4"
|
||||
role="alert"
|
||||
>
|
||||
{{ catalog.error.value || styleQuery.error.value || castingQuery.error.value }}
|
||||
当前相关操作已暂停,请刷新核对后端。
|
||||
</p>
|
||||
<p v-if="styleQuery.data.value && !hasStyle" class="alert mt-4">
|
||||
先创建项目视觉风格,才能 AI 生成身份或身份图片。<RouterLink
|
||||
:to="`/projects/${projectId}/visual-style`"
|
||||
class="text-button ml-2"
|
||||
>设置视觉风格 →</RouterLink
|
||||
>
|
||||
</p>
|
||||
<section v-if="casting" class="panel my-5 p-5" aria-label="角色选角流程">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="production-step"><UserCheck :size="17" /></span>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">角色选角</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
只处理 Character。每张候选都从 Identity 独立生成,不复用旧 Anchor;确认选择时才会同时切换
|
||||
Anchor 并锁定 Identity。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-muted">{{ casting.ready }} / {{ casting.total }} 已完成</span>
|
||||
</div>
|
||||
<dl class="casting-stats mt-4">
|
||||
<WorkspacePage split
|
||||
><template #header
|
||||
><div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<dt>缺身份文本</dt>
|
||||
<dd>{{ casting.missingIdentity }}</dd>
|
||||
<h2 class="text-lg font-semibold">主体身份</h2>
|
||||
<p class="mt-2 text-sm text-muted">为同一个人物、场景或道具固定稳定特征,再生成不同造型。</p>
|
||||
</div>
|
||||
<div>
|
||||
<dt>缺候选图</dt>
|
||||
<dd>{{ casting.missingAnchor }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>待确认演员</dt>
|
||||
<dd>{{ casting.candidatePending }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>母版未锁定</dt>
|
||||
<dd>{{ casting.unlocked }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>选角完成</dt>
|
||||
<dd>{{ casting.ready }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="mt-5 flex flex-wrap items-end gap-4 border-t border-line pt-4">
|
||||
<label class="w-28">
|
||||
<span class="field-label">并发数</span>
|
||||
<input
|
||||
v-model.number="concurrency"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input"
|
||||
:disabled="operation.pending"
|
||||
/>
|
||||
</label>
|
||||
<label class="w-28">
|
||||
<span class="field-label">本批上限</span>
|
||||
<input
|
||||
v-model.number="candidateLimit"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input"
|
||||
:disabled="operation.pending"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex gap-2 pb-2 text-xs">
|
||||
<input v-model="force" type="checkbox" :disabled="operation.pending" />覆盖未锁定身份文本
|
||||
</label>
|
||||
<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 调用 Seedream,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
|
||||
@confirm="generateCastingCandidates"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!batchValid || !candidateLimitValid" class="mt-3 text-xs text-danger">
|
||||
并发数与本批上限都必须是正整数。
|
||||
</p>
|
||||
<div class="casting-list mt-5">
|
||||
<button
|
||||
v-for="item in casting.items"
|
||||
:key="item.subjectId"
|
||||
class="casting-item"
|
||||
:class="{ selected: selectedId === item.subjectId }"
|
||||
@click="select(item.subjectId)"
|
||||
<RouterLink :to="`/projects/${projectId}/subject-images`" class="text-button"
|
||||
>下一步:形态图片 →</RouterLink
|
||||
>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-xs font-medium">{{ item.subjectName }}</span>
|
||||
<span class="mt-1 block font-mono text-[10px] text-muted">{{ item.subjectRef }}</span>
|
||||
</span>
|
||||
<StatusBadge :status="item.status" :label="castingStatusLabel(item.status)" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<details class="panel mb-5 p-5">
|
||||
<summary class="cursor-pointer text-sm font-medium">高级:全部主体身份文本</summary>
|
||||
<p class="mt-3 text-xs leading-6 text-muted">
|
||||
同时处理 Character、Scene 和 Prop;角色选角建议优先使用上方专用入口。已锁定身份始终跳过。
|
||||
</p>
|
||||
<ConfirmAction
|
||||
class="mt-4"
|
||||
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
|
||||
:disabled="blocked || !hasStyle || !batchValid || dirty || !subjects.length"
|
||||
acknowledgement
|
||||
description="生成全项目主体身份描述与提示词,不生成图片。覆盖不会自动更新旧身份图、形态图、首帧或视频。"
|
||||
@confirm="generateProject(false)"
|
||||
/>
|
||||
</details>
|
||||
<section v-if="session.receipt" class="panel mb-5 p-5" aria-label="身份文本批量回执">
|
||||
<div class="flex justify-between gap-3">
|
||||
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
|
||||
<button class="text-button" @click="exportReceipt">导出诊断</button>
|
||||
</div>
|
||||
<p v-if="session.receipt.kind === 'identities'" class="mt-3 text-xs text-muted">
|
||||
共 {{ session.receipt.result.total }} 个主体 · 目标 {{ session.receipt.result.targetCount }} · 生成
|
||||
{{ session.receipt.result.generated }} · 跳过 {{ session.receipt.result.skipped }}(含锁定
|
||||
{{ session.receipt.result.skippedLocked }})· 失败 {{ session.receipt.result.failed }}
|
||||
</p>
|
||||
<p v-else class="mt-3 text-xs text-muted">
|
||||
共 {{ session.receipt.result.totalCharacters }} 个角色 · 缺候选
|
||||
{{ session.receipt.result.missingAnchor }} · 本批目标 {{ session.receipt.result.targetCount }} · 已生成
|
||||
{{ session.receipt.result.generated }} · 失败 {{ session.receipt.result.failed }}
|
||||
</p>
|
||||
<p v-if="session.receipt.result.failed" class="alert alert-error mt-3" role="alert">
|
||||
本次存在部分失败,成功结果不会回滚。请检查逐角色原因后再重试。
|
||||
</p>
|
||||
<ul class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectId">
|
||||
{{ failure.subjectRef }} · {{ failure.subjectId }}:{{ failure.error }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话;最终状态以刷新后的正式记录为准。</p>
|
||||
</section>
|
||||
</div></template
|
||||
>
|
||||
<NAlert
|
||||
v-if="catalog.error.value || styleQuery.error.value || castingQuery.error.value"
|
||||
role="alert"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
class="mt-4"
|
||||
>{{ catalog.error.value || styleQuery.error.value || castingQuery.error.value }}
|
||||
当前相关操作已暂停,请刷新核对后端。
|
||||
</NAlert>
|
||||
<NCollapse v-model:expanded-names="overview" class="workspace-overview"
|
||||
><NCollapseItem name="casting" title="角色选角总览与批量操作"
|
||||
><div class="overview-content">
|
||||
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
||||
Character 现在需要依次完成身份文本、选角候选、确认演员并锁定
|
||||
Identity,之后首帧才能就绪。未明确人物背景时,AI
|
||||
默认以中国人物作为选角基线;项目和角色的明确事实始终优先。场景和道具仍使用普通身份母版流程。
|
||||
</NAlert>
|
||||
<NAlert v-if="styleQuery.data.value && !hasStyle" type="info" :show-icon="false" class="mt-4">
|
||||
先创建项目视觉风格,才能 AI 生成身份或身份图片。<RouterLink
|
||||
:to="`/projects/${projectId}/visual-style`"
|
||||
class="text-button ml-2"
|
||||
>设置视觉风格 →</RouterLink
|
||||
></NAlert
|
||||
>
|
||||
<section v-if="casting" class="panel my-5 p-5" aria-label="角色选角流程">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="production-step"><UserCheck :size="17" /></span>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">角色选角</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
只处理 Character。每张候选都从 Identity 独立生成,不复用旧
|
||||
Anchor;确认选择时才会同时切换 Anchor 并锁定 Identity。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-muted"
|
||||
>{{ casting.ready }} / {{ casting.total }} 已完成</span
|
||||
>
|
||||
</div>
|
||||
<dl class="casting-stats mt-4">
|
||||
<div>
|
||||
<dt>缺身份文本</dt>
|
||||
<dd>{{ casting.missingIdentity }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>缺候选图</dt>
|
||||
<dd>{{ casting.missingAnchor }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>待确认演员</dt>
|
||||
<dd>{{ casting.candidatePending }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>母版未锁定</dt>
|
||||
<dd>{{ casting.unlocked }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>选角完成</dt>
|
||||
<dd>{{ casting.ready }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="mt-5 flex flex-wrap items-end gap-4 border-t border-line 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="flex gap-2 pb-2 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 调用 Seedream,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
|
||||
@confirm="generateCastingCandidates"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!batchValid || !candidateLimitValid" class="mt-3 text-xs text-danger">
|
||||
并发数与本批上限都必须是正整数。
|
||||
</p>
|
||||
<div class="casting-list mt-5">
|
||||
<NButton
|
||||
v-for="item in casting.items"
|
||||
:key="item.subjectId"
|
||||
@click="select(item.subjectId)"
|
||||
class="casting-item"
|
||||
:class="{ selected: selectedId === item.subjectId }"
|
||||
><span class="min-w-0">
|
||||
<span class="block truncate text-xs font-medium">{{ item.subjectName }}</span>
|
||||
<span class="mt-1 block font-mono text-[10px] text-muted">{{
|
||||
item.subjectRef
|
||||
}}</span> </span
|
||||
><StatusBadge :status="item.status" :label="castingStatusLabel(item.status)"
|
||||
/></NButton>
|
||||
</div>
|
||||
</section>
|
||||
<NCollapse class="panel mb-5 p-5"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>高级:全部主体身份文本</template>
|
||||
<p class="mt-3 text-xs leading-6 text-muted">
|
||||
同时处理 Character、Scene 和
|
||||
Prop;角色选角建议优先使用上方专用入口。已锁定身份始终跳过。
|
||||
</p>
|
||||
<ConfirmAction
|
||||
class="mt-4"
|
||||
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
|
||||
:disabled="blocked || !hasStyle || !batchValid || dirty || !subjects.length"
|
||||
acknowledgement
|
||||
description="生成全项目主体身份描述与提示词,不生成图片。覆盖不会自动更新旧身份图、形态图、首帧或视频。"
|
||||
@confirm="generateProject(false)" /></NCollapseItem
|
||||
></NCollapse>
|
||||
<section v-if="session.receipt" class="panel mb-5 p-5" aria-label="身份文本批量回执">
|
||||
<div class="flex justify-between gap-3">
|
||||
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
|
||||
<NButton @click="exportReceipt" text size="small">导出诊断</NButton>
|
||||
</div>
|
||||
<p v-if="session.receipt.kind === 'identities'" class="mt-3 text-xs text-muted">
|
||||
共 {{ session.receipt.result.total }} 个主体 · 目标
|
||||
{{ session.receipt.result.targetCount }} · 生成 {{ session.receipt.result.generated }} ·
|
||||
跳过 {{ session.receipt.result.skipped }}(含锁定
|
||||
{{ session.receipt.result.skippedLocked }})· 失败 {{ session.receipt.result.failed }}
|
||||
</p>
|
||||
<p v-else class="mt-3 text-xs text-muted">
|
||||
共 {{ session.receipt.result.totalCharacters }} 个角色 · 缺候选
|
||||
{{ session.receipt.result.missingAnchor }} · 本批目标
|
||||
{{ session.receipt.result.targetCount }} · 已生成 {{ session.receipt.result.generated }} ·
|
||||
失败 {{ session.receipt.result.failed }}
|
||||
</p>
|
||||
<NAlert
|
||||
v-if="session.receipt.result.failed"
|
||||
role="alert"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
class="mt-3"
|
||||
>
|
||||
本次存在部分失败,成功结果不会回滚。请检查逐角色原因后再重试。
|
||||
</NAlert>
|
||||
<ul class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectId">
|
||||
{{ failure.subjectRef }} · {{ failure.subjectId }}:{{ failure.error }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-3 text-[11px] text-muted">
|
||||
回执仅保留于当前浏览器会话;最终状态以刷新后的正式记录为准。
|
||||
</p>
|
||||
</section>
|
||||
</div></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
<div v-if="subjects.length" class="identity-workspace">
|
||||
<aside class="panel min-w-0 p-4">
|
||||
<h3 class="text-sm font-medium">
|
||||
主体目录 <span class="ml-2 text-xs text-muted">{{ subjects.length }}</span>
|
||||
</h3>
|
||||
<input v-model="search" class="input mt-4" placeholder="搜索主体或引用" aria-label="搜索主体身份" />
|
||||
<select v-model="module" class="input mt-3" aria-label="身份主体类型">
|
||||
<option value="all">全部类型</option>
|
||||
<option value="character">人物</option>
|
||||
<option value="scene">场景</option>
|
||||
<option value="prop">道具</option>
|
||||
</select>
|
||||
<NInput
|
||||
placeholder="搜索主体或引用"
|
||||
aria-label="搜索主体身份"
|
||||
:input-props="{ 'aria-label': '搜索主体身份' }"
|
||||
class="mt-4"
|
||||
v-model:value="search"
|
||||
></NInput>
|
||||
<NSelect
|
||||
aria-label="身份主体类型"
|
||||
v-model:value="module"
|
||||
:options="[
|
||||
{ label: String('全部类型'), value: 'all' },
|
||||
{ label: String('人物'), value: 'character' },
|
||||
{ label: String('场景'), value: 'scene' },
|
||||
{ label: String('道具'), value: 'prop' }
|
||||
]"
|
||||
class="mt-3"
|
||||
></NSelect>
|
||||
<div class="identity-subject-list mt-4">
|
||||
<button
|
||||
<NButton
|
||||
v-for="item in filtered"
|
||||
:key="item.id"
|
||||
class="identity-subject-item"
|
||||
:class="{ selected: selectedId === item.id }"
|
||||
:aria-pressed="selectedId === item.id"
|
||||
@click="select(item.id)"
|
||||
>
|
||||
<span class="block font-medium">{{ item.name }}</span
|
||||
class="identity-subject-item"
|
||||
:class="{ selected: selectedId === item.id }"
|
||||
><span class="block font-medium">{{ item.name }}</span
|
||||
><span class="mt-1 block text-[11px] text-muted"
|
||||
>{{ item.ref }} · {{ item.forms.length }} 个形态<span
|
||||
v-if="casting?.items.find(row => row.subjectId === item.id)"
|
||||
@@ -316,26 +364,28 @@ function chooseAnchor(imageId: string) {
|
||||
castingStatusLabel(casting.items.find(row => row.subjectId === item.id)!.status)
|
||||
}}</span
|
||||
></span
|
||||
>
|
||||
</button>
|
||||
></NButton
|
||||
>
|
||||
<p v-if="!filtered.length" class="py-6 text-xs text-muted">没有匹配主体,请调整筛选。</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-button mt-4"
|
||||
<NButton
|
||||
:disabled="catalog.loading.value || castingQuery.loading.value"
|
||||
@click="refreshDirectory"
|
||||
text
|
||||
size="small"
|
||||
class="mt-4"
|
||||
>
|
||||
刷新主体目录
|
||||
</button>
|
||||
</NButton>
|
||||
</aside>
|
||||
<div class="min-w-0">
|
||||
<div v-if="pendingSelection" class="alert mb-4">
|
||||
<p class="text-xs">当前主体有未保存修改,切换会丢弃草稿。</p>
|
||||
<NAlert v-if="pendingSelection" type="info" :show-icon="false" class="mb-4"
|
||||
><p class="text-xs">当前主体有未保存修改,切换会丢弃草稿。</p>
|
||||
<div class="mt-3 flex gap-3">
|
||||
<button class="button button-secondary" @click="discardAndSelect">放弃草稿并切换</button
|
||||
><button class="text-button" @click="pendingSelection = ''">继续编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
<NButton @click="discardAndSelect">放弃草稿并切换</NButton
|
||||
><NButton @click="pendingSelection = ''" text size="small">继续编辑</NButton>
|
||||
</div></NAlert
|
||||
>
|
||||
<section v-if="subject" class="panel p-5 sm:p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
@@ -357,13 +407,13 @@ function chooseAnchor(imageId: string) {
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<button class="text-button" :disabled="detail.loading.value" @click="detail.refresh">
|
||||
<NButton :disabled="detail.loading.value" @click="detail.refresh" text size="small">
|
||||
刷新身份
|
||||
</button>
|
||||
</NButton>
|
||||
</div>
|
||||
<p v-if="detail.error.value" class="alert alert-error mt-4" role="alert">
|
||||
{{ detail.error.value }} 查询失败时不允许生成或覆盖。
|
||||
</p>
|
||||
<NAlert v-if="detail.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||||
>{{ detail.error.value }} 查询失败时不允许生成或覆盖。
|
||||
</NAlert>
|
||||
<p v-if="!detail.data.value && detail.loading.value" class="py-8 text-sm text-muted" role="status">
|
||||
正在读取主体身份…
|
||||
</p>
|
||||
@@ -375,21 +425,18 @@ function chooseAnchor(imageId: string) {
|
||||
acknowledgement
|
||||
description="调用文本模型生成稳定身份描述和提示词。旧图片不会自动重生成;已锁定身份需先解锁并保存。"
|
||||
@confirm="generate"
|
||||
/><button
|
||||
/><NButton
|
||||
v-if="subject.module === 'character'"
|
||||
class="button button-secondary"
|
||||
:disabled="!canGenerateCandidate"
|
||||
@click="castingOpen = true"
|
||||
>
|
||||
生成选角候选</button
|
||||
><button
|
||||
生成选角候选</NButton
|
||||
><NButton
|
||||
v-if="subject.module !== 'character' || !!anchor"
|
||||
class="button button-secondary"
|
||||
:disabled="!canGenerateImage"
|
||||
@click="imageOpen = true"
|
||||
>{{ subject.module === 'character' ? '生成辅助身份图' : '生成身份参考图' }}</NButton
|
||||
>
|
||||
{{ subject.module === 'character' ? '生成辅助身份图' : '生成身份参考图' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="dirty" class="mt-3 text-xs text-muted">请先保存身份修改,再使用 AI 或生成图片。</p>
|
||||
<IdentityEditor
|
||||
@@ -434,8 +481,8 @@ function chooseAnchor(imageId: string) {
|
||||
<div class="mt-5 border-t border-line pt-5">
|
||||
<h4 class="text-xs font-medium">此主体的形态</h4>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<span v-for="form in subject.forms" :key="form.id" class="tag"
|
||||
>{{ form.name }}{{ form.isDefault ? ' · 默认' : '' }}</span
|
||||
<NTag v-for="form in subject.forms" :key="form.id" size="small" :bordered="false"
|
||||
>{{ form.name }}{{ form.isDefault ? ' · 默认' : '' }}</NTag
|
||||
>
|
||||
</div>
|
||||
<p v-if="!subject.forms.length" class="mt-3 text-xs text-muted">
|
||||
@@ -467,8 +514,7 @@ function chooseAnchor(imageId: string) {
|
||||
:module="subject.module"
|
||||
:images="images"
|
||||
:disabled="!canGenerateImage"
|
||||
@generate="generateImage"
|
||||
/>
|
||||
@generate="generateImage" />
|
||||
<CastingCandidateDialog
|
||||
v-if="subject?.module === 'character'"
|
||||
v-model:open="castingOpen"
|
||||
@@ -476,6 +522,5 @@ function chooseAnchor(imageId: string) {
|
||||
:subject-name="subject.name"
|
||||
:disabled="!canGenerateCandidate"
|
||||
@generate="generateCastingCandidate"
|
||||
/>
|
||||
</section>
|
||||
/></WorkspacePage>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NCheckbox, NInput, NInputNumber } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { AppDialog } from '../../../components/ui'
|
||||
import { validImageSize } from '../../subject-images/model'
|
||||
@@ -38,18 +39,20 @@ function submit() {
|
||||
<template>
|
||||
<AppDialog v-model:open="open" title="生成角色选角候选" :description="`${subjectName} · 候选不会自动成为身份母版`">
|
||||
<form class="mt-5 space-y-4" @submit.prevent="submit">
|
||||
<p class="alert text-xs">
|
||||
<NAlert type="info" :show-icon="false" class="text-xs">
|
||||
后端会依据角色 Identity、项目视觉风格和默认选角约束独立生成一张 primary
|
||||
候选,不会复用当前身份母版。生成完成后仍需人工点击“确认选角”。
|
||||
</p>
|
||||
</NAlert>
|
||||
<label class="block">
|
||||
<span class="field-label">覆盖本次完整提示词(可选)</span>
|
||||
<textarea
|
||||
id="casting-candidate-prompt"
|
||||
v-model="prompt"
|
||||
class="input min-h-24"
|
||||
<NInput
|
||||
placeholder="建议留空,使用后端编译的稳定身份和选角约束;未明确人物背景时默认中国人物"
|
||||
/>
|
||||
:input-props="{ id: 'casting-candidate-prompt' }"
|
||||
class="min-h-24"
|
||||
v-model:value="prompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
<p v-if="prompt.trim()" class="text-xs text-danger">
|
||||
自定义文本会替换后端完整提示词,请自行包含人物身份、基础服装和中性参考图要求。
|
||||
@@ -57,27 +60,35 @@ function submit() {
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label
|
||||
><span class="field-label">宽度(px)</span
|
||||
><input v-model.number="width" class="input" type="number" min="1" placeholder="后端默认"
|
||||
/></label>
|
||||
><NInputNumber
|
||||
placeholder="后端默认"
|
||||
:value="typeof width === 'number' ? width : null"
|
||||
@update:value="width = $event ?? ''"
|
||||
:min="1"
|
||||
></NInputNumber
|
||||
></label>
|
||||
<label
|
||||
><span class="field-label">高度(px)</span
|
||||
><input v-model.number="height" class="input" type="number" min="1" placeholder="后端默认"
|
||||
/></label>
|
||||
><NInputNumber
|
||||
placeholder="后端默认"
|
||||
:value="typeof height === 'number' ? height : null"
|
||||
@update:value="height = $event ?? ''"
|
||||
:min="1"
|
||||
></NInputNumber
|
||||
></label>
|
||||
</div>
|
||||
<p v-if="!valid" class="text-xs text-danger">宽高需要同时留空,或同时填写正整数。</p>
|
||||
<label class="flex items-start gap-2 text-xs leading-6">
|
||||
<input
|
||||
id="casting-candidate-cost"
|
||||
v-model="acknowledged"
|
||||
type="checkbox"
|
||||
class="mt-1.5 accent-accent"
|
||||
/>我已确认调用 Seedream 可能产生费用,且当前没有同项目生成任务。
|
||||
</label>
|
||||
<NCheckbox
|
||||
id="casting-candidate-cost"
|
||||
v-model:checked="acknowledged"
|
||||
class="flex items-start gap-2 text-xs leading-6"
|
||||
>我已确认调用 Seedream 可能产生费用,且当前没有同项目生成任务。
|
||||
</NCheckbox>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="button button-secondary" @click="open = false">取消</button>
|
||||
<button type="submit" class="button button-primary" :disabled="disabled || !valid || !acknowledged">
|
||||
<NButton @click="open = false">取消</NButton>
|
||||
<NButton :disabled="disabled || !valid || !acknowledged" type="primary" attr-type="submit">
|
||||
确认生成候选
|
||||
</button>
|
||||
</NButton>
|
||||
</div>
|
||||
</form>
|
||||
</AppDialog>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NCheckbox, NInput } from 'naive-ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { SaveIdentityInput, SubjectIdentity } from '../types'
|
||||
|
||||
@@ -34,21 +35,27 @@ function save() {
|
||||
<fieldset :disabled="disabled" class="space-y-4">
|
||||
<label class="block"
|
||||
><span class="field-label">稳定身份描述</span
|
||||
><textarea
|
||||
id="identity-description"
|
||||
v-model="form.description"
|
||||
class="input min-h-28"
|
||||
><NInput
|
||||
:disabled="disabled"
|
||||
placeholder="只描述跨形态不变的面部、结构、材质等特征"
|
||||
/>
|
||||
:input-props="{ id: 'identity-description' }"
|
||||
class="min-h-28"
|
||||
v-model:value="form.description"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
<label class="block"
|
||||
><span class="field-label">身份核心提示词(稳定事实)</span
|
||||
><textarea
|
||||
id="identity-prompt"
|
||||
v-model="form.generationPrompt"
|
||||
class="input min-h-36"
|
||||
><NInput
|
||||
:disabled="disabled"
|
||||
placeholder="不固定某一形态的服装、伤势或临时状态"
|
||||
/>
|
||||
:input-props="{ id: 'identity-prompt' }"
|
||||
class="min-h-36"
|
||||
v-model:value="form.generationPrompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
<p class="text-xs leading-6 text-muted">
|
||||
{{
|
||||
@@ -60,18 +67,22 @@ function save() {
|
||||
}}
|
||||
这里保存身份事实;背景、视角和参考图约束由后端另行编译,不是最终整段生图 Prompt。
|
||||
</p>
|
||||
<label class="flex items-start gap-2 text-xs leading-6"
|
||||
><input id="identity-lock" v-model="form.isLocked" type="checkbox" class="mt-1.5 accent-accent" />{{
|
||||
<NCheckbox
|
||||
:disabled="disabled"
|
||||
id="identity-lock"
|
||||
v-model:checked="form.isLocked"
|
||||
class="flex items-start gap-2 text-xs leading-6"
|
||||
>{{
|
||||
module === 'character'
|
||||
? '确认并锁定演员身份。Character 必须同时具有身份母版并锁定 Identity,首帧才能进入就绪状态。'
|
||||
: '确认并锁定身份文本。锁定后仍可人工保存、生成图片及切换母版。'
|
||||
}}</label
|
||||
}}</NCheckbox
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="text-xs text-muted">{{
|
||||
dirty ? '有未保存修改,自动刷新不会覆盖草稿。' : '图片以已保存的身份提示词为准。'
|
||||
}}</span
|
||||
><button class="button button-primary" type="submit" :disabled="disabled">保存主体身份</button>
|
||||
><NButton :disabled="disabled" type="primary" attr-type="submit">保存主体身份</NButton>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { AssetImage, StatusBadge } from '../../../components/ui'
|
||||
import { referenceImageUrl } from '../../../lib/assets'
|
||||
@@ -49,14 +50,14 @@ function chooseAnchor() {
|
||||
<section class="mt-5 border-t border-line pt-5" aria-label="身份参考图库">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-medium">身份参考图 · {{ images.length }}</h3>
|
||||
<span class="tag">{{ anchor ? '已选母版' : '尚无母版' }}</span>
|
||||
<NTag size="small" :bordered="false">{{ anchor ? '已选母版' : '尚无母版' }}</NTag>
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
身份母版确定“是谁”;形态主图确定“该造型长什么样”。切换母版仅影响之后的生成,不会替换已有形态图、分镜参考图或提示词。
|
||||
</p>
|
||||
<p v-if="character && anchor" class="alert mt-3 text-xs leading-6">
|
||||
<NAlert v-if="character && anchor" type="info" :show-icon="false" class="mt-3 text-xs leading-6">
|
||||
如果要更换演员身份,请生成新的“选角候选”并确认选角。正面、三分之四、全身等辅助身份图会继续引用当前母版,只用于保持同一演员,不会重新选择人物。
|
||||
</p>
|
||||
</NAlert>
|
||||
<template v-if="selected">
|
||||
<AssetImage
|
||||
:src="selected.status === 'completed' ? selected.imageUrl : null"
|
||||
@@ -66,13 +67,13 @@ function chooseAnchor() {
|
||||
/>
|
||||
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge :status="selected.status" /><span class="tag">{{
|
||||
<StatusBadge :status="selected.status" /><NTag size="small" :bordered="false">{{
|
||||
selected.isAnchor
|
||||
? '当前身份母版'
|
||||
: selected.viewType === 'primary'
|
||||
? '母版候选'
|
||||
: identityViewLabels[selected.viewType]
|
||||
}}</span
|
||||
}}</NTag
|
||||
><span class="text-xs text-muted"
|
||||
>{{ selected.enabled ? '启用' : '停用' }} · {{ selected.width || '—' }} ×
|
||||
{{ selected.height || '—' }}</span
|
||||
@@ -87,15 +88,15 @@ function chooseAnchor() {
|
||||
>打开原图</a
|
||||
>
|
||||
</div>
|
||||
<p v-if="selected.error" class="alert alert-error mt-3" role="alert">{{ selected.error }}</p>
|
||||
<NAlert v-if="selected.error" role="alert" type="error" :show-icon="false" class="mt-3">{{
|
||||
selected.error
|
||||
}}</NAlert>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
<NButton
|
||||
v-if="!selected.isAnchor || (character && !identityLocked)"
|
||||
class="button button-secondary"
|
||||
:disabled="!canSelect"
|
||||
@click="confirming = true"
|
||||
>
|
||||
{{ character ? '确认选角并锁定' : '设为身份母版' }}</button
|
||||
>{{ character ? '确认选角并锁定' : '设为身份母版' }}</NButton
|
||||
><span class="text-[11px] text-muted"
|
||||
>{{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}</span
|
||||
>
|
||||
@@ -103,8 +104,8 @@ function chooseAnchor() {
|
||||
<p v-if="selected.viewType !== 'primary'" class="mt-2 text-xs text-muted">
|
||||
辅助视图不能直接设为母版;它会继承当前身份母版,不适合用来更换演员。
|
||||
</p>
|
||||
<div v-if="confirming" class="alert mt-3">
|
||||
<p class="text-xs">
|
||||
<NAlert v-if="confirming" type="info" :show-icon="false" class="mt-3"
|
||||
><p class="text-xs">
|
||||
{{
|
||||
character
|
||||
? '确认选择这张图片作为正式演员?后端会在同一事务中切换身份母版并锁定 Identity,完成后首帧才具备稳定人物身份。'
|
||||
@@ -112,15 +113,18 @@ function chooseAnchor() {
|
||||
}}
|
||||
</p>
|
||||
<div class="mt-3 flex gap-3">
|
||||
<button class="button button-primary" :disabled="!canSelect" @click="chooseAnchor">
|
||||
{{ character ? '确认演员选择' : '确认切换身份母版' }}</button
|
||||
><button class="text-button" @click="confirming = false">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<details v-if="selected.prompt" class="mt-4 text-xs">
|
||||
<summary class="cursor-pointer text-muted">本次实际提示词</summary>
|
||||
<p class="mt-3 whitespace-pre-wrap leading-6">{{ selected.prompt }}</p>
|
||||
</details>
|
||||
<NButton :disabled="!canSelect" @click="chooseAnchor" type="primary">{{
|
||||
character ? '确认演员选择' : '确认切换身份母版'
|
||||
}}</NButton
|
||||
><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></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
<p
|
||||
v-if="readImageProvenance(selected.rawJson).referenceImageId"
|
||||
class="mt-3 break-all text-[11px] text-muted"
|
||||
@@ -129,16 +133,15 @@ function chooseAnchor() {
|
||||
</p>
|
||||
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
|
||||
<div class="image-history mt-4" aria-label="身份图片历史">
|
||||
<button
|
||||
<NButton
|
||||
v-for="image in images"
|
||||
:key="image.id"
|
||||
class="image-history-item"
|
||||
:class="{ selected: image.id === selected.id }"
|
||||
:aria-label="`查看身份图片 ${image.id}`"
|
||||
:aria-pressed="image.id === selected.id"
|
||||
@click="selectedId = image.id"
|
||||
>
|
||||
<AssetImage
|
||||
class="image-history-item"
|
||||
:class="{ selected: image.id === selected.id }"
|
||||
><AssetImage
|
||||
:src="image.status === 'completed' ? image.imageUrl : null"
|
||||
:alt="identityViewLabels[image.viewType]"
|
||||
:retryable="false"
|
||||
@@ -149,8 +152,8 @@ function chooseAnchor() {
|
||||
: image.viewType === 'primary'
|
||||
? '母版候选'
|
||||
: identityViewLabels[image.viewType]
|
||||
}}</span>
|
||||
</button>
|
||||
}}</span></NButton
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="py-8 text-sm text-muted">尚无身份参考图。先保存身份提示词,再生成第一张母版。</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NCheckbox, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { AppDialog } from '../../../components/ui'
|
||||
import { validImageSize } from '../../subject-images/model'
|
||||
@@ -61,40 +62,50 @@ function submit() {
|
||||
<template>
|
||||
<AppDialog v-model:open="open" title="生成身份参考图" :description="subjectName">
|
||||
<form class="mt-5 space-y-4" @submit.prevent="submit">
|
||||
<p class="alert text-xs">
|
||||
<NAlert type="info" :show-icon="false" class="text-xs">
|
||||
调用 Seedream,每次新增一张图片,可能产生费用。第一张成功的 primary 图自动成为母版;已有母版时,新
|
||||
primary 图仅作为停用候选保留。
|
||||
</p>
|
||||
</NAlert>
|
||||
<label class="block"
|
||||
><span class="field-label">参考视角</span
|
||||
><select id="identity-view" v-model="viewType" class="input">
|
||||
<option v-for="[value, label] in allowedViews" :key="value" :value="value">
|
||||
{{ label }}({{ value }})
|
||||
</option>
|
||||
</select></label
|
||||
>
|
||||
><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
|
||||
><select id="identity-reference" v-model="referenceImageId" class="input">
|
||||
<option value="">
|
||||
{{ anchor ? '自动引用当前身份母版' : '自动选择(当前无母版,将按文本生成)' }}
|
||||
</option>
|
||||
<option v-for="image in references" :key="image.id" :value="image.id">
|
||||
{{ identityViewLabels[image.viewType] }} · {{ image.id }}
|
||||
</option>
|
||||
</select></label
|
||||
>
|
||||
><NSelect
|
||||
id="identity-reference"
|
||||
v-model:value="referenceImageId"
|
||||
:options="[
|
||||
{
|
||||
label: String(anchor ? '自动引用当前身份母版' : '自动选择(当前无母版,将按文本生成)'),
|
||||
value: ''
|
||||
},
|
||||
...references.map(image => ({
|
||||
label: String(identityViewLabels[image.viewType] + ' · ' + image.id),
|
||||
value: image.id
|
||||
}))
|
||||
]"
|
||||
class="select-control"
|
||||
></NSelect
|
||||
></label>
|
||||
<p class="text-xs leading-6 text-muted">
|
||||
只能选择此主体已完成的图片。后端需取得 Provider 可访问的远程地址;本地预览可见不保证远程地址仍有效。
|
||||
</p>
|
||||
<label class="block"
|
||||
><span class="field-label">覆盖本次完整提示词(可选)</span
|
||||
><textarea
|
||||
id="identity-image-prompt"
|
||||
v-model="prompt"
|
||||
class="input min-h-24"
|
||||
><NInput
|
||||
placeholder="建议留空,使用后端编译的身份约束和视角要求"
|
||||
/>
|
||||
:input-props="{ id: 'identity-image-prompt' }"
|
||||
class="min-h-24"
|
||||
v-model:value="prompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
<p v-if="prompt.trim()" class="text-xs text-danger">
|
||||
自定义文本会替换后端编译的完整提示词,请自行包含身份约束和视角要求。
|
||||
@@ -102,40 +113,38 @@ function submit() {
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label
|
||||
><span class="field-label">宽度(px)</span
|
||||
><input
|
||||
id="identity-width"
|
||||
v-model.number="width"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input"
|
||||
placeholder="后端默认" /></label
|
||||
><NInputNumber
|
||||
placeholder="后端默认"
|
||||
:input-props="{ id: 'identity-width' }"
|
||||
:value="typeof width === 'number' ? width : null"
|
||||
@update:value="width = $event ?? ''"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber></label
|
||||
><label
|
||||
><span class="field-label">高度(px)</span
|
||||
><input
|
||||
id="identity-height"
|
||||
v-model.number="height"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input"
|
||||
><NInputNumber
|
||||
placeholder="后端默认"
|
||||
/></label>
|
||||
:input-props="{ id: 'identity-height' }"
|
||||
:value="typeof height === 'number' ? height : null"
|
||||
@update:value="height = $event ?? ''"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
></label>
|
||||
</div>
|
||||
<p v-if="!valid" class="text-xs text-danger" role="alert">尺寸须成对填写正整数;指定的参考图必须仍可用。</p>
|
||||
<label class="flex items-start gap-2 text-xs leading-6"
|
||||
><input
|
||||
id="identity-image-cost"
|
||||
v-model="acknowledged"
|
||||
type="checkbox"
|
||||
class="mt-1.5 accent-accent"
|
||||
/>我已确认后台没有同项目任务,了解本次会调用模型并可能产生费用。</label
|
||||
<NCheckbox
|
||||
id="identity-image-cost"
|
||||
v-model:checked="acknowledged"
|
||||
class="flex items-start gap-2 text-xs leading-6"
|
||||
>我已确认后台没有同项目任务,了解本次会调用模型并可能产生费用。</NCheckbox
|
||||
>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="button button-secondary" @click="open = false">取消</button
|
||||
><button class="button button-primary" type="submit" :disabled="disabled || !valid || !acknowledged">
|
||||
<NButton @click="open = false">取消</NButton
|
||||
><NButton :disabled="disabled || !valid || !acknowledged" type="primary" attr-type="submit">
|
||||
确认生成身份图
|
||||
</button>
|
||||
</NButton>
|
||||
</div>
|
||||
</form>
|
||||
</AppDialog>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './model'
|
||||
import { identityImageFixture } from './testing/fixtures'
|
||||
import { formFixture } from '../subject-images/testing/fixtures'
|
||||
import { expandSections, selectControl } from '../../testing/naive'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
@@ -23,7 +24,7 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 从真实 Reka Portal 内获取确认按钮。 */
|
||||
/** 从真实 Naive 弹窗内获取确认按钮。 */
|
||||
function button(label: string) {
|
||||
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
|
||||
if (!item) throw new Error(`缺少按钮 ${label}`)
|
||||
@@ -32,6 +33,10 @@ function button(label: string) {
|
||||
|
||||
/** 修改 Portal 表单控件并触发 Vue 绑定。 */
|
||||
function input(selector: string, value: string) {
|
||||
if (selector === '#identity-view' || selector === '#identity-reference') {
|
||||
selectControl(wrapper!, 'id', selector.slice(1)).vm.$emit('update:value', value)
|
||||
return
|
||||
}
|
||||
const item = document.querySelector<HTMLInputElement | HTMLSelectElement>(selector)!
|
||||
item.value = value
|
||||
item.dispatchEvent(new Event(item.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }))
|
||||
@@ -136,8 +141,8 @@ describe('身份图与母版契约', () => {
|
||||
await wrapper.setProps({ images: [] })
|
||||
expect(button('确认生成身份图').disabled).toBe(true)
|
||||
await wrapper.setProps({ subjectId: 's2', subjectName: '陆川' })
|
||||
expect(document.querySelector<HTMLSelectElement>('#identity-reference')!.value).toBe('')
|
||||
expect(document.querySelector<HTMLInputElement>('#identity-image-cost')!.checked).toBe(false)
|
||||
expect(selectControl(wrapper!, 'id', 'identity-reference').props('value')).toBe('')
|
||||
expect(document.querySelector('#identity-image-cost')!.getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('Character 普通身份图入口只提供辅助视角,primary 必须走选角候选接口', async () => {
|
||||
@@ -153,9 +158,9 @@ describe('身份图与母版契约', () => {
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
const options = [...document.querySelectorAll<HTMLOptionElement>('#identity-view option')]
|
||||
expect(options.map(item => item.value)).toEqual(['front', 'three-quarter', 'full-body'])
|
||||
expect(document.querySelector<HTMLSelectElement>('#identity-view')!.value).toBe('front')
|
||||
const select = selectControl(wrapper!, 'id', 'identity-view')
|
||||
expect(select.props('options')!.map(item => item.value)).toEqual(['front', 'three-quarter', 'full-body'])
|
||||
expect(select.props('value')).toBe('front')
|
||||
})
|
||||
|
||||
it('辅助图不能切换母版,提示词按纯文本展示', async () => {
|
||||
@@ -170,6 +175,7 @@ describe('身份图与母版契约', () => {
|
||||
}
|
||||
})
|
||||
expect(button('确认选角并锁定').disabled).toBe(true)
|
||||
await expandSections()
|
||||
expect(wrapper.text()).toContain('<script>不执行</script>')
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect, NTag } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Search, RefreshCw, Download, ImagePlus, ArrowRight, AlertTriangle } from '@lucide/vue'
|
||||
@@ -87,20 +89,23 @@ function resetFilters() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mt-7">
|
||||
<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}/storyboard`" class="text-button"
|
||||
>去检查分镜参考图<ArrowRight :size="14"
|
||||
/></RouterLink>
|
||||
</div>
|
||||
<p class="alert mt-5 text-xs">
|
||||
Character 形态图会记录生成时使用的 Identity Anchor。更换演员母版后,旧主图会标记为“身份母版已变更”,且后端会阻止它继续进入分镜首帧;请基于当前母版重新生成候选并确认新的主参考图。
|
||||
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2">管理主体身份 →</RouterLink>
|
||||
</p>
|
||||
<WorkspacePage
|
||||
><template #header
|
||||
><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}/storyboard`" class="text-button"
|
||||
>去检查分镜参考图<ArrowRight :size="14"
|
||||
/></RouterLink></div></template
|
||||
><NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
||||
Character 形态图会记录生成时使用的 Identity
|
||||
Anchor。更换演员母版后,旧主图会标记为“身份母版已变更”,且后端会阻止它继续进入分镜首帧;请基于当前母版重新生成候选并确认新的主参考图。
|
||||
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
|
||||
>管理主体身份 →</RouterLink
|
||||
></NAlert
|
||||
>
|
||||
<div class="panel mt-5 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<p class="text-sm">
|
||||
@@ -110,17 +115,19 @@ function resetFilters() {
|
||||
</span>
|
||||
<span class="ml-3 text-xs text-muted">Seedream · 后端配置</span>
|
||||
</p>
|
||||
<button class="text-button" :disabled="query.loading.value" @click="query.refresh">
|
||||
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图库
|
||||
</button>
|
||||
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
|
||||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图库
|
||||
</NButton>
|
||||
</div>
|
||||
<p class="mt-2 text-[11px] leading-6 text-muted">
|
||||
每 6 秒更新图片记录;主图供分镜引用,候选图和历史失败记录均保留。过期主图不会自动删除,也不会被批量任务静默替换。
|
||||
每 6
|
||||
秒更新图片记录;主图供分镜引用,候选图和历史失败记录均保留。过期主图不会自动删除,也不会被批量任务静默替换。
|
||||
</p>
|
||||
<div v-if="staleForms.length" class="alert alert-error mt-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<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 }} 个 Character Form 仍在使用旧 Identity Anchor。
|
||||
<AlertTriangle :size="14" />检测到 {{ staleForms.length }} 个 Character Form 仍在使用旧 Identity
|
||||
Anchor。
|
||||
</span>
|
||||
<ConfirmAction
|
||||
label="重新生成过期形态"
|
||||
@@ -128,75 +135,78 @@ function resetFilters() {
|
||||
acknowledgement
|
||||
description="只为身份母版已经变化的 Character Form 调用 Seedream,各新增一张基于当前 Anchor 的候选图;不会自动替换旧主图,生成后请人工确认并设置新的主参考图。"
|
||||
@confirm="generateStale"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<details class="mt-4 border-t border-line pt-4">
|
||||
<summary class="cursor-pointer text-xs font-medium">项目批量生图</summary>
|
||||
<div class="mt-4 flex flex-wrap items-end gap-4">
|
||||
<label class="w-28">
|
||||
<span class="field-label">批量并发</span>
|
||||
<input
|
||||
id="image-concurrency"
|
||||
v-model.number="concurrency"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input"
|
||||
/></div
|
||||
></NAlert>
|
||||
<NCollapse class="mt-4 border-t border-line 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
|
||||
: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>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="flex items-center gap-2 pb-2 text-xs"
|
||||
>已有主图也新增候选图
|
||||
</NCheckbox>
|
||||
<ConfirmAction
|
||||
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
|
||||
:disabled="blocked || !batchValid || !forms.length"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '对项目全部形态调用 Seedream 新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
|
||||
: '仅对没有主图的形态调用 Seedream 生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
|
||||
"
|
||||
@confirm="generateProject"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 pb-2 text-xs">
|
||||
<input
|
||||
v-model="force"
|
||||
type="checkbox"
|
||||
class="accent-accent"
|
||||
:disabled="operation.pending"
|
||||
/>已有主图也新增候选图
|
||||
</label>
|
||||
<ConfirmAction
|
||||
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
|
||||
:disabled="blocked || !batchValid || !forms.length"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '对项目全部形态调用 Seedream 新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
|
||||
: '仅对没有主图的形态调用 Seedream 生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
|
||||
"
|
||||
@confirm="generateProject"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!batchValid" class="mt-2 text-xs text-danger">并发必须是正整数。</p>
|
||||
<p class="mt-3 text-xs leading-6 text-muted">
|
||||
作用于整个项目,不受下方筛选影响。批量使用后端默认尺寸与形态提示词,不会启动视频生成。
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<p v-if="!batchValid" class="mt-2 text-xs text-danger">并发必须是正整数。</p>
|
||||
<p class="mt-3 text-xs leading-6 text-muted">
|
||||
作用于整个项目,不受下方筛选影响。批量使用后端默认尺寸与形态提示词,不会启动视频生成。
|
||||
</p></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
</div>
|
||||
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
|
||||
{{ query.error.value }}<span v-if="forms.length"> 当前展示上次读取的数据。</span>
|
||||
</p>
|
||||
<p v-if="running" class="alert mt-4" role="status">
|
||||
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||||
>{{ query.error.value }}<span v-if="forms.length"> 当前展示上次读取的数据。</span></NAlert
|
||||
><NAlert v-if="running" role="status" type="info" :show-icon="false" class="mt-4">
|
||||
数据库中仍有排队或生成中的图片,已暂停新的生图操作。长时间无变化时,请先核对后台任务;刷新或关闭页面不会取消任务。
|
||||
</p>
|
||||
</NAlert>
|
||||
<section v-if="session.receipt" class="panel mt-4 p-5" aria-label="生图批量回执">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
|
||||
<button class="text-button" @click="exportReceipt"><Download :size="13" />导出诊断</button>
|
||||
<NButton @click="exportReceipt" text size="small"><Download :size="13" />导出诊断</NButton>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
共 {{ session.receipt.result.total }} 个形态 · 本次目标 {{ session.receipt.result.targetCount }} · 生成
|
||||
{{ session.receipt.result.generated }} · 跳过 {{ session.receipt.result.skipped }} · 失败
|
||||
{{ session.receipt.result.failed }}
|
||||
</p>
|
||||
<p v-if="session.receipt.result.failed" class="alert alert-error mt-3" role="alert">
|
||||
<NAlert v-if="session.receipt.result.failed" role="alert" type="error" :show-icon="false" class="mt-3">
|
||||
部分形态生图失败。已生成的图片保留;先查看失败原因,再按形态重新生图。
|
||||
</p>
|
||||
<p v-else-if="session.receipt.title === '刷新过期人物形态图'" class="alert mt-3 text-xs">
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-else-if="session.receipt.title === '刷新过期人物形态图'"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-3 text-xs"
|
||||
>
|
||||
新图当前仍是候选图。请进入对应形态的“查看图片与记录”,确认人物身份和造型后再设为主参考图;旧主图在此之前仍保持过期状态。
|
||||
</p>
|
||||
</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>:{{ failure.error }}
|
||||
<code>{{ failure.subjectFormId }}</code
|
||||
>:{{ failure.error }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话;图片以刷新后的数据库记录为准。</p>
|
||||
@@ -204,20 +214,25 @@ function resetFilters() {
|
||||
<div class="my-6 flex flex-wrap items-center gap-4">
|
||||
<label class="search-field">
|
||||
<Search :size="14" />
|
||||
<input v-model="search" placeholder="搜索主体、引用或形态" aria-label="搜索形态图片" />
|
||||
</label>
|
||||
<select v-model="module" class="input w-auto" aria-label="筛选主体类型">
|
||||
<option value="all">全部类型</option>
|
||||
<option value="character">人物</option>
|
||||
<option value="scene">场景</option>
|
||||
<option value="prop">道具</option>
|
||||
</select>
|
||||
<label class="flex items-center gap-2 text-xs">
|
||||
<input v-model="onlyMissing" type="checkbox" class="accent-accent" />仅看缺少主图
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-xs">
|
||||
<input v-model="onlyStale" type="checkbox" class="accent-accent" />仅看身份过期
|
||||
<NInput
|
||||
placeholder="搜索主体、引用或形态"
|
||||
:input-props="{ 'aria-label': '搜索形态图片' }"
|
||||
v-model:value="search"
|
||||
></NInput>
|
||||
</label>
|
||||
<NSelect
|
||||
aria-label="筛选主体类型"
|
||||
v-model:value="module"
|
||||
:options="[
|
||||
{ label: String('全部类型'), value: 'all' },
|
||||
{ label: String('人物'), value: 'character' },
|
||||
{ label: String('场景'), value: 'scene' },
|
||||
{ label: String('道具'), value: 'prop' }
|
||||
]"
|
||||
class="select-control"
|
||||
></NSelect>
|
||||
<NCheckbox v-model:checked="onlyMissing" class="flex items-center gap-2 text-xs">仅看缺少主图 </NCheckbox>
|
||||
<NCheckbox v-model:checked="onlyStale" class="flex items-center gap-2 text-xs">仅看身份过期 </NCheckbox>
|
||||
<span class="ml-auto text-xs text-muted">{{ filtered.length }} 个形态</span>
|
||||
</div>
|
||||
<p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status">
|
||||
@@ -241,7 +256,7 @@ function resetFilters() {
|
||||
<code class="subject-ref">{{ form.subject.ref }}</code>
|
||||
</div>
|
||||
<p class="mt-2 text-xs">
|
||||
{{ form.name }}<span v-if="form.isDefault" class="tag ml-2">默认形态</span>
|
||||
{{ form.name }}<NTag v-if="form.isDefault" size="small" :bordered="false">默认形态</NTag>
|
||||
</p>
|
||||
<p v-if="form.description" class="mt-3 line-clamp-2 text-xs leading-6 text-muted">
|
||||
{{ form.description }}
|
||||
@@ -249,27 +264,42 @@ function resetFilters() {
|
||||
<RouterLink
|
||||
:to="{ path: `/projects/${id}/subject-identity`, query: { subjectId: form.subjectId } }"
|
||||
class="text-button mt-3"
|
||||
>查看主体身份与母版 →</RouterLink>
|
||||
>查看主体身份与母版 →</RouterLink
|
||||
>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<StatusBadge v-if="hasRunningImages(form.images)" status="generating" />
|
||||
<span v-if="isPrimaryIdentityStale(form)" class="tag text-danger">身份母版已变更</span>
|
||||
<span v-else-if="primaryImage(form.images)" class="tag">主参考图</span>
|
||||
<span v-else-if="coverImage(form)" class="tag">候选图 · 尚未设主图</span>
|
||||
<span v-else-if="form.images[0]?.status === 'failed'" class="text-xs text-danger">最近一次生成失败</span>
|
||||
<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"
|
||||
>最近一次生成失败</span
|
||||
>
|
||||
<span class="text-[10px] text-muted">{{ form.images.length }} 条记录</span>
|
||||
</div>
|
||||
<p v-if="isPrimaryIdentityStale(form)" class="alert alert-error mt-3 text-xs leading-6">
|
||||
当前主图由旧演员母版生成,后续分镜已禁止继续引用。请基于当前 Identity Anchor 重新生成候选图,并在确认后设置为新的主参考图。
|
||||
</p>
|
||||
<NAlert
|
||||
v-if="isPrimaryIdentityStale(form)"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
class="mt-3 text-xs leading-6"
|
||||
>
|
||||
当前主图由旧演员母版生成,后续分镜已禁止继续引用。请基于当前 Identity Anchor
|
||||
重新生成候选图,并在确认后设置为新的主参考图。
|
||||
</NAlert>
|
||||
<p v-if="form.images[0]?.status === 'failed'" class="mt-2 line-clamp-2 text-xs text-danger">
|
||||
{{ form.images[0].error }}
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<button class="text-button" @click="openGallery(form)">查看图片与记录</button>
|
||||
<button class="button button-secondary" :disabled="blocked" @click="openGenerate(form)">
|
||||
<ImagePlus :size="14" />
|
||||
{{ isPrimaryIdentityStale(form) ? '基于当前母版重新生成' : coverImage(form) ? '再生成一张' : '生成图片' }}
|
||||
</button>
|
||||
<NButton @click="openGallery(form)" text size="small">查看图片与记录</NButton>
|
||||
<NButton :disabled="blocked" @click="openGenerate(form)"
|
||||
><ImagePlus :size="14" />
|
||||
{{
|
||||
isPrimaryIdentityStale(form)
|
||||
? '基于当前母版重新生成'
|
||||
: coverImage(form)
|
||||
? '再生成一张'
|
||||
: '生成图片'
|
||||
}}</NButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -283,21 +313,19 @@ function resetFilters() {
|
||||
: '先完成剧本拆解和主体形态持久化,再来生成参考图。'
|
||||
"
|
||||
>
|
||||
<button v-if="forms.length" class="button button-secondary" @click="resetFilters">清除筛选</button>
|
||||
<RouterLink v-else :to="`/projects/${id}/breakdown`" class="button button-secondary">前往剧本拆解</RouterLink>
|
||||
</EmptyState>
|
||||
<GenerateImageDialog
|
||||
<NButton v-if="forms.length" @click="resetFilters">清除筛选</NButton>
|
||||
<RouterLink v-else :to="`/projects/${id}/breakdown`" class="button button-secondary"
|
||||
>前往剧本拆解</RouterLink
|
||||
> </EmptyState
|
||||
><GenerateImageDialog
|
||||
v-model:open="generateOpen"
|
||||
:form="generateForm"
|
||||
:disabled="blocked || !generateForm"
|
||||
@generate="submitGenerate"
|
||||
/>
|
||||
<ImageGalleryDialog
|
||||
@generate="submitGenerate" /><ImageGalleryDialog
|
||||
v-model:open="galleryOpen"
|
||||
:project-id="id"
|
||||
:form="galleryForm"
|
||||
:disabled="blocked || !galleryForm"
|
||||
@changed="query.refresh"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
/></WorkspacePage>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { AppDialog } from '../../../components/ui'
|
||||
import { primaryImage, validImageSize } from '../model'
|
||||
@@ -51,7 +52,9 @@ function submit() {
|
||||
:description="`${form?.subject.name || ''} · ${form?.name || ''}`"
|
||||
>
|
||||
<form class="mt-6 space-y-5" @submit.prevent="submit">
|
||||
<p class="alert text-xs">使用后端配置的 Seedream 模型,可能产生费用。每次新增一张图片,不删除历史结果。</p>
|
||||
<NAlert type="info" :show-icon="false" class="text-xs"
|
||||
>使用后端配置的 Seedream 模型,可能产生费用。每次新增一张图片,不删除历史结果。</NAlert
|
||||
>
|
||||
<p class="text-xs leading-6 text-muted">
|
||||
{{
|
||||
form?.subject.module === 'character' || form?.subject.module === 'scene'
|
||||
@@ -62,66 +65,68 @@ function submit() {
|
||||
</p>
|
||||
<label class="block"
|
||||
><span class="field-label">自定义提示词(可选,仅用于本次)</span
|
||||
><textarea
|
||||
id="image-prompt"
|
||||
v-model="prompt"
|
||||
class="input min-h-28"
|
||||
><NInput
|
||||
placeholder="留空使用已保存的 generationPrompt,其次使用 appearancePrompt"
|
||||
/>
|
||||
:input-props="{ id: 'image-prompt' }"
|
||||
class="min-h-28"
|
||||
v-model:value="prompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
<details v-if="form?.generationPrompt || form?.appearancePrompt" class="text-xs">
|
||||
<summary class="cursor-pointer text-muted">查看后端默认提示词</summary>
|
||||
<p class="mt-3 whitespace-pre-wrap leading-6">{{ form.generationPrompt ?? form.appearancePrompt }}</p>
|
||||
</details>
|
||||
<NCollapse v-if="form?.generationPrompt || form?.appearancePrompt" class="text-xs"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>查看后端默认提示词</template>
|
||||
<p class="mt-3 whitespace-pre-wrap leading-6">
|
||||
{{ form.generationPrompt ?? form.appearancePrompt }}
|
||||
</p></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label
|
||||
><span class="field-label">宽度(px)</span
|
||||
><input
|
||||
id="image-width"
|
||||
v-model.number="width"
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="后端默认" /></label
|
||||
><NInputNumber
|
||||
placeholder="后端默认"
|
||||
:input-props="{ id: 'image-width' }"
|
||||
:value="typeof width === 'number' ? width : null"
|
||||
@update:value="width = $event ?? ''"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber></label
|
||||
><label
|
||||
><span class="field-label">高度(px)</span
|
||||
><input
|
||||
id="image-height"
|
||||
v-model.number="height"
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
><NInputNumber
|
||||
placeholder="后端默认"
|
||||
/></label>
|
||||
:input-props="{ id: 'image-height' }"
|
||||
:value="typeof height === 'number' ? height : null"
|
||||
@update:value="height = $event ?? ''"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
></label>
|
||||
</div>
|
||||
<p class="text-xs text-muted">宽高同时留空时使用后端默认 2K;自定义尺寸须满足模型限制。</p>
|
||||
<p v-if="!dimensionsValid" class="text-xs text-danger" role="alert">宽高需要同时填写正整数,或同时留空。</p>
|
||||
<p v-if="!hasPrompt" class="text-xs text-danger" role="alert">当前形态没有可用提示词,请填写本次提示词。</p>
|
||||
<label class="flex gap-2 text-sm"
|
||||
><input v-model="setPrimary" type="checkbox" class="accent-accent" />生成成功后设为此形态主参考图</label
|
||||
>
|
||||
<NCheckbox v-model:checked="setPrimary" class="flex gap-2 text-sm">生成成功后设为此形态主参考图</NCheckbox>
|
||||
<p v-if="setPrimary && form && primaryImage(form.images)" class="text-xs text-muted">
|
||||
会替换当前主图标记,旧图片保留。已有视频提示词不会自动更新。
|
||||
</p>
|
||||
<label class="flex items-start gap-2 text-xs leading-6"
|
||||
><input
|
||||
id="confirm-image-cost"
|
||||
v-model="acknowledged"
|
||||
type="checkbox"
|
||||
class="mt-1.5 accent-accent"
|
||||
/>我已确认后台没有同项目的生成任务,了解本次会调用模型并产生费用。</label
|
||||
<NCheckbox
|
||||
id="confirm-image-cost"
|
||||
v-model:checked="acknowledged"
|
||||
class="flex items-start gap-2 text-xs leading-6"
|
||||
>我已确认后台没有同项目的生成任务,了解本次会调用模型并产生费用。</NCheckbox
|
||||
>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="button button-secondary" @click="open = false">取消</button
|
||||
><button
|
||||
type="submit"
|
||||
class="button button-primary"
|
||||
<NButton @click="open = false">取消</NButton
|
||||
><NButton
|
||||
:disabled="disabled || !acknowledged || !dimensionsValid || !hasPrompt"
|
||||
type="primary"
|
||||
attr-type="submit"
|
||||
>
|
||||
确认生成图片
|
||||
</button>
|
||||
</NButton>
|
||||
</div>
|
||||
</form>
|
||||
</AppDialog>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ExternalLink, RefreshCw } from '@lucide/vue'
|
||||
import { AppDialog, AssetImage, StatusBadge } from '../../../components/ui'
|
||||
@@ -83,14 +84,16 @@ async function choosePrimary() {
|
||||
>
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<span class="text-xs text-muted">{{ images.length }} 条图片记录</span
|
||||
><button class="text-button" :disabled="query.loading.value" @click="query.refresh">
|
||||
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图片
|
||||
</button>
|
||||
><NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
|
||||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图片
|
||||
</NButton>
|
||||
</div>
|
||||
<p v-if="query.error.value" class="alert alert-error mt-3" role="alert">
|
||||
{{ query.error.value }} 当前保留上次读取的记录,主图操作已暂停。
|
||||
</p>
|
||||
<p v-if="operation.error" class="alert alert-error mt-3" role="alert">{{ operation.error }}</p>
|
||||
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-3"
|
||||
>{{ query.error.value }} 当前保留上次读取的记录,主图操作已暂停。
|
||||
</NAlert>
|
||||
<NAlert v-if="operation.error" role="alert" type="error" :show-icon="false" class="mt-3">{{
|
||||
operation.error
|
||||
}}</NAlert>
|
||||
<div v-if="selected" class="mt-4">
|
||||
<AssetImage
|
||||
:key="selected.id"
|
||||
@@ -101,8 +104,11 @@ async function choosePrimary() {
|
||||
/>
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusBadge :status="selected.status" /><span v-if="selected.isPrimary" class="tag"
|
||||
>当前主参考图</span
|
||||
<StatusBadge :status="selected.status" /><NTag
|
||||
v-if="selected.isPrimary"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>当前主参考图</NTag
|
||||
><span class="text-xs text-muted">{{ selected.width || '—' }} × {{ selected.height || '—' }}</span>
|
||||
</div>
|
||||
<a
|
||||
@@ -114,36 +120,37 @@ async function choosePrimary() {
|
||||
>打开原图<ExternalLink :size="12"
|
||||
/></a>
|
||||
</div>
|
||||
<p v-if="selected.error" class="alert alert-error mt-3 whitespace-pre-wrap" role="alert">
|
||||
{{ selected.error }}
|
||||
</p>
|
||||
<NAlert
|
||||
v-if="selected.error"
|
||||
role="alert"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
class="mt-3 whitespace-pre-wrap"
|
||||
>{{ selected.error }}</NAlert
|
||||
>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
v-if="!selected.isPrimary"
|
||||
class="button button-secondary"
|
||||
:disabled="!canSetPrimary"
|
||||
@click="confirming = true"
|
||||
>
|
||||
设为主参考图</button
|
||||
<NButton v-if="!selected.isPrimary" :disabled="!canSetPrimary" @click="confirming = true">
|
||||
设为主参考图</NButton
|
||||
><span class="text-[11px] text-muted"
|
||||
>{{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="confirming" class="alert mt-3">
|
||||
<p class="text-xs">确认将这张图用于此形态后续的分镜参考?旧主图仍会保留,此操作不调用模型。</p>
|
||||
<NAlert v-if="confirming" type="info" :show-icon="false" class="mt-3"
|
||||
><p class="text-xs">确认将这张图用于此形态后续的分镜参考?旧主图仍会保留,此操作不调用模型。</p>
|
||||
<div class="mt-3 flex gap-3">
|
||||
<button class="button button-primary" :disabled="!canSetPrimary" @click="choosePrimary">
|
||||
确认切换主图</button
|
||||
><button class="text-button" @click="confirming = false">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<details v-if="selected.prompt" class="mt-4 text-xs">
|
||||
<summary class="cursor-pointer text-muted">查看本次实际提示词</summary>
|
||||
<p class="mt-3 whitespace-pre-wrap leading-6">{{ selected.prompt }}</p>
|
||||
<p v-if="selected.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6">
|
||||
Negative prompt: {{ selected.negativePrompt }}
|
||||
</p>
|
||||
</details>
|
||||
<NButton :disabled="!canSetPrimary" @click="choosePrimary" type="primary"> 确认切换主图</NButton
|
||||
><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">
|
||||
{{
|
||||
@@ -153,16 +160,15 @@ async function choosePrimary() {
|
||||
}}
|
||||
</p>
|
||||
<div class="image-history mt-5" aria-label="图片历史">
|
||||
<button
|
||||
<NButton
|
||||
v-for="image in images"
|
||||
:key="image.id"
|
||||
class="image-history-item"
|
||||
:class="{ selected: image.id === selected.id }"
|
||||
:aria-label="`查看图片 ${image.id}`"
|
||||
:aria-pressed="image.id === selected.id"
|
||||
@click="selectedId = image.id"
|
||||
>
|
||||
<AssetImage
|
||||
class="image-history-item"
|
||||
:class="{ selected: image.id === selected.id }"
|
||||
><AssetImage
|
||||
:src="image.status === 'completed' ? image.imageUrl : null"
|
||||
:alt="`${form?.name}候选图`"
|
||||
:retryable="false"
|
||||
@@ -177,8 +183,8 @@ async function choosePrimary() {
|
||||
: { completed: '候选图', failed: '失败记录', pending: '排队中', generating: '生成中' }[
|
||||
image.status
|
||||
]
|
||||
}}</span>
|
||||
</button>
|
||||
}}</span></NButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="py-10 text-center text-sm text-muted">
|
||||
|
||||
@@ -6,9 +6,10 @@ import GenerateImageDialog from './components/GenerateImageDialog.vue'
|
||||
import ImageGalleryDialog from './components/ImageGalleryDialog.vue'
|
||||
import { coverImage, hasRunningImages, primaryImage, validImageSize } from './model'
|
||||
import { formFixture, imageFixture } from './testing/fixtures'
|
||||
import { expandSections } from '../../testing/naive'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
/** Reka 将弹窗挂载到 body,需要从实际弹窗找到按钮。 */
|
||||
/** Naive 将弹窗挂载到 body,需要从实际弹窗找到按钮。 */
|
||||
function button(label: string): HTMLButtonElement {
|
||||
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
|
||||
if (!element) throw new Error('找不到按钮:' + label)
|
||||
@@ -88,7 +89,7 @@ describe('形态图片选择与操作', () => {
|
||||
await wrapper.setProps({ form: { ...form, id: 'form-db-2' } })
|
||||
await flushPromises()
|
||||
expect(width.value).toBe('')
|
||||
expect(document.querySelector<HTMLInputElement>('#confirm-image-cost')!.checked).toBe(false)
|
||||
expect(document.querySelector('#confirm-image-cost')!.getAttribute('aria-checked')).toBe('false')
|
||||
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成图片').click()
|
||||
@@ -125,6 +126,7 @@ describe('形态图片选择与操作', () => {
|
||||
})
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls).toHaveLength(1)
|
||||
await expandSections()
|
||||
expect(document.body.textContent).toContain('<script>模型提示词</script>')
|
||||
expect(document.querySelector('[role="dialog"] script')).toBeNull()
|
||||
button('设为主参考图').click()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import { NAlert, NButton, NTag } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { runOperation } from '../workflows/operations'
|
||||
@@ -87,18 +89,22 @@ function removeImage(image: VisualStyleImage) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mt-7">
|
||||
<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/${projectId}/subject-identity`" class="text-button"
|
||||
>下一步:主体身份 →</RouterLink
|
||||
>
|
||||
</div>
|
||||
<WorkspacePage
|
||||
><template #header
|
||||
><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/${projectId}/subject-identity`" class="text-button"
|
||||
>下一步:主体身份 →</RouterLink
|
||||
>
|
||||
</div></template
|
||||
>
|
||||
<div class="my-5 flex flex-wrap items-center gap-3">
|
||||
<span class="tag">{{ style ? (style.isLocked ? '已锁定' : '未锁定') : '尚未创建' }}</span>
|
||||
<NTag size="small" :bordered="false">{{
|
||||
style ? (style.isLocked ? '已锁定' : '未锁定') : '尚未创建'
|
||||
}}</NTag>
|
||||
<ConfirmAction
|
||||
:label="style ? 'AI 重新生成风格' : 'AI 生成风格'"
|
||||
:disabled="disabled || !!style?.isLocked || dirty"
|
||||
@@ -106,26 +112,24 @@ function removeImage(image: VisualStyleImage) {
|
||||
description="调用文本模型生成整体及分类风格。重新生成会覆盖已保存的文本与硬约束;旧身份、旧图片和已有提示词不会自动更新。"
|
||||
@confirm="generate"
|
||||
/>
|
||||
<button class="text-button" :disabled="query.loading.value" @click="query.refresh">刷新风格</button>
|
||||
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small">刷新风格</NButton>
|
||||
</div>
|
||||
<p v-if="query.error.value" class="alert alert-error mb-4" role="alert">
|
||||
{{ query.error.value }} 请确认后端 dev 已更新并重启;查询失败不等同于尚未创建。
|
||||
</p>
|
||||
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mb-4"
|
||||
>{{ query.error.value }} 请确认后端 dev 已更新并重启;查询失败不等同于尚未创建。
|
||||
</NAlert>
|
||||
<p v-if="dirty" class="mb-4 text-xs text-muted">
|
||||
有未保存修改,AI 重生成暂不可用。自动刷新不会覆盖编辑内容;离开页面不会自动保存。
|
||||
</p>
|
||||
<p class="alert mb-5 text-xs">
|
||||
<NAlert type="info" :show-icon="false" class="mb-5 text-xs">
|
||||
AI
|
||||
生成时,若项目和世界观没有明确其它人物背景,人物风格默认采用中国人物选角基线;人工填写的人物背景和角色事实优先。修改风格只影响之后的生成,旧资产不会自动更新。
|
||||
</p>
|
||||
<StyleEditor
|
||||
生成时,若项目和世界观没有明确其它人物背景,人物风格默认采用中国人物选角基线;人工填写的人物背景和角色事实优先。修改风格只影响之后的生成,旧资产不会自动更新。 </NAlert
|
||||
><StyleEditor
|
||||
v-if="query.data.value"
|
||||
:key="editorRevision"
|
||||
:style="style"
|
||||
:visual-style="style"
|
||||
:disabled="disabled"
|
||||
@save="save"
|
||||
@dirty="dirty = $event"
|
||||
/>
|
||||
@dirty="dirty = $event" />
|
||||
<p v-else-if="query.loading.value" class="py-8 text-sm text-muted" role="status">正在读取视觉风格…</p>
|
||||
<StyleImages
|
||||
:key="imageRevision"
|
||||
@@ -134,6 +138,5 @@ function removeImage(image: VisualStyleImage) {
|
||||
@add="addImage"
|
||||
@toggle="toggleImage"
|
||||
@remove="removeImage"
|
||||
/>
|
||||
</section>
|
||||
/></WorkspacePage>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NCheckbox, NInput, NTag } from 'naive-ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { SaveVisualStyleInput, VisualStyle } from '../types'
|
||||
|
||||
/** 编辑草稿独立于轮询结果,刷新不会覆盖尚未保存的输入。 */
|
||||
const props = defineProps<{ style: VisualStyle | null; disabled: boolean }>()
|
||||
const props = defineProps<{ visualStyle: VisualStyle | null; disabled: boolean }>()
|
||||
const emit = defineEmits<{ save: [input: SaveVisualStyleInput]; dirty: [value: boolean] }>()
|
||||
const form = reactive({
|
||||
name: '',
|
||||
@@ -27,7 +28,7 @@ const constraintsError = computed(() => {
|
||||
}
|
||||
})
|
||||
watch(
|
||||
() => props.style,
|
||||
() => props.visualStyle,
|
||||
value => {
|
||||
if (baseline.value && dirty.value) return
|
||||
Object.assign(form, {
|
||||
@@ -65,63 +66,84 @@ function save() {
|
||||
<fieldset :disabled="disabled" class="space-y-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="font-medium">风格说明</h3>
|
||||
<span v-if="dirty" class="tag">有未保存修改</span>
|
||||
<NTag v-if="dirty" size="small" :bordered="false">有未保存修改</NTag>
|
||||
</div>
|
||||
<label class="block"
|
||||
><span class="field-label">风格名称</span><input id="style-name" v-model="form.name" class="input"
|
||||
/></label>
|
||||
><span class="field-label">风格名称</span
|
||||
><NInput :disabled="disabled" :input-props="{ id: 'style-name' }" v-model:value="form.name"></NInput
|
||||
></label>
|
||||
<label class="block"
|
||||
><span class="field-label">整体视觉语言</span
|
||||
><textarea
|
||||
id="style-prompt"
|
||||
v-model="form.prompt"
|
||||
class="input min-h-32"
|
||||
><NInput
|
||||
:disabled="disabled"
|
||||
placeholder="媒介、真实感、色彩和整体美术方向"
|
||||
/>
|
||||
:input-props="{ id: 'style-prompt' }"
|
||||
class="min-h-32"
|
||||
v-model:value="form.prompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
<div class="grid gap-5 lg:grid-cols-3">
|
||||
<label
|
||||
><span class="field-label">人物风格与选角背景</span
|
||||
><textarea
|
||||
v-model="form.characterPrompt"
|
||||
class="input min-h-32"
|
||||
><NInput
|
||||
:disabled="disabled"
|
||||
placeholder="人物质感、妆造与默认选角背景;留空时 AI 默认采用中国人物基线"
|
||||
/>
|
||||
class="min-h-32"
|
||||
v-model:value="form.characterPrompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
<label
|
||||
><span class="field-label">场景风格补充</span
|
||||
><textarea v-model="form.scenePrompt" class="input min-h-32" />
|
||||
><NInput
|
||||
: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
|
||||
><textarea v-model="form.propPrompt" class="input min-h-32" />
|
||||
><NInput
|
||||
:disabled="disabled"
|
||||
class="min-h-32"
|
||||
v-model:value="form.propPrompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
</div>
|
||||
<label class="block"
|
||||
><span class="field-label">硬约束 · JSON 字符串数组</span
|
||||
><textarea
|
||||
id="style-constraints"
|
||||
v-model="form.constraints"
|
||||
class="input min-h-24 font-mono text-xs"
|
||||
><NInput
|
||||
:disabled="disabled"
|
||||
spellcheck="false"
|
||||
/>
|
||||
:input-props="{ id: 'style-constraints' }"
|
||||
class="min-h-24 font-mono text-xs"
|
||||
v-model:value="form.constraints"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
<p v-if="constraintsError" class="text-xs text-danger" role="alert">{{ constraintsError }}</p>
|
||||
<label class="flex items-start gap-2 text-sm"
|
||||
><input
|
||||
id="style-lock"
|
||||
v-model="form.isLocked"
|
||||
type="checkbox"
|
||||
class="mt-1 accent-accent"
|
||||
/>确认并锁定风格,阻止后续 AI 自动覆盖</label
|
||||
<NCheckbox
|
||||
:disabled="disabled"
|
||||
id="style-lock"
|
||||
v-model:checked="form.isLocked"
|
||||
class="flex items-start gap-2 text-sm"
|
||||
>确认并锁定风格,阻止后续 AI 自动覆盖</NCheckbox
|
||||
>
|
||||
<p class="text-xs leading-6 text-muted">
|
||||
锁定后仍可人工编辑并保存。取消勾选并保存后,才能再次使用 AI 重生成。
|
||||
</p>
|
||||
<div class="flex justify-end">
|
||||
<button class="button button-primary" type="submit" :disabled="disabled || !!constraintsError">
|
||||
<NButton :disabled="disabled || !!constraintsError" type="primary" attr-type="submit">
|
||||
保存视觉风格
|
||||
</button>
|
||||
</NButton>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { AssetImage } from '../../../components/ui'
|
||||
import { referenceImageUrl } from '../../../lib/assets'
|
||||
@@ -43,29 +44,36 @@ function remove(image: VisualStyleImage) {
|
||||
<fieldset :disabled="disabled" class="flex flex-wrap items-end gap-3">
|
||||
<label class="min-w-48 flex-1"
|
||||
><span class="field-label">图片地址</span
|
||||
><input
|
||||
v-model="form.imageUrl"
|
||||
class="input"
|
||||
><NInput
|
||||
placeholder="https://… 或 /storage/…"
|
||||
aria-label="风格图片地址"
|
||||
/></label>
|
||||
:input-props="{ 'aria-label': '风格图片地址' }"
|
||||
v-model:value="form.imageUrl"
|
||||
></NInput
|
||||
></label>
|
||||
<label
|
||||
><span class="field-label">分类</span
|
||||
><select v-model="form.category" class="input">
|
||||
<option v-for="(label, value) in categories" :key="value" :value="value">{{ label }}</option>
|
||||
</select></label
|
||||
>
|
||||
><NSelect
|
||||
v-model:value="form.category"
|
||||
:options="[
|
||||
...Object.entries(categories).map(([value, label]) => ({
|
||||
label: String(label),
|
||||
value: value
|
||||
}))
|
||||
]"
|
||||
class="select-control"
|
||||
></NSelect
|
||||
></label>
|
||||
<label class="w-24"
|
||||
><span class="field-label">排序</span
|
||||
><input
|
||||
v-model.number="form.sortOrder"
|
||||
class="input"
|
||||
type="number"
|
||||
step="1"
|
||||
aria-label="风格图片排序"
|
||||
/></label>
|
||||
<label class="flex gap-2 pb-2 text-xs"><input v-model="form.enabled" type="checkbox" />启用</label>
|
||||
<button class="button button-secondary" type="submit" :disabled="disabled || !valid">登记参考图</button>
|
||||
><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>
|
||||
</fieldset>
|
||||
</form>
|
||||
<p v-if="form.imageUrl && !valid" class="mt-2 text-xs text-danger" role="alert">
|
||||
@@ -85,10 +93,12 @@ 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>
|
||||
<details v-if="image.prompt" class="mt-2 text-xs">
|
||||
<summary>实际提示词</summary>
|
||||
<p class="mt-2 whitespace-pre-wrap">{{ image.prompt }}</p>
|
||||
</details>
|
||||
<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)"
|
||||
@@ -98,19 +108,25 @@ function remove(image: VisualStyleImage) {
|
||||
rel="noopener noreferrer"
|
||||
>原图</a
|
||||
>
|
||||
<button class="text-button" :disabled="disabled" @click="emit('toggle', image)">
|
||||
{{ image.enabled ? '停用' : '启用' }}
|
||||
</button>
|
||||
<button class="text-button text-danger" :disabled="disabled" @click="confirmingId = image.id">
|
||||
<NButton :disabled="disabled" @click="emit('toggle', image)" text size="small">{{
|
||||
image.enabled ? '停用' : '启用'
|
||||
}}</NButton>
|
||||
<NButton
|
||||
:disabled="disabled"
|
||||
@click="confirmingId = image.id"
|
||||
text
|
||||
size="small"
|
||||
class="text-danger"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</NButton>
|
||||
</div>
|
||||
<div v-if="confirmingId === image.id" class="mt-3 text-xs">
|
||||
<p>仅移除参考图记录,不删除远程文件。</p>
|
||||
<div class="mt-2 flex gap-3">
|
||||
<button class="text-button text-danger" :disabled="disabled" @click="remove(image)">
|
||||
确认移除</button
|
||||
><button class="text-button" @click="confirmingId = ''">取消</button>
|
||||
<NButton :disabled="disabled" @click="remove(image)" text size="small" class="text-danger">
|
||||
确认移除</NButton
|
||||
><NButton @click="confirmingId = ''" text size="small">取消</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NCheckbox } from 'naive-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { DialogTrigger } from 'reka-ui'
|
||||
import { AppDialog } from '../../components/ui'
|
||||
|
||||
/** 耗费模型额度或覆盖结果的动作必须经过明确确认。 */
|
||||
@@ -32,32 +32,24 @@ function confirm() {
|
||||
<template>
|
||||
<AppDialog v-model:open="open" :title="label" :description="description">
|
||||
<template #trigger
|
||||
><DialogTrigger
|
||||
class="button"
|
||||
:class="primary ? 'button-primary' : 'button-secondary'"
|
||||
:disabled="disabled"
|
||||
>{{ label }}</DialogTrigger
|
||||
></template
|
||||
><NButton :type="primary ? 'primary' : 'default'" :disabled="disabled" @click="open = true">{{
|
||||
label
|
||||
}}</NButton></template
|
||||
>
|
||||
<p class="mt-5 text-sm leading-6 text-muted">
|
||||
此操作会提交到真实后端,可能调用模型并产生费用。请勿在其他页面或终端同时启动相同工作流。
|
||||
</p>
|
||||
<label v-if="acknowledgement" class="mt-5 flex items-start gap-3 text-sm leading-6"
|
||||
><input
|
||||
v-model="acknowledged"
|
||||
class="mt-1 accent-accent"
|
||||
type="checkbox"
|
||||
/>我已确认后台任务停止,当前没有同项目的生成或拆解任务在运行。</label
|
||||
<NCheckbox
|
||||
v-model:checked="acknowledged"
|
||||
v-if="acknowledgement"
|
||||
class="mt-5 flex items-start gap-3 text-sm leading-6"
|
||||
>我已确认后台任务停止,当前没有同项目的生成或拆解任务在运行。</NCheckbox
|
||||
>
|
||||
<div class="dialog-footer mt-6">
|
||||
<button class="button button-secondary" @click="open = false">取消</button
|
||||
><button
|
||||
class="button button-primary"
|
||||
:disabled="disabled || (acknowledgement && !acknowledged)"
|
||||
@click="confirm"
|
||||
<NButton @click="open = false">取消</NButton
|
||||
><NButton :disabled="disabled || (acknowledgement && !acknowledged)" @click="confirm" type="primary">
|
||||
确认{{ label }}</NButton
|
||||
>
|
||||
确认{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NEmpty, NTimeline, NTimelineItem } from 'naive-ui'
|
||||
import { Clock3 } from '@lucide/vue'
|
||||
import { formatDate, nodeLabel } from '../../lib/format'
|
||||
import { workflowCheckpoints } from './selectors'
|
||||
@@ -14,9 +15,13 @@ const history = computed(() => workflowCheckpoints(props.checkpoints, props.work
|
||||
<aside class="history-panel">
|
||||
<h3 class="mb-1 flex items-center gap-2 text-sm font-semibold"><Clock3 :size="15" />执行记录</h3>
|
||||
<p class="mb-6 text-xs leading-5 text-muted">最近 18 个 checkpoint · 自动刷新</p>
|
||||
<p v-if="!history.length" class="text-xs leading-6 text-muted">工作流尚未保存执行记录。</p>
|
||||
<ol v-else class="timeline">
|
||||
<li v-for="(item, index) in history" :key="item.checkpointId" :class="{ 'timeline-latest': index === 0 }">
|
||||
<NEmpty v-if="!history.length" size="small" description="工作流尚未保存执行记录。" />
|
||||
<NTimeline v-else>
|
||||
<NTimelineItem
|
||||
v-for="item in history"
|
||||
:key="item.checkpointId"
|
||||
:type="item.state.workflowExecution?.status === 'failed' ? 'error' : 'default'"
|
||||
>
|
||||
<p class="text-xs font-medium">{{ nodeLabel(item.metadata?.nodeName) }}</p>
|
||||
<p class="mt-1 text-[11px] text-muted">
|
||||
{{ formatDate(item.createdAt)
|
||||
@@ -27,8 +32,8 @@ const history = computed(() => workflowCheckpoints(props.checkpoints, props.work
|
||||
<span v-if="item.state.workflowExecution?.status === 'failed'" class="mt-1 block text-xs text-danger"
|
||||
>运行失败</span
|
||||
>
|
||||
</li>
|
||||
</ol>
|
||||
</NTimelineItem>
|
||||
</NTimeline>
|
||||
<p class="mt-5 border-t border-line pt-4 text-[11px] leading-5 text-muted">
|
||||
记录在节点或批次结束后更新,长时间无新记录不一定意味着任务失败。
|
||||
</p>
|
||||
|
||||
@@ -27,6 +27,9 @@ import SubjectIdentityPage from '../subject-identity/SubjectIdentityPage.vue'
|
||||
import { styleFixture, styleImageFixture } from '../visual-style/testing/fixtures'
|
||||
import { identityFixture, identityImageFixture } from '../subject-identity/testing/fixtures'
|
||||
import { getIdentitySession } from '../subject-identity/model'
|
||||
import { expandSections, selectControl } from '../../testing/naive'
|
||||
import ProductionPage from '../production/ProductionPage.vue'
|
||||
import { getProductionSession } from '../production/model'
|
||||
|
||||
/** 新资产页面复用已有项目上下文和内存路由,不接入真实模型。 */
|
||||
async function mountAssets(page: 'style' | 'identity', query = '') {
|
||||
@@ -39,6 +42,7 @@ async function mountAssets(page: 'style' | 'identity', query = '') {
|
||||
global: { plugins: [router], provide: { [projectContextKey as symbol]: provided } }
|
||||
})
|
||||
await flushPromises()
|
||||
if (page === 'identity') await expandSections()
|
||||
return provided
|
||||
}
|
||||
|
||||
@@ -78,7 +82,7 @@ function context(): ReturnType<typeof useProjectData> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 找到挂载到 body 的 Reka 弹窗按钮。 */
|
||||
/** 找到挂载到 body 的 Naive 弹窗按钮。 */
|
||||
function button(label: string): HTMLButtonElement {
|
||||
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
|
||||
if (!element) throw new Error('找不到按钮:' + label)
|
||||
@@ -89,7 +93,7 @@ function button(label: string): HTMLButtonElement {
|
||||
async function confirmGeneration(label: string) {
|
||||
button(label).click()
|
||||
await flushPromises()
|
||||
const acknowledgement = document.querySelector<HTMLInputElement>('[role="dialog"] input[type="checkbox"]')
|
||||
const acknowledgement = document.querySelector<HTMLElement>('[role="dialog"] [role="checkbox"]')
|
||||
expect(button('确认' + label).disabled).toBe(true)
|
||||
acknowledgement!.click()
|
||||
await flushPromises()
|
||||
@@ -98,13 +102,15 @@ async function confirmGeneration(label: string) {
|
||||
}
|
||||
|
||||
/** 挂载分镜页面并复用项目上下文 fixture。 */
|
||||
function mountStoryboard() {
|
||||
async function mountStoryboard() {
|
||||
const provided = context()
|
||||
provided.data.value!.checkpoints = [storyboardCheckpoint()]
|
||||
wrapper = mount(StoryboardPage, {
|
||||
attachTo: document.body,
|
||||
global: { provide: { [projectContextKey as symbol]: provided }, stubs: { RouterLink: true } }
|
||||
})
|
||||
await flushPromises()
|
||||
await expandSections()
|
||||
return provided
|
||||
}
|
||||
|
||||
@@ -120,9 +126,11 @@ async function mountSubjectImages() {
|
||||
global: { plugins: [router], provide: { [projectContextKey as symbol]: context() } }
|
||||
})
|
||||
await flushPromises()
|
||||
await expandSections()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
getProductionSession(fixture.id).receipt = null
|
||||
getIdentitySession(fixture.id).receipt = null
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
@@ -133,6 +141,38 @@ afterEach(() => {
|
||||
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
|
||||
})
|
||||
|
||||
describe('内容级滚动下的生产反馈', () => {
|
||||
it('默认折叠总览时查询错误仍显示在内容区外层', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new Error('生产连接中断')))
|
||||
wrapper = mount(ProductionPage, {
|
||||
attachTo: document.body,
|
||||
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.workspace-overview .n-collapse-item--active').exists()).toBe(false)
|
||||
const alert = wrapper.get('.workspace-split [role="alert"]')
|
||||
expect(alert.element.closest('.workspace-overview')).toBeNull()
|
||||
expect(alert.text()).toContain('无法连接后端')
|
||||
})
|
||||
|
||||
it('回执返回时自动展开总览,部分失败诊断不会留在折叠区内', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new Error('离线测试')))
|
||||
wrapper = mount(ProductionPage, {
|
||||
attachTo: document.body,
|
||||
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
|
||||
})
|
||||
await flushPromises()
|
||||
getProductionSession(fixture.id).receipt = {
|
||||
kind: 'retry',
|
||||
title: '重试失败视频任务',
|
||||
result: { totalFailed: 1, retried: 0, failed: 1, failures: [{ shotId: 'shot-1', error: '供应商不可用' }] }
|
||||
}
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.workspace-overview .n-collapse-item--active').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('供应商不可用')
|
||||
})
|
||||
})
|
||||
|
||||
describe('视觉风格与主体身份工作区', () => {
|
||||
it('从形态深链接进入后,手动切换主体不会被目录刷新切回', async () => {
|
||||
const form = formFixture()
|
||||
@@ -180,7 +220,7 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountAssets('identity')
|
||||
await wrapper!.get('#identity-description').setValue('人工稳定身份')
|
||||
await wrapper!.get('#identity-lock').setValue(true)
|
||||
await wrapper!.get('#identity-lock').trigger('click')
|
||||
button('保存主体身份').click()
|
||||
await flushPromises()
|
||||
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
||||
@@ -226,7 +266,7 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
await mountAssets('style')
|
||||
button('AI 重新生成风格').click()
|
||||
await flushPromises()
|
||||
document.querySelector<HTMLInputElement>('[role="dialog"] input[type="checkbox"]')!.click()
|
||||
document.querySelector<HTMLElement>('[role="dialog"] [role="checkbox"]')!.click()
|
||||
await flushPromises()
|
||||
expect(button('确认AI 重新生成风格').disabled).toBe(false)
|
||||
button('取消').click()
|
||||
@@ -250,7 +290,7 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
await wrapper!.get('#style-constraints').setValue('{"不应静默丢弃":true}')
|
||||
expect(button('保存视觉风格').disabled).toBe(true)
|
||||
await wrapper!.get('#style-constraints').setValue('["真人写实"]')
|
||||
await wrapper!.get('#style-lock').setValue(true)
|
||||
await wrapper!.get('#style-lock').trigger('click')
|
||||
await wrapper!.get('#style-name').setValue('雨夜风格')
|
||||
button('保存视觉风格').click()
|
||||
await flushPromises()
|
||||
@@ -625,7 +665,7 @@ describe('工作台页面交互', () => {
|
||||
expect(wrapper!.text()).toContain('服务暂不可用')
|
||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', concurrency: 2, force: false })
|
||||
await wrapper!.get('.panel details input[type="checkbox"]').setValue(true)
|
||||
await wrapper!.get('.n-collapse [role="checkbox"]').trigger('click')
|
||||
await confirmGeneration('为全项目新增候选图')
|
||||
const lastPost = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST').at(-1)!
|
||||
expect(JSON.parse(lastPost[1]!.body as string).force).toBe(true)
|
||||
@@ -740,11 +780,11 @@ describe('工作台页面交互', () => {
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await mountStoryboard()
|
||||
await flushPromises()
|
||||
await confirmGeneration('生成本集导演设计')
|
||||
expect(button('补齐项目导演设计').disabled).toBe(true)
|
||||
await wrapper!.get('select').setValue('2')
|
||||
selectControl(wrapper!, 'aria-label', '选择分镜剧集').vm.$emit('update:value', 2)
|
||||
await flushPromises()
|
||||
wrapper!.unmount()
|
||||
wrapper = undefined
|
||||
@@ -791,7 +831,7 @@ describe('工作台页面交互', () => {
|
||||
return new Response(JSON.stringify({ data: result }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await mountStoryboard()
|
||||
await flushPromises()
|
||||
expect(button('生成本集导演设计').disabled).toBe(false)
|
||||
expect(button('生成本集即时状态').disabled).toBe(true)
|
||||
@@ -844,12 +884,12 @@ describe('工作台页面交互', () => {
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await mountStoryboard()
|
||||
await flushPromises()
|
||||
await confirmGeneration('补齐项目导演设计')
|
||||
expect(wrapper!.get('[aria-label="生成回执"] [role="alert"]').text()).toContain('存在失败')
|
||||
expect(wrapper!.text()).toContain('模型校验失败')
|
||||
await wrapper!.get('input[type="checkbox"]').setValue(true)
|
||||
await wrapper!.get('[role="checkbox"]').trigger('click')
|
||||
await confirmGeneration('重生成全部导演设计')
|
||||
const posts = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||
expect(posts.map(([, init]) => JSON.parse(init!.body as string))).toEqual([
|
||||
@@ -877,9 +917,9 @@ describe('工作台页面交互', () => {
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await mountStoryboard()
|
||||
await flushPromises()
|
||||
await wrapper!.get('select[aria-label="选择分镜剧集"]').setValue('2')
|
||||
selectControl(wrapper!, 'aria-label', '选择分镜剧集').vm.$emit('update:value', 2)
|
||||
await flushPromises()
|
||||
expect(oldRequests.every(request => request.signal.aborted)).toBe(true)
|
||||
for (const request of oldRequests)
|
||||
@@ -911,7 +951,7 @@ describe('工作台页面交互', () => {
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await mountStoryboard()
|
||||
await flushPromises()
|
||||
fail = true
|
||||
button('刷新分镜').click()
|
||||
|
||||
Reference in New Issue
Block a user