From bd66f7e71962490e7430c5256d36c85d6fb2d491 Mon Sep 17 00:00:00 2001
From: GouJ
Date: Fri, 4 Sep 2026 11:38:30 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E5=90=88=E5=B9=B6=E5=9B=BE=E5=BA=93?=
=?UTF-8?q?=E5=90=B8=E9=A1=B6=E5=8C=BA=E5=9F=9F=E5=B9=B6=E5=85=B3=E9=97=AD?=
=?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 6 +-
src/App.vue | 2 +-
src/admin.css | 14 +-
src/components/ui/flatTheme.test.ts | 8 +-
src/composables/usePolling.test.ts | 41 ++++
src/composables/usePolling.ts | 17 +-
src/features/production/useShotReferences.ts | 33 ++-
src/features/projects/ProjectLayout.vue | 12 +-
src/features/projects/access.test.ts | 34 +++
src/features/projects/context.ts | 22 +-
.../subject-images/SubjectImagesPage.vue | 218 +++++++++---------
.../subject-images/asset-impact.test.ts | 28 ++-
.../components/ImageGalleryDialog.vue | 16 +-
src/features/subject-images/images.test.ts | 4 +
.../subject-images/useKeyframeImpact.ts | 54 +++--
.../subject-images/useSubjectImages.ts | 47 ++--
16 files changed, 359 insertions(+), 197 deletions(-)
diff --git a/README.md b/README.md
index 06de6d1..f788d4d 100644
--- a/README.md
+++ b/README.md
@@ -96,7 +96,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
- 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。
- 工作流只保留左侧导航,项目标题下不再重复展示相同标签。主体身份、分镜设计、镜头生产和图库采用内容优先布局:常用筛选/剧集选择/刷新在固定的紧凑工具栏,列表占据剩余高度。
-- 项目标题栏只显示返回入口和名称,不再放置状态标签与刷新按钮;项目仍每 6 秒自动刷新,读取失败时在错误提示内提供重试入口。
+- 项目标题栏只显示返回入口和名称,不再放置状态标签与刷新按钮;除形态图库使用手动刷新外,其他项目工作区每 6 秒自动刷新,读取失败时在错误提示内提供重试入口。
- 只有当前项目正式状态为 `completed` 才解锁下游工作区;草稿、生成中、待审核、失败和读取期间默认禁用下游导航。直接访问下游链接只显示返回剧本创作的引导,不挂载操作组件;轮询确认完成后自动解锁,切换项目立即重新判定。页面内提交守卫同步检查完成状态;这是前端交互限制,不代替后端权限与业务校验。
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
- “我的剧本”状态筛选采用中号控件,与搜索框等高并扩大点击区域;刷新、主题、折叠和关闭等单图标按钮统一使用直角正方形,加载时保持尺寸不变,保留可访问名称。
@@ -199,7 +199,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
1. `POST /projects` 返回顶层 `202 { projectId, status }`,其他接口通常返回 `{ data }`;HTTP 层分别兼容。
2. Breakdown、Storyboard 和恢复接口目前是**长 HTTP 请求**。不设客户端短超时,不自动重试 POST。关闭页面只会丢失请求连接,**不会取消后端任务**。
-3. 项目列表每 12 秒、项目详情每 6 秒串行刷新。隐藏页面暂停自动查询。切换项目或离开布局时取消查询,旧响应不会覆盖新项目。
+3. 项目列表每 12 秒、项目详情每 6 秒串行刷新。形态图库暂停父布局和页面定时查询,返回其他工作区恢复轮询;图库直接链接被未完成状态拦截时可手动刷新项目状态。隐藏页面暂停自动查询。切换项目或离开布局时取消查询,旧响应不会覆盖新项目。
4. 抽取任务进度不是全流程进度。抽取完成之后还需要主体合并、形态、节拍、镜头和持久化。
5. 后端失败 checkpoint 可能只有错误。页面回看最近可用的完整快照以保留成果,同时使用最新记录的 execution 状态。显示的是**最近可用快照**,并不保证全部成果来自最后一次执行。
6. 后端并不为每个阶段提供持久化的 running 状态。没有明确终态时,页面显示“阶段快照 · 执行状态待确认”,不会猜测成功或失败。
@@ -227,7 +227,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
4. 单图宽高同时留空使用后端默认 2K,自定义时必须同时提供正整数,具体尺寸限制仍由模型校验。当前 后端图片模型 Provider 没有使用 negativePrompt,所以页面不提供无效的负向提示词输入。
5. 单图生成新增历史记录,不删除旧图。无主图时默认勾选“生成成功后设为主图”;已有主图时默认不替换。候选图可以在历史弹窗二次确认后通过 PUT 设为主图,只允许已完成图片。
6. 批量默认跳过已有主图的形态;勾选“已有主图也新增候选图”发送 `force: true`。后端批量 force 只生成新图,**不会替换已有主图**。批量作用于整个项目,不受类型/文字/缺图筛选影响,也不接收单图的尺寸与自定义 Prompt。
-7. 图库每 6 秒读取一次真实记录,弹窗只在打开时查询该形态图片。数据库存在 pending / generating 时暂停再次生图;网络错误不自动重试有费用的请求。HTTP 200 中的批量部分失败单独展示。
+7. 图库、镜头影响检查、关联素材和图片历史均不定时刷新;首次进入、切换查询目标、手动刷新及本次写操作完成后按需读取。“刷新图库”同时更新项目状态。数据库存在 pending / generating 时暂停再次生图;网络错误不自动重试有费用的请求。HTTP 200 中的批量部分失败单独展示。当前形态列表接口返回完整数组,无分页参数或游标,因此不显示分页或触底加载入口。筛选栏与有值的关联素材行共用一个不透明背景的吸顶容器,内部无 margin 断层;关联素材保持单行横向滚动、上下居中,图片区域沿正文向下滚动。
8. 图片使用持久化后的 `/storage/...` 地址,开发代理已配置。生产环境也必须代理 `/storage/`,不能让 SPA 回退返回 index.html。无效 URL、加载失败、无图和后端生成失败有各自提示;不会自动使用可能过期的 Provider 临时地址。
9. 设为主图后,回到分镜点击“刷新参考图”读取最新绑定。已有视频提示词不会自动重生成,需要按需覆盖。本页面不会调用 `/production/start` 或实际视频生成。
10. 人物、场景、道具均仅继承已锁定身份的当前母版;无母版或未锁定不继承。刷新过期形态只生成候选,验图后手动切换主图。批量图片可限制本次数量,回执独立展示因上限未执行数,不把它算作已有结果的跳过数。
diff --git a/src/App.vue b/src/App.vue
index a04de8e..975d700 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -170,7 +170,7 @@ const menuOptions = computed(() => [
生产环境
为 /api、/storage 配置反向代理,并为长工作流设置足够的超时。
状态更新
- 项目详情每 6 秒刷新;关闭页面不会取消后端任务。
+ 形态图库使用手动刷新,其他项目工作区每 6 秒更新;关闭页面不会取消后端任务。
diff --git a/src/admin.css b/src/admin.css
index e1441eb..16d9351 100644
--- a/src/admin.css
+++ b/src/admin.css
@@ -458,13 +458,16 @@ body {
.gallery-workspace-page .workspace-scroll-content {
padding-top: 8px;
}
-/* 图库只使用正文纵向滚动;筛选栏在同一滚动上下文吸顶,不再被头部高度限制。 */
-.gallery-workspace-page .form-image-filter-region {
+/* 筛选与关联素材整体吸顶;间距由内部 padding 承担,避免出现透明断层。 */
+.gallery-workspace-page .gallery-sticky-controls {
position: sticky;
top: 0;
z-index: 10;
background: var(--app-subtle);
- margin-block: 10px 12px;
+ margin-block: 10px 0;
+}
+.gallery-sticky-controls > .form-image-filter-region {
+ margin-block: 0;
}
.gallery-workspace-page .workspace-scroll > .n-scrollbar-container {
overflow-anchor: none;
@@ -1511,7 +1514,7 @@ body {
gap: 10px;
padding: 14px 16px;
background: var(--app-subtle);
- margin-bottom: 12px;
+ margin: 0;
min-width: 0;
}
/* 关联素材始终单行横向浏览,数量和长名称不会撑高定位区或压缩图片区域。 */
@@ -1526,7 +1529,8 @@ body {
gap: 12px;
width: max-content;
white-space: nowrap;
- padding-bottom: 8px;
+ min-height: 32px;
+ padding-block: 6px;
}
.asset-impact-links > * {
flex-shrink: 0;
diff --git a/src/components/ui/flatTheme.test.ts b/src/components/ui/flatTheme.test.ts
index 978ec08..e9d9276 100644
--- a/src/components/ui/flatTheme.test.ts
+++ b/src/components/ui/flatTheme.test.ts
@@ -145,12 +145,16 @@ describe('全站平面主题', () => {
// 静态保护吸顶和横向尺寸规则;真实视口滚动与位置仍需浏览器验收。
const css = readFileSync('src/admin.css', 'utf8')
expect(css).toMatch(
- /\.gallery-workspace-page \.form-image-filter-region\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*z-index:\s*10;[^}]*background:\s*var\(--app-subtle\);/
+ /\.gallery-workspace-page \.gallery-sticky-controls\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*z-index:\s*10;[^}]*background:\s*var\(--app-subtle\);/
)
expect(css).toMatch(
/\.gallery-workspace-page \.workspace-scroll > \.n-scrollbar-container\s*\{[^}]*overflow-anchor:\s*none;/
)
expect(css).toMatch(/\.asset-impact-links\s*\{[^}]*width:\s*max-content;[^}]*white-space:\s*nowrap;/)
+ expect(css).toMatch(/\.gallery-sticky-controls > \.form-image-filter-region\s*\{[^}]*margin-block:\s*0;/)
+ expect(css).toMatch(
+ /\.asset-impact-links\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*32px;[^}]*padding-block:\s*6px;/
+ )
expect(css).toMatch(/\.asset-impact-links-scroll\.n-scrollbar\s*\{[^}]*height:\s*auto;[^}]*max-width:\s*100%;/)
expect(readFileSync('src/features/subject-images/SubjectImagesPage.vue', 'utf8')).toMatch(
/]*>\s*/
@@ -171,7 +175,7 @@ describe('全站平面主题', () => {
/@container \(max-width: 1200px\)\s*\{\s*\.form-image-toolbar\.has-impact-picker,\s*\.form-image-toolbar\.has-impact-picker\.has-impact-actions\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/
)
expect(css).toMatch(/\.asset-impact-picker\s*\{[^}]*align-items:\s*center;[^}]*gap:\s*8px;/)
- expect(css).toMatch(/\.asset-impact-context\s*\{[^}]*gap:\s*10px;[^}]*margin-bottom:\s*12px;/)
+ expect(css).toMatch(/\.asset-impact-context\s*\{[^}]*gap:\s*10px;[^}]*margin:\s*0;/)
})
it('单图标按钮以当前控件高度为边长,尺寸与内容按钮互不影响', () => {
diff --git a/src/composables/usePolling.test.ts b/src/composables/usePolling.test.ts
index a3a86a2..5582b11 100644
--- a/src/composables/usePolling.test.ts
+++ b/src/composables/usePolling.test.ts
@@ -5,6 +5,47 @@ import { usePolling } from './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')
diff --git a/src/composables/usePolling.ts b/src/composables/usePolling.ts
index c42003f..f64229f 100644
--- a/src/composables/usePolling.ts
+++ b/src/composables/usePolling.ts
@@ -1,11 +1,11 @@
-import { onScopeDispose, ref, watch, type Ref } from 'vue'
+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 = 6000
+ interval: MaybeRefOrGetter = 6000
) {
const data = ref(null) as Ref
const loading = ref(false)
@@ -18,10 +18,13 @@ export function usePolling(
/** 隐藏页面只安排下一次检查,不继续拉取完整 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()
- }, interval)
+ }, delay)
}
/** 手动刷新也取消上一次查询,避免同时存在多个轮询链。 */
@@ -49,6 +52,14 @@ export function usePolling(
}
}
+ // 页面可关闭定时刷新;首次读取、切换查询目标和手动刷新仍然有效。
+ watch(
+ () => toValue(interval),
+ () => {
+ clearTimeout(timer)
+ if (!loading.value) schedule()
+ }
+ )
watch(
key,
() => {
diff --git a/src/features/production/useShotReferences.ts b/src/features/production/useShotReferences.ts
index 783321b..32739be 100644
--- a/src/features/production/useShotReferences.ts
+++ b/src/features/production/useShotReferences.ts
@@ -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, shotId: Ref) {
+export function useShotReferences(
+ projectId: Ref,
+ shotId: Ref,
+ interval: MaybeRefOrGetter = 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
+ )
}
diff --git a/src/features/projects/ProjectLayout.vue b/src/features/projects/ProjectLayout.vue
index 550465c..099d418 100644
--- a/src/features/projects/ProjectLayout.vue
+++ b/src/features/projects/ProjectLayout.vue
@@ -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 ? '请手动刷新项目状态。' : '状态会自动更新。'}`"
>
返回剧本创作
+ 刷新项目状态
{
+ 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 => {
diff --git a/src/features/projects/context.ts b/src/features/projects/context.ts
index 4ed74ff..685f6f0 100644
--- a/src/features/projects/context.ts
+++ b/src/features/projects/context.ts
@@ -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) {
- 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, 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
+ )
const project = computed(() => query.data.value?.project ?? null)
const checkpoints = computed(() => query.data.value?.checkpoints ?? [])
return { ...query, project, checkpoints }
diff --git a/src/features/subject-images/SubjectImagesPage.vue b/src/features/subject-images/SubjectImagesPage.vue
index 81dccf4..8bcb42a 100644
--- a/src/features/subject-images/SubjectImagesPage.vue
+++ b/src/features/subject-images/SubjectImagesPage.vue
@@ -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() {
- 每 6
- 秒更新图片记录;主图供分镜引用,候选图和历史失败记录均保留。过期主图不会自动删除,也不会被批量任务静默替换。
+ 图库不自动刷新,可点击“刷新图库”读取最新记录。主图供分镜引用,候选图和历史失败记录均保留;过期主图不会自动删除或替换。
@@ -420,117 +420,119 @@ async function showStaleForms() {
>镜头影响检查失败,暂时无法确认有无过期首帧:{{ impact.query.error.value }}
重试检查
-