diff --git a/src/composables/usePolling.ts b/src/composables/usePolling.ts deleted file mode 100644 index f64229f..0000000 --- a/src/composables/usePolling.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue' -import { errorMessage } from '../lib/http' - -/** 串行轮询:请求完成后才计时;切换项目立即取消,过期响应不会覆盖新项目。 */ -export function usePolling( - key: Ref, - loader: (key: string, signal: AbortSignal) => Promise, - interval: MaybeRefOrGetter = 6000 -) { - const data = ref(null) as Ref - const loading = ref(false) - const error = ref('') - const updatedAt = ref('') - let generation = 0 - let controller: AbortController | undefined - let timer: ReturnType | 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 } -} diff --git a/src/composables/useQuery.ts b/src/composables/useQuery.ts new file mode 100644 index 0000000..47c41ec --- /dev/null +++ b/src/composables/useQuery.ts @@ -0,0 +1,51 @@ +import { onScopeDispose, ref, watch, type Ref } from 'vue' +import { errorMessage } from '../lib/http' + +/** 异步查询仅在首次加载、查询目标变化或显式刷新时请求,切换目标会取消过期响应。 */ +export function useQuery(key: Ref, loader: (key: string, signal: AbortSignal) => Promise) { + const data = ref(null) as Ref + 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 } +} diff --git a/src/features/generation-config/components/CreativeProfileSettings.vue b/src/features/generation-config/components/CreativeProfileSettings.vue index 519b4eb..20b406c 100644 --- a/src/features/generation-config/components/CreativeProfileSettings.vue +++ b/src/features/generation-config/components/CreativeProfileSettings.vue @@ -179,40 +179,61 @@ onScopeDispose(() => controller?.abort()) > 后端尚未启用完整的图片和视频模型,暂时无法保存项目生成配置。 -
- - - -
-
- +
+ +
+
+

项目画布

+

统一图片和视频的画面比例。

+
+ +
+ + +
+
+
+

图片生成

+

选择首帧图片模型并配置生成参数。

+
+ + + +
-
- - - + +
+
+
+

视频生成

+

选择成片模型并配置时长、画质等参数。

+
+ + + +
controller?.abort()) />
-
+ +
controller?.abort()) diff --git a/src/features/generation-config/components/GenerationOptionFields.vue b/src/features/generation-config/components/GenerationOptionFields.vue index 589e24b..7c600eb 100644 --- a/src/features/generation-config/components/GenerationOptionFields.vue +++ b/src/features/generation-config/components/GenerationOptionFields.vue @@ -75,7 +75,8 @@ function selectOptions(schema: GenerationOptionSchema) { diff --git a/src/features/projects/ProjectsPage.vue b/src/features/projects/ProjectsPage.vue index 348f990..c0aa8bf 100644 --- a/src/features/projects/ProjectsPage.vue +++ b/src/features/projects/ProjectsPage.vue @@ -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') /** 接口暂未分页:对完整查询结果分批展示,滚动时追加而非切换页码。 */ diff --git a/src/features/projects/context.ts b/src/features/projects/context.ts index 685f6f0..aed198f 100644 --- a/src/features/projects/context.ts +++ b/src/features/projects/context.ts @@ -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, interval: MaybeRefOrGetter = 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) { + 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(() => query.data.value?.project ?? null) const checkpoints = computed(() => query.data.value?.checkpoints ?? []) return { ...query, project, checkpoints } diff --git a/src/features/storyboard/useStoryboard.ts b/src/features/storyboard/useStoryboard.ts index 4839dd3..1bd06ef 100644 --- a/src/features/storyboard/useStoryboard.ts +++ b/src/features/storyboard/useStoryboard.ts @@ -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 快照。 */ diff --git a/src/features/subject-identity/SubjectIdentityPage.vue b/src/features/subject-identity/SubjectIdentityPage.vue index 537c109..7e62cf5 100644 --- a/src/features/subject-identity/SubjectIdentityPage.vue +++ b/src/features/subject-identity/SubjectIdentityPage.vue @@ -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 ) diff --git a/src/features/subject-identity/components/IdentityEditor.vue b/src/features/subject-identity/components/IdentityEditor.vue index f083926..860e369 100644 --- a/src/features/subject-identity/components/IdentityEditor.vue +++ b/src/features/subject-identity/components/IdentityEditor.vue @@ -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 }) diff --git a/src/features/subject-identity/components/IdentityThumbnail.vue b/src/features/subject-identity/components/IdentityThumbnail.vue index 8fb0a37..d9e794b 100644 --- a/src/features/subject-identity/components/IdentityThumbnail.vue +++ b/src/features/subject-identity/components/IdentityThumbnail.vue @@ -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) => { diff --git a/src/features/subject-identity/useSubjectIdentity.ts b/src/features/subject-identity/useSubjectIdentity.ts index 13bcda8..a450445 100644 --- a/src/features/subject-identity/useSubjectIdentity.ts +++ b/src/features/subject-identity/useSubjectIdentity.ts @@ -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(() => diff --git a/src/features/subject-images/SubjectImagesPage.vue b/src/features/subject-images/SubjectImagesPage.vue index 74d6032..40f7bd2 100644 --- a/src/features/subject-images/SubjectImagesPage.vue +++ b/src/features/subject-images/SubjectImagesPage.vue @@ -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; diff --git a/src/features/subject-images/components/ImageGalleryDialog.vue b/src/features/subject-images/components/ImageGalleryDialog.vue index 63dd3a1..1737208 100644 --- a/src/features/subject-images/components/ImageGalleryDialog.vue +++ b/src/features/subject-images/components/ImageGalleryDialog.vue @@ -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] diff --git a/src/features/subject-images/useKeyframeImpact.ts b/src/features/subject-images/useKeyframeImpact.ts index 609619b..d0b9809 100644 --- a/src/features/subject-images/useKeyframeImpact.ts +++ b/src/features/subject-images/useKeyframeImpact.ts @@ -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, sourceShotId: Ref) { - 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) ?? []) diff --git a/src/features/subject-images/useSubjectImages.ts b/src/features/subject-images/useSubjectImages.ts index d3c7245..3d9cdd5 100644 --- a/src/features/subject-images/useSubjectImages.ts +++ b/src/features/subject-images/useSubjectImages.ts @@ -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('') 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)) diff --git a/src/features/visual-style/VisualStylePage.vue b/src/features/visual-style/VisualStylePage.vue index 972fc77..ded53d2 100644 --- a/src/features/visual-style/VisualStylePage.vue +++ b/src/features/visual-style/VisualStylePage.vue @@ -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) diff --git a/src/features/visual-style/components/StyleEditor.vue b/src/features/visual-style/components/StyleEditor.vue index 63e7fff..1c9f953 100644 --- a/src/features/visual-style/components/StyleEditor.vue +++ b/src/features/visual-style/components/StyleEditor.vue @@ -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({ diff --git a/src/features/workflows/WorkflowDiagnosticsDialog.vue b/src/features/workflows/WorkflowDiagnosticsDialog.vue index fa8433d..4d35bca 100644 --- a/src/features/workflows/WorkflowDiagnosticsDialog.vue +++ b/src/features/workflows/WorkflowDiagnosticsDialog.vue @@ -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('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('') diff --git a/src/lib/document-title.ts b/src/lib/document-title.ts new file mode 100644 index 0000000..7193f69 --- /dev/null +++ b/src/lib/document-title.ts @@ -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(' · ') +} diff --git a/src/router/index.ts b/src/router/index.ts index 3a95a54..3b74ddc 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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) }) diff --git a/tests/components/ui/layout.test.ts b/tests/components/ui/layout.test.ts index 316a439..b4a36c1 100644 --- a/tests/components/ui/layout.test.ts +++ b/tests/components/ui/layout.test.ts @@ -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('确认按钮有稳定根元素承接调用方间距,生产按钮显式保留上下留白', () => { diff --git a/tests/composables/usePolling.test.ts b/tests/composables/usePolling.test.ts deleted file mode 100644 index 09aae0b..0000000 --- a/tests/composables/usePolling.test.ts +++ /dev/null @@ -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>(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(1000) - const loader = vi.fn<() => Promise>(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 void>() - const loader = vi.fn<(id: string) => Promise>( - (id: string) => - new Promise(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>() - .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() - }) -}) diff --git a/tests/composables/useQuery.test.ts b/tests/composables/useQuery.test.ts new file mode 100644 index 0000000..d916cf8 --- /dev/null +++ b/tests/composables/useQuery.test.ts @@ -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>(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() + const loader = vi.fn<(id: string, signal: AbortSignal) => Promise>( + (id, signal) => + new Promise(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>() + .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() + }) +}) diff --git a/tests/features/breakdown/BreakdownPage.test.ts b/tests/features/breakdown/BreakdownPage.test.ts index 8a0e077..76325c5 100644 --- a/tests/features/breakdown/BreakdownPage.test.ts +++ b/tests/features/breakdown/BreakdownPage.test.ts @@ -99,7 +99,7 @@ function checkpoint(): Checkpoint { } } -/** 提供响应式 checkpoint,模拟轮询更新但不连接后端或启动模型任务。 */ +/** 提供响应式 checkpoint,模拟手动刷新但不连接后端或启动模型任务。 */ function mountPage(records = [checkpoint()], episodes = 1): ReturnType { 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) diff --git a/tests/features/projects/access.test.ts b/tests/features/projects/access.test.ts index 04ce8b3..0130e7b 100644 --- a/tests/features/projects/access.test.ts +++ b/tests/features/projects/access.test.ts @@ -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) diff --git a/tests/features/subject-identity/identity.test.ts b/tests/features/subject-identity/identity.test.ts index 0974218..260b711 100644 --- a/tests/features/subject-identity/identity.test.ts +++ b/tests/features/subject-identity/identity.test.ts @@ -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, { diff --git a/tests/features/subject-images/asset-impact.test.ts b/tests/features/subject-images/asset-impact.test.ts index 8c8c7d4..557b834 100644 --- a/tests/features/subject-images/asset-impact.test.ts +++ b/tests/features/subject-images/asset-impact.test.ts @@ -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