fix: 合并图库吸顶区域并关闭自动刷新
This commit is contained in:
@@ -96,7 +96,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
|
|
||||||
- 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。
|
- 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。
|
||||||
- 工作流只保留左侧导航,项目标题下不再重复展示相同标签。主体身份、分镜设计、镜头生产和图库采用内容优先布局:常用筛选/剧集选择/刷新在固定的紧凑工具栏,列表占据剩余高度。
|
- 工作流只保留左侧导航,项目标题下不再重复展示相同标签。主体身份、分镜设计、镜头生产和图库采用内容优先布局:常用筛选/剧集选择/刷新在固定的紧凑工具栏,列表占据剩余高度。
|
||||||
- 项目标题栏只显示返回入口和名称,不再放置状态标签与刷新按钮;项目仍每 6 秒自动刷新,读取失败时在错误提示内提供重试入口。
|
- 项目标题栏只显示返回入口和名称,不再放置状态标签与刷新按钮;除形态图库使用手动刷新外,其他项目工作区每 6 秒自动刷新,读取失败时在错误提示内提供重试入口。
|
||||||
- 只有当前项目正式状态为 `completed` 才解锁下游工作区;草稿、生成中、待审核、失败和读取期间默认禁用下游导航。直接访问下游链接只显示返回剧本创作的引导,不挂载操作组件;轮询确认完成后自动解锁,切换项目立即重新判定。页面内提交守卫同步检查完成状态;这是前端交互限制,不代替后端权限与业务校验。
|
- 只有当前项目正式状态为 `completed` 才解锁下游工作区;草稿、生成中、待审核、失败和读取期间默认禁用下游导航。直接访问下游链接只显示返回剧本创作的引导,不挂载操作组件;轮询确认完成后自动解锁,切换项目立即重新判定。页面内提交守卫同步检查完成状态;这是前端交互限制,不代替后端权限与业务校验。
|
||||||
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
||||||
- “我的剧本”状态筛选采用中号控件,与搜索框等高并扩大点击区域;刷新、主题、折叠和关闭等单图标按钮统一使用直角正方形,加载时保持尺寸不变,保留可访问名称。
|
- “我的剧本”状态筛选采用中号控件,与搜索框等高并扩大点击区域;刷新、主题、折叠和关闭等单图标按钮统一使用直角正方形,加载时保持尺寸不变,保留可访问名称。
|
||||||
@@ -199,7 +199,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
|
|||||||
|
|
||||||
1. `POST /projects` 返回顶层 `202 { projectId, status }`,其他接口通常返回 `{ data }`;HTTP 层分别兼容。
|
1. `POST /projects` 返回顶层 `202 { projectId, status }`,其他接口通常返回 `{ data }`;HTTP 层分别兼容。
|
||||||
2. Breakdown、Storyboard 和恢复接口目前是**长 HTTP 请求**。不设客户端短超时,不自动重试 POST。关闭页面只会丢失请求连接,**不会取消后端任务**。
|
2. Breakdown、Storyboard 和恢复接口目前是**长 HTTP 请求**。不设客户端短超时,不自动重试 POST。关闭页面只会丢失请求连接,**不会取消后端任务**。
|
||||||
3. 项目列表每 12 秒、项目详情每 6 秒串行刷新。隐藏页面暂停自动查询。切换项目或离开布局时取消查询,旧响应不会覆盖新项目。
|
3. 项目列表每 12 秒、项目详情每 6 秒串行刷新。形态图库暂停父布局和页面定时查询,返回其他工作区恢复轮询;图库直接链接被未完成状态拦截时可手动刷新项目状态。隐藏页面暂停自动查询。切换项目或离开布局时取消查询,旧响应不会覆盖新项目。
|
||||||
4. 抽取任务进度不是全流程进度。抽取完成之后还需要主体合并、形态、节拍、镜头和持久化。
|
4. 抽取任务进度不是全流程进度。抽取完成之后还需要主体合并、形态、节拍、镜头和持久化。
|
||||||
5. 后端失败 checkpoint 可能只有错误。页面回看最近可用的完整快照以保留成果,同时使用最新记录的 execution 状态。显示的是**最近可用快照**,并不保证全部成果来自最后一次执行。
|
5. 后端失败 checkpoint 可能只有错误。页面回看最近可用的完整快照以保留成果,同时使用最新记录的 execution 状态。显示的是**最近可用快照**,并不保证全部成果来自最后一次执行。
|
||||||
6. 后端并不为每个阶段提供持久化的 running 状态。没有明确终态时,页面显示“阶段快照 · 执行状态待确认”,不会猜测成功或失败。
|
6. 后端并不为每个阶段提供持久化的 running 状态。没有明确终态时,页面显示“阶段快照 · 执行状态待确认”,不会猜测成功或失败。
|
||||||
@@ -227,7 +227,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
|
|||||||
4. 单图宽高同时留空使用后端默认 2K,自定义时必须同时提供正整数,具体尺寸限制仍由模型校验。当前 后端图片模型 Provider 没有使用 negativePrompt,所以页面不提供无效的负向提示词输入。
|
4. 单图宽高同时留空使用后端默认 2K,自定义时必须同时提供正整数,具体尺寸限制仍由模型校验。当前 后端图片模型 Provider 没有使用 negativePrompt,所以页面不提供无效的负向提示词输入。
|
||||||
5. 单图生成新增历史记录,不删除旧图。无主图时默认勾选“生成成功后设为主图”;已有主图时默认不替换。候选图可以在历史弹窗二次确认后通过 PUT 设为主图,只允许已完成图片。
|
5. 单图生成新增历史记录,不删除旧图。无主图时默认勾选“生成成功后设为主图”;已有主图时默认不替换。候选图可以在历史弹窗二次确认后通过 PUT 设为主图,只允许已完成图片。
|
||||||
6. 批量默认跳过已有主图的形态;勾选“已有主图也新增候选图”发送 `force: true`。后端批量 force 只生成新图,**不会替换已有主图**。批量作用于整个项目,不受类型/文字/缺图筛选影响,也不接收单图的尺寸与自定义 Prompt。
|
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 临时地址。
|
8. 图片使用持久化后的 `/storage/...` 地址,开发代理已配置。生产环境也必须代理 `/storage/`,不能让 SPA 回退返回 index.html。无效 URL、加载失败、无图和后端生成失败有各自提示;不会自动使用可能过期的 Provider 临时地址。
|
||||||
9. 设为主图后,回到分镜点击“刷新参考图”读取最新绑定。已有视频提示词不会自动重生成,需要按需覆盖。本页面不会调用 `/production/start` 或实际视频生成。
|
9. 设为主图后,回到分镜点击“刷新参考图”读取最新绑定。已有视频提示词不会自动重生成,需要按需覆盖。本页面不会调用 `/production/start` 或实际视频生成。
|
||||||
10. 人物、场景、道具均仅继承已锁定身份的当前母版;无母版或未锁定不继承。刷新过期形态只生成候选,验图后手动切换主图。批量图片可限制本次数量,回执独立展示因上限未执行数,不把它算作已有结果的跳过数。
|
10. 人物、场景、道具均仅继承已锁定身份的当前母版;无母版或未锁定不继承。刷新过期形态只生成候选,验图后手动切换主图。批量图片可限制本次数量,回执独立展示因上限未执行数,不把它算作已有结果的跳过数。
|
||||||
|
|||||||
+1
-1
@@ -170,7 +170,7 @@ const menuOptions = computed<MenuOption[]>(() => [
|
|||||||
<dt>生产环境</dt>
|
<dt>生产环境</dt>
|
||||||
<dd>为 /api、/storage 配置反向代理,并为长工作流设置足够的超时。</dd>
|
<dd>为 /api、/storage 配置反向代理,并为长工作流设置足够的超时。</dd>
|
||||||
<dt>状态更新</dt>
|
<dt>状态更新</dt>
|
||||||
<dd>项目详情每 6 秒刷新;关闭页面不会取消后端任务。</dd>
|
<dd>形态图库使用手动刷新,其他项目工作区每 6 秒更新;关闭页面不会取消后端任务。</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</NConfigProvider>
|
</NConfigProvider>
|
||||||
|
|||||||
+9
-5
@@ -458,13 +458,16 @@ body {
|
|||||||
.gallery-workspace-page .workspace-scroll-content {
|
.gallery-workspace-page .workspace-scroll-content {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
/* 图库只使用正文纵向滚动;筛选栏在同一滚动上下文吸顶,不再被头部高度限制。 */
|
/* 筛选与关联素材整体吸顶;间距由内部 padding 承担,避免出现透明断层。 */
|
||||||
.gallery-workspace-page .form-image-filter-region {
|
.gallery-workspace-page .gallery-sticky-controls {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
background: var(--app-subtle);
|
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 {
|
.gallery-workspace-page .workspace-scroll > .n-scrollbar-container {
|
||||||
overflow-anchor: none;
|
overflow-anchor: none;
|
||||||
@@ -1511,7 +1514,7 @@ body {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
background: var(--app-subtle);
|
background: var(--app-subtle);
|
||||||
margin-bottom: 12px;
|
margin: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
/* 关联素材始终单行横向浏览,数量和长名称不会撑高定位区或压缩图片区域。 */
|
/* 关联素材始终单行横向浏览,数量和长名称不会撑高定位区或压缩图片区域。 */
|
||||||
@@ -1526,7 +1529,8 @@ body {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding-bottom: 8px;
|
min-height: 32px;
|
||||||
|
padding-block: 6px;
|
||||||
}
|
}
|
||||||
.asset-impact-links > * {
|
.asset-impact-links > * {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -145,12 +145,16 @@ describe('全站平面主题', () => {
|
|||||||
// 静态保护吸顶和横向尺寸规则;真实视口滚动与位置仍需浏览器验收。
|
// 静态保护吸顶和横向尺寸规则;真实视口滚动与位置仍需浏览器验收。
|
||||||
const css = readFileSync('src/admin.css', 'utf8')
|
const css = readFileSync('src/admin.css', 'utf8')
|
||||||
expect(css).toMatch(
|
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(
|
expect(css).toMatch(
|
||||||
/\.gallery-workspace-page \.workspace-scroll > \.n-scrollbar-container\s*\{[^}]*overflow-anchor:\s*none;/
|
/\.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(/\.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(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(
|
expect(readFileSync('src/features/subject-images/SubjectImagesPage.vue', 'utf8')).toMatch(
|
||||||
/<WorkspacePage\b[^>]*>\s*<template #default>/
|
/<WorkspacePage\b[^>]*>\s*<template #default>/
|
||||||
@@ -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\);/
|
/@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-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('单图标按钮以当前控件高度为边长,尺寸与内容按钮互不影响', () => {
|
it('单图标按钮以当前控件高度为边长,尺寸与内容按钮互不影响', () => {
|
||||||
|
|||||||
@@ -5,6 +5,47 @@ import { usePolling } from './usePolling'
|
|||||||
afterEach(() => vi.useRealTimers())
|
afterEach(() => vi.useRealTimers())
|
||||||
|
|
||||||
describe('异步轮询生命周期', () => {
|
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 () => {
|
it('切换项目忽略旧响应,卸载时不再排队请求', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
const key = ref('first')
|
const key = ref('first')
|
||||||
|
|||||||
@@ -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'
|
import { errorMessage } from '../lib/http'
|
||||||
|
|
||||||
/** 串行轮询:请求完成后才计时;切换项目立即取消,过期响应不会覆盖新项目。 */
|
/** 串行轮询:请求完成后才计时;切换项目立即取消,过期响应不会覆盖新项目。 */
|
||||||
export function usePolling<T>(
|
export function usePolling<T>(
|
||||||
key: Ref<string>,
|
key: Ref<string>,
|
||||||
loader: (key: string, signal: AbortSignal) => Promise<T>,
|
loader: (key: string, signal: AbortSignal) => Promise<T>,
|
||||||
interval = 6000
|
interval: MaybeRefOrGetter<number | false> = 6000
|
||||||
) {
|
) {
|
||||||
const data = ref<T | null>(null) as Ref<T | null>
|
const data = ref<T | null>(null) as Ref<T | null>
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -18,10 +18,13 @@ export function usePolling<T>(
|
|||||||
|
|
||||||
/** 隐藏页面只安排下一次检查,不继续拉取完整 checkpoint。 */
|
/** 隐藏页面只安排下一次检查,不继续拉取完整 checkpoint。 */
|
||||||
function schedule() {
|
function schedule() {
|
||||||
|
const delay = toValue(interval)
|
||||||
|
if (disposed || delay === false) return
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
|
if (toValue(interval) === false) return
|
||||||
if (document.visibilityState === 'hidden') schedule()
|
if (document.visibilityState === 'hidden') schedule()
|
||||||
else void refresh()
|
else void refresh()
|
||||||
}, interval)
|
}, delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 手动刷新也取消上一次查询,避免同时存在多个轮询链。 */
|
/** 手动刷新也取消上一次查询,避免同时存在多个轮询链。 */
|
||||||
@@ -49,6 +52,14 @@ export function usePolling<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 页面可关闭定时刷新;首次读取、切换查询目标和手动刷新仍然有效。
|
||||||
|
watch(
|
||||||
|
() => toValue(interval),
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (!loading.value) schedule()
|
||||||
|
}
|
||||||
|
)
|
||||||
watch(
|
watch(
|
||||||
key,
|
key,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
import { computed, type Ref } from 'vue'
|
import { computed, type MaybeRefOrGetter, type Ref } from 'vue'
|
||||||
import { usePolling } from '../../composables/usePolling'
|
import { usePolling } from '../../composables/usePolling'
|
||||||
import { storyboardApi } from '../storyboard/api'
|
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]))
|
const key = computed(() => JSON.stringify([projectId.value, shotId.value]))
|
||||||
return usePolling(key, async (value, signal) => {
|
return usePolling(
|
||||||
const [project, shot] = JSON.parse(value) as [string, string]
|
key,
|
||||||
if (!project || !shot) return null
|
async (value, signal) => {
|
||||||
const result = await storyboardApi.references(shot, signal)
|
const [project, shot] = JSON.parse(value) as [string, string]
|
||||||
if (!result || result.shotId !== shot || !Array.isArray(result.references) || !Array.isArray(result.missing))
|
if (!project || !shot) return null
|
||||||
throw new Error('镜头参考素材返回不匹配,请刷新后重试。')
|
const result = await storyboardApi.references(shot, signal)
|
||||||
return result
|
if (
|
||||||
})
|
!result ||
|
||||||
|
result.shotId !== shot ||
|
||||||
|
!Array.isArray(result.references) ||
|
||||||
|
!Array.isArray(result.missing)
|
||||||
|
)
|
||||||
|
throw new Error('镜头参考素材返回不匹配,请刷新后重试。')
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
interval
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import { isProjectComplete, projectAccessKey, type ProjectAccess } from './acces
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const id = computed(() => String(route.params.projectId))
|
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)
|
provide(projectContextKey, context)
|
||||||
const operation = computed(() => getOperation(id.value))
|
const operation = computed(() => getOperation(id.value))
|
||||||
const complete = computed(() => isProjectComplete(context.project.value, id.value))
|
const complete = computed(() => isProjectComplete(context.project.value, id.value))
|
||||||
@@ -60,9 +65,12 @@ onScopeDispose(() => {
|
|||||||
v-else-if="context.project.value?.id === id"
|
v-else-if="context.project.value?.id === id"
|
||||||
class="project-access-gate"
|
class="project-access-gate"
|
||||||
title="请先完成剧本创作"
|
title="请先完成剧本创作"
|
||||||
description="剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、分镜设计与镜头生产。状态会自动更新。"
|
:description="`剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、分镜设计与镜头生产。${isGallery ? '请手动刷新项目状态。' : '状态会自动更新。'}`"
|
||||||
>
|
>
|
||||||
<NButton type="primary" @click="router.push(`/projects/${id}/create-drama`)">返回剧本创作</NButton>
|
<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>
|
</EmptyState>
|
||||||
<NSpin
|
<NSpin
|
||||||
v-else-if="context.loading.value"
|
v-else-if="context.loading.value"
|
||||||
|
|||||||
@@ -67,6 +67,40 @@ async function openProject(initialPath: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('剧本完成前的下游访问限制', () => {
|
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)(
|
it.each(['draft', 'generating', 'need_review', 'failed'] as const)(
|
||||||
'%s 不解锁导航,也不挂载直接链接对应的工作区',
|
'%s 不解锁导航,也不挂载直接链接对应的工作区',
|
||||||
async status => {
|
async status => {
|
||||||
|
|||||||
@@ -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 { usePolling } from '../../composables/usePolling'
|
||||||
import { projectsApi } from './api'
|
import { projectsApi } from './api'
|
||||||
import type { ProjectDetail } from './types'
|
import type { ProjectDetail } from './types'
|
||||||
@@ -6,14 +6,18 @@ import type { Checkpoint } from '../workflows/types'
|
|||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
/** 同一项目的各个 graph 共用一份查询,避免每个面板重复请求。 */
|
/** 同一项目的各个 graph 共用一份查询,避免每个面板重复请求。 */
|
||||||
export function useProjectData(id: Ref<string>) {
|
export function useProjectData(id: Ref<string>, interval: MaybeRefOrGetter<number | false> = 6000) {
|
||||||
const query = usePolling(id, async (projectId, signal) => {
|
const query = usePolling(
|
||||||
const [project, checkpoints] = await Promise.all([
|
id,
|
||||||
projectsApi.detail(projectId, signal),
|
async (projectId, signal) => {
|
||||||
projectsApi.checkpoints(projectId, signal)
|
const [project, checkpoints] = await Promise.all([
|
||||||
])
|
projectsApi.detail(projectId, signal),
|
||||||
return { project, checkpoints }
|
projectsApi.checkpoints(projectId, signal)
|
||||||
})
|
])
|
||||||
|
return { project, checkpoints }
|
||||||
|
},
|
||||||
|
interval
|
||||||
|
)
|
||||||
const project = computed<ProjectDetail | null>(() => query.data.value?.project ?? null)
|
const project = computed<ProjectDetail | null>(() => query.data.value?.project ?? null)
|
||||||
const checkpoints = computed<Checkpoint[]>(() => query.data.value?.checkpoints ?? [])
|
const checkpoints = computed<Checkpoint[]>(() => query.data.value?.checkpoints ?? [])
|
||||||
return { ...query, project, checkpoints }
|
return { ...query, project, checkpoints }
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import FormPromptBatch from './components/FormPromptBatch.vue'
|
|||||||
import { useKeyframeImpact } from './useKeyframeImpact'
|
import { useKeyframeImpact } from './useKeyframeImpact'
|
||||||
import { queryText, referenceLocation, referenceTargets } from '../production/asset-links'
|
import { queryText, referenceLocation, referenceTargets } from '../production/asset-links'
|
||||||
|
|
||||||
/** 形态图库以正式数据库为准,自动显示已在后端生成的图片。 */
|
/** 形态图库以正式数据库为准;首次读取、手动刷新和自身操作完成后更新,不定时刷新。 */
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const {
|
const {
|
||||||
@@ -39,6 +39,7 @@ const {
|
|||||||
forms,
|
forms,
|
||||||
staleForms,
|
staleForms,
|
||||||
query,
|
query,
|
||||||
|
refreshProject,
|
||||||
operation,
|
operation,
|
||||||
session,
|
session,
|
||||||
concurrency,
|
concurrency,
|
||||||
@@ -205,7 +206,7 @@ function selectImpactShot(shotId: string | null) {
|
|||||||
|
|
||||||
/** 同时刷新素材和镜头影响状态,查询仍不启动生成。 */
|
/** 同时刷新素材和镜头影响状态,查询仍不启动生成。 */
|
||||||
async function refreshAssets() {
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-[11px] leading-6 text-muted">
|
<p class="mt-2 text-[11px] leading-6 text-muted">
|
||||||
每 6
|
图库不自动刷新,可点击“刷新图库”读取最新记录。主图供分镜引用,候选图和历史失败记录均保留;过期主图不会自动删除或替换。
|
||||||
秒更新图片记录;主图供分镜引用,候选图和历史失败记录均保留。过期主图不会自动删除,也不会被批量任务静默替换。
|
|
||||||
</p>
|
</p>
|
||||||
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-4"
|
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-4"
|
||||||
><div class="flex flex-wrap items-center justify-between gap-3">
|
><div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -420,117 +420,119 @@ async function showStaleForms() {
|
|||||||
>镜头影响检查失败,暂时无法确认有无过期首帧:{{ impact.query.error.value }}
|
>镜头影响检查失败,暂时无法确认有无过期首帧:{{ impact.query.error.value }}
|
||||||
<NButton text size="small" @click="impact.query.refresh">重试检查</NButton></NAlert
|
<NButton text size="small" @click="impact.query.refresh">重试检查</NButton></NAlert
|
||||||
>
|
>
|
||||||
<div class="form-image-filter-region">
|
<div class="gallery-sticky-controls">
|
||||||
<div
|
<div class="form-image-filter-region">
|
||||||
class="form-image-toolbar"
|
|
||||||
:class="{
|
|
||||||
'has-impact-picker': impactOptions.length || sourceShotId,
|
|
||||||
'has-impact-actions': sourceShotId || targetFormId
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-if="impactOptions.length || sourceShotId"
|
class="form-image-toolbar"
|
||||||
class="asset-impact-picker"
|
:class="{
|
||||||
role="group"
|
'has-impact-picker': impactOptions.length || sourceShotId,
|
||||||
aria-label="镜头定位与导航"
|
'has-impact-actions': sourceShotId || targetFormId
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<NSelect
|
<div
|
||||||
:value="sourceShot?.shotId ?? null"
|
v-if="impactOptions.length || sourceShotId"
|
||||||
:options="impactOptions"
|
class="asset-impact-picker"
|
||||||
placeholder="选择过期镜头,查看关联素材"
|
role="group"
|
||||||
clearable
|
aria-label="镜头定位与导航"
|
||||||
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>
|
<NSelect
|
||||||
</NButton>
|
:value="sourceShot?.shotId ?? null"
|
||||||
</div>
|
:options="impactOptions"
|
||||||
<div class="form-image-filters" role="group" aria-label="形态图片筛选">
|
placeholder="选择过期镜头,查看关联素材"
|
||||||
<NInput
|
clearable
|
||||||
placeholder="搜索主体、引用或形态"
|
aria-label="选择关联镜头"
|
||||||
:input-props="{ 'aria-label': '搜索形态图片' }"
|
class="asset-impact-select"
|
||||||
v-model:value="search"
|
@update:value="selectImpactShot"
|
||||||
clearable
|
/>
|
||||||
><template #prefix><Search :size="14" /></template
|
<RouterLink v-if="sourceLocation" :to="sourceLocation" custom v-slot="{ href, navigate }">
|
||||||
></NInput>
|
<NButton
|
||||||
<NSelect
|
tag="a"
|
||||||
aria-label="筛选主体类型"
|
:href="href"
|
||||||
v-model:value="module"
|
@click="navigate"
|
||||||
:options="[
|
class="icon-button"
|
||||||
{ label: String('全部类型'), value: 'all' },
|
:aria-label="`返回第 ${sourceShot?.episodeNo} 集 · 镜头 ${sourceShot?.shotNo}`"
|
||||||
{ label: String('人物'), value: 'character' },
|
:title="`返回第 ${sourceShot?.episodeNo} 集 · 镜头 ${sourceShot?.shotNo}`"
|
||||||
{ label: String('场景'), value: 'scene' },
|
>
|
||||||
{ label: String('道具'), value: 'prop' }
|
<template #icon><ArrowLeft :size="16" /></template>
|
||||||
]"
|
<span class="sr-only"
|
||||||
></NSelect>
|
>返回第 {{ sourceShot?.episodeNo }} 集 · 镜头 {{ sourceShot?.shotNo }}</span
|
||||||
<div class="filter-toggles">
|
>
|
||||||
<NCheckbox v-model:checked="onlyMissing">仅看缺少主图</NCheckbox>
|
</NButton>
|
||||||
<NCheckbox v-model:checked="onlyStale">仅看身份过期</NCheckbox>
|
</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>
|
</div>
|
||||||
<span class="filter-result-count">{{ filtered.length }} 个形态</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-if="showImpactDetails" class="asset-impact-context" aria-label="镜头与素材定位">
|
||||||
<div v-if="showImpactDetails" class="asset-impact-context mt-3" aria-label="镜头与素材定位">
|
<p v-if="sourceMissing" class="text-danger">来源镜头不存在或不属于当前项目,未读取其素材。</p>
|
||||||
<p v-if="sourceMissing" class="text-danger">来源镜头不存在或不属于当前项目,未读取其素材。</p>
|
<p v-if="sourceCurrent" class="text-xs">
|
||||||
<p v-if="sourceCurrent" class="text-xs">
|
当前检查:{{
|
||||||
当前检查:{{
|
sourceShot?.primaryKeyframeId ? '该镜头主首帧未被标记为过期' : '该镜头暂无主首帧'
|
||||||
sourceShot?.primaryKeyframeId ? '该镜头主首帧未被标记为过期' : '该镜头暂无主首帧'
|
}}。请以后端最新就绪状态为准。
|
||||||
}}。请以后端最新就绪状态为准。
|
</p>
|
||||||
</p>
|
<p v-if="sourceShot?.inconsistent" class="text-xs text-danger">
|
||||||
<p v-if="sourceShot?.inconsistent" class="text-xs text-danger">
|
当前镜头:首帧检查未标记过期,但视频检查报告过期。请核对后端检查逻辑与身份母版,暂不建议重复生图。
|
||||||
当前镜头:首帧检查未标记过期,但视频检查报告过期。请核对后端检查逻辑与身份母版,暂不建议重复生图。
|
</p>
|
||||||
</p>
|
<p v-if="targetUnlinked" class="text-xs text-danger">
|
||||||
<p v-if="targetUnlinked" class="text-xs text-danger">
|
指定形态不在该镜头当前的有效引用列表中,关联可能已调整或主图缺失,请从下方关联素材重新核对。
|
||||||
指定形态不在该镜头当前的有效引用列表中,关联可能已调整或主图缺失,请从下方关联素材重新核对。
|
</p>
|
||||||
</p>
|
<NScrollbar
|
||||||
<NScrollbar
|
v-if="linkedReferences.length && !impact.references.error.value"
|
||||||
v-if="linkedReferences.length && !impact.references.error.value"
|
:key="sourceShotId"
|
||||||
:key="sourceShotId"
|
class="asset-impact-links-scroll"
|
||||||
class="asset-impact-links-scroll"
|
content-class="asset-impact-links"
|
||||||
content-class="asset-impact-links"
|
x-scrollable
|
||||||
x-scrollable
|
trigger="none"
|
||||||
trigger="none"
|
role="region"
|
||||||
role="region"
|
aria-label="本镜头关联素材,可横向滚动"
|
||||||
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
|
|
||||||
>
|
>
|
||||||
</NScrollbar>
|
<span class="text-xs text-muted">本镜头当前关联素材:</span
|
||||||
<p v-if="impact.references.loading.value" class="text-xs text-muted">正在读取镜头关联素材…</p>
|
><RouterLink
|
||||||
<p v-if="impact.references.error.value" class="text-xs text-danger">
|
v-for="target in linkedReferences"
|
||||||
{{ impact.references.error.value }}
|
:key="target.formId || target.subjectRef"
|
||||||
<NButton text size="small" @click="impact.references.refresh">重试读取素材</NButton>
|
:to="referenceLocation(id, sourceShotId, target)"
|
||||||
</p>
|
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>
|
</div>
|
||||||
<NAlert
|
<NAlert
|
||||||
v-if="targetFormId"
|
v-if="targetFormId"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ afterEach(() => {
|
|||||||
wrapper?.unmount()
|
wrapper?.unmount()
|
||||||
wrapper = undefined
|
wrapper = undefined
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
|
vi.useRealTimers()
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -132,14 +133,34 @@ async function gallery(query = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('过期首帧到具体素材定位', () => {
|
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 () => {
|
it('切换镜头时筛选、关联说明和图片共用稳定纵向容器,长关联列表单独横向滚动', async () => {
|
||||||
const { keyframes, videos, references, fetcher } = await gallery('?sourceShotId=shot-target')
|
const { keyframes, videos, references, fetcher } = await gallery('?sourceShotId=shot-target')
|
||||||
expect(wrapper!.find('.workspace-heading-scroll').exists()).toBe(false)
|
expect(wrapper!.find('.workspace-heading-scroll').exists()).toBe(false)
|
||||||
const scroll = wrapper!.get<HTMLElement>('.workspace-scroll > .n-scrollbar-container').element
|
const scroll = wrapper!.get<HTMLElement>('.workspace-scroll > .n-scrollbar-container').element
|
||||||
const content = wrapper!.get('.workspace-scroll-content').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
|
const toolbar = wrapper!.get('.form-image-filter-region').element
|
||||||
expect(toolbar.parentElement).toBe(content)
|
expect(sticky.parentElement).toBe(content)
|
||||||
expect(wrapper!.get('.asset-impact-context').element.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)
|
expect(wrapper!.get('.form-image-grid').element.parentElement).toBe(content)
|
||||||
scroll.scrollTop = 480
|
scroll.scrollTop = 480
|
||||||
keyframes.items.push({ ...keyframes.items[0]!, shotId: 'shot-next', shotNo: 2 })
|
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('.workspace-scroll > .n-scrollbar-container').element).toBe(scroll)
|
||||||
expect(wrapper!.get('.form-image-filter-region').element).toBe(toolbar)
|
expect(wrapper!.get('.form-image-filter-region').element).toBe(toolbar)
|
||||||
expect(scroll.scrollTop).toBe(480)
|
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)
|
expect(wrapper!.get('.form-image-grid').element.parentElement).toBe(content)
|
||||||
const links = wrapper!.get('.asset-impact-links-scroll')
|
const links = wrapper!.get('.asset-impact-links-scroll')
|
||||||
expect(links.findAll('a')).toHaveLength(24)
|
expect(links.findAll('a')).toHaveLength(24)
|
||||||
|
|||||||
@@ -19,12 +19,16 @@ const emit = defineEmits<{ changed: [] }>()
|
|||||||
const selectedId = ref('')
|
const selectedId = ref('')
|
||||||
const confirming = ref(false)
|
const confirming = ref(false)
|
||||||
const key = computed(() => (open.value ? (props.form?.id ?? '') : ''))
|
const key = computed(() => (open.value ? (props.form?.id ?? '') : ''))
|
||||||
const query = usePolling(key, async (id, signal) => {
|
const query = usePolling(
|
||||||
if (!id) return null
|
key,
|
||||||
const rows = await subjectImagesApi.listImages(id, signal)
|
async (id, signal) => {
|
||||||
if (rows.some(image => image.subjectFormId !== id)) throw new Error('图片记录与当前形态不匹配,请刷新后重试。')
|
if (!id) return null
|
||||||
return rows
|
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 images = computed(() => query.data.value ?? [])
|
||||||
const selected = computed(
|
const selected = computed(
|
||||||
() => images.value.find(image => image.id === selectedId.value) ?? primaryImage(images.value) ?? images.value[0]
|
() => images.value.find(image => image.id === selectedId.value) ?? primaryImage(images.value) ?? images.value[0]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ afterEach(() => {
|
|||||||
wrapper?.unmount()
|
wrapper?.unmount()
|
||||||
wrapper = undefined
|
wrapper = undefined
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
|
vi.useRealTimers()
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
Object.assign(getOperation('gallery-test'), { pending: false, label: '', error: '', notice: '' })
|
Object.assign(getOperation('gallery-test'), { pending: false, label: '', error: '', notice: '' })
|
||||||
})
|
})
|
||||||
@@ -134,6 +135,7 @@ describe('形态图片选择与操作', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('图库不自动生图,主图切换经确认后 PUT,失败图片不能设主图', async () => {
|
it('图库不自动生图,主图切换经确认后 PUT,失败图片不能设主图', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
let primary = false
|
let primary = false
|
||||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
|
||||||
if (init?.method === 'PUT') {
|
if (init?.method === 'PUT') {
|
||||||
@@ -162,6 +164,8 @@ describe('形态图片选择与操作', () => {
|
|||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(fetcher.mock.calls).toHaveLength(1)
|
expect(fetcher.mock.calls).toHaveLength(1)
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000)
|
||||||
|
expect(fetcher.mock.calls).toHaveLength(1)
|
||||||
await expandSections()
|
await expandSections()
|
||||||
expect(document.body.textContent).toContain('<script>模型提示词</script>')
|
expect(document.body.textContent).toContain('<script>模型提示词</script>')
|
||||||
expect(document.querySelector('[role="dialog"] script')).toBeNull()
|
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>) {
|
export function useKeyframeImpact(projectId: Ref<string>, sourceShotId: Ref<string>) {
|
||||||
const query = usePolling(projectId, async (id, signal) => {
|
const query = usePolling(
|
||||||
if (!id) return null
|
projectId,
|
||||||
const [keyframes, videos] = await Promise.all([
|
async (id, signal) => {
|
||||||
productionApi.keyframeReadiness(id, false, signal),
|
if (!id) return null
|
||||||
productionApi.videoReadiness(id, false, signal)
|
const [keyframes, videos] = await Promise.all([
|
||||||
])
|
productionApi.keyframeReadiness(id, false, signal),
|
||||||
if (!keyframes || !Array.isArray(keyframes.items) || !videos || !Array.isArray(videos.items))
|
productionApi.videoReadiness(id, false, signal)
|
||||||
throw new Error('主首帧影响检查未返回镜头列表。')
|
])
|
||||||
const byShot = new Map(videos.items.map(item => [item.shotId, item]))
|
if (!keyframes || !Array.isArray(keyframes.items) || !videos || !Array.isArray(videos.items))
|
||||||
if (byShot.size !== keyframes.items.length || keyframes.items.some(item => !byShot.has(item.shotId)))
|
throw new Error('主首帧影响检查未返回镜头列表。')
|
||||||
throw new Error('首帧与视频检查的镜头列表不一致,可能正在重新拆解,请刷新后重试。')
|
const byShot = new Map(videos.items.map(item => [item.shotId, item]))
|
||||||
return {
|
if (byShot.size !== keyframes.items.length || keyframes.items.some(item => !byShot.has(item.shotId)))
|
||||||
...keyframes,
|
throw new Error('首帧与视频检查的镜头列表不一致,可能正在重新拆解,请刷新后重试。')
|
||||||
items: keyframes.items.map(item => {
|
return {
|
||||||
const issues = byShot.get(item.shotId)?.issues.filter(issue => issue.code === 'stale_keyframe') ?? []
|
...keyframes,
|
||||||
return {
|
items: keyframes.items.map(item => {
|
||||||
...item,
|
const issues =
|
||||||
primaryKeyframeStale: !!item.primaryKeyframeStale || issues.length > 0,
|
byShot.get(item.shotId)?.issues.filter(issue => issue.code === 'stale_keyframe') ?? []
|
||||||
inconsistent: item.primaryKeyframeStale === false && issues.length > 0
|
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) ?? [])
|
const stale = computed(() => query.data.value?.items.filter(item => item.primaryKeyframeStale) ?? [])
|
||||||
// 不根据 URL 中的任意镜头 ID 发请求,必须属于当前项目的就绪列表。
|
// 不根据 URL 中的任意镜头 ID 发请求,必须属于当前项目的就绪列表。
|
||||||
const source = computed(() => query.data.value?.items.find(item => item.shotId === sourceShotId.value))
|
const source = computed(() => query.data.value?.items.find(item => item.shotId === sourceShotId.value))
|
||||||
const references = useShotReferences(
|
const references = useShotReferences(
|
||||||
projectId,
|
projectId,
|
||||||
computed(() => source.value?.shotId ?? '')
|
computed(() => source.value?.shotId ?? ''),
|
||||||
|
false
|
||||||
)
|
)
|
||||||
const relatedForms = computed(
|
const relatedForms = computed(
|
||||||
() => new Set(references.data.value?.references.map(item => item.subjectFormId) ?? [])
|
() => new Set(references.data.value?.references.map(item => item.subjectFormId) ?? [])
|
||||||
|
|||||||
@@ -17,28 +17,32 @@ export function useSubjectImages() {
|
|||||||
const limit = ref<number | ''>('')
|
const limit = ref<number | ''>('')
|
||||||
const promptConcurrency = ref(3)
|
const promptConcurrency = ref(3)
|
||||||
const promptForce = ref(false)
|
const promptForce = ref(false)
|
||||||
const query = usePolling(id, async (projectId, signal) => {
|
const query = usePolling(
|
||||||
if (!projectId) return []
|
id,
|
||||||
try {
|
async (projectId, signal) => {
|
||||||
const forms = await subjectImagesApi.listForms(projectId, signal)
|
if (!projectId) return []
|
||||||
if (
|
try {
|
||||||
forms.some(
|
const forms = await subjectImagesApi.listForms(projectId, signal)
|
||||||
form =>
|
if (
|
||||||
form.subject.projectId !== projectId ||
|
forms.some(
|
||||||
form.images.some(image => image.subjectFormId !== form.id)
|
form =>
|
||||||
|
form.subject.projectId !== projectId ||
|
||||||
|
form.images.some(image => image.subjectFormId !== form.id)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
throw new Error('形态图库返回了不匹配的项目或图片,请刷新后重试。')
|
||||||
throw new Error('形态图库返回了不匹配的项目或图片,请刷新后重试。')
|
return forms
|
||||||
return forms
|
} catch (error) {
|
||||||
} catch (error) {
|
if (error instanceof ApiError && error.status === 404)
|
||||||
if (error instanceof ApiError && error.status === 404)
|
throw new Error(
|
||||||
throw new Error(
|
'后端尚未提供形态图库查询,请更新后端 dev 并重启服务(GET /projects/:id/subject-forms)。',
|
||||||
'后端尚未提供形态图库查询,请更新后端 dev 并重启服务(GET /projects/:id/subject-forms)。',
|
{ cause: error }
|
||||||
{ cause: error }
|
)
|
||||||
)
|
throw error
|
||||||
throw error
|
}
|
||||||
}
|
},
|
||||||
})
|
false
|
||||||
|
)
|
||||||
const forms = computed(() => query.data.value ?? [])
|
const forms = computed(() => query.data.value ?? [])
|
||||||
const staleForms = computed(() => forms.value.filter(form => isPrimaryIdentityStale(form)))
|
const staleForms = computed(() => forms.value.filter(form => isPrimaryIdentityStale(form)))
|
||||||
const operation = computed(() => getOperation(id.value))
|
const operation = computed(() => getOperation(id.value))
|
||||||
@@ -188,6 +192,7 @@ export function useSubjectImages() {
|
|||||||
forms,
|
forms,
|
||||||
staleForms,
|
staleForms,
|
||||||
query,
|
query,
|
||||||
|
refreshProject: context.refresh,
|
||||||
operation,
|
operation,
|
||||||
session,
|
session,
|
||||||
concurrency,
|
concurrency,
|
||||||
|
|||||||
Reference in New Issue
Block a user