feat(short-drama-agent-front): 优化项目查询与工作区界面

This commit is contained in:
GJ
2026-09-21 19:36:47 +08:00
parent 227db5450d
commit e6d02a20a7
33 changed files with 439 additions and 513 deletions
-80
View File
@@ -1,80 +0,0 @@
import { onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
import { errorMessage } from '../lib/http'
/** 串行轮询:请求完成后才计时;切换项目立即取消,过期响应不会覆盖新项目。 */
export function usePolling<T>(
key: Ref<string>,
loader: (key: string, signal: AbortSignal) => Promise<T>,
interval: MaybeRefOrGetter<number | false> = 6000
) {
const data = ref<T | null>(null) as Ref<T | null>
const loading = ref(false)
const error = ref('')
const updatedAt = ref('')
let generation = 0
let controller: AbortController | undefined
let timer: ReturnType<typeof setTimeout> | undefined
let disposed = false
/** 隐藏页面只安排下一次检查,不继续拉取完整 checkpoint。 */
function schedule() {
const delay = toValue(interval)
if (disposed || delay === false) return
timer = setTimeout(() => {
if (toValue(interval) === false) return
if (document.visibilityState === 'hidden') schedule()
else void refresh()
}, delay)
}
/** 手动刷新也取消上一次查询,避免同时存在多个轮询链。 */
async function refresh() {
if (disposed) return
const current = ++generation
clearTimeout(timer)
controller?.abort()
controller = new AbortController()
const signal = controller.signal
loading.value = true
try {
const result = await loader(key.value, signal)
if (current !== generation || disposed) return
data.value = result
error.value = ''
updatedAt.value = new Date().toISOString()
} catch (cause) {
if (current === generation && !signal.aborted && !disposed) error.value = errorMessage(cause)
} finally {
if (current === generation && !disposed) {
loading.value = false
schedule()
}
}
}
// 页面可关闭定时刷新;首次读取、切换查询目标和手动刷新仍然有效。
watch(
() => toValue(interval),
() => {
clearTimeout(timer)
if (!loading.value) schedule()
}
)
watch(
key,
() => {
data.value = null
error.value = ''
updatedAt.value = ''
void refresh()
},
{ immediate: true }
)
onScopeDispose(() => {
disposed = true
generation++
clearTimeout(timer)
controller?.abort()
})
return { data, loading, error, updatedAt, refresh }
}
+51
View File
@@ -0,0 +1,51 @@
import { onScopeDispose, ref, watch, type Ref } from 'vue'
import { errorMessage } from '../lib/http'
/** 异步查询仅在首次加载、查询目标变化或显式刷新时请求,切换目标会取消过期响应。 */
export function useQuery<T>(key: Ref<string>, loader: (key: string, signal: AbortSignal) => Promise<T>) {
const data = ref<T | null>(null) as Ref<T | null>
const loading = ref(false)
const error = ref('')
const updatedAt = ref('')
let generation = 0
let controller: AbortController | undefined
let disposed = false
/** 取消上一次查询并读取当前目标,失败时保留已有数据。 */
async function refresh() {
if (disposed) return
const current = ++generation
controller?.abort()
controller = new AbortController()
const signal = controller.signal
loading.value = true
try {
const result = await loader(key.value, signal)
if (current !== generation || disposed) return
data.value = result
error.value = ''
updatedAt.value = new Date().toISOString()
} catch (cause) {
if (current === generation && !signal.aborted && !disposed) error.value = errorMessage(cause)
} finally {
if (current === generation && !disposed) loading.value = false
}
}
watch(
key,
() => {
data.value = null
error.value = ''
updatedAt.value = ''
void refresh()
},
{ immediate: true }
)
onScopeDispose(() => {
disposed = true
generation++
controller?.abort()
})
return { data, loading, error, updatedAt, refresh }
}
@@ -179,40 +179,61 @@ onScopeDispose(() => controller?.abort())
>
后端尚未启用完整的图片和视频模型暂时无法保存项目生成配置
</NAlert>
<div v-if="profile" class="creative-profile-grid">
<NFormItem label="作品宽高比">
<NSelect
:value="profile.aspectRatio"
:options="aspectRatios"
:disabled="saving"
@update:value="profile.aspectRatio = $event as CreativeProfileAspectRatio"
/>
</NFormItem>
<div></div>
<section class="model-section">
<NFormItem label="图片模型">
<div v-if="profile" class="creative-profile-layout">
<!-- 以下是项目画布设置模块 -->
<section class="canvas-settings" aria-labelledby="canvas-settings-title">
<div class="setting-heading">
<h3 id="canvas-settings-title">项目画布</h3>
<p>统一图片和视频的画面比例</p>
</div>
<NFormItem label="作品宽高比" :show-feedback="false" class="canvas-ratio-field">
<NSelect
:value="imageSelection"
:options="modelOptions(enabledImages)"
:value="profile.aspectRatio"
:options="aspectRatios"
:disabled="saving"
@update:value="selectModel('image', $event)"
@update:value="profile.aspectRatio = $event as CreativeProfileAspectRatio"
/>
</NFormItem>
</section>
<!-- 以下是图片生成模型设置模块 -->
<section class="model-section" aria-labelledby="image-model-title">
<div class="model-section-heading">
<div class="setting-heading">
<h3 id="image-model-title">图片生成</h3>
<p>选择首帧图片模型并配置生成参数</p>
</div>
<NFormItem label="图片模型" :show-feedback="false" class="model-picker">
<NSelect
:value="imageSelection"
:options="modelOptions(enabledImages)"
:disabled="saving"
@update:value="selectModel('image', $event)"
/>
</NFormItem>
</div>
<GenerationOptionFields
v-model="profile.imageOptions"
:schemas="imageModel?.generationOptions || []"
:disabled="saving"
/>
</section>
<section class="model-section">
<NFormItem label="视频模型">
<NSelect
:value="videoSelection"
:options="modelOptions(enabledVideos)"
:disabled="saving"
@update:value="selectModel('video', $event)"
/>
</NFormItem>
<!-- 以下是视频生成模型设置模块 -->
<section class="model-section" aria-labelledby="video-model-title">
<div class="model-section-heading">
<div class="setting-heading">
<h3 id="video-model-title">视频生成</h3>
<p>选择成片模型并配置时长画质等参数</p>
</div>
<NFormItem label="视频模型" :show-feedback="false" class="model-picker">
<NSelect
:value="videoSelection"
:options="modelOptions(enabledVideos)"
:disabled="saving"
@update:value="selectModel('video', $event)"
/>
</NFormItem>
</div>
<GenerationOptionFields
v-model="profile.videoOptions"
:schemas="videoModel?.generationOptions || []"
@@ -220,7 +241,8 @@ onScopeDispose(() => controller?.abort())
/>
</section>
</div>
<div class="mt-3 flex items-center gap-3">
<!-- 以下是配置保存操作模块 -->
<div class="profile-save-actions">
<NButton
type="primary"
:loading="saving"
@@ -237,18 +259,43 @@ onScopeDispose(() => controller?.abort())
<style scoped>
@reference "../../../styles/styles.css";
.creative-profile-grid {
@apply grid grid-cols-[repeat(2,_minmax(0,_1fr))] gap-x-5;
.creative-profile-layout {
@apply grid gap-4;
}
.canvas-settings,
.model-section {
@apply min-w-0 bg-(--app-subtle);
}
.canvas-settings {
@apply grid grid-cols-[minmax(0,_1fr)_220px] items-center gap-6 px-5 py-4;
}
.setting-heading h3 {
@apply text-[13px] font-semibold text-(--app-text);
}
.setting-heading p {
@apply mt-1 text-xs leading-5 text-muted;
}
.canvas-ratio-field,
.model-picker {
@apply mb-0;
}
.model-section {
@apply min-w-0 p-4 bg-(--app-subtle);
@apply px-5 pt-4 pb-5;
}
.model-section-heading {
@apply grid grid-cols-[minmax(0,_1fr)_minmax(260px,_360px)] items-center gap-6 pb-5 mb-5 border-b border-(--app-border);
}
.profile-save-actions {
@apply mt-4 flex flex-wrap items-center gap-x-3 gap-y-2;
}
@media (max-width: 900px) {
.creative-profile-grid {
@apply grid-cols-[minmax(0,_1fr)];
.canvas-settings,
.model-section-heading {
@apply grid-cols-[minmax(0,_1fr)] gap-3;
}
.creative-profile-grid > div:empty {
@apply hidden;
.canvas-ratio-field,
.model-picker {
@apply max-w-none;
}
}
</style>
@@ -75,7 +75,8 @@ function selectOptions(schema: GenerationOptionSchema) {
<style scoped>
@reference "../../../styles/styles.css";
.generation-option-grid {
@apply grid grid-cols-[repeat(2,_minmax(0,_1fr))] gap-x-4;
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
@apply grid gap-x-5 gap-y-1;
}
@media (max-width: 760px) {
.generation-option-grid {
+3 -2
View File
@@ -290,8 +290,9 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
:show-icon="false"
class="mt-4"
>
当前有 {{ videoRunning }} 个视频任务等待或生成中后端正在轮询
Provider同一镜头不会重复提交活动任务
当前有
{{ videoRunning }}
个视频任务等待或生成中请手动刷新查看最新状态同一镜头不会重复提交活动任务
</NAlert>
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
当前有 {{ identityBlocked }} 项角色身份前置问题Character 必须生成
@@ -4,7 +4,7 @@ import { Download } from '@lucide/vue'
import { downloadText } from '../../../lib/format'
import type { ProductionReceipt } from '../types'
/** 批量回执只描述本次请求,不能替代持续轮询的数据库状态。 */
/** 批量回执只描述本次请求,不能替代显式刷新后的数据库状态。 */
const props = defineProps<{ receipt: ProductionReceipt }>()
/** 导出完整回执,保留逐镜失败 ID 供后端排障。 */
@@ -4,7 +4,7 @@ import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { ExternalLink, ImagePlus, RefreshCw } from '@lucide/vue'
import { AssetImage, StatusBadge } from '../../../components/ui'
import { usePolling } from '../../../composables/usePolling'
import { useQuery } from '../../../composables/useQuery'
import { mediaAssetUrl } from '../../../lib/assets'
import { errorMessage } from '../../../lib/http'
import { formatDate } from '../../../lib/format'
@@ -41,20 +41,16 @@ const props = defineProps<{
}>()
const emit = defineEmits<{ changed: [] }>()
const key = computed(() => props.shot.shotId)
/** 单镜头资产已有刷新入口,不再定时轮询。 */
const query = usePolling(
key,
async (shotId, signal) => {
const [keyframes, videos] = await Promise.all([
productionApi.listKeyframes(shotId, signal),
productionApi.listVideos(shotId, signal)
])
if (keyframes.some(item => item.shotId !== shotId) || videos.some(item => item.shotId !== shotId))
throw new Error('资产记录与当前镜头不匹配,请刷新后重试。')
return { keyframes, videos }
},
false
)
/** 单镜头资产仅在进入、切换镜头或显式刷新时读取。 */
const query = useQuery(key, async (shotId, signal) => {
const [keyframes, videos] = await Promise.all([
productionApi.listKeyframes(shotId, signal),
productionApi.listVideos(shotId, signal)
])
if (keyframes.some(item => item.shotId !== shotId) || videos.some(item => item.shotId !== shotId))
throw new Error('资产记录与当前镜头不匹配,请刷新后重试。')
return { keyframes, videos }
})
const keyframes = computed(() => query.data.value?.keyframes ?? [])
const videos = computed(() => query.data.value?.videos ?? [])
const currentKeyframe = computed(() => primaryKeyframe(keyframes.value))
+16 -20
View File
@@ -1,5 +1,5 @@
import { computed, ref } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { useQuery } from '../../composables/useQuery'
import { useProjectContext } from '../projects/context'
import { mergeDesignedShots } from '../storyboard/model'
import { storyboardApi } from '../storyboard/api'
@@ -45,24 +45,20 @@ export function useProduction() {
)
const queryKey = computed(() => JSON.stringify([id.value, episodeNo.value, force.value]))
/** 生产页已有刷新按钮,就绪状态改为手动更新。 */
const query = usePolling(
queryKey,
async (key, signal) => {
const [projectId, number, overwrite] = JSON.parse(key) as [string, number, boolean]
if (!projectId || !number) return null
const [directions, prompts, keyframes, videos, videoStatus] = await Promise.all([
storyboardApi.directions(projectId, number, signal),
productionApi.promptReadiness(projectId, overwrite, signal),
productionApi.keyframeReadiness(projectId, overwrite, signal),
productionApi.videoReadiness(projectId, overwrite, signal),
productionApi.projectVideoStatus(projectId, signal)
])
if (directions.projectId !== projectId || directions.episodeNo !== number)
throw new Error('镜头生产查询返回了不匹配的项目或剧集,请刷新后重试。')
return { directions, prompts, keyframes, videos, videoStatus }
},
false
)
const query = useQuery(queryKey, async (key, signal) => {
const [projectId, number, overwrite] = JSON.parse(key) as [string, number, boolean]
if (!projectId || !number) return null
const [directions, prompts, keyframes, videos, videoStatus] = await Promise.all([
storyboardApi.directions(projectId, number, signal),
productionApi.promptReadiness(projectId, overwrite, signal),
productionApi.keyframeReadiness(projectId, overwrite, signal),
productionApi.videoReadiness(projectId, overwrite, signal),
productionApi.projectVideoStatus(projectId, signal)
])
if (directions.projectId !== projectId || directions.episodeNo !== number)
throw new Error('镜头生产查询返回了不匹配的项目或剧集,请刷新后重试。')
return { directions, prompts, keyframes, videos, videoStatus }
})
const source = computed(() => sourceEpisodes.value.find(item => item.episodeNo === episodeNo.value))
const shots = computed(() => mergeDesignedShots(query.data.value?.directions ?? null, null, source.value))
const shot = computed(() => shots.value.find(item => item.shotId === selectedShot.value) ?? shots.value[0])
@@ -99,7 +95,7 @@ export function useProduction() {
context.project.value?.status !== 'completed'
)
/** 批量操作只向就绪镜头提交;最终资产状态由轮询查询确认。 */
/** 批量操作只向就绪镜头提交;最终资产状态由显式刷新确认。 */
async function run(command: ProductionCommand) {
if (
blocked.value ||
+11 -24
View File
@@ -1,29 +1,16 @@
import { computed, type MaybeRefOrGetter, type Ref } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { computed, type Ref } from 'vue'
import { useQuery } from '../../composables/useQuery'
import { storyboardApi } from '../storyboard/api'
/** 仅查询已由当前项目列表确认的镜头,切换目标自动取消旧响应。 */
export function useShotReferences(
projectId: Ref<string>,
shotId: Ref<string>,
interval: MaybeRefOrGetter<number | false> = 6000
) {
export function useShotReferences(projectId: Ref<string>, shotId: Ref<string>) {
const key = computed(() => JSON.stringify([projectId.value, shotId.value]))
return usePolling(
key,
async (value, signal) => {
const [project, shot] = JSON.parse(value) as [string, string]
if (!project || !shot) return null
const result = await storyboardApi.references(shot, signal)
if (
!result ||
result.shotId !== shot ||
!Array.isArray(result.references) ||
!Array.isArray(result.missing)
)
throw new Error('镜头参考素材返回不匹配,请刷新后重试。')
return result
},
interval
)
return useQuery(key, async (value, signal) => {
const [project, shot] = JSON.parse(value) as [string, string]
if (!project || !shot) return null
const result = await storyboardApi.references(shot, signal)
if (!result || result.shotId !== shot || !Array.isArray(result.references) || !Array.isArray(result.missing))
throw new Error('镜头参考素材返回不匹配,请刷新后重试。')
return result
})
}
+19 -51
View File
@@ -1,32 +1,31 @@
<script setup lang="ts">
import { computed, inject, onScopeDispose, provide, ref, watchEffect } from 'vue'
import { computed, inject, onScopeDispose, provide, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NScrollbar, NAlert, NButton, NPageHeader, NSpin, NEllipsis } from 'naive-ui'
import { NScrollbar, NAlert, NButton, NSpin } from 'naive-ui'
import { EmptyState } from '../../components/ui'
import { buildDocumentTitle } from '../../lib/document-title'
import { projectContextKey, useProjectData } from './context'
import { getOperation } from '../workflows/operations'
import { isProjectComplete, projectAccessKey, type ProjectAccess } from './access'
/** 项目标题只保留名称;下游工作区在剧本完成前不挂载,直接链接也无法越过限制。 */
/** 下游工作区在剧本完成前不挂载,直接链接也无法越过限制。 */
const route = useRoute()
const router = useRouter()
/** 当前路由中的项目 ID。 */
const id = computed(() => String(route.params.projectId))
/** 移动端标题只截断,不弹悬浮提示;跟随视口变化并在卸载时清理监听。 */
const mobileMedia = window.matchMedia('(max-width: 760px)')
const isMobile = ref(mobileMedia.matches)
function syncMobile() {
isMobile.value = mobileMedia.matches
}
mobileMedia.addEventListener('change', syncMobile)
onScopeDispose(() => mobileMedia.removeEventListener('change', syncMobile))
// 图库使用手动刷新,父布局也停止轮询,避免浏览素材时被周期性更新打断。
const isGallery = computed(() => route.path.replace(/\/+$/, '').endsWith('/subject-images'))
const context = useProjectData(
id,
computed(() => (isGallery.value ? false : 6000))
)
/** 项目数据仅在进入、切换项目或显式刷新时读取。 */
const context = useProjectData(id)
provide(projectContextKey, context)
/** 当前项目名称,仅使用与路由项目 ID 匹配的数据,避免切换项目时短暂显示旧标题。 */
const projectTitle = computed(() => {
const project = context.project.value
return project?.id === id.value ? project.title || project.topic : ''
})
/** 项目数据或子页面变化时同步浏览器标签标题。 */
watchEffect(() => {
document.title = buildDocumentTitle(route.meta.title, projectTitle.value)
})
const operation = computed(() => getOperation(id.value))
const complete = computed(() => isProjectComplete(context.project.value, id.value))
const isCreation = computed(() => route.path.replace(/\/+$/, '').endsWith('/create-drama'))
@@ -38,19 +37,11 @@ watchEffect(() => {
})
onScopeDispose(() => {
if (access?.value === published) access.value = null
document.title = buildDocumentTitle(route.meta.title)
})
</script>
<template>
<section class="project-frame">
<header class="project-header">
<NPageHeader @back="router.push('/projects')">
<template #title
><NEllipsis :key="isMobile ? 'mobile' : 'desktop'" class="project-title" :tooltip="!isMobile">{{
context.project.value?.title || context.project.value?.topic || '读取项目'
}}</NEllipsis></template
>
</NPageHeader>
</header>
<NScrollbar
v-if="context.error.value || operation.pending || operation.error || operation.notice"
class="project-notices"
@@ -74,12 +65,10 @@ onScopeDispose(() => {
v-else-if="context.project.value?.id === id"
class="project-access-gate"
title="请先完成剧本创作"
:description="`剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、分镜设计与镜头生产。${isGallery ? '请手动刷新项目状态。' : '状态会自动更新。'}`"
description="剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、分镜设计与镜头生产。请手动刷新项目状态。"
>
<NButton type="primary" @click="router.push(`/projects/${id}/create-drama`)">返回剧本创作</NButton>
<NButton v-if="isGallery" class="ml-3" :loading="context.loading.value" @click="context.refresh"
>刷新项目状态</NButton
>
<NButton class="ml-3" :loading="context.loading.value" @click="context.refresh">刷新项目状态</NButton>
</EmptyState>
<NSpin
v-else-if="context.loading.value"
@@ -96,16 +85,6 @@ onScopeDispose(() => {
.project-frame {
@apply flex flex-col h-full min-h-0 overflow-hidden;
}
.project-header {
@apply shrink-0 py-[9px] px-5 bg-(--app-surface);
}
.project-header .n-page-header__main,
.project-header .n-page-header__title {
@apply min-w-0 overflow-hidden;
}
.project-title {
@apply max-w-full text-base font-semibold;
}
.project-notices.n-scrollbar {
@apply shrink-0 h-auto max-h-[100px] overflow-hidden;
}
@@ -118,21 +97,10 @@ onScopeDispose(() => {
.project-view {
@apply flex-1 min-h-0 overflow-hidden;
}
.project-header .n-page-header-wrapper {
@apply min-w-0;
}
.project-access-gate {
@apply h-full;
}
.workspace-loading {
@apply grid place-content-center h-full;
}
@media (max-width: 760px) {
.project-header {
@apply py-2 px-3;
}
.project-title {
@apply text-sm;
}
}
</style>
+2 -2
View File
@@ -13,7 +13,7 @@ import {
} from 'naive-ui'
import { RefreshCw, Search } from '@lucide/vue'
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { usePolling } from '../../composables/usePolling'
import { useQuery } from '../../composables/useQuery'
import { StatusBadge } from '../../components/ui'
import { formatDate } from '../../lib/format'
import { projectsApi } from './api'
@@ -23,7 +23,7 @@ import CreateProjectDialog from './components/CreateProjectDialog.vue'
/** 项目表格使用固定表头与独立滚动;搜索筛选仍作用于真实接口数据。 */
const router = useRouter()
/** 列表已有刷新按钮,只在进入和手动刷新时读取;筛选在完整结果中进行。 */
const query = usePolling(ref('projects'), (_, signal) => projectsApi.list(signal), false)
const query = useQuery(ref('projects'), (_, signal) => projectsApi.list(signal))
const search = ref('')
const filter = ref('all')
/** 接口暂未分页:对完整查询结果分批展示,滚动时追加而非切换页码。 */
+10 -14
View File
@@ -1,23 +1,19 @@
import { computed, inject, type InjectionKey, type MaybeRefOrGetter } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { computed, inject, type InjectionKey } from 'vue'
import { useQuery } from '../../composables/useQuery'
import { projectsApi } from './api'
import type { ProjectDetail } from './types'
import type { Checkpoint } from '../workflows/types'
import type { Ref } from 'vue'
/** 同一项目的各个 graph 共用一份查询,避免每个面板重复请求。 */
export function useProjectData(id: Ref<string>, interval: MaybeRefOrGetter<number | false> = 6000) {
const query = usePolling(
id,
async (projectId, signal) => {
const [project, checkpoints] = await Promise.all([
projectsApi.detail(projectId, signal),
projectsApi.checkpoints(projectId, signal)
])
return { project, checkpoints }
},
interval
)
export function useProjectData(id: Ref<string>) {
const query = useQuery(id, async (projectId, signal) => {
const [project, checkpoints] = await Promise.all([
projectsApi.detail(projectId, signal),
projectsApi.checkpoints(projectId, signal)
])
return { project, checkpoints }
})
const project = computed<ProjectDetail | null>(() => query.data.value?.project ?? null)
const checkpoints = computed<Checkpoint[]>(() => query.data.value?.checkpoints ?? [])
return { ...query, project, checkpoints }
+19 -23
View File
@@ -1,5 +1,5 @@
import { computed, ref } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { useQuery } from '../../composables/useQuery'
import { useProjectContext } from '../projects/context'
import { breakdownSnapshot } from '../workflows/selectors'
import { getOperation, runOperation } from '../workflows/operations'
@@ -38,28 +38,24 @@ export function useStoryboard() {
0
)
const queryKey = computed(() => JSON.stringify([id.value, episodeNo.value]))
/** 分镜页已有刷新按钮,不再定时轮询当前剧集。 */
const query = usePolling(
queryKey,
async (key, signal) => {
const [projectId, number] = JSON.parse(key) as [string, number]
if (!projectId || !number) return null
const [directions, visualStates] = await Promise.all([
storyboardApi.directions(projectId, number, signal),
storyboardApi.visualStates(projectId, number, signal)
])
if (
directions.projectId !== projectId ||
visualStates.projectId !== projectId ||
directions.episodeNo !== number ||
visualStates.episodeNo !== number
) {
throw new Error('分镜查询返回了不匹配的项目或剧集,请刷新后重试。')
}
return { directions, visualStates }
},
false
)
/** 分镜页仅在进入、切换剧集或显式刷新时读取。 */
const query = useQuery(queryKey, async (key, signal) => {
const [projectId, number] = JSON.parse(key) as [string, number]
if (!projectId || !number) return null
const [directions, visualStates] = await Promise.all([
storyboardApi.directions(projectId, number, signal),
storyboardApi.visualStates(projectId, number, signal)
])
if (
directions.projectId !== projectId ||
visualStates.projectId !== projectId ||
directions.episodeNo !== number ||
visualStates.episodeNo !== number
) {
throw new Error('分镜查询返回了不匹配的项目或剧集,请刷新后重试。')
}
return { directions, visualStates }
})
const data = query.data
const session = computed(() => getStoryboardSession(id.value))
/** 单集重生成返回的是最新镜头正文,优先于不会随局部覆盖更新的历史 Breakdown 快照。 */
@@ -99,7 +99,7 @@ const filtered = computed(() =>
)
)
/** 深链接仅在目标 ID 变化时选择,不因目录轮询把用户切回原主体。 */
/** 深链接仅在目标 ID 变化时选择,不因目录刷新把用户切回原主体。 */
const linkedSubjectId = computed(
() => subjects.value.find(item => item.id === route.query.subjectId || item.ref === route.query.subjectRef)?.id
)
@@ -6,7 +6,7 @@ import { NButton, NCheckbox, NInput } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import type { SaveIdentityInput, SubjectIdentity } from '../types'
/** 身份文本草稿不随询覆盖;父页面切换主体或保存成功后重建编辑器。 */
/** 身份文本草稿不随询覆盖;父页面切换主体或保存成功后重建编辑器。 */
const props = defineProps<{ identity: SubjectIdentity | null; disabled: boolean; module: string }>()
const emit = defineEmits<{ save: [input: SaveIdentityInput]; dirty: [value: boolean] }>()
const form = reactive({ description: '', generationPrompt: '', isLocked: false })
@@ -53,7 +53,7 @@ watch(url, () => {
imageFailed.value = false
})
/** 仅读取正式主体的身份图库;切项目、筛选或刷新时中止旧请求,不轮询每个缩略图。 */
/** 仅读取正式主体的身份图库;切项目、筛选或刷新时中止旧请求。 */
watch(
() => [props.projectId, props.subjectId, props.identityId, props.source, props.refreshKey, visible.value],
async (_value, _oldValue, onCleanup) => {
@@ -1,5 +1,5 @@
import { computed, ref, watch } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { useQuery } from '../../composables/useQuery'
import { visualStyleApi } from '../visual-style'
import { subjectImagesApi } from '../subject-images/api'
import { hasRunningImages } from '../subject-images/model'
@@ -30,58 +30,42 @@ export function useSubjectIdentity() {
const candidateLimit = ref(3)
const force = ref(false)
/** 目录与身份已有刷新按钮,查询只在进入、切换和手动刷新时读取。 */
const catalog = usePolling(
projectId,
async (id, signal) => {
const forms = await subjectImagesApi.listForms(id, signal)
if (
forms.some(
form => form.subject.projectId !== id || form.images.some(image => image.subjectFormId !== form.id)
)
const catalog = useQuery(projectId, async (id, signal) => {
const forms = await subjectImagesApi.listForms(id, signal)
if (
forms.some(
form => form.subject.projectId !== id || form.images.some(image => image.subjectFormId !== form.id)
)
throw new Error('主体目录与当前项目不匹配,请刷新后重试。')
return {
subjects: groupIdentitySubjects(forms),
running: forms.some(form => hasRunningImages(form.images))
}
},
false
)
const styleQuery = usePolling(
projectId,
async (id, signal) => {
const style = await visualStyleApi.get(id, signal)
if (style && style.projectId !== id) throw new Error('视觉风格与当前项目不匹配。')
return { style }
},
false
)
const castingQuery = usePolling(
projectId,
async (id, signal) => {
const readiness = await subjectIdentityApi.castingReadiness(id, signal)
return readiness
},
false
)
)
throw new Error('主体目录与当前项目不匹配,请刷新后重试。')
return {
subjects: groupIdentitySubjects(forms),
running: forms.some(form => hasRunningImages(form.images))
}
})
const styleQuery = useQuery(projectId, async (id, signal) => {
const style = await visualStyleApi.get(id, signal)
if (style && style.projectId !== id) throw new Error('视觉风格与当前项目不匹配。')
return { style }
})
const castingQuery = useQuery(projectId, async (id, signal) => {
const readiness = await subjectIdentityApi.castingReadiness(id, signal)
return readiness
})
const subjects = computed(() =>
mergeCastingSubjects(catalog.data.value?.subjects ?? [], castingQuery.data.value?.items ?? [], projectId.value)
)
const subject = computed(() => subjects.value.find(item => item.id === selectedId.value))
const selectionKey = computed(() => subject.value?.id ?? '')
const detail = usePolling(
selectionKey,
async (id, signal) => {
if (!id) return null
const identity = await subjectIdentityApi.get(id, signal)
if (!identity) return { identity: null, images: [] as IdentityImage[] }
assertIdentity(identity, id)
const images = await subjectIdentityApi.listImages(id, signal)
if (images.some(image => image.identityId !== identity.id)) throw new Error('身份图片与当前主体不匹配。')
return { identity, images }
},
false
)
const detail = useQuery(selectionKey, async (id, signal) => {
if (!id) return null
const identity = await subjectIdentityApi.get(id, signal)
if (!identity) return { identity: null, images: [] as IdentityImage[] }
assertIdentity(identity, id)
const images = await subjectIdentityApi.listImages(id, signal)
if (images.some(image => image.identityId !== identity.id)) throw new Error('身份图片与当前主体不匹配。')
return { identity, images }
})
const identity = computed(() => detail.data.value?.identity ?? null)
const images = computed(() => detail.data.value?.images ?? [])
const castingItem = computed(() =>
@@ -701,6 +701,7 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
:src="coverImage(form)?.imageUrl"
:alt="`${form.subject.name} · ${form.name}`"
:object-fit="layout === 'masonry' ? 'contain' : 'cover'"
:class="{ 'form-image-empty': !coverImage(form) }"
preview
:empty-text="hasRunningImages(form.images) ? '后台正在生成图片' : '尚未生成图片'"
/>
@@ -842,6 +843,10 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
.form-image-masonry .asset-image {
@apply aspect-auto;
}
/* 无图占位保持正方形,避免瀑布流的自适应比例将占位区域压扁。 */
.form-image-grid .asset-image.form-image-empty {
@apply aspect-square;
}
.form-image-masonry .asset-image .n-image,
.form-image-masonry .asset-image .n-image img {
@apply h-auto;
@@ -3,7 +3,7 @@ import { NScrollbar, NAlert, NButton, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { ExternalLink, RefreshCw } from '@lucide/vue'
import { AppDialog, AssetImage, StatusBadge } from '../../../components/ui'
import { usePolling } from '../../../composables/usePolling'
import { useQuery } from '../../../composables/useQuery'
import { referenceImageUrl } from '../../../lib/assets'
import { formatDate } from '../../../lib/format'
import { getOperation, runOperation } from '../../workflows/operations'
@@ -18,16 +18,12 @@ const emit = defineEmits<{ changed: [] }>()
const selectedId = ref('')
const confirming = ref(false)
const key = computed(() => (open.value ? (props.form?.id ?? '') : ''))
const query = usePolling(
key,
async (id, signal) => {
if (!id) return null
const rows = await subjectImagesApi.listImages(id, signal)
if (rows.some(image => image.subjectFormId !== id)) throw new Error('图片记录与当前形态不匹配,请刷新后重试。')
return rows
},
false
)
const query = useQuery(key, async (id, signal) => {
if (!id) return null
const rows = await subjectImagesApi.listImages(id, signal)
if (rows.some(image => image.subjectFormId !== id)) throw new Error('图片记录与当前形态不匹配,请刷新后重试。')
return rows
})
const images = computed(() => query.data.value ?? [])
const selected = computed(
() => images.value.find(image => image.id === selectedId.value) ?? primaryImage(images.value) ?? images.value[0]
@@ -1,45 +1,39 @@
import { computed, type Ref } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { useQuery } from '../../composables/useQuery'
import { productionApi } from '../production/api'
import { useShotReferences } from '../production/useShotReferences'
/** 镜头影响检查与素材查询独立;检查失败不把素材误判为正常或阻断独立的素材操作。 */
export function useKeyframeImpact(projectId: Ref<string>, sourceShotId: Ref<string>) {
const query = usePolling(
projectId,
async (id, signal) => {
if (!id) return null
const [keyframes, videos] = await Promise.all([
productionApi.keyframeReadiness(id, false, signal),
productionApi.videoReadiness(id, false, signal)
])
if (!keyframes || !Array.isArray(keyframes.items) || !videos || !Array.isArray(videos.items))
throw new Error('主首帧影响检查未返回镜头列表。')
const byShot = new Map(videos.items.map(item => [item.shotId, item]))
if (byShot.size !== keyframes.items.length || keyframes.items.some(item => !byShot.has(item.shotId)))
throw new Error('首帧与视频检查的镜头列表不一致,可能正在重新拆解,请刷新后重试。')
return {
...keyframes,
items: keyframes.items.map(item => {
const issues =
byShot.get(item.shotId)?.issues.filter(issue => issue.code === 'stale_keyframe') ?? []
return {
...item,
primaryKeyframeStale: !!item.primaryKeyframeStale || issues.length > 0,
inconsistent: item.primaryKeyframeStale === false && issues.length > 0
}
})
}
},
false
)
const query = useQuery(projectId, async (id, signal) => {
if (!id) return null
const [keyframes, videos] = await Promise.all([
productionApi.keyframeReadiness(id, false, signal),
productionApi.videoReadiness(id, false, signal)
])
if (!keyframes || !Array.isArray(keyframes.items) || !videos || !Array.isArray(videos.items))
throw new Error('主首帧影响检查未返回镜头列表。')
const byShot = new Map(videos.items.map(item => [item.shotId, item]))
if (byShot.size !== keyframes.items.length || keyframes.items.some(item => !byShot.has(item.shotId)))
throw new Error('首帧与视频检查的镜头列表不一致,可能正在重新拆解,请刷新后重试。')
return {
...keyframes,
items: keyframes.items.map(item => {
const issues = byShot.get(item.shotId)?.issues.filter(issue => issue.code === 'stale_keyframe') ?? []
return {
...item,
primaryKeyframeStale: !!item.primaryKeyframeStale || issues.length > 0,
inconsistent: item.primaryKeyframeStale === false && issues.length > 0
}
})
}
})
const stale = computed(() => query.data.value?.items.filter(item => item.primaryKeyframeStale) ?? [])
// 不根据 URL 中的任意镜头 ID 发请求,必须属于当前项目的就绪列表。
const source = computed(() => query.data.value?.items.find(item => item.shotId === sourceShotId.value))
const references = useShotReferences(
projectId,
computed(() => source.value?.shotId ?? ''),
false
computed(() => source.value?.shotId ?? '')
)
const relatedForms = computed(
() => new Set(references.data.value?.references.map(item => item.subjectFormId) ?? [])
+22 -26
View File
@@ -1,5 +1,5 @@
import { computed, ref } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { useQuery } from '../../composables/useQuery'
import { ApiError } from '../../lib/http'
import { useProjectContext } from '../projects/context'
import { getOperation, runOperation } from '../workflows/operations'
@@ -17,32 +17,28 @@ export function useSubjectImages() {
const limit = ref<number | ''>('')
const promptConcurrency = ref(3)
const promptForce = ref(false)
const query = usePolling(
id,
async (projectId, signal) => {
if (!projectId) return []
try {
const forms = await subjectImagesApi.listForms(projectId, signal)
if (
forms.some(
form =>
form.subject.projectId !== projectId ||
form.images.some(image => image.subjectFormId !== form.id)
)
const query = useQuery(id, async (projectId, signal) => {
if (!projectId) return []
try {
const forms = await subjectImagesApi.listForms(projectId, signal)
if (
forms.some(
form =>
form.subject.projectId !== projectId ||
form.images.some(image => image.subjectFormId !== form.id)
)
throw new Error('形态图库返回了不匹配的项目或图片,请刷新后重试。')
return forms
} catch (error) {
if (error instanceof ApiError && error.status === 404)
throw new Error(
'后端尚未提供形态图库查询,请更新后端 dev 并重启服务(GET /projects/:id/subject-forms)。',
{ cause: error }
)
throw error
}
},
false
)
)
throw new Error('形态图库返回了不匹配的项目或图片,请刷新后重试。')
return forms
} catch (error) {
if (error instanceof ApiError && error.status === 404)
throw new Error(
'后端尚未提供形态图库查询,请更新后端 dev 并重启服务(GET /projects/:id/subject-forms)。',
{ cause: error }
)
throw error
}
})
const forms = computed(() => query.data.value ?? [])
const operation = computed(() => getOperation(id.value))
const session = computed(() => getImageSession(id.value))
+7 -11
View File
@@ -4,7 +4,7 @@ import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
import { NAlert, NButton } from 'naive-ui'
import { LoaderCircle, LockKeyhole, LockKeyholeOpen, Palette, RefreshCw, TriangleAlert } from '@lucide/vue'
import { computed, ref } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { useQuery } from '../../composables/useQuery'
import { runOperation } from '../workflows/operations'
import { useProjectMutationGuard } from '../workflows/useProjectMutationGuard'
import ConfirmAction from '../workflows/ConfirmAction.vue'
@@ -15,16 +15,12 @@ import StyleImages from './components/StyleImages.vue'
/** 项目视觉风格工作区:文本编辑、锁定和风格图记录分开管理。 */
const { projectId, blocked } = useProjectMutationGuard()
/** 风格页已有刷新按钮,不再定时轮询。 */
const query = usePolling(
projectId,
async (id, signal) => {
const style = await visualStyleApi.get(id, signal)
assertProject(style, id)
return { style }
},
false
)
/** 风格页仅在进入、切换项目或显式刷新时读取。 */
const query = useQuery(projectId, async (id, signal) => {
const style = await visualStyleApi.get(id, signal)
assertProject(style, id)
return { style }
})
const style = computed(() => query.data.value?.style ?? null)
const disabled = computed(() => blocked.value || !!query.error.value || !query.data.value)
const editorRevision = ref(0)
@@ -7,7 +7,7 @@ import { NButton, NCheckbox, NInput, NTag } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import type { SaveVisualStyleInput, VisualStyle } from '../types'
/** 编辑草稿独立于询结果,刷新不会覆盖尚未保存的输入。 */
/** 编辑草稿独立于询结果,刷新不会覆盖尚未保存的输入。 */
const props = defineProps<{ visualStyle: VisualStyle | null; disabled: boolean }>()
const emit = defineEmits<{ save: [input: SaveVisualStyleInput]; dirty: [value: boolean] }>()
const form = reactive({
@@ -3,7 +3,7 @@ import { computed, ref, watch } from 'vue'
import { NAlert, NButton, NCollapse, NCollapseItem, NInput, NSelect, NTab, NTabs } from 'naive-ui'
import { Download, RefreshCw, Search } from '@lucide/vue'
import { AppDialog, EmptyState } from '../../components/ui'
import { usePolling } from '../../composables/usePolling'
import { useQuery } from '../../composables/useQuery'
import { downloadText, formatDate, nodeLabel } from '../../lib/format'
import { loadWorkflowDiagnostics } from './diagnostics'
import type { Checkpoint } from './types'
@@ -13,7 +13,7 @@ const props = defineProps<{ projectId: string; checkpoints: Checkpoint[] }>()
const open = defineModel<boolean>('open', { default: false })
const key = computed(() => (open.value ? props.projectId : ''))
/** 诊断弹窗已有刷新按钮,打开后只读取一次。 */
const query = usePolling(key, (id, signal) => (id ? loadWorkflowDiagnostics(id, signal) : Promise.resolve(null)), false)
const query = useQuery(key, (id, signal) => (id ? loadWorkflowDiagnostics(id, signal) : Promise.resolve(null)))
const tab = ref('timeline')
const workflow = ref('all')
const search = ref('')
+11
View File
@@ -0,0 +1,11 @@
const APP_TITLE = '短剧工作台'
/**
* 生成浏览器标签标题,并在项目工作区中优先展示项目名称。
* @param pageTitle 当前页面名称
* @param projectTitle 当前项目名称
* @returns 浏览器标签标题
*/
export function buildDocumentTitle(pageTitle: unknown, projectTitle?: string | null) {
return [projectTitle?.trim(), String(pageTitle || '工作空间'), APP_TITLE].filter(Boolean).join(' · ')
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
import { buildDocumentTitle } from '../lib/document-title'
/** 按 graph 分路由,项目布局保留共享数据与长请求状态。 */
export const router = createRouter({
@@ -62,5 +63,5 @@ export const router = createRouter({
/** 路由切换同步浏览器标题,不依赖页面组件主动修改。 */
router.afterEach(to => {
document.title = `${String(to.meta.title || '工作空间')} · 短剧工作台`
document.title = buildDocumentTitle(to.meta.title)
})
+3 -4
View File
@@ -383,7 +383,7 @@ describe('管理后台组件边界', () => {
expect(document.querySelector('.n-drawer-container')).toBeNull()
})
it('完成项目解锁全部左侧链接,标题不再显示状态标签和刷新按钮', async () => {
it('完成项目解锁全部左侧链接,移除项目标题栏并将项目名写入浏览器标题', async () => {
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue({
id: 'navigation-test',
title: '导航测试项目',
@@ -423,7 +423,8 @@ describe('管理后台组件边界', () => {
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
expect(wrapper.find('[aria-label="项目工作流"]').exists()).toBe(false)
expect(wrapper.get('.project-title').text()).toBe('导航测试项目')
expect(wrapper.find('.project-header').exists()).toBe(false)
expect(document.title).toBe('导航测试项目 · 工作空间 · 短剧工作台')
for (const path of paths) {
await wrapper.get(`.n-menu a[href="/projects/navigation-test/${path}"]`).trigger('click')
await flushPromises()
@@ -431,8 +432,6 @@ describe('管理后台组件边界', () => {
expect(wrapper.get('.project-view .workspace-page').text()).toBe(path)
}
expect(detail).toHaveBeenCalledOnce()
expect(wrapper.find('.project-header .n-button').exists()).toBe(false)
expect(wrapper.find('.project-header .n-tag').exists()).toBe(false)
})
it('确认按钮有稳定根元素承接调用方间距,生产按钮显式保留上下留白', () => {
-86
View File
@@ -1,86 +0,0 @@
import { effectScope, nextTick, ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { usePolling } from '@/composables/usePolling'
afterEach(() => vi.useRealTimers())
describe('异步轮询生命周期', () => {
it('关闭定时刷新后仍支持首次读取、手动刷新及查询目标切换', async () => {
vi.useFakeTimers()
const key = ref('first')
const loader = vi.fn<(id: string) => Promise<string>>(async id => id)
const scope = effectScope()
const query = scope.run(() => usePolling(key, loader, false))!
await Promise.resolve()
expect(query.data.value).toBe('first')
await vi.advanceTimersByTimeAsync(30_000)
expect(loader).toHaveBeenCalledTimes(1)
await query.refresh()
expect(loader).toHaveBeenCalledTimes(2)
key.value = 'second'
await nextTick()
await Promise.resolve()
expect(query.data.value).toBe('second')
await vi.advanceTimersByTimeAsync(30_000)
expect(loader).toHaveBeenCalledTimes(3)
scope.stop()
})
it('响应式暂停清除旧定时器,恢复只创建一个轮询链', async () => {
vi.useFakeTimers()
const interval = ref<number | false>(1000)
const loader = vi.fn<() => Promise<string>>(async () => '数据')
const scope = effectScope()
scope.run(() => usePolling(ref('p'), loader, interval))
await Promise.resolve()
interval.value = false
await nextTick()
await vi.advanceTimersByTimeAsync(5000)
expect(loader).toHaveBeenCalledTimes(1)
interval.value = 1000
await nextTick()
await vi.advanceTimersByTimeAsync(2000)
expect(loader).toHaveBeenCalledTimes(3)
scope.stop()
await vi.advanceTimersByTimeAsync(5000)
expect(loader).toHaveBeenCalledTimes(3)
})
it('切换项目忽略旧响应,卸载时不再排队请求', async () => {
vi.useFakeTimers()
const key = ref('first')
const finish = new Map<string, (value: string) => void>()
const loader = vi.fn<(id: string) => Promise<string>>(
(id: string) =>
new Promise<string>(resolve => {
finish.set(id, resolve)
})
)
const scope = effectScope()
const query = scope.run(() => usePolling(key, loader, 1000))!
key.value = 'second'
await nextTick()
finish.get('second')!('新项目')
await Promise.resolve()
finish.get('first')!('旧项目')
await Promise.resolve()
expect(query.data.value).toBe('新项目')
scope.stop()
await vi.advanceTimersByTimeAsync(10_000)
expect(loader).toHaveBeenCalledTimes(2)
})
it('失败时保留已有数据,并向页面显示错误', async () => {
const scope = effectScope()
const loader = vi
.fn<() => Promise<string>>()
.mockResolvedValueOnce('上次成功数据')
.mockRejectedValueOnce(new Error('连接已断开'))
const query = scope.run(() => usePolling(ref('p'), loader))!
await Promise.resolve()
await query.refresh()
expect(query.data.value).toBe('上次成功数据')
expect(query.error.value).toBe('连接已断开')
scope.stop()
})
})
+73
View File
@@ -0,0 +1,73 @@
import { effectScope, nextTick, ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useQuery } from '@/composables/useQuery'
afterEach(() => vi.useRealTimers())
/** 测试中尚未完成的查询请求。 */
interface PendingRequest {
/** 完成当前查询。 */
resolve: (value: string) => void
/** 当前查询的取消信号。 */
signal: AbortSignal
}
describe('异步查询生命周期', () => {
it('页面停留期间不重复请求,仍支持首次读取、手动刷新及查询目标切换', async () => {
vi.useFakeTimers()
const key = ref('first')
const loader = vi.fn<(id: string) => Promise<string>>(async id => id)
const scope = effectScope()
const query = scope.run(() => useQuery(key, loader))!
await Promise.resolve()
expect(query.data.value).toBe('first')
await vi.advanceTimersByTimeAsync(30_000)
expect(loader).toHaveBeenCalledTimes(1)
await query.refresh()
expect(loader).toHaveBeenCalledTimes(2)
key.value = 'second'
await nextTick()
await Promise.resolve()
expect(query.data.value).toBe('second')
await vi.advanceTimersByTimeAsync(30_000)
expect(loader).toHaveBeenCalledTimes(3)
scope.stop()
})
it('切换目标取消旧请求并忽略迟到响应', async () => {
const key = ref('first')
const requests = new Map<string, PendingRequest>()
const loader = vi.fn<(id: string, signal: AbortSignal) => Promise<string>>(
(id, signal) =>
new Promise<string>(resolve => {
requests.set(id, { resolve, signal })
})
)
const scope = effectScope()
const query = scope.run(() => useQuery(key, loader))!
key.value = 'second'
await nextTick()
expect(requests.get('first')!.signal.aborted).toBe(true)
requests.get('second')!.resolve('新项目')
await Promise.resolve()
requests.get('first')!.resolve('旧项目')
await Promise.resolve()
expect(query.data.value).toBe('新项目')
scope.stop()
expect(requests.get('second')!.signal.aborted).toBe(true)
})
it('失败时保留已有数据,并向页面显示错误', async () => {
const scope = effectScope()
const loader = vi
.fn<() => Promise<string>>()
.mockResolvedValueOnce('上次成功数据')
.mockRejectedValueOnce(new Error('连接已断开'))
const query = scope.run(() => useQuery(ref('p'), loader))!
await Promise.resolve()
await query.refresh()
expect(query.data.value).toBe('上次成功数据')
expect(query.error.value).toBe('连接已断开')
scope.stop()
})
})
@@ -99,7 +99,7 @@ function checkpoint(): Checkpoint {
}
}
/** 提供响应式 checkpoint,模拟轮询更新但不连接后端或启动模型任务。 */
/** 提供响应式 checkpoint,模拟手动刷新但不连接后端或启动模型任务。 */
function mountPage(records = [checkpoint()], episodes = 1): ReturnType<typeof useProjectData> {
const project: ProjectDetail = {
id: 'breakdown-scroll-test',
@@ -341,7 +341,7 @@ describe('拆解页内容滚动', () => {
expect(toolbar.text()).toContain('第二集')
expect(toolbar.get('.breakdown-storyboard-summary').text()).toContain('2 个节拍 · 2 个镜头')
expect(wrapper!.findAll('.beat-section')).toHaveLength(2)
// 轮询删除当前选项时,输入框与正文一起回到仍存在的第一集。
// 刷新删除当前选项时,输入框与正文一起回到仍存在的第一集。
provided.data.value!.checkpoints = [checkpoint()]
await flushPromises()
expect(select.props('value')).toBe(1)
+11 -22
View File
@@ -66,21 +66,14 @@ async function openProject(initialPath: string) {
}
describe('剧本完成前的下游访问限制', () => {
it('进入图库暂停父级轮询,返回其他工作区恢复定时更新', async () => {
it('项目工作区停留期间不重复读取项目详情和工作流记录', async () => {
vi.useFakeTimers()
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('manual-gallery', 'completed'))
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('manual-project', 'completed'))
const checkpoints = vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const { router } = await openProject('/projects/manual-gallery/production')
await router.push('/projects/manual-gallery/subject-images')
await flushPromises()
await openProject('/projects/manual-project/production')
await vi.advanceTimersByTimeAsync(30_000)
expect(detail).toHaveBeenCalledTimes(1)
expect(checkpoints).toHaveBeenCalledTimes(1)
await router.push('/projects/manual-gallery/production')
await flushPromises()
await vi.advanceTimersByTimeAsync(6000)
expect(detail).toHaveBeenCalledTimes(2)
expect(checkpoints).toHaveBeenCalledTimes(2)
})
it('图库直接链接只读取一次项目,未完成时可手动刷新解锁', async () => {
@@ -91,7 +84,7 @@ describe('剧本完成前的下游访问限制', () => {
await openProject('/projects/manual-gate/subject-images')
await vi.advanceTimersByTimeAsync(30_000)
expect(detail).toHaveBeenCalledTimes(1)
expect(wrapper!.get('.project-access-gate').text()).not.toContain('状态会自动更新')
expect(wrapper!.get('.project-access-gate').text()).toContain('请手动刷新项目状态')
status = 'completed'
const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新项目状态')!
await refresh.trigger('click')
@@ -123,24 +116,21 @@ describe('剧本完成前的下游访问限制', () => {
}
)
it('轮询完成状态自动解锁;重新变为待审核时撤下下游面板', async () => {
it('未完成项目停留期间不自动解锁,仅在手动刷新后进入下游页面', async () => {
vi.useFakeTimers()
let status: ProjectStatus = 'generating'
vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status))
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const { mounted } = await openProject('/projects/polling/production')
const { mounted } = await openProject('/projects/manual-refresh/production')
expect(mounted).not.toHaveBeenCalled()
status = 'completed'
await vi.advanceTimersByTimeAsync(6000)
await flushPromises()
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
await wrapper!.get('.project-access-gate button:nth-of-type(2)').trigger('click')
await flushPromises()
expect(wrapper!.get('.workspace-probe').text()).toBe('production')
expect(wrapper!.findAll('.n-menu a')).toHaveLength(8)
status = 'need_review'
await vi.advanceTimersByTimeAsync(6000)
await flushPromises()
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
expect(wrapper!.get('.project-access-gate').text()).toContain('请先完成剧本创作')
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
})
@@ -168,7 +158,7 @@ describe('剧本完成前的下游访问限制', () => {
expect(wrapper!.findAll('.n-menu a')).toHaveLength(1)
})
it('读取失败保持锁定;错误区重试成功后解锁,页头不恢复标签与刷新按钮', async () => {
it('读取失败保持锁定;错误区重试成功后解锁且不恢复项目标题栏', async () => {
const detail = vi
.spyOn(projectsApi, 'detail')
.mockRejectedValueOnce(new Error('项目读取失败'))
@@ -177,8 +167,7 @@ describe('剧本完成前的下游访问限制', () => {
const { mounted } = await openProject('/projects/retry/storyboard')
expect(mounted).not.toHaveBeenCalled()
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
expect(wrapper!.find('.project-header .n-button').exists()).toBe(false)
expect(wrapper!.find('.project-header .n-tag').exists()).toBe(false)
expect(wrapper!.find('.project-header').exists()).toBe(false)
await wrapper!.get('.project-notices button').trigger('click')
await flushPromises()
expect(detail).toHaveBeenCalledTimes(2)
@@ -159,7 +159,7 @@ describe('身份图与母版契约', () => {
expect(wrapper.emitted('anchor')).toBeUndefined()
})
it('轮询新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => {
it('刷新新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => {
const anchor = identityImageFixture({ id: 'anchor' })
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
wrapper = mount(IdentityGallery, {
@@ -133,6 +133,15 @@ async function gallery(query = '') {
}
describe('过期首帧到具体素材定位', () => {
it('没有图片的形态卡片标记为方形占位', async () => {
const { first } = await gallery()
first.images = []
await wrapper!.get('[aria-label="刷新图库"]').trigger('click')
await flushPromises()
const card = wrapper!.findAll('.form-image-card').find(item => item.attributes('data-form-id') === first.id)!
expect(card.get('.asset-image').classes()).toContain('form-image-empty')
})
it('网格与瀑布流切换保留卡片、滚动容器和筛选,不发起额外请求', async () => {
const { fetcher } = await gallery()
const requestCount = fetcher.mock.calls.length