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