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,40 +179,61 @@ onScopeDispose(() => controller?.abort())
|
||||
>
|
||||
后端尚未启用完整的图片和视频模型,暂时无法保存项目生成配置。
|
||||
</NAlert>
|
||||
<div v-if="profile" class="creative-profile-grid">
|
||||
<NFormItem label="作品宽高比">
|
||||
<NSelect
|
||||
:value="profile.aspectRatio"
|
||||
:options="aspectRatios"
|
||||
:disabled="saving"
|
||||
@update:value="profile.aspectRatio = $event as CreativeProfileAspectRatio"
|
||||
/>
|
||||
</NFormItem>
|
||||
<div></div>
|
||||
<section class="model-section">
|
||||
<NFormItem label="图片模型">
|
||||
<div v-if="profile" class="creative-profile-layout">
|
||||
<!-- 以下是项目画布设置模块 -->
|
||||
<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
|
||||
:value="imageSelection"
|
||||
:options="modelOptions(enabledImages)"
|
||||
:value="profile.aspectRatio"
|
||||
:options="aspectRatios"
|
||||
:disabled="saving"
|
||||
@update:value="selectModel('image', $event)"
|
||||
@update:value="profile.aspectRatio = $event as CreativeProfileAspectRatio"
|
||||
/>
|
||||
</NFormItem>
|
||||
</section>
|
||||
|
||||
<!-- 以下是图片生成模型设置模块 -->
|
||||
<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
|
||||
:value="imageSelection"
|
||||
:options="modelOptions(enabledImages)"
|
||||
:disabled="saving"
|
||||
@update:value="selectModel('image', $event)"
|
||||
/>
|
||||
</NFormItem>
|
||||
</div>
|
||||
<GenerationOptionFields
|
||||
v-model="profile.imageOptions"
|
||||
:schemas="imageModel?.generationOptions || []"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</section>
|
||||
<section class="model-section">
|
||||
<NFormItem label="视频模型">
|
||||
<NSelect
|
||||
:value="videoSelection"
|
||||
:options="modelOptions(enabledVideos)"
|
||||
:disabled="saving"
|
||||
@update:value="selectModel('video', $event)"
|
||||
/>
|
||||
</NFormItem>
|
||||
<!-- 以下是视频生成模型设置模块 -->
|
||||
<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
|
||||
:value="videoSelection"
|
||||
:options="modelOptions(enabledVideos)"
|
||||
:disabled="saving"
|
||||
@update:value="selectModel('video', $event)"
|
||||
/>
|
||||
</NFormItem>
|
||||
</div>
|
||||
<GenerationOptionFields
|
||||
v-model="profile.videoOptions"
|
||||
:schemas="videoModel?.generationOptions || []"
|
||||
@@ -220,7 +241,8 @@ onScopeDispose(() => controller?.abort())
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<!-- 以下是配置保存操作模块 -->
|
||||
<div class="profile-save-actions">
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@@ -237,18 +259,43 @@ onScopeDispose(() => controller?.abort())
|
||||
|
||||
<style scoped>
|
||||
@reference "../../../styles/styles.css";
|
||||
.creative-profile-grid {
|
||||
@apply grid grid-cols-[repeat(2,_minmax(0,_1fr))] gap-x-5;
|
||||
.creative-profile-layout {
|
||||
@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 {
|
||||
@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) {
|
||||
.creative-profile-grid {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
.canvas-settings,
|
||||
.model-section-heading {
|
||||
@apply grid-cols-[minmax(0,_1fr)] gap-3;
|
||||
}
|
||||
.creative-profile-grid > div:empty {
|
||||
@apply hidden;
|
||||
.canvas-ratio-field,
|
||||
.model-picker {
|
||||
@apply max-w-none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -75,7 +75,8 @@ function selectOptions(schema: GenerationOptionSchema) {
|
||||
<style scoped>
|
||||
@reference "../../../styles/styles.css";
|
||||
.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) {
|
||||
.generation-option-grid {
|
||||
|
||||
@@ -290,8 +290,9 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
:show-icon="false"
|
||||
class="mt-4"
|
||||
>
|
||||
当前有 {{ videoRunning }} 个视频任务等待或生成中。后端正在轮询
|
||||
Provider;同一镜头不会重复提交活动任务。
|
||||
当前有
|
||||
{{ videoRunning }}
|
||||
个视频任务等待或生成中。请手动刷新查看最新状态;同一镜头不会重复提交活动任务。
|
||||
</NAlert>
|
||||
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
|
||||
当前有 {{ identityBlocked }} 项角色身份前置问题。Character 必须生成
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Download } from '@lucide/vue'
|
||||
import { downloadText } from '../../../lib/format'
|
||||
import type { ProductionReceipt } from '../types'
|
||||
|
||||
/** 批量回执只描述本次请求,不能替代持续轮询的数据库状态。 */
|
||||
/** 批量回执只描述本次请求,不能替代显式刷新后的数据库状态。 */
|
||||
const props = defineProps<{ receipt: ProductionReceipt }>()
|
||||
|
||||
/** 导出完整回执,保留逐镜失败 ID 供后端排障。 */
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ExternalLink, ImagePlus, RefreshCw } from '@lucide/vue'
|
||||
import { AssetImage, StatusBadge } from '../../../components/ui'
|
||||
import { usePolling } from '../../../composables/usePolling'
|
||||
import { useQuery } from '../../../composables/useQuery'
|
||||
import { mediaAssetUrl } from '../../../lib/assets'
|
||||
import { errorMessage } from '../../../lib/http'
|
||||
import { formatDate } from '../../../lib/format'
|
||||
@@ -41,20 +41,16 @@ const props = defineProps<{
|
||||
}>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const key = computed(() => props.shot.shotId)
|
||||
/** 单镜头资产已有刷新入口,不再定时轮询。 */
|
||||
const query = usePolling(
|
||||
key,
|
||||
async (shotId, signal) => {
|
||||
const [keyframes, videos] = await Promise.all([
|
||||
productionApi.listKeyframes(shotId, signal),
|
||||
productionApi.listVideos(shotId, signal)
|
||||
])
|
||||
if (keyframes.some(item => item.shotId !== shotId) || videos.some(item => item.shotId !== shotId))
|
||||
throw new Error('资产记录与当前镜头不匹配,请刷新后重试。')
|
||||
return { keyframes, videos }
|
||||
},
|
||||
false
|
||||
)
|
||||
/** 单镜头资产仅在进入、切换镜头或显式刷新时读取。 */
|
||||
const query = useQuery(key, async (shotId, signal) => {
|
||||
const [keyframes, videos] = await Promise.all([
|
||||
productionApi.listKeyframes(shotId, signal),
|
||||
productionApi.listVideos(shotId, signal)
|
||||
])
|
||||
if (keyframes.some(item => item.shotId !== shotId) || videos.some(item => item.shotId !== shotId))
|
||||
throw new Error('资产记录与当前镜头不匹配,请刷新后重试。')
|
||||
return { keyframes, videos }
|
||||
})
|
||||
const keyframes = computed(() => query.data.value?.keyframes ?? [])
|
||||
const videos = computed(() => query.data.value?.videos ?? [])
|
||||
const currentKeyframe = computed(() => primaryKeyframe(keyframes.value))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { useQuery } from '../../composables/useQuery'
|
||||
import { useProjectContext } from '../projects/context'
|
||||
import { mergeDesignedShots } from '../storyboard/model'
|
||||
import { storyboardApi } from '../storyboard/api'
|
||||
@@ -45,24 +45,20 @@ export function useProduction() {
|
||||
)
|
||||
const queryKey = computed(() => JSON.stringify([id.value, episodeNo.value, force.value]))
|
||||
/** 生产页已有刷新按钮,就绪状态改为手动更新。 */
|
||||
const query = usePolling(
|
||||
queryKey,
|
||||
async (key, signal) => {
|
||||
const [projectId, number, overwrite] = JSON.parse(key) as [string, number, boolean]
|
||||
if (!projectId || !number) return null
|
||||
const [directions, prompts, keyframes, videos, videoStatus] = await Promise.all([
|
||||
storyboardApi.directions(projectId, number, signal),
|
||||
productionApi.promptReadiness(projectId, overwrite, signal),
|
||||
productionApi.keyframeReadiness(projectId, overwrite, signal),
|
||||
productionApi.videoReadiness(projectId, overwrite, signal),
|
||||
productionApi.projectVideoStatus(projectId, signal)
|
||||
])
|
||||
if (directions.projectId !== projectId || directions.episodeNo !== number)
|
||||
throw new Error('镜头生产查询返回了不匹配的项目或剧集,请刷新后重试。')
|
||||
return { directions, prompts, keyframes, videos, videoStatus }
|
||||
},
|
||||
false
|
||||
)
|
||||
const query = useQuery(queryKey, async (key, signal) => {
|
||||
const [projectId, number, overwrite] = JSON.parse(key) as [string, number, boolean]
|
||||
if (!projectId || !number) return null
|
||||
const [directions, prompts, keyframes, videos, videoStatus] = await Promise.all([
|
||||
storyboardApi.directions(projectId, number, signal),
|
||||
productionApi.promptReadiness(projectId, overwrite, signal),
|
||||
productionApi.keyframeReadiness(projectId, overwrite, signal),
|
||||
productionApi.videoReadiness(projectId, overwrite, signal),
|
||||
productionApi.projectVideoStatus(projectId, signal)
|
||||
])
|
||||
if (directions.projectId !== projectId || directions.episodeNo !== number)
|
||||
throw new Error('镜头生产查询返回了不匹配的项目或剧集,请刷新后重试。')
|
||||
return { directions, prompts, keyframes, videos, videoStatus }
|
||||
})
|
||||
const source = computed(() => sourceEpisodes.value.find(item => item.episodeNo === episodeNo.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])
|
||||
@@ -99,7 +95,7 @@ export function useProduction() {
|
||||
context.project.value?.status !== 'completed'
|
||||
)
|
||||
|
||||
/** 批量操作只向就绪镜头提交;最终资产状态由轮询查询确认。 */
|
||||
/** 批量操作只向就绪镜头提交;最终资产状态由显式刷新确认。 */
|
||||
async function run(command: ProductionCommand) {
|
||||
if (
|
||||
blocked.value ||
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
import { computed, type MaybeRefOrGetter, type Ref } from 'vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { computed, type Ref } from 'vue'
|
||||
import { useQuery } from '../../composables/useQuery'
|
||||
import { storyboardApi } from '../storyboard/api'
|
||||
|
||||
/** 仅查询已由当前项目列表确认的镜头,切换目标自动取消旧响应。 */
|
||||
export function useShotReferences(
|
||||
projectId: Ref<string>,
|
||||
shotId: Ref<string>,
|
||||
interval: MaybeRefOrGetter<number | false> = 6000
|
||||
) {
|
||||
export function useShotReferences(projectId: Ref<string>, shotId: Ref<string>) {
|
||||
const key = computed(() => JSON.stringify([projectId.value, shotId.value]))
|
||||
return usePolling(
|
||||
key,
|
||||
async (value, signal) => {
|
||||
const [project, shot] = JSON.parse(value) as [string, string]
|
||||
if (!project || !shot) return null
|
||||
const result = await storyboardApi.references(shot, signal)
|
||||
if (
|
||||
!result ||
|
||||
result.shotId !== shot ||
|
||||
!Array.isArray(result.references) ||
|
||||
!Array.isArray(result.missing)
|
||||
)
|
||||
throw new Error('镜头参考素材返回不匹配,请刷新后重试。')
|
||||
return result
|
||||
},
|
||||
interval
|
||||
)
|
||||
return useQuery(key, async (value, signal) => {
|
||||
const [project, shot] = JSON.parse(value) as [string, string]
|
||||
if (!project || !shot) return null
|
||||
const result = await storyboardApi.references(shot, signal)
|
||||
if (!result || result.shotId !== shot || !Array.isArray(result.references) || !Array.isArray(result.missing))
|
||||
throw new Error('镜头参考素材返回不匹配,请刷新后重试。')
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
<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 { NScrollbar, NAlert, NButton, NPageHeader, NSpin, NEllipsis } from 'naive-ui'
|
||||
import { NScrollbar, NAlert, NButton, NSpin } from 'naive-ui'
|
||||
import { EmptyState } from '../../components/ui'
|
||||
import { buildDocumentTitle } from '../../lib/document-title'
|
||||
import { projectContextKey, useProjectData } from './context'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
import { isProjectComplete, projectAccessKey, type ProjectAccess } from './access'
|
||||
|
||||
/** 项目标题只保留名称;下游工作区在剧本完成前不挂载,直接链接也无法越过限制。 */
|
||||
/** 下游工作区在剧本完成前不挂载,直接链接也无法越过限制。 */
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
/** 当前路由中的项目 ID。 */
|
||||
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,
|
||||
computed(() => (isGallery.value ? false : 6000))
|
||||
)
|
||||
/** 项目数据仅在进入、切换项目或显式刷新时读取。 */
|
||||
const context = useProjectData(id)
|
||||
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 complete = computed(() => isProjectComplete(context.project.value, id.value))
|
||||
const isCreation = computed(() => route.path.replace(/\/+$/, '').endsWith('/create-drama'))
|
||||
@@ -38,19 +37,11 @@ watchEffect(() => {
|
||||
})
|
||||
onScopeDispose(() => {
|
||||
if (access?.value === published) access.value = null
|
||||
document.title = buildDocumentTitle(route.meta.title)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<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
|
||||
v-if="context.error.value || operation.pending || operation.error || operation.notice"
|
||||
class="project-notices"
|
||||
@@ -74,12 +65,10 @@ onScopeDispose(() => {
|
||||
v-else-if="context.project.value?.id === id"
|
||||
class="project-access-gate"
|
||||
title="请先完成剧本创作"
|
||||
:description="`剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、分镜设计与镜头生产。${isGallery ? '请手动刷新项目状态。' : '状态会自动更新。'}`"
|
||||
description="剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、分镜设计与镜头生产。请手动刷新项目状态。"
|
||||
>
|
||||
<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
|
||||
>
|
||||
<NButton class="ml-3" :loading="context.loading.value" @click="context.refresh">刷新项目状态</NButton>
|
||||
</EmptyState>
|
||||
<NSpin
|
||||
v-else-if="context.loading.value"
|
||||
@@ -96,16 +85,6 @@ onScopeDispose(() => {
|
||||
.project-frame {
|
||||
@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 {
|
||||
@apply shrink-0 h-auto max-h-[100px] overflow-hidden;
|
||||
}
|
||||
@@ -118,21 +97,10 @@ onScopeDispose(() => {
|
||||
.project-view {
|
||||
@apply flex-1 min-h-0 overflow-hidden;
|
||||
}
|
||||
.project-header .n-page-header-wrapper {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.project-access-gate {
|
||||
@apply h-full;
|
||||
}
|
||||
.workspace-loading {
|
||||
@apply grid place-content-center h-full;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.project-header {
|
||||
@apply py-2 px-3;
|
||||
}
|
||||
.project-title {
|
||||
@apply text-sm;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from 'naive-ui'
|
||||
import { RefreshCw, Search } from '@lucide/vue'
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { useQuery } from '../../composables/useQuery'
|
||||
import { StatusBadge } from '../../components/ui'
|
||||
import { formatDate } from '../../lib/format'
|
||||
import { projectsApi } from './api'
|
||||
@@ -23,7 +23,7 @@ import CreateProjectDialog from './components/CreateProjectDialog.vue'
|
||||
/** 项目表格使用固定表头与独立滚动;搜索筛选仍作用于真实接口数据。 */
|
||||
const router = useRouter()
|
||||
/** 列表已有刷新按钮,只在进入和手动刷新时读取;筛选在完整结果中进行。 */
|
||||
const query = usePolling(ref('projects'), (_, signal) => projectsApi.list(signal), false)
|
||||
const query = useQuery(ref('projects'), (_, signal) => projectsApi.list(signal))
|
||||
const search = ref('')
|
||||
const filter = ref('all')
|
||||
/** 接口暂未分页:对完整查询结果分批展示,滚动时追加而非切换页码。 */
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { computed, inject, type InjectionKey, type MaybeRefOrGetter } from 'vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { computed, inject, type InjectionKey } from 'vue'
|
||||
import { useQuery } from '../../composables/useQuery'
|
||||
import { projectsApi } from './api'
|
||||
import type { ProjectDetail } from './types'
|
||||
import type { Checkpoint } from '../workflows/types'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
/** 同一项目的各个 graph 共用一份查询,避免每个面板重复请求。 */
|
||||
export function useProjectData(id: Ref<string>, interval: MaybeRefOrGetter<number | false> = 6000) {
|
||||
const query = usePolling(
|
||||
id,
|
||||
async (projectId, signal) => {
|
||||
const [project, checkpoints] = await Promise.all([
|
||||
projectsApi.detail(projectId, signal),
|
||||
projectsApi.checkpoints(projectId, signal)
|
||||
])
|
||||
return { project, checkpoints }
|
||||
},
|
||||
interval
|
||||
)
|
||||
export function useProjectData(id: Ref<string>) {
|
||||
const query = useQuery(id, async (projectId, signal) => {
|
||||
const [project, checkpoints] = await Promise.all([
|
||||
projectsApi.detail(projectId, signal),
|
||||
projectsApi.checkpoints(projectId, signal)
|
||||
])
|
||||
return { project, checkpoints }
|
||||
})
|
||||
const project = computed<ProjectDetail | null>(() => query.data.value?.project ?? null)
|
||||
const checkpoints = computed<Checkpoint[]>(() => query.data.value?.checkpoints ?? [])
|
||||
return { ...query, project, checkpoints }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { useQuery } from '../../composables/useQuery'
|
||||
import { useProjectContext } from '../projects/context'
|
||||
import { breakdownSnapshot } from '../workflows/selectors'
|
||||
import { getOperation, runOperation } from '../workflows/operations'
|
||||
@@ -38,28 +38,24 @@ export function useStoryboard() {
|
||||
0
|
||||
)
|
||||
const queryKey = computed(() => JSON.stringify([id.value, episodeNo.value]))
|
||||
/** 分镜页已有刷新按钮,不再定时轮询当前剧集。 */
|
||||
const query = usePolling(
|
||||
queryKey,
|
||||
async (key, signal) => {
|
||||
const [projectId, number] = JSON.parse(key) as [string, number]
|
||||
if (!projectId || !number) return null
|
||||
const [directions, visualStates] = await Promise.all([
|
||||
storyboardApi.directions(projectId, number, signal),
|
||||
storyboardApi.visualStates(projectId, number, signal)
|
||||
])
|
||||
if (
|
||||
directions.projectId !== projectId ||
|
||||
visualStates.projectId !== projectId ||
|
||||
directions.episodeNo !== number ||
|
||||
visualStates.episodeNo !== number
|
||||
) {
|
||||
throw new Error('分镜查询返回了不匹配的项目或剧集,请刷新后重试。')
|
||||
}
|
||||
return { directions, visualStates }
|
||||
},
|
||||
false
|
||||
)
|
||||
/** 分镜页仅在进入、切换剧集或显式刷新时读取。 */
|
||||
const query = useQuery(queryKey, async (key, signal) => {
|
||||
const [projectId, number] = JSON.parse(key) as [string, number]
|
||||
if (!projectId || !number) return null
|
||||
const [directions, visualStates] = await Promise.all([
|
||||
storyboardApi.directions(projectId, number, signal),
|
||||
storyboardApi.visualStates(projectId, number, signal)
|
||||
])
|
||||
if (
|
||||
directions.projectId !== projectId ||
|
||||
visualStates.projectId !== projectId ||
|
||||
directions.episodeNo !== number ||
|
||||
visualStates.episodeNo !== number
|
||||
) {
|
||||
throw new Error('分镜查询返回了不匹配的项目或剧集,请刷新后重试。')
|
||||
}
|
||||
return { directions, visualStates }
|
||||
})
|
||||
const data = query.data
|
||||
const session = computed(() => getStoryboardSession(id.value))
|
||||
/** 单集重生成返回的是最新镜头正文,优先于不会随局部覆盖更新的历史 Breakdown 快照。 */
|
||||
|
||||
@@ -99,7 +99,7 @@ const filtered = computed(() =>
|
||||
)
|
||||
)
|
||||
|
||||
/** 深链接仅在目标 ID 变化时选择,不因目录轮询把用户切回原主体。 */
|
||||
/** 深链接仅在目标 ID 变化时选择,不因目录刷新把用户切回原主体。 */
|
||||
const linkedSubjectId = computed(
|
||||
() => subjects.value.find(item => item.id === route.query.subjectId || item.ref === route.query.subjectRef)?.id
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { NButton, NCheckbox, NInput } from 'naive-ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { SaveIdentityInput, SubjectIdentity } from '../types'
|
||||
|
||||
/** 身份文本草稿不随轮询覆盖;父页面切换主体或保存成功后重建编辑器。 */
|
||||
/** 身份文本草稿不随查询覆盖;父页面切换主体或保存成功后重建编辑器。 */
|
||||
const props = defineProps<{ identity: SubjectIdentity | null; disabled: boolean; module: string }>()
|
||||
const emit = defineEmits<{ save: [input: SaveIdentityInput]; dirty: [value: boolean] }>()
|
||||
const form = reactive({ description: '', generationPrompt: '', isLocked: false })
|
||||
|
||||
@@ -53,7 +53,7 @@ watch(url, () => {
|
||||
imageFailed.value = false
|
||||
})
|
||||
|
||||
/** 仅读取正式主体的身份图库;切项目、筛选或刷新时中止旧请求,不轮询每个缩略图。 */
|
||||
/** 仅读取正式主体的身份图库;切项目、筛选或刷新时中止旧请求。 */
|
||||
watch(
|
||||
() => [props.projectId, props.subjectId, props.identityId, props.source, props.refreshKey, visible.value],
|
||||
async (_value, _oldValue, onCleanup) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { useQuery } from '../../composables/useQuery'
|
||||
import { visualStyleApi } from '../visual-style'
|
||||
import { subjectImagesApi } from '../subject-images/api'
|
||||
import { hasRunningImages } from '../subject-images/model'
|
||||
@@ -30,58 +30,42 @@ export function useSubjectIdentity() {
|
||||
const candidateLimit = ref(3)
|
||||
const force = ref(false)
|
||||
/** 目录与身份已有刷新按钮,查询只在进入、切换和手动刷新时读取。 */
|
||||
const catalog = usePolling(
|
||||
projectId,
|
||||
async (id, signal) => {
|
||||
const forms = await subjectImagesApi.listForms(id, signal)
|
||||
if (
|
||||
forms.some(
|
||||
form => form.subject.projectId !== id || form.images.some(image => image.subjectFormId !== form.id)
|
||||
)
|
||||
const catalog = useQuery(projectId, async (id, signal) => {
|
||||
const forms = await subjectImagesApi.listForms(id, signal)
|
||||
if (
|
||||
forms.some(
|
||||
form => form.subject.projectId !== id || form.images.some(image => image.subjectFormId !== form.id)
|
||||
)
|
||||
throw new Error('主体目录与当前项目不匹配,请刷新后重试。')
|
||||
return {
|
||||
subjects: groupIdentitySubjects(forms),
|
||||
running: forms.some(form => hasRunningImages(form.images))
|
||||
}
|
||||
},
|
||||
false
|
||||
)
|
||||
const styleQuery = usePolling(
|
||||
projectId,
|
||||
async (id, signal) => {
|
||||
const style = await visualStyleApi.get(id, signal)
|
||||
if (style && style.projectId !== id) throw new Error('视觉风格与当前项目不匹配。')
|
||||
return { style }
|
||||
},
|
||||
false
|
||||
)
|
||||
const castingQuery = usePolling(
|
||||
projectId,
|
||||
async (id, signal) => {
|
||||
const readiness = await subjectIdentityApi.castingReadiness(id, signal)
|
||||
return readiness
|
||||
},
|
||||
false
|
||||
)
|
||||
)
|
||||
throw new Error('主体目录与当前项目不匹配,请刷新后重试。')
|
||||
return {
|
||||
subjects: groupIdentitySubjects(forms),
|
||||
running: forms.some(form => hasRunningImages(form.images))
|
||||
}
|
||||
})
|
||||
const styleQuery = useQuery(projectId, async (id, signal) => {
|
||||
const style = await visualStyleApi.get(id, signal)
|
||||
if (style && style.projectId !== id) throw new Error('视觉风格与当前项目不匹配。')
|
||||
return { style }
|
||||
})
|
||||
const castingQuery = useQuery(projectId, async (id, signal) => {
|
||||
const readiness = await subjectIdentityApi.castingReadiness(id, signal)
|
||||
return readiness
|
||||
})
|
||||
const subjects = computed(() =>
|
||||
mergeCastingSubjects(catalog.data.value?.subjects ?? [], castingQuery.data.value?.items ?? [], projectId.value)
|
||||
)
|
||||
const subject = computed(() => subjects.value.find(item => item.id === selectedId.value))
|
||||
const selectionKey = computed(() => subject.value?.id ?? '')
|
||||
const detail = usePolling(
|
||||
selectionKey,
|
||||
async (id, signal) => {
|
||||
if (!id) return null
|
||||
const identity = await subjectIdentityApi.get(id, signal)
|
||||
if (!identity) return { identity: null, images: [] as IdentityImage[] }
|
||||
assertIdentity(identity, id)
|
||||
const images = await subjectIdentityApi.listImages(id, signal)
|
||||
if (images.some(image => image.identityId !== identity.id)) throw new Error('身份图片与当前主体不匹配。')
|
||||
return { identity, images }
|
||||
},
|
||||
false
|
||||
)
|
||||
const detail = useQuery(selectionKey, async (id, signal) => {
|
||||
if (!id) return null
|
||||
const identity = await subjectIdentityApi.get(id, signal)
|
||||
if (!identity) return { identity: null, images: [] as IdentityImage[] }
|
||||
assertIdentity(identity, id)
|
||||
const images = await subjectIdentityApi.listImages(id, signal)
|
||||
if (images.some(image => image.identityId !== identity.id)) throw new Error('身份图片与当前主体不匹配。')
|
||||
return { identity, images }
|
||||
})
|
||||
const identity = computed(() => detail.data.value?.identity ?? null)
|
||||
const images = computed(() => detail.data.value?.images ?? [])
|
||||
const castingItem = computed(() =>
|
||||
|
||||
@@ -701,6 +701,7 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
|
||||
:src="coverImage(form)?.imageUrl"
|
||||
:alt="`${form.subject.name} · ${form.name}`"
|
||||
:object-fit="layout === 'masonry' ? 'contain' : 'cover'"
|
||||
:class="{ 'form-image-empty': !coverImage(form) }"
|
||||
preview
|
||||
:empty-text="hasRunningImages(form.images) ? '后台正在生成图片' : '尚未生成图片'"
|
||||
/>
|
||||
@@ -842,6 +843,10 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
|
||||
.form-image-masonry .asset-image {
|
||||
@apply aspect-auto;
|
||||
}
|
||||
/* 无图占位保持正方形,避免瀑布流的自适应比例将占位区域压扁。 */
|
||||
.form-image-grid .asset-image.form-image-empty {
|
||||
@apply aspect-square;
|
||||
}
|
||||
.form-image-masonry .asset-image .n-image,
|
||||
.form-image-masonry .asset-image .n-image img {
|
||||
@apply h-auto;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NScrollbar, NAlert, NButton, NTag } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ExternalLink, RefreshCw } from '@lucide/vue'
|
||||
import { AppDialog, AssetImage, StatusBadge } from '../../../components/ui'
|
||||
import { usePolling } from '../../../composables/usePolling'
|
||||
import { useQuery } from '../../../composables/useQuery'
|
||||
import { referenceImageUrl } from '../../../lib/assets'
|
||||
import { formatDate } from '../../../lib/format'
|
||||
import { getOperation, runOperation } from '../../workflows/operations'
|
||||
@@ -18,16 +18,12 @@ const emit = defineEmits<{ changed: [] }>()
|
||||
const selectedId = ref('')
|
||||
const confirming = ref(false)
|
||||
const key = computed(() => (open.value ? (props.form?.id ?? '') : ''))
|
||||
const query = usePolling(
|
||||
key,
|
||||
async (id, signal) => {
|
||||
if (!id) return null
|
||||
const rows = await subjectImagesApi.listImages(id, signal)
|
||||
if (rows.some(image => image.subjectFormId !== id)) throw new Error('图片记录与当前形态不匹配,请刷新后重试。')
|
||||
return rows
|
||||
},
|
||||
false
|
||||
)
|
||||
const query = useQuery(key, async (id, signal) => {
|
||||
if (!id) return null
|
||||
const rows = await subjectImagesApi.listImages(id, signal)
|
||||
if (rows.some(image => image.subjectFormId !== id)) throw new Error('图片记录与当前形态不匹配,请刷新后重试。')
|
||||
return rows
|
||||
})
|
||||
const images = computed(() => query.data.value ?? [])
|
||||
const selected = computed(
|
||||
() => images.value.find(image => image.id === selectedId.value) ?? primaryImage(images.value) ?? images.value[0]
|
||||
|
||||
@@ -1,45 +1,39 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { useQuery } from '../../composables/useQuery'
|
||||
import { productionApi } from '../production/api'
|
||||
import { useShotReferences } from '../production/useShotReferences'
|
||||
|
||||
/** 镜头影响检查与素材查询独立;检查失败不把素材误判为正常或阻断独立的素材操作。 */
|
||||
export function useKeyframeImpact(projectId: Ref<string>, sourceShotId: Ref<string>) {
|
||||
const query = usePolling(
|
||||
projectId,
|
||||
async (id, signal) => {
|
||||
if (!id) return null
|
||||
const [keyframes, videos] = await Promise.all([
|
||||
productionApi.keyframeReadiness(id, false, signal),
|
||||
productionApi.videoReadiness(id, false, signal)
|
||||
])
|
||||
if (!keyframes || !Array.isArray(keyframes.items) || !videos || !Array.isArray(videos.items))
|
||||
throw new Error('主首帧影响检查未返回镜头列表。')
|
||||
const byShot = new Map(videos.items.map(item => [item.shotId, item]))
|
||||
if (byShot.size !== keyframes.items.length || keyframes.items.some(item => !byShot.has(item.shotId)))
|
||||
throw new Error('首帧与视频检查的镜头列表不一致,可能正在重新拆解,请刷新后重试。')
|
||||
return {
|
||||
...keyframes,
|
||||
items: keyframes.items.map(item => {
|
||||
const issues =
|
||||
byShot.get(item.shotId)?.issues.filter(issue => issue.code === 'stale_keyframe') ?? []
|
||||
return {
|
||||
...item,
|
||||
primaryKeyframeStale: !!item.primaryKeyframeStale || issues.length > 0,
|
||||
inconsistent: item.primaryKeyframeStale === false && issues.length > 0
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
false
|
||||
)
|
||||
const query = useQuery(projectId, async (id, signal) => {
|
||||
if (!id) return null
|
||||
const [keyframes, videos] = await Promise.all([
|
||||
productionApi.keyframeReadiness(id, false, signal),
|
||||
productionApi.videoReadiness(id, false, signal)
|
||||
])
|
||||
if (!keyframes || !Array.isArray(keyframes.items) || !videos || !Array.isArray(videos.items))
|
||||
throw new Error('主首帧影响检查未返回镜头列表。')
|
||||
const byShot = new Map(videos.items.map(item => [item.shotId, item]))
|
||||
if (byShot.size !== keyframes.items.length || keyframes.items.some(item => !byShot.has(item.shotId)))
|
||||
throw new Error('首帧与视频检查的镜头列表不一致,可能正在重新拆解,请刷新后重试。')
|
||||
return {
|
||||
...keyframes,
|
||||
items: keyframes.items.map(item => {
|
||||
const issues = byShot.get(item.shotId)?.issues.filter(issue => issue.code === 'stale_keyframe') ?? []
|
||||
return {
|
||||
...item,
|
||||
primaryKeyframeStale: !!item.primaryKeyframeStale || issues.length > 0,
|
||||
inconsistent: item.primaryKeyframeStale === false && issues.length > 0
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
const stale = computed(() => query.data.value?.items.filter(item => item.primaryKeyframeStale) ?? [])
|
||||
// 不根据 URL 中的任意镜头 ID 发请求,必须属于当前项目的就绪列表。
|
||||
const source = computed(() => query.data.value?.items.find(item => item.shotId === sourceShotId.value))
|
||||
const references = useShotReferences(
|
||||
projectId,
|
||||
computed(() => source.value?.shotId ?? ''),
|
||||
false
|
||||
computed(() => source.value?.shotId ?? '')
|
||||
)
|
||||
const relatedForms = computed(
|
||||
() => new Set(references.data.value?.references.map(item => item.subjectFormId) ?? [])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { useQuery } from '../../composables/useQuery'
|
||||
import { ApiError } from '../../lib/http'
|
||||
import { useProjectContext } from '../projects/context'
|
||||
import { getOperation, runOperation } from '../workflows/operations'
|
||||
@@ -17,32 +17,28 @@ export function useSubjectImages() {
|
||||
const limit = ref<number | ''>('')
|
||||
const promptConcurrency = ref(3)
|
||||
const promptForce = ref(false)
|
||||
const query = usePolling(
|
||||
id,
|
||||
async (projectId, signal) => {
|
||||
if (!projectId) return []
|
||||
try {
|
||||
const forms = await subjectImagesApi.listForms(projectId, signal)
|
||||
if (
|
||||
forms.some(
|
||||
form =>
|
||||
form.subject.projectId !== projectId ||
|
||||
form.images.some(image => image.subjectFormId !== form.id)
|
||||
)
|
||||
const query = useQuery(id, async (projectId, signal) => {
|
||||
if (!projectId) return []
|
||||
try {
|
||||
const forms = await subjectImagesApi.listForms(projectId, signal)
|
||||
if (
|
||||
forms.some(
|
||||
form =>
|
||||
form.subject.projectId !== projectId ||
|
||||
form.images.some(image => image.subjectFormId !== form.id)
|
||||
)
|
||||
throw new Error('形态图库返回了不匹配的项目或图片,请刷新后重试。')
|
||||
return forms
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404)
|
||||
throw new Error(
|
||||
'后端尚未提供形态图库查询,请更新后端 dev 并重启服务(GET /projects/:id/subject-forms)。',
|
||||
{ cause: error }
|
||||
)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
false
|
||||
)
|
||||
)
|
||||
throw new Error('形态图库返回了不匹配的项目或图片,请刷新后重试。')
|
||||
return forms
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404)
|
||||
throw new Error(
|
||||
'后端尚未提供形态图库查询,请更新后端 dev 并重启服务(GET /projects/:id/subject-forms)。',
|
||||
{ cause: error }
|
||||
)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
const forms = computed(() => query.data.value ?? [])
|
||||
const operation = computed(() => getOperation(id.value))
|
||||
const session = computed(() => getImageSession(id.value))
|
||||
|
||||
@@ -4,7 +4,7 @@ import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
|
||||
import { NAlert, NButton } from 'naive-ui'
|
||||
import { LoaderCircle, LockKeyhole, LockKeyholeOpen, Palette, RefreshCw, TriangleAlert } from '@lucide/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { useQuery } from '../../composables/useQuery'
|
||||
import { runOperation } from '../workflows/operations'
|
||||
import { useProjectMutationGuard } from '../workflows/useProjectMutationGuard'
|
||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||
@@ -15,16 +15,12 @@ import StyleImages from './components/StyleImages.vue'
|
||||
|
||||
/** 项目视觉风格工作区:文本编辑、锁定和风格图记录分开管理。 */
|
||||
const { projectId, blocked } = useProjectMutationGuard()
|
||||
/** 风格页已有刷新按钮,不再定时轮询。 */
|
||||
const query = usePolling(
|
||||
projectId,
|
||||
async (id, signal) => {
|
||||
const style = await visualStyleApi.get(id, signal)
|
||||
assertProject(style, id)
|
||||
return { style }
|
||||
},
|
||||
false
|
||||
)
|
||||
/** 风格页仅在进入、切换项目或显式刷新时读取。 */
|
||||
const query = useQuery(projectId, async (id, signal) => {
|
||||
const style = await visualStyleApi.get(id, signal)
|
||||
assertProject(style, id)
|
||||
return { style }
|
||||
})
|
||||
const style = computed(() => query.data.value?.style ?? null)
|
||||
const disabled = computed(() => blocked.value || !!query.error.value || !query.data.value)
|
||||
const editorRevision = ref(0)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NButton, NCheckbox, NInput, NTag } from 'naive-ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { SaveVisualStyleInput, VisualStyle } from '../types'
|
||||
|
||||
/** 编辑草稿独立于轮询结果,刷新不会覆盖尚未保存的输入。 */
|
||||
/** 编辑草稿独立于查询结果,刷新不会覆盖尚未保存的输入。 */
|
||||
const props = defineProps<{ visualStyle: VisualStyle | null; disabled: boolean }>()
|
||||
const emit = defineEmits<{ save: [input: SaveVisualStyleInput]; dirty: [value: boolean] }>()
|
||||
const form = reactive({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NCollapse, NCollapseItem, NInput, NSelect, NTab, NTabs } from 'naive-ui'
|
||||
import { Download, RefreshCw, Search } from '@lucide/vue'
|
||||
import { AppDialog, EmptyState } from '../../components/ui'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { useQuery } from '../../composables/useQuery'
|
||||
import { downloadText, formatDate, nodeLabel } from '../../lib/format'
|
||||
import { loadWorkflowDiagnostics } from './diagnostics'
|
||||
import type { Checkpoint } from './types'
|
||||
@@ -13,7 +13,7 @@ const props = defineProps<{ projectId: string; checkpoints: Checkpoint[] }>()
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
const key = computed(() => (open.value ? props.projectId : ''))
|
||||
/** 诊断弹窗已有刷新按钮,打开后只读取一次。 */
|
||||
const query = usePolling(key, (id, signal) => (id ? loadWorkflowDiagnostics(id, signal) : Promise.resolve(null)), false)
|
||||
const query = useQuery(key, (id, signal) => (id ? loadWorkflowDiagnostics(id, signal) : Promise.resolve(null)))
|
||||
const tab = ref('timeline')
|
||||
const workflow = ref('all')
|
||||
const search = ref('')
|
||||
|
||||
@@ -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 { buildDocumentTitle } from '../lib/document-title'
|
||||
|
||||
/** 按 graph 分路由,项目布局保留共享数据与长请求状态。 */
|
||||
export const router = createRouter({
|
||||
@@ -62,5 +63,5 @@ export const router = createRouter({
|
||||
|
||||
/** 路由切换同步浏览器标题,不依赖页面组件主动修改。 */
|
||||
router.afterEach(to => {
|
||||
document.title = `${String(to.meta.title || '工作空间')} · 短剧工作台`
|
||||
document.title = buildDocumentTitle(to.meta.title)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user