fix: 合并图库吸顶区域并关闭自动刷新

This commit is contained in:
GouJ
2026-09-04 11:38:30 +08:00
parent acc0a1e9ad
commit bd66f7e719
16 changed files with 359 additions and 197 deletions
+23 -10
View File
@@ -1,16 +1,29 @@
import { computed, type Ref } from 'vue'
import { computed, type MaybeRefOrGetter, type Ref } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { storyboardApi } from '../storyboard/api'
/** 仅查询已由当前项目列表确认的镜头,切换目标自动取消旧响应。 */
export function useShotReferences(projectId: Ref<string>, shotId: Ref<string>) {
export function useShotReferences(
projectId: Ref<string>,
shotId: Ref<string>,
interval: MaybeRefOrGetter<number | false> = 6000
) {
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
})
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
)
}
+10 -2
View File
@@ -11,7 +11,12 @@ import { isProjectComplete, projectAccessKey, type ProjectAccess } from './acces
const route = useRoute()
const router = useRouter()
const id = computed(() => String(route.params.projectId))
const context = useProjectData(id)
// 图库使用手动刷新,父布局也停止轮询,避免浏览素材时被周期性更新打断。
const isGallery = computed(() => route.path.replace(/\/+$/, '').endsWith('/subject-images'))
const context = useProjectData(
id,
computed(() => (isGallery.value ? false : 6000))
)
provide(projectContextKey, context)
const operation = computed(() => getOperation(id.value))
const complete = computed(() => isProjectComplete(context.project.value, id.value))
@@ -60,9 +65,12 @@ onScopeDispose(() => {
v-else-if="context.project.value?.id === id"
class="project-access-gate"
title="请先完成剧本创作"
description="剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、分镜设计与镜头生产。状态会自动更新。"
:description="`剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、分镜设计与镜头生产。${isGallery ? '请手动刷新项目状态。' : '状态会自动更新。'}`"
>
<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
>
</EmptyState>
<NSpin
v-else-if="context.loading.value"
+34
View File
@@ -67,6 +67,40 @@ async function openProject(initialPath: string) {
}
describe('剧本完成前的下游访问限制', () => {
it('进入图库暂停父级轮询,返回其他工作区恢复定时更新', async () => {
vi.useFakeTimers()
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('manual-gallery', '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 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 () => {
vi.useFakeTimers()
let status: ProjectStatus = 'generating'
const detail = vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status))
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
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('状态会自动更新')
status = 'completed'
const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新项目状态')!
await refresh.trigger('click')
await flushPromises()
expect(detail).toHaveBeenCalledTimes(2)
expect(wrapper!.get('.workspace-probe').text()).toBe('subject-images')
})
it.each(['draft', 'generating', 'need_review', 'failed'] as const)(
'%s 不解锁导航,也不挂载直接链接对应的工作区',
async status => {
+13 -9
View File
@@ -1,4 +1,4 @@
import { computed, inject, type InjectionKey } from 'vue'
import { computed, inject, type InjectionKey, type MaybeRefOrGetter } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { projectsApi } from './api'
import type { ProjectDetail } from './types'
@@ -6,14 +6,18 @@ import type { Checkpoint } from '../workflows/types'
import type { Ref } from 'vue'
/** 同一项目的各个 graph 共用一份查询,避免每个面板重复请求。 */
export function useProjectData(id: Ref<string>) {
const query = usePolling(id, async (projectId, signal) => {
const [project, checkpoints] = await Promise.all([
projectsApi.detail(projectId, signal),
projectsApi.checkpoints(projectId, signal)
])
return { project, checkpoints }
})
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
)
const project = computed<ProjectDetail | null>(() => query.data.value?.project ?? null)
const checkpoints = computed<Checkpoint[]>(() => query.data.value?.checkpoints ?? [])
return { ...query, project, checkpoints }
+110 -108
View File
@@ -31,7 +31,7 @@ import FormPromptBatch from './components/FormPromptBatch.vue'
import { useKeyframeImpact } from './useKeyframeImpact'
import { queryText, referenceLocation, referenceTargets } from '../production/asset-links'
/** 形态图库以正式数据库为准,自动显示已在后端生成的图片。 */
/** 形态图库以正式数据库为准;首次读取、手动刷新和自身操作完成后更新,不定时刷新。 */
const route = useRoute()
const router = useRouter()
const {
@@ -39,6 +39,7 @@ const {
forms,
staleForms,
query,
refreshProject,
operation,
session,
concurrency,
@@ -205,7 +206,7 @@ function selectImpactShot(shotId: string | null) {
/** 同时刷新素材和镜头影响状态,查询仍不启动生成。 */
async function refreshAssets() {
await Promise.all([query.refresh(), impact.query.refresh(), impact.references.refresh()])
await Promise.all([refreshProject(), query.refresh(), impact.query.refresh(), impact.references.refresh()])
}
/** 全局过期筛选不沿用深链接的单个素材限制。 */
@@ -279,8 +280,7 @@ async function showStaleForms() {
</p>
</div>
<p class="mt-2 text-[11px] leading-6 text-muted">
6
秒更新图片记录主图供分镜引用候选图和历史失败记录均保留过期主图不会自动删除也不会被批量任务静默替换
图库不自动刷新可点击刷新图库读取最新记录主图供分镜引用候选图和历史失败记录均保留过期主图不会自动删除或替换
</p>
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-4"
><div class="flex flex-wrap items-center justify-between gap-3">
@@ -420,117 +420,119 @@ async function showStaleForms() {
>镜头影响检查失败暂时无法确认有无过期首帧{{ impact.query.error.value }}
<NButton text size="small" @click="impact.query.refresh">重试检查</NButton></NAlert
>
<div class="form-image-filter-region">
<div
class="form-image-toolbar"
:class="{
'has-impact-picker': impactOptions.length || sourceShotId,
'has-impact-actions': sourceShotId || targetFormId
}"
>
<div class="gallery-sticky-controls">
<div class="form-image-filter-region">
<div
v-if="impactOptions.length || sourceShotId"
class="asset-impact-picker"
role="group"
aria-label="镜头定位与导航"
class="form-image-toolbar"
:class="{
'has-impact-picker': impactOptions.length || sourceShotId,
'has-impact-actions': sourceShotId || targetFormId
}"
>
<NSelect
:value="sourceShot?.shotId ?? null"
:options="impactOptions"
placeholder="选择过期镜头,查看关联素材"
clearable
aria-label="选择关联镜头"
class="asset-impact-select"
@update:value="selectImpactShot"
/>
<RouterLink v-if="sourceLocation" :to="sourceLocation" custom v-slot="{ href, navigate }">
<NButton
tag="a"
:href="href"
@click="navigate"
class="icon-button"
:aria-label="`返回第 ${sourceShot?.episodeNo} · 镜头 ${sourceShot?.shotNo}`"
:title="`返回第 ${sourceShot?.episodeNo} 集 · 镜头 ${sourceShot?.shotNo}`"
>
<template #icon><ArrowLeft :size="16" /></template>
<span class="sr-only"
>返回第 {{ sourceShot?.episodeNo }} · 镜头 {{ sourceShot?.shotNo }}</span
>
</NButton>
</RouterLink>
<NButton
v-if="sourceShotId || targetFormId"
class="icon-button"
aria-label="查看全部素材"
title="查看全部素材(清除定位与筛选)"
@click="resetFilters"
<div
v-if="impactOptions.length || sourceShotId"
class="asset-impact-picker"
role="group"
aria-label="镜头定位与导航"
>
<template #icon><LayoutGrid :size="16" /></template>
</NButton>
</div>
<div class="form-image-filters" role="group" aria-label="形态图片筛选">
<NInput
placeholder="搜索主体、引用或形态"
:input-props="{ 'aria-label': '搜索形态图片' }"
v-model:value="search"
clearable
><template #prefix><Search :size="14" /></template
></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' }
]"
></NSelect>
<div class="filter-toggles">
<NCheckbox v-model:checked="onlyMissing">仅看缺少主图</NCheckbox>
<NCheckbox v-model:checked="onlyStale">仅看身份过期</NCheckbox>
<NSelect
:value="sourceShot?.shotId ?? null"
:options="impactOptions"
placeholder="选择过期镜头,查看关联素材"
clearable
aria-label="选择关联镜头"
class="asset-impact-select"
@update:value="selectImpactShot"
/>
<RouterLink v-if="sourceLocation" :to="sourceLocation" custom v-slot="{ href, navigate }">
<NButton
tag="a"
:href="href"
@click="navigate"
class="icon-button"
:aria-label="`返回第 ${sourceShot?.episodeNo} · 镜头 ${sourceShot?.shotNo}`"
:title="`返回第 ${sourceShot?.episodeNo} 集 · 镜头 ${sourceShot?.shotNo}`"
>
<template #icon><ArrowLeft :size="16" /></template>
<span class="sr-only"
>返回第 {{ sourceShot?.episodeNo }} · 镜头 {{ sourceShot?.shotNo }}</span
>
</NButton>
</RouterLink>
<NButton
v-if="sourceShotId || targetFormId"
class="icon-button"
aria-label="查看全部素材"
title="查看全部素材(清除定位与筛选)"
@click="resetFilters"
>
<template #icon><LayoutGrid :size="16" /></template>
</NButton>
</div>
<div class="form-image-filters" role="group" aria-label="形态图片筛选">
<NInput
placeholder="搜索主体、引用或形态"
:input-props="{ 'aria-label': '搜索形态图片' }"
v-model:value="search"
clearable
><template #prefix><Search :size="14" /></template
></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' }
]"
></NSelect>
<div class="filter-toggles">
<NCheckbox v-model:checked="onlyMissing">仅看缺少主图</NCheckbox>
<NCheckbox v-model:checked="onlyStale">仅看身份过期</NCheckbox>
</div>
<span class="filter-result-count">{{ filtered.length }} 个形态</span>
</div>
<span class="filter-result-count">{{ filtered.length }} 个形态</span>
</div>
</div>
</div>
<div v-if="showImpactDetails" class="asset-impact-context mt-3" aria-label="镜头与素材定位">
<p v-if="sourceMissing" class="text-danger">来源镜头不存在或不属于当前项目未读取其素材</p>
<p v-if="sourceCurrent" class="text-xs">
当前检查{{
sourceShot?.primaryKeyframeId ? '该镜头主首帧未被标记为过期' : '该镜头暂无主首帧'
}}请以后端最新就绪状态为准
</p>
<p v-if="sourceShot?.inconsistent" class="text-xs text-danger">
当前镜头首帧检查未标记过期但视频检查报告过期请核对后端检查逻辑与身份母版暂不建议重复生图
</p>
<p v-if="targetUnlinked" class="text-xs text-danger">
指定形态不在该镜头当前的有效引用列表中关联可能已调整或主图缺失请从下方关联素材重新核对
</p>
<NScrollbar
v-if="linkedReferences.length && !impact.references.error.value"
:key="sourceShotId"
class="asset-impact-links-scroll"
content-class="asset-impact-links"
x-scrollable
trigger="none"
role="region"
aria-label="本镜头关联素材可横向滚动"
>
<span class="text-xs text-muted">本镜头当前关联素材</span
><RouterLink
v-for="target in linkedReferences"
:key="target.formId || target.subjectRef"
:to="referenceLocation(id, sourceShotId, target)"
class="text-button"
>{{ target.label }} </RouterLink
<div v-if="showImpactDetails" class="asset-impact-context" aria-label="镜头与素材定位">
<p v-if="sourceMissing" class="text-danger">来源镜头不存在或不属于当前项目未读取其素材</p>
<p v-if="sourceCurrent" class="text-xs">
当前检查{{
sourceShot?.primaryKeyframeId ? '该镜头主首帧未被标记为过期' : '该镜头暂无主首帧'
}}请以后端最新就绪状态为准
</p>
<p v-if="sourceShot?.inconsistent" class="text-xs text-danger">
当前镜头首帧检查未标记过期但视频检查报告过期请核对后端检查逻辑与身份母版暂不建议重复生图
</p>
<p v-if="targetUnlinked" class="text-xs text-danger">
指定形态不在该镜头当前的有效引用列表中关联可能已调整或主图缺失请从下方关联素材重新核对
</p>
<NScrollbar
v-if="linkedReferences.length && !impact.references.error.value"
:key="sourceShotId"
class="asset-impact-links-scroll"
content-class="asset-impact-links"
x-scrollable
trigger="none"
role="region"
aria-label="本镜头关联素材可横向滚动"
>
</NScrollbar>
<p v-if="impact.references.loading.value" class="text-xs text-muted">正在读取镜头关联素材</p>
<p v-if="impact.references.error.value" class="text-xs text-danger">
{{ impact.references.error.value }}
<NButton text size="small" @click="impact.references.refresh">重试读取素材</NButton>
</p>
<span class="text-xs text-muted">本镜头当前关联素材</span
><RouterLink
v-for="target in linkedReferences"
:key="target.formId || target.subjectRef"
:to="referenceLocation(id, sourceShotId, target)"
class="text-button"
>{{ target.label }} </RouterLink
>
</NScrollbar>
<p v-if="impact.references.loading.value" class="text-xs text-muted">正在读取镜头关联素材</p>
<p v-if="impact.references.error.value" class="text-xs text-danger">
{{ impact.references.error.value }}
<NButton text size="small" @click="impact.references.refresh">重试读取素材</NButton>
</p>
</div>
</div>
<NAlert
v-if="targetFormId"
@@ -18,6 +18,7 @@ afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.useRealTimers()
vi.unstubAllGlobals()
})
@@ -132,14 +133,34 @@ async function gallery(query = '') {
}
describe('过期首帧到具体素材定位', () => {
it('图库与关联检查不定时刷新,手动刷新仍能更新数据', async () => {
vi.useFakeTimers()
const { fetcher } = await gallery('?sourceShotId=shot-target')
const count = fetcher.mock.calls.length
const grid = wrapper!.get('.form-image-grid').element
await vi.advanceTimersByTimeAsync(30_000)
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(count)
expect(wrapper!.get('.form-image-grid').element).toBe(grid)
const refresh = wrapper!.findAll('button').find(button => button.text().includes('刷新图库'))!
await refresh.trigger('click')
await flushPromises()
expect(fetcher.mock.calls.length).toBeGreaterThan(count)
const refreshed = fetcher.mock.calls.length
await vi.advanceTimersByTimeAsync(30_000)
expect(fetcher).toHaveBeenCalledTimes(refreshed)
})
it('切换镜头时筛选、关联说明和图片共用稳定纵向容器,长关联列表单独横向滚动', async () => {
const { keyframes, videos, references, fetcher } = await gallery('?sourceShotId=shot-target')
expect(wrapper!.find('.workspace-heading-scroll').exists()).toBe(false)
const scroll = wrapper!.get<HTMLElement>('.workspace-scroll > .n-scrollbar-container').element
const content = wrapper!.get('.workspace-scroll-content').element
const sticky = wrapper!.get('.gallery-sticky-controls').element
const toolbar = wrapper!.get('.form-image-filter-region').element
expect(toolbar.parentElement).toBe(content)
expect(wrapper!.get('.asset-impact-context').element.parentElement).toBe(content)
expect(sticky.parentElement).toBe(content)
expect(toolbar.parentElement).toBe(sticky)
expect(wrapper!.get('.asset-impact-context').element.parentElement).toBe(sticky)
expect(wrapper!.get('.form-image-grid').element.parentElement).toBe(content)
scroll.scrollTop = 480
keyframes.items.push({ ...keyframes.items[0]!, shotId: 'shot-next', shotNo: 2 })
@@ -184,7 +205,8 @@ describe('过期首帧到具体素材定位', () => {
expect(wrapper!.get('.workspace-scroll > .n-scrollbar-container').element).toBe(scroll)
expect(wrapper!.get('.form-image-filter-region').element).toBe(toolbar)
expect(scroll.scrollTop).toBe(480)
expect(wrapper!.get('.asset-impact-context').element.parentElement).toBe(content)
expect(wrapper!.get('.gallery-sticky-controls').element).toBe(sticky)
expect(wrapper!.get('.asset-impact-context').element.parentElement).toBe(sticky)
expect(wrapper!.get('.form-image-grid').element.parentElement).toBe(content)
const links = wrapper!.get('.asset-impact-links-scroll')
expect(links.findAll('a')).toHaveLength(24)
@@ -19,12 +19,16 @@ 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
})
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 images = computed(() => query.data.value ?? [])
const selected = computed(
() => images.value.find(image => image.id === selectedId.value) ?? primaryImage(images.value) ?? images.value[0]
@@ -19,6 +19,7 @@ afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.useRealTimers()
vi.unstubAllGlobals()
Object.assign(getOperation('gallery-test'), { pending: false, label: '', error: '', notice: '' })
})
@@ -134,6 +135,7 @@ describe('形态图片选择与操作', () => {
})
it('图库不自动生图,主图切换经确认后 PUT,失败图片不能设主图', async () => {
vi.useFakeTimers()
let primary = false
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
if (init?.method === 'PUT') {
@@ -162,6 +164,8 @@ describe('形态图片选择与操作', () => {
})
await flushPromises()
expect(fetcher.mock.calls).toHaveLength(1)
await vi.advanceTimersByTimeAsync(30_000)
expect(fetcher.mock.calls).toHaveLength(1)
await expandSections()
expect(document.body.textContent).toContain('<script>模型提示词</script>')
expect(document.querySelector('[role="dialog"] script')).toBeNull()
@@ -5,35 +5,41 @@ 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
}
})
}
})
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 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 ?? '')
computed(() => source.value?.shotId ?? ''),
false
)
const relatedForms = computed(
() => new Set(references.data.value?.references.map(item => item.subjectFormId) ?? [])
+26 -21
View File
@@ -17,28 +17,32 @@ 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 = 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)
)
)
)
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
}
})
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
)
const forms = computed(() => query.data.value ?? [])
const staleForms = computed(() => forms.value.filter(form => isPrimaryIdentityStale(form)))
const operation = computed(() => getOperation(id.value))
@@ -188,6 +192,7 @@ export function useSubjectImages() {
forms,
staleForms,
query,
refreshProject: context.refresh,
operation,
session,
concurrency,