Compare commits
31
Commits
1bbfd30627
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ce9ae00e7 | ||
|
|
94ae147250 | ||
|
|
1c5b1e448a | ||
|
|
be06caaa9f | ||
|
|
65e8f9c3ba | ||
|
|
a442087ea9 | ||
|
|
7763170830 | ||
|
|
869ebf2f2c | ||
|
|
05cebc5331 | ||
|
|
8e8fab307f | ||
|
|
b9db84c261 | ||
|
|
6e5856714e | ||
|
|
e9547b13fa | ||
|
|
f0957c237b | ||
|
|
9502ebdc16 | ||
|
|
6e542a5de7 | ||
|
|
5f78efaad1 | ||
|
|
a3599b03d0 | ||
|
|
df449e0d7d | ||
|
|
f3c9f6ca41 | ||
|
|
2240a90424 | ||
|
|
2d0d7e22d3 | ||
|
|
fec3b8dcac | ||
|
|
eec957a2e4 | ||
|
|
b834d1d259 | ||
|
|
e6d02a20a7 | ||
|
|
227db5450d | ||
|
|
c1ec88daf9 | ||
|
|
b78152778b | ||
|
|
a253711435 | ||
|
|
01f274cd8e |
@@ -7,3 +7,4 @@ coverage
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
.worktree/
|
||||
.worktrees/
|
||||
|
||||
@@ -44,3 +44,18 @@
|
||||
- 浅色主画布统一白色,输入框使用浅灰,Tab 导航、辅助区和图库使用统一主题变量,首屏 theme-color 与应用一致。深色视频播放背景保留。
|
||||
|
||||
验证使用模拟 API,不调用真实生成模型。浏览器无法访问当前本地预览地址,尚未完成浏览器截图验收或真实后端联调。
|
||||
|
||||
## 2026-09-21 项目资产接口
|
||||
|
||||
- `POST /projects/:projectId/assets` 使用 multipart 的 `file`、`name`、`category`,浏览器负责生成 boundary,前端不手工设置 `Content-Type`。
|
||||
- GET/PUT/DELETE 项目资产接口全部使用正式项目 ID 与资产 ID;编辑只修改名称和分类,不替换物理文件。
|
||||
- 素材公开字段包含 `publicUrl`、类型、MIME、扩展名、大小、分类、元数据和时间;页面只读取 `metadata.originalName` 作为辅助搜索,不依赖内部存储路径。
|
||||
- 视觉风格新增参考图可发送 `projectAssetId`,后端从所属项目素材解析 `imageUrl`;外部 `imageUrl` 入口继续兼容。资产关联关系不在公开风格图 DTO 中推断。
|
||||
- 删除仍由后端执行引用保护;前端不先删风格记录,也不将失败响应显示为已删除。
|
||||
|
||||
## 2026-09-22 增量同步
|
||||
|
||||
- 新增形态图和镜头首帧的 POST 预览接口;请求体默认 `{}`,只读取后端当前生成配置与参考图规划,不产生模型任务。
|
||||
- 首帧预览按 `referencePlan.selected`/`omitted` 展示 Provider 上限后的真实输入;不再从通用 `keyframe-spec` 推断最终选图。
|
||||
- 形态提示词、形态图片及主体身份文本批处理均可发送可选 `module`;省略时保持全项目行为。
|
||||
- 场景/道具身份母版使用 `/projects/:projectId/subject-identities/anchors/generate`,请求包含 `module`、`limit`、`concurrency`;成功项已由后端设为 Anchor 并锁定,不再由前端逐项补 PUT。
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
| 拆解与恢复 | 拆解设置与恢复 | `breakdown-preview`、`breakdown/start`、`retry`、`resume-shots`、`resume-storyboard` | 保留正式剧集分组、模块选择、预览、失败恢复与 JSON 导出 |
|
||||
| 运行诊断 | 创作/拆解的执行记录 → 查看完整运行诊断 | GET `metrics`、`timeline/grouped`、共享 `checkpoints` | 新增全量时间线、阶段分组、工作流与节点筛选、独立失败提示、JSON 导出 |
|
||||
| 视觉风格 | 视觉风格 | GET/PUT/POST `visual-style` 及图片登记、启停、删除 | 保留锁定、分类提示词、硬约束、AI 生成与图片记录 |
|
||||
| 项目资产库 | 资产库/视觉风格参考图选择器 | `/projects/:id/assets` 上传、列表、详情、编辑、删除 | 新增 JPEG/PNG/WebP 上传、名称与分类管理、筛选预览;被视觉风格引用时由后端阻止删除 |
|
||||
| 稳定身份 | 主体身份 | `subjects/:id/identity`、单个/项目/角色文本生成 | 保留人工修改、锁定及批量文本生成 |
|
||||
| 角色选角 | 主体身份角色模块 | `character-casting/readiness`、批量/单个 candidates、图片 `casting` | 保留候选、检查、确认母版并原子锁定 |
|
||||
| 身份参考图 | 主体身份图库 | `identity/images`、图片 `anchor` | 保留完整预览、历史、辅助视角与母版切换;修正道具继承说明 |
|
||||
@@ -77,3 +78,20 @@
|
||||
- 已通过 lint、格式、TypeScript、Vitest(含质量链路及菜单/折叠交互回归)和生产构建;定位回归见 `src/features/subject-images/asset-impact.test.ts`。测试不触发真实模型或生产任务。
|
||||
- 当前环境无法打开应用预览,未进行浏览器视觉验收;需在可访问的开发环境检查浅/暗主题和窄屏布局,并由用户明确批准后进行真实小批量模型联调。
|
||||
|
||||
## 2026-09-21 项目资产与形态参考同步
|
||||
|
||||
核对后端 dev `d9ca93ef54a4c5fbe3917c504531a45f31cb9966`。新增独立项目资产库页面,接入图片上传、列表筛选、详情预览、名称/分类编辑和删除;上传使用 multipart,前端与后端同时限制 JPEG、PNG、WebP 及单张 20MB。视觉风格页可按需读取资产库并以正式 `projectAssetId` 登记参考图,仍保留外部 URL 兼容入口。后端负责校验资产归属、图片类型和删除引用保护。
|
||||
|
||||
启用的视觉风格参考图会由后端编译为形态生图的分类参考职责,身份母版继续承担主体一致性。前端不拼接 Provider Prompt,也不把资产库图片自动设为形态主图;已有形态图不会因资产或风格参考变化被客户端静默替换。
|
||||
|
||||
## 2026-09-22 生图预览与主体模块批处理
|
||||
|
||||
核对后端 dev `9d927cd3e9d7e01415ad953bf3a0957bc3825050`,补齐以下正式能力:
|
||||
|
||||
- 形态生图弹窗接入 POST `/subject-forms/:subjectFormId/images/preview`,展示后端实际 Provider、模型、参考图顺序、生成配置与最终 Prompt;预览不创建生图任务。
|
||||
- 首帧弹窗接入 POST `/storyboard-shots/:shotId/keyframe/preview`,展示视觉风格、身份母版和形态主图经过去重与 Provider 上限规划后的实际选中/省略结果。
|
||||
- 形态提示词与形态图片批处理支持 `module=character|scene|prop`,前端提供统一批处理范围选择,不再只能执行全项目全部类型。
|
||||
- 身份文本批处理支持模块筛选;场景/道具新增稳定母版小批量入口,成功后由后端原子设为 Anchor 并锁定 Identity,前端保留部分失败回执。
|
||||
- 全局 GET `/assets` 已由现有资产库页面使用,无需新增重复页面。
|
||||
|
||||
预览结果只用于提交前核对,不作为持久化任务或质量通过证明;真正生成仍需费用确认,并以刷新后的正式资产记录为准。
|
||||
|
||||
+18
-12
@@ -15,6 +15,7 @@ import {
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
Clapperboard,
|
||||
Archive,
|
||||
Camera,
|
||||
Images,
|
||||
Palette,
|
||||
@@ -22,11 +23,12 @@ import {
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Layers,
|
||||
Menu,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Settings2,
|
||||
X
|
||||
} from '@lucide/vue'
|
||||
import { AppDialog } from './components/ui'
|
||||
import { AppDialog, StudioLogo } from './components/ui'
|
||||
import { useTheme } from './composables/useTheme'
|
||||
import ThemeToggle from './components/ui/ThemeToggle.vue'
|
||||
import { projectAccessKey, type ProjectAccess } from './features/projects/access'
|
||||
@@ -124,6 +126,11 @@ const menuOptions = computed<MenuOption[]>(() => {
|
||||
label: () => h(RouterLink, { to: '/projects' }, () => h('span', { class: 'admin-menu-label' }, '我的剧本')),
|
||||
icon: createMenuIcon(FolderOpen)
|
||||
},
|
||||
{
|
||||
key: '/assets',
|
||||
label: () => h(RouterLink, { to: '/assets' }, () => h('span', { class: 'admin-menu-label' }, '资产库')),
|
||||
icon: createMenuIcon(Archive)
|
||||
},
|
||||
...(projectId.value
|
||||
? [
|
||||
{ type: 'divider' as const, key: 'divider' },
|
||||
@@ -189,7 +196,7 @@ const menuOptions = computed<MenuOption[]>(() => {
|
||||
:class="{ 'is-collapsed': collapsed }"
|
||||
aria-label="短剧工作台首页"
|
||||
>
|
||||
<Clapperboard :size="narrow ? NAV_ICON_SIZE : 24" /><span
|
||||
<StudioLogo :size="narrow ? 23 : 27" /><span class="admin-brand-copy"
|
||||
>短剧工作台<small>SHORT DRAMA STUDIO</small></span
|
||||
>
|
||||
</RouterLink>
|
||||
@@ -243,7 +250,9 @@ const menuOptions = computed<MenuOption[]>(() => {
|
||||
aria-controls="main-navigation"
|
||||
@click="toggleNav"
|
||||
>
|
||||
<template #icon><Menu :size="20" /></template>
|
||||
<template #icon
|
||||
><component :is="collapsed ? PanelLeftOpen : PanelLeftClose" :size="20"
|
||||
/></template>
|
||||
</NButton>
|
||||
<span class="truncate text-sm">{{ route.meta.title || '工作空间' }}</span>
|
||||
</div>
|
||||
@@ -356,20 +365,17 @@ const menuOptions = computed<MenuOption[]>(() => {
|
||||
@apply flex shrink-0 items-center gap-3 h-[74px] overflow-hidden py-0 px-5 text-ink whitespace-nowrap;
|
||||
min-width: var(--admin-sider-expanded);
|
||||
}
|
||||
.admin-brand > svg {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.admin-brand span {
|
||||
.admin-brand-copy {
|
||||
@apply text-[15px] font-semibold;
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
.admin-sider.is-collapsed .admin-brand span {
|
||||
.admin-sider.is-collapsed .admin-brand-copy {
|
||||
opacity: 0;
|
||||
}
|
||||
.admin-brand small {
|
||||
.admin-brand-copy small {
|
||||
@apply block text-[8px] tracking-[1px] font-normal text-muted mt-[3px];
|
||||
}
|
||||
.admin-topbar {
|
||||
@@ -519,7 +525,7 @@ const menuOptions = computed<MenuOption[]>(() => {
|
||||
.admin-brand {
|
||||
@apply h-12 min-w-0 flex-1 justify-start px-3.5;
|
||||
}
|
||||
.admin-sider.is-collapsed .admin-brand span,
|
||||
.admin-sider.is-collapsed .admin-brand-copy,
|
||||
.admin-sider.is-collapsed .n-menu-item-content-header,
|
||||
.admin-sider.is-collapsed .admin-settings-label {
|
||||
opacity: 1;
|
||||
@@ -539,7 +545,7 @@ const menuOptions = computed<MenuOption[]>(() => {
|
||||
.admin-sider.is-collapsed .admin-sider-footer,
|
||||
.admin-sider .n-menu-item-content-header,
|
||||
.admin-menu-label,
|
||||
.admin-brand span,
|
||||
.admin-brand-copy,
|
||||
.admin-settings-label {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NImage } from 'naive-ui'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ImageOff } from '@lucide/vue'
|
||||
import { referenceImageUrl } from '../../lib/assets'
|
||||
import RevealImage from './RevealImage.vue'
|
||||
|
||||
/** 图片统一铺满容器;原图预览按场景开启,历史选择按钮不触发预览。 */
|
||||
const props = withDefaults(
|
||||
@@ -28,16 +29,12 @@ watch(
|
||||
|
||||
<template>
|
||||
<div class="asset-image">
|
||||
<NImage
|
||||
<RevealImage
|
||||
v-if="url && !failed"
|
||||
:src="url"
|
||||
:preview-src="url"
|
||||
:alt="alt"
|
||||
lazy
|
||||
:preview-disabled="!preview"
|
||||
:preview="preview"
|
||||
:object-fit="objectFit"
|
||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||||
:previewed-img-props="{ alt, referrerpolicy: 'no-referrer' }"
|
||||
@error="failed = true"
|
||||
/>
|
||||
<span v-else class="asset-image-empty"
|
||||
@@ -63,6 +60,7 @@ watch(
|
||||
.asset-image-empty {
|
||||
@apply flex items-center justify-center flex-col gap-2.5 p-5 text-muted text-[11px] text-center;
|
||||
}
|
||||
.asset-image .reveal-image,
|
||||
.asset-image .n-image {
|
||||
@apply w-full h-full min-h-0 min-w-0 flex justify-center;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { ref } from 'vue'
|
||||
import { NButton, NCollapse, NCollapseItem } from 'naive-ui'
|
||||
|
||||
/** 保留说明与诊断的可发现入口,默认不占据主内容区域;阻塞错误不应放入此组件。 */
|
||||
/** 说明与诊断默认直接展示,同时保留手动收起能力;阻塞错误不应放入此组件。 */
|
||||
withDefaults(defineProps<{ title?: string }>(), { title: '使用说明' })
|
||||
const expanded = ref<string[]>([])
|
||||
const expanded = ref<string[]>(['details'])
|
||||
/** 原生按钮保留 Enter/Space 键盘激活;文字点击不再向外重复触发折叠。 */
|
||||
function toggle() {
|
||||
expanded.value = expanded.value.length ? [] : ['details']
|
||||
@@ -33,6 +33,12 @@ defineExpose({
|
||||
.detail-disclosure.n-collapse {
|
||||
@apply text-xs text-muted;
|
||||
}
|
||||
.detail-disclosure .n-button,
|
||||
.detail-disclosure .n-button:hover,
|
||||
.detail-disclosure .n-button:focus,
|
||||
.detail-disclosure .n-button:active {
|
||||
color: var(--app-ink) !important;
|
||||
}
|
||||
.detail-disclosure-content {
|
||||
@apply py-3 px-3.5 bg-(--app-subtle) text-ink leading-[1.8];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { NImage } from 'naive-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
/** 全局图片加载组件参数。 */
|
||||
interface Props {
|
||||
/** 图片地址。 */
|
||||
src: string
|
||||
/** 图片替代文本。 */
|
||||
alt: string
|
||||
/** 图片在容器内的适应方式。 */
|
||||
objectFit?: 'contain' | 'cover'
|
||||
/** 是否允许查看原图。 */
|
||||
preview?: boolean
|
||||
/** 是否启用浏览器原生懒加载。 */
|
||||
lazy?: boolean
|
||||
}
|
||||
|
||||
/** 全局图片加载组件事件。 */
|
||||
interface Emits {
|
||||
/** 图片加载失败事件。 */
|
||||
error: [event: Event]
|
||||
}
|
||||
|
||||
/** 图片展示参数。 */
|
||||
const props = withDefaults(defineProps<Props>(), { objectFit: 'cover', preview: false, lazy: true })
|
||||
|
||||
/** 图片加载事件。 */
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 图片是否已经加载完成。 */
|
||||
const loaded = ref(false)
|
||||
|
||||
/** 图片地址变化后恢复加载占位状态。 */
|
||||
function reset() {
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
/** 图片加载完成后直接切换为淡入状态。 */
|
||||
function reveal() {
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 转发图片加载失败事件。
|
||||
* @param event 图片加载失败事件
|
||||
*/
|
||||
function fail(event: Event) {
|
||||
emit('error', event)
|
||||
}
|
||||
|
||||
/** 监听图片地址变化并重置加载状态。 */
|
||||
watch(() => props.src, reset)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="reveal-image" :class="{ 'is-loaded': loaded }" :aria-busy="!loaded">
|
||||
<NImage
|
||||
:src="src"
|
||||
:preview-src="src"
|
||||
:alt="alt"
|
||||
:lazy="lazy"
|
||||
:preview-disabled="!preview"
|
||||
:object-fit="objectFit"
|
||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||||
:previewed-img-props="{ alt, referrerpolicy: 'no-referrer' }"
|
||||
@load="reveal"
|
||||
@error="fail"
|
||||
/>
|
||||
<!-- 以下是 Instagram 风格的图片加载骨架模块 -->
|
||||
<span v-if="!loaded" class="reveal-image-placeholder" aria-hidden="true" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@reference "../../styles/styles.css";
|
||||
.reveal-image {
|
||||
@apply relative block w-full h-full min-w-0 min-h-0 overflow-hidden bg-(--app-subtle);
|
||||
}
|
||||
.reveal-image > .n-image,
|
||||
.reveal-image > .n-image img {
|
||||
@apply w-full h-full min-w-0 min-h-0;
|
||||
}
|
||||
.reveal-image > .n-image img {
|
||||
opacity: 0;
|
||||
transition: opacity 220ms cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
.reveal-image.is-loaded > .n-image img {
|
||||
opacity: 1;
|
||||
}
|
||||
.reveal-image-placeholder {
|
||||
@apply absolute inset-0 pointer-events-none;
|
||||
background-color: var(--app-control);
|
||||
animation: reveal-image-pulse 1.2s cubic-bezier(0.65, 0, 0.35, 1) infinite alternate;
|
||||
}
|
||||
@keyframes reveal-image-pulse {
|
||||
from {
|
||||
opacity: 0.72;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal-image > .n-image img,
|
||||
.reveal-image-placeholder {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { Film, Sparkles } from '@lucide/vue'
|
||||
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 28 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="studio-logo" :style="{ width: `${size}px`, height: `${size}px` }" aria-hidden="true">
|
||||
<Film class="studio-logo-film" :size="size" :stroke-width="1.8" />
|
||||
<Sparkles class="studio-logo-ai" :size="Math.round(size * 0.46)" :stroke-width="2.4" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.studio-logo {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
color: var(--app-ink);
|
||||
}
|
||||
.studio-logo-film {
|
||||
display: block;
|
||||
}
|
||||
.studio-logo-ai {
|
||||
position: absolute;
|
||||
top: -12%;
|
||||
right: -15%;
|
||||
color: var(--app-accent-text);
|
||||
fill: var(--app-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,8 @@
|
||||
export { default as AppDialog } from './AppDialog.vue'
|
||||
export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as StatusBadge } from './StatusBadge.vue'
|
||||
export { default as StudioLogo } from './StudioLogo.vue'
|
||||
export { default as AssetImage } from './AssetImage.vue'
|
||||
export { default as RevealImage } from './RevealImage.vue'
|
||||
export { default as DirectoryItem } from './DirectoryItem.vue'
|
||||
export { default as WorkspaceTools } from './WorkspaceTools.vue'
|
||||
|
||||
@@ -78,3 +78,4 @@ export function usePolling<T>(
|
||||
})
|
||||
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 }
|
||||
}
|
||||
@@ -58,11 +58,11 @@ const filtered = computed(() =>
|
||||
>
|
||||
{{ subject.description }}
|
||||
</p>
|
||||
<div v-if="(subject.description?.length ?? 0) > 160 || projectId" class="subject-record-actions">
|
||||
<NButton
|
||||
v-if="(subject.description?.length ?? 0) > 160"
|
||||
text
|
||||
size="small"
|
||||
class="mt-2"
|
||||
:aria-expanded="expanded.has(subject.profileId)"
|
||||
@click="toggleDescription(subject.profileId)"
|
||||
>{{ expanded.has(subject.profileId) ? '收起介绍' : '展开完整介绍' }}</NButton
|
||||
@@ -70,16 +70,17 @@ const filtered = computed(() =>
|
||||
<RouterLink
|
||||
v-if="projectId"
|
||||
:to="{ path: `/projects/${projectId}/subject-identity`, query: { subjectRef: subject.ref } }"
|
||||
class="text-button mt-3 mr-4"
|
||||
class="text-button"
|
||||
>管理稳定身份/母版</RouterLink
|
||||
>
|
||||
<RouterLink
|
||||
v-if="projectId"
|
||||
:to="{ path: `/projects/${projectId}/subject-images`, query: { subjectRef: subject.ref } }"
|
||||
class="text-button mt-3"
|
||||
class="text-button"
|
||||
>查看形态图片/生图</RouterLink
|
||||
>
|
||||
<NCollapse class="subject-details mt-4"
|
||||
</div>
|
||||
<NCollapse class="subject-details mt-4" :default-expanded-names="['details']"
|
||||
><NCollapseItem name="details"
|
||||
><template #header
|
||||
>外观描述与形态
|
||||
@@ -128,6 +129,9 @@ const filtered = computed(() =>
|
||||
.subject-form-card {
|
||||
@apply mt-3 p-4 min-w-0 wrap-anywhere bg-(--app-subtle);
|
||||
}
|
||||
.subject-record-actions {
|
||||
@apply mt-4 flex flex-wrap items-center gap-y-2 gap-x-4;
|
||||
}
|
||||
.subject-record-list > article:nth-child(even) .subject-form-card {
|
||||
@apply bg-(--app-control);
|
||||
}
|
||||
|
||||
@@ -135,12 +135,6 @@ function exportScript() {
|
||||
]"
|
||||
@select="$event === 'history' ? (showHistory = !showHistory) : exportScript()"
|
||||
/>
|
||||
<RouterLink
|
||||
v-if="complete"
|
||||
class="text-button text-accent script-next-link"
|
||||
:to="`/projects/${project?.id}/breakdown`"
|
||||
>进入拆解<ArrowRight :size="14"
|
||||
/></RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</NTabs>
|
||||
@@ -198,7 +192,10 @@ function exportScript() {
|
||||
<dd>{{ project.world[field.key] || '未提供' }}</dd></template
|
||||
>
|
||||
</dl>
|
||||
<NCollapse v-if="project.world.rules" class="mt-6 text-sm"
|
||||
<NCollapse
|
||||
v-if="project.world.rules"
|
||||
class="mt-6 text-sm"
|
||||
:default-expanded-names="['details']"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>世界规则</template>
|
||||
<NScrollbar class="code-scroll mt-3" x-scrollable>
|
||||
@@ -219,7 +216,10 @@ function exportScript() {
|
||||
v-else
|
||||
title="暂无可用审核结果"
|
||||
description="项目详情不再提供审核记录;可在执行记录中查看工作流状态。"
|
||||
/><NCollapse v-if="state?.rewriteSuggestion" class="mt-5 text-sm"
|
||||
/><NCollapse
|
||||
v-if="state?.rewriteSuggestion"
|
||||
class="mt-5 text-sm"
|
||||
:default-expanded-names="['details']"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>查看改写建议原文</template>
|
||||
<NScrollbar class="code-scroll mt-3" x-scrollable>
|
||||
@@ -298,9 +298,6 @@ function exportScript() {
|
||||
.script-section .tabs-toolbar-actions {
|
||||
@apply min-h-0 flex-nowrap;
|
||||
}
|
||||
.script-section .tabs-toolbar-actions .script-next-link {
|
||||
@apply hidden;
|
||||
}
|
||||
.script-section .content-with-history:not(.history-hidden) {
|
||||
@apply relative;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
@@ -229,6 +229,7 @@ onBeforeUnmount(() => observer?.disconnect())
|
||||
@apply outline-[2px_solid_var(--app-accent)] outline-offset-[-3px];
|
||||
}
|
||||
.reader-content {
|
||||
--reader-gutter: 32px;
|
||||
@apply mx-0 p-8 text-left;
|
||||
/* 正文紧邻目录左对齐,保留阅读行宽,不在宽屏内容区居中。 */
|
||||
max-width: 900px;
|
||||
@@ -241,7 +242,9 @@ onBeforeUnmount(() => observer?.disconnect())
|
||||
min-height: max(160px, calc(var(--reader-height) - 64px));
|
||||
}
|
||||
.reader-chapter-heading {
|
||||
@apply mb-6;
|
||||
@apply sticky top-0 z-[2] mb-6 bg-(--app-surface);
|
||||
margin-inline: calc(var(--reader-gutter) * -1);
|
||||
padding: 14px var(--reader-gutter) 18px;
|
||||
}
|
||||
.reader-chapter-heading p {
|
||||
@apply text-muted text-[13px] font-medium mb-2.5;
|
||||
@@ -277,6 +280,7 @@ onBeforeUnmount(() => observer?.disconnect())
|
||||
@apply text-[11px];
|
||||
}
|
||||
.reader-content {
|
||||
--reader-gutter: 18px;
|
||||
@apply px-[18px];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { request } from '../../lib/http'
|
||||
import type {
|
||||
CreativeProfile,
|
||||
CreativeProfileInput,
|
||||
GenerationMediaType,
|
||||
GenerationModelCatalog,
|
||||
GenerationOverride,
|
||||
GenerationOverrideInput,
|
||||
GenerationOverrideTargetType,
|
||||
ResolvedGenerationConfig
|
||||
} from './types'
|
||||
|
||||
function projectPath(projectId: string) {
|
||||
return `/projects/${encodeURIComponent(projectId)}`
|
||||
}
|
||||
|
||||
function overridePath(
|
||||
projectId: string,
|
||||
targetType: GenerationOverrideTargetType,
|
||||
targetId: string,
|
||||
mediaType: GenerationMediaType
|
||||
) {
|
||||
return `${projectPath(projectId)}/generation-overrides/${targetType}/${encodeURIComponent(targetId)}/${mediaType}`
|
||||
}
|
||||
|
||||
export const generationConfigApi = {
|
||||
catalog: (signal?: AbortSignal) => request<GenerationModelCatalog>('/generation-models', { signal }),
|
||||
profile: (projectId: string, signal?: AbortSignal) =>
|
||||
request<CreativeProfile | null>(`${projectPath(projectId)}/creative-profile`, { signal }),
|
||||
saveProfile: (projectId: string, input: CreativeProfileInput) =>
|
||||
request<CreativeProfile>(`${projectPath(projectId)}/creative-profile`, { method: 'PUT', body: input }),
|
||||
resolve: (
|
||||
projectId: string,
|
||||
mediaType: GenerationMediaType,
|
||||
input: {
|
||||
provider?: string
|
||||
model?: string
|
||||
options?: Record<string, unknown>
|
||||
target?: { targetType: GenerationOverrideTargetType; targetId: string }
|
||||
}
|
||||
) =>
|
||||
request<ResolvedGenerationConfig>(`${projectPath(projectId)}/generation-config/${mediaType}/resolve`, {
|
||||
method: 'POST',
|
||||
body: input
|
||||
}),
|
||||
override: (
|
||||
projectId: string,
|
||||
targetType: GenerationOverrideTargetType,
|
||||
targetId: string,
|
||||
mediaType: GenerationMediaType,
|
||||
signal?: AbortSignal
|
||||
) => request<GenerationOverride | null>(overridePath(projectId, targetType, targetId, mediaType), { signal }),
|
||||
saveOverride: (
|
||||
projectId: string,
|
||||
targetType: GenerationOverrideTargetType,
|
||||
targetId: string,
|
||||
mediaType: GenerationMediaType,
|
||||
input: GenerationOverrideInput
|
||||
) =>
|
||||
request<GenerationOverride>(overridePath(projectId, targetType, targetId, mediaType), {
|
||||
method: 'PUT',
|
||||
body: input
|
||||
}),
|
||||
deleteOverride: (
|
||||
projectId: string,
|
||||
targetType: GenerationOverrideTargetType,
|
||||
targetId: string,
|
||||
mediaType: GenerationMediaType
|
||||
) => request<unknown>(overridePath(projectId, targetType, targetId, mediaType), { method: 'DELETE' })
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NCollapse, NCollapseItem, NFormItem, NSelect, NSpin } from 'naive-ui'
|
||||
import { errorMessage } from '../../../lib/http'
|
||||
import { generationConfigApi } from '../api'
|
||||
import type {
|
||||
CreativeProfileAspectRatio,
|
||||
CreativeProfileInput,
|
||||
GenerationModelCatalog,
|
||||
GenerationModelItem,
|
||||
GenerationOptions
|
||||
} from '../types'
|
||||
import GenerationOptionFields from './GenerationOptionFields.vue'
|
||||
|
||||
const props = defineProps<{ projectId: string }>()
|
||||
const emit = defineEmits<{ saved: [profile: CreativeProfileInput] }>()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const notice = ref('')
|
||||
const catalog = ref<GenerationModelCatalog | null>(null)
|
||||
const profile = ref<CreativeProfileInput | null>(null)
|
||||
let controller: AbortController | null = null
|
||||
|
||||
const aspectRatios = ['9:16', '16:9', '1:1', '4:3', '3:4'].map(value => ({ label: value, value }))
|
||||
const enabledImages = computed(() => catalog.value?.images?.filter(item => item.enabled && item.model) ?? [])
|
||||
const enabledVideos = computed(() => catalog.value?.videos?.filter(item => item.enabled && item.model) ?? [])
|
||||
const imageSelection = computed(() =>
|
||||
profile.value ? selectionValue(profile.value.imageProvider, profile.value.imageModel) : null
|
||||
)
|
||||
const videoSelection = computed(() =>
|
||||
profile.value ? selectionValue(profile.value.videoProvider, profile.value.videoModel) : null
|
||||
)
|
||||
const imageModel = computed(() =>
|
||||
enabledImages.value.find(item =>
|
||||
profile.value ? item.provider === profile.value.imageProvider && item.model === profile.value.imageModel : false
|
||||
)
|
||||
)
|
||||
const videoModel = computed(() =>
|
||||
enabledVideos.value.find(item =>
|
||||
profile.value ? item.provider === profile.value.videoProvider && item.model === profile.value.videoModel : false
|
||||
)
|
||||
)
|
||||
|
||||
function selectionValue(provider: string, model: string) {
|
||||
return `${provider}\u0000${model}`
|
||||
}
|
||||
|
||||
function modelOptions(items: GenerationModelItem[]) {
|
||||
return items.map(item => ({
|
||||
label: `${item.providerLabel} · ${item.modelLabel}`,
|
||||
value: selectionValue(item.provider, item.model || '')
|
||||
}))
|
||||
}
|
||||
|
||||
function defaultOptions(item: GenerationModelItem): GenerationOptions {
|
||||
return Object.fromEntries(
|
||||
item.generationOptions.flatMap(schema => {
|
||||
if (schema.providerDefault !== undefined) return [[schema.key, schema.providerDefault]]
|
||||
if (!schema.required) return []
|
||||
if (schema.type === 'boolean') return [[schema.key, false]]
|
||||
if (schema.type === 'select' && schema.options?.length) {
|
||||
const first = schema.options[0]
|
||||
return first ? [[schema.key, first.value]] : []
|
||||
}
|
||||
if ((schema.type === 'integer' || schema.type === 'number') && schema.min !== undefined)
|
||||
return [[schema.key, schema.min]]
|
||||
if (schema.type === 'size') return [[schema.key, 'auto']]
|
||||
return []
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function selectModel(type: 'image' | 'video', value: string) {
|
||||
if (!profile.value) return
|
||||
const items = type === 'image' ? enabledImages.value : enabledVideos.value
|
||||
const item = items.find(row => selectionValue(row.provider, row.model || '') === value)
|
||||
if (!item?.model) return
|
||||
if (type === 'image') {
|
||||
profile.value.imageProvider = item.provider
|
||||
profile.value.imageModel = item.model
|
||||
profile.value.imageOptions = defaultOptions(item)
|
||||
} else {
|
||||
profile.value.videoProvider = item.provider
|
||||
profile.value.videoModel = item.model
|
||||
profile.value.videoOptions = defaultOptions(item)
|
||||
}
|
||||
}
|
||||
|
||||
function createInitial(nextCatalog: GenerationModelCatalog): CreativeProfileInput | null {
|
||||
const image =
|
||||
nextCatalog.images.find(item => item.enabled && item.isDefault && item.model) ??
|
||||
nextCatalog.images.find(item => item.enabled && item.model)
|
||||
const video =
|
||||
nextCatalog.videos.find(item => item.enabled && item.isDefault && item.model) ??
|
||||
nextCatalog.videos.find(item => item.enabled && item.model)
|
||||
if (!image?.model || !video?.model) return null
|
||||
return {
|
||||
aspectRatio: '9:16',
|
||||
imageProvider: image.provider,
|
||||
imageModel: image.model,
|
||||
imageOptions: defaultOptions(image),
|
||||
videoProvider: video.provider,
|
||||
videoModel: video.model,
|
||||
videoOptions: defaultOptions(video)
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
try {
|
||||
const [nextCatalog, saved] = await Promise.all([
|
||||
generationConfigApi.catalog(controller.signal),
|
||||
generationConfigApi.profile(props.projectId, controller.signal)
|
||||
])
|
||||
catalog.value = nextCatalog
|
||||
profile.value = saved
|
||||
? {
|
||||
aspectRatio: saved.aspectRatio,
|
||||
imageProvider: saved.imageProvider,
|
||||
imageModel: saved.imageModel,
|
||||
imageOptions: { ...saved.imageOptions },
|
||||
videoProvider: saved.videoProvider,
|
||||
videoModel: saved.videoModel,
|
||||
videoOptions: { ...saved.videoOptions }
|
||||
}
|
||||
: createInitial(nextCatalog)
|
||||
} catch (cause) {
|
||||
if (!controller.signal.aborted) error.value = errorMessage(cause)
|
||||
} finally {
|
||||
if (!controller.signal.aborted) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!profile.value || saving.value) return
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
try {
|
||||
const saved = await generationConfigApi.saveProfile(props.projectId, profile.value)
|
||||
profile.value = {
|
||||
aspectRatio: saved.aspectRatio,
|
||||
imageProvider: saved.imageProvider,
|
||||
imageModel: saved.imageModel,
|
||||
imageOptions: { ...saved.imageOptions },
|
||||
videoProvider: saved.videoProvider,
|
||||
videoModel: saved.videoModel,
|
||||
videoOptions: { ...saved.videoOptions }
|
||||
}
|
||||
notice.value = '项目生成配置已保存,后续首帧和视频生成会继承此配置。'
|
||||
emit('saved', profile.value)
|
||||
} catch (cause) {
|
||||
error.value = errorMessage(cause)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.projectId, load, { immediate: true })
|
||||
onScopeDispose(() => controller?.abort())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCollapse class="production-tool-section creative-profile-settings" :default-expanded-names="['creative-profile']">
|
||||
<NCollapseItem name="creative-profile" title="项目画布与生成模型">
|
||||
<NSpin :show="loading">
|
||||
<NAlert v-if="error" type="error" :show-icon="false" class="mb-3">{{ error }}</NAlert>
|
||||
<NAlert v-if="notice" type="success" :show-icon="false" class="mb-3">{{ notice }}</NAlert>
|
||||
<NAlert
|
||||
v-if="!loading && (!enabledImages.length || !enabledVideos.length)"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
>
|
||||
后端尚未启用完整的图片和视频模型,暂时无法保存项目生成配置。
|
||||
</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="图片模型">
|
||||
<NSelect
|
||||
:value="imageSelection"
|
||||
:options="modelOptions(enabledImages)"
|
||||
:disabled="saving"
|
||||
@update:value="selectModel('image', $event)"
|
||||
/>
|
||||
</NFormItem>
|
||||
<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>
|
||||
<GenerationOptionFields
|
||||
v-model="profile.videoOptions"
|
||||
:schemas="videoModel?.generationOptions || []"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<div class="page-form-actions creative-profile-actions">
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="loading || !profile || !enabledImages.length || !enabledVideos.length"
|
||||
@click="save"
|
||||
>保存项目生成配置</NButton
|
||||
>
|
||||
<span class="text-xs text-muted">切换模型时会按新模型重置生成参数。</span>
|
||||
</div>
|
||||
</NSpin>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "../../../styles/styles.css";
|
||||
.creative-profile-grid {
|
||||
@apply grid grid-cols-[repeat(2,_minmax(0,_1fr))] items-start gap-x-5 gap-y-6;
|
||||
}
|
||||
.model-section {
|
||||
@apply min-w-0 p-4 bg-(--app-subtle);
|
||||
}
|
||||
.model-section > :deep(.n-form-item) {
|
||||
@apply mb-6;
|
||||
}
|
||||
.creative-profile-grid > :deep(.n-form-item--top-labelled),
|
||||
.model-section > :deep(.n-form-item--top-labelled) {
|
||||
grid-template-rows: minmax(var(--n-label-height), auto) auto auto;
|
||||
}
|
||||
.creative-profile-grid > :deep(.n-form-item-label),
|
||||
.model-section > :deep(.n-form-item-label) {
|
||||
min-height: 30px;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
.creative-profile-grid > :deep(.n-form-item-blank),
|
||||
.model-section > :deep(.n-form-item-blank) {
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
}
|
||||
.creative-profile-settings {
|
||||
@apply mb-7;
|
||||
}
|
||||
.creative-profile-actions {
|
||||
@apply mt-5 pt-1;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.creative-profile-grid {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
}
|
||||
.creative-profile-grid > div:empty {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { NCheckbox, NFormItem, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import type { GenerationOptionSchema, GenerationOptions } from '../types'
|
||||
|
||||
defineProps<{ schemas: GenerationOptionSchema[]; modelValue: GenerationOptions; disabled?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: GenerationOptions] }>()
|
||||
|
||||
function update(model: GenerationOptions, key: string, value: string | number | boolean | null) {
|
||||
const next = { ...model }
|
||||
if (value === null || value === '') delete next[key]
|
||||
else next[key] = value
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
function selectValue(value: string | number | boolean | undefined) {
|
||||
return typeof value === 'string' || typeof value === 'number' ? value : null
|
||||
}
|
||||
|
||||
function selectOptions(schema: GenerationOptionSchema) {
|
||||
return [...(schema.options || []), ...(schema.specialValues || [])].flatMap(option =>
|
||||
typeof option.value === 'string' || typeof option.value === 'number'
|
||||
? [{ label: option.label, value: option.value }]
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
function isDuration(schema: GenerationOptionSchema) {
|
||||
return /duration|时长/i.test(`${schema.key} ${schema.label}`)
|
||||
}
|
||||
|
||||
function optionKey(value: string | number) {
|
||||
return `${typeof value}:${value}`
|
||||
}
|
||||
|
||||
/** 尺寸和时长使用有限选项,避免用户输入 Provider 不支持的自由值。 */
|
||||
function constrainedOptions(schema: GenerationOptionSchema, current: string | number | boolean | undefined) {
|
||||
const options = selectOptions(schema)
|
||||
if (schema.type === 'size') {
|
||||
for (const value of ['1K', '1.5K', '2K']) options.push({ label: value, value })
|
||||
} else if (
|
||||
isDuration(schema) &&
|
||||
Number.isInteger(schema.min) &&
|
||||
Number.isInteger(schema.max) &&
|
||||
schema.max! >= schema.min! &&
|
||||
schema.max! - schema.min! <= 60
|
||||
) {
|
||||
for (let value = schema.min!; value <= schema.max!; value++) {
|
||||
options.push({ label: `${value} 秒`, value })
|
||||
}
|
||||
}
|
||||
if (typeof schema.providerDefault === 'string' || typeof schema.providerDefault === 'number') {
|
||||
options.push({ label: String(schema.providerDefault), value: schema.providerDefault })
|
||||
}
|
||||
if (typeof current === 'string' || typeof current === 'number') {
|
||||
options.push({ label: String(current), value: current })
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
return options.filter(option => {
|
||||
const key = optionKey(option.value)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="schemas.length" class="generation-option-grid">
|
||||
<NFormItem
|
||||
v-for="schema in schemas"
|
||||
:key="schema.key"
|
||||
:label="`${schema.label}${schema.required ? ' *' : ''}`"
|
||||
:feedback="schema.description"
|
||||
:show-feedback="!!schema.description"
|
||||
>
|
||||
<NCheckbox
|
||||
v-if="schema.type === 'boolean'"
|
||||
:checked="modelValue[schema.key] === true"
|
||||
:disabled="disabled"
|
||||
@update:checked="update(modelValue, schema.key, $event)"
|
||||
>启用</NCheckbox
|
||||
>
|
||||
<NSelect
|
||||
v-else-if="schema.type === 'select' || schema.type === 'size' || isDuration(schema)"
|
||||
:value="selectValue(modelValue[schema.key])"
|
||||
:options="constrainedOptions(schema, modelValue[schema.key])"
|
||||
:disabled="disabled"
|
||||
:clearable="!schema.required"
|
||||
@update:value="update(modelValue, schema.key, $event)"
|
||||
/>
|
||||
<NInputNumber
|
||||
v-else-if="schema.type === 'integer' || schema.type === 'number'"
|
||||
:value="typeof modelValue[schema.key] === 'number' ? (modelValue[schema.key] as number) : null"
|
||||
:min="schema.min"
|
||||
:max="schema.max"
|
||||
:precision="schema.type === 'integer' ? 0 : undefined"
|
||||
:disabled="disabled"
|
||||
clearable
|
||||
@update:value="update(modelValue, schema.key, $event)"
|
||||
/>
|
||||
<NInput
|
||||
v-else
|
||||
:value="typeof modelValue[schema.key] === 'string' ? (modelValue[schema.key] as string) : ''"
|
||||
:disabled="disabled"
|
||||
placeholder="例如 2048x2048 或 auto"
|
||||
clearable
|
||||
@update:value="update(modelValue, schema.key, $event)"
|
||||
/>
|
||||
</NFormItem>
|
||||
</div>
|
||||
<p v-else class="text-xs text-muted">当前模型没有开放额外生成参数。</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "../../../styles/styles.css";
|
||||
.generation-option-grid {
|
||||
@apply grid grid-cols-[repeat(2,_minmax(0,_1fr))] items-start gap-x-4 gap-y-6;
|
||||
}
|
||||
.generation-option-grid :deep(.n-form-item) {
|
||||
@apply min-w-0 mb-0 self-start;
|
||||
}
|
||||
/* Naive 默认使用 1fr 扩展控件行;同排说明文字不等高时会把较短字段的控件推到底部。 */
|
||||
.generation-option-grid :deep(.n-form-item--top-labelled) {
|
||||
grid-template-rows: minmax(var(--n-label-height), auto) auto auto;
|
||||
}
|
||||
.generation-option-grid :deep(.n-form-item-label) {
|
||||
min-height: 30px;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
.generation-option-grid :deep(.n-form-item-blank) {
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
}
|
||||
.generation-option-grid :deep(.n-form-item-feedback-wrapper:not(:empty)) {
|
||||
min-height: 0;
|
||||
padding: 8px 0 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.generation-option-grid {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
export type CreativeProfileAspectRatio = '9:16' | '16:9' | '1:1' | '4:3' | '3:4'
|
||||
|
||||
export type GenerationOptionValue = string | number | boolean
|
||||
export type GenerationOptions = Record<string, GenerationOptionValue>
|
||||
|
||||
export interface GenerationOptionChoice {
|
||||
label: string
|
||||
value: GenerationOptionValue
|
||||
}
|
||||
|
||||
export interface GenerationOptionSchema {
|
||||
key: string
|
||||
label: string
|
||||
type: 'boolean' | 'integer' | 'number' | 'select' | 'size'
|
||||
required: boolean
|
||||
options?: GenerationOptionChoice[]
|
||||
min?: number
|
||||
max?: number
|
||||
specialValues?: GenerationOptionChoice[]
|
||||
providerDefault?: GenerationOptionValue
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface GenerationModelItem {
|
||||
type: 'image' | 'video'
|
||||
provider: string
|
||||
providerLabel: string
|
||||
model: string | null
|
||||
modelLabel: string
|
||||
supported: boolean
|
||||
enabled: boolean
|
||||
isDefault: boolean
|
||||
capabilities: Record<string, unknown>
|
||||
generationOptions: GenerationOptionSchema[]
|
||||
}
|
||||
|
||||
export interface GenerationModelCatalog {
|
||||
defaults: { imageProvider: string; videoProvider: string }
|
||||
images: GenerationModelItem[]
|
||||
videos: GenerationModelItem[]
|
||||
}
|
||||
|
||||
export interface CreativeProfileInput {
|
||||
aspectRatio: CreativeProfileAspectRatio
|
||||
imageProvider: string
|
||||
imageModel: string
|
||||
imageOptions: GenerationOptions
|
||||
videoProvider: string
|
||||
videoModel: string
|
||||
videoOptions: GenerationOptions
|
||||
}
|
||||
|
||||
export interface CreativeProfile extends CreativeProfileInput {
|
||||
id?: string
|
||||
projectId: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface ResolvedGenerationConfig {
|
||||
provider: string
|
||||
model: string
|
||||
options: GenerationOptions
|
||||
}
|
||||
|
||||
export type GenerationOverrideTargetType = 'shot' | 'asset'
|
||||
export type GenerationMediaType = 'image' | 'video'
|
||||
|
||||
export interface GenerationOverrideInput {
|
||||
provider?: string | null
|
||||
model?: string | null
|
||||
options: GenerationOptions
|
||||
}
|
||||
|
||||
export interface GenerationOverride extends GenerationOverrideInput {
|
||||
projectId: string
|
||||
targetType: GenerationOverrideTargetType
|
||||
targetId: string
|
||||
mediaType: GenerationMediaType
|
||||
}
|
||||
@@ -30,6 +30,8 @@ import { routeLocationKey } from 'vue-router'
|
||||
import { queryText } from './asset-links'
|
||||
import QualityDialog from './components/QualityDialog.vue'
|
||||
import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
|
||||
import CreativeProfileSettings from '../generation-config/components/CreativeProfileSettings.vue'
|
||||
import EpisodeAssemblyPanel from './components/EpisodeAssemblyPanel.vue'
|
||||
const qualityOpen = ref(false)
|
||||
|
||||
/** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */
|
||||
@@ -167,6 +169,17 @@ watch(
|
||||
if (receipt) toolsOpen.value = true
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => route?.query.configure,
|
||||
value => {
|
||||
if (value === 'profile') toolsOpen.value = true
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function profileSaved() {
|
||||
operation.value.error = ''
|
||||
}
|
||||
const batchModel = computed(() => ({ concurrency: concurrency.value }))
|
||||
const batchRules = { concurrency: integerRule('批量并发') }
|
||||
const keyframeModel = computed(() => ({
|
||||
@@ -225,8 +238,9 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
label="批量生产"
|
||||
:has-receipt="!!session.receipt || !!session.pipelineReceipt"
|
||||
>
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div class="production-tools-intro">
|
||||
<div>
|
||||
<p class="eyebrow">PRODUCTION</p>
|
||||
<h2 class="text-lg font-semibold">镜头生产</h2>
|
||||
<p class="mt-2 text-sm text-muted">
|
||||
从可生成的提示词和首帧,到异步视频任务与最终成片。
|
||||
@@ -236,7 +250,13 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
<ArrowLeft :size="14" />回到分镜设计
|
||||
</RouterLink>
|
||||
</div>
|
||||
<section class="mt-3" aria-label="项目生产配置">
|
||||
<section class="production-batch-config" aria-label="项目生产配置">
|
||||
<div class="production-section-heading">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">批量执行设置</h3>
|
||||
<p class="mt-1 text-xs text-muted">控制本次手动批处理的并发与覆盖方式。</p>
|
||||
</div>
|
||||
</div>
|
||||
<AppForm
|
||||
:model="batchModel"
|
||||
:rules="batchRules"
|
||||
@@ -262,14 +282,16 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
</NCheckbox>
|
||||
</div>
|
||||
</AppForm>
|
||||
<DetailDisclosure title="处理范围与覆盖规则" class="mt-3"
|
||||
</section>
|
||||
<div class="production-tool-stack">
|
||||
<CreativeProfileSettings :project-id="id" @saved="profileSaved" />
|
||||
<DetailDisclosure title="处理范围与覆盖规则" class="production-tool-section"
|
||||
><p class="text-xs leading-6 text-muted">
|
||||
提示词与视频就绪检查面向全项目;视频实际提交受本批镜头上限控制,并使用严格质量流水线。首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式处理全项目;过期首帧成功后会自动接替旧主图,其余新增候选。图片模型、视频模型与视觉校验均可能产生费用。
|
||||
</p></DetailDisclosure
|
||||
>
|
||||
</section>
|
||||
<div class="workspace-overview-panel">
|
||||
<NCollapse v-model:expanded-names="overview"
|
||||
<NCollapse v-model:expanded-names="overview" class="production-tool-section"
|
||||
><NCollapseItem name="overview" title="项目生产总览与批量操作"
|
||||
><div class="overview-content">
|
||||
<NAlert
|
||||
@@ -351,7 +373,10 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
Math.max(
|
||||
0,
|
||||
(stage.done /
|
||||
Math.max(1, Math.max(stage.data?.total ?? 0, 1))) *
|
||||
Math.max(
|
||||
1,
|
||||
Math.max(stage.data?.total ?? 0, 1)
|
||||
)) *
|
||||
100
|
||||
)
|
||||
)
|
||||
@@ -382,13 +407,15 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
v-if="stage.key === 'videos' && staleVideos"
|
||||
class="mt-3 text-[11px] leading-5"
|
||||
>
|
||||
主视频已过期 {{ staleVideos }} · 会按当前主首帧与参考资产重新生成
|
||||
主视频已过期 {{ staleVideos }} ·
|
||||
会按当前主首帧与参考资产重新生成
|
||||
</p>
|
||||
<p
|
||||
v-if="stage.key === 'keyframes' && identityBlocked"
|
||||
class="mt-3 text-[11px] leading-5 text-danger"
|
||||
>
|
||||
身份缺失 {{ query.data.value?.keyframes.missingIdentity ?? 0 }} ·
|
||||
身份缺失
|
||||
{{ query.data.value?.keyframes.missingIdentity ?? 0 }} ·
|
||||
母版缺失
|
||||
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} ·
|
||||
未锁定
|
||||
@@ -447,7 +474,9 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
><NFormItem path="keyframeHeight" label="高度(像素)"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '高度(像素)' }"
|
||||
:value="keyframeHeight === '' ? null : keyframeHeight"
|
||||
:value="
|
||||
keyframeHeight === '' ? null : keyframeHeight
|
||||
"
|
||||
@update:value="keyframeHeight = $event ?? ''"
|
||||
:min="1"
|
||||
:disabled="operation.pending"
|
||||
@@ -493,7 +522,9 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
<ConfirmAction
|
||||
label="重试失败视频"
|
||||
:disabled="
|
||||
blocked || !batchValid || !query.data.value.videoStatus.failed
|
||||
blocked ||
|
||||
!batchValid ||
|
||||
!query.data.value.videoStatus.failed
|
||||
"
|
||||
acknowledgement
|
||||
description="仅重新提交后端判定可重试的失败视频;不可重试项会跳过,并在回执中显示数量。"
|
||||
@@ -527,7 +558,9 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
></NCollapse
|
||||
>
|
||||
</div>
|
||||
<EpisodeAssemblyPanel :project-id="id" />
|
||||
<AdvancedProduction />
|
||||
</div>
|
||||
</WorkspaceTools>
|
||||
</div></div
|
||||
></template>
|
||||
@@ -691,6 +724,46 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.production-tools-intro {
|
||||
@apply flex flex-wrap items-end justify-between gap-4 p-[18px] bg-(--app-surface);
|
||||
}
|
||||
.production-tools-intro .eyebrow {
|
||||
@apply mb-2;
|
||||
}
|
||||
.production-batch-config {
|
||||
@apply mt-3 p-4 bg-(--app-surface);
|
||||
}
|
||||
.production-section-heading {
|
||||
@apply flex items-start justify-between gap-4;
|
||||
}
|
||||
.workspace-tools-body .production-batch-config .production-controls {
|
||||
@apply mt-4 mb-0;
|
||||
}
|
||||
.production-tool-stack {
|
||||
@apply mt-3 px-4 bg-(--app-surface);
|
||||
}
|
||||
.production-tool-stack .production-tool-section.n-collapse {
|
||||
@apply m-0;
|
||||
box-shadow: inset 0 1px var(--app-border);
|
||||
}
|
||||
.production-tool-stack > :first-child.production-tool-section.n-collapse {
|
||||
box-shadow: none;
|
||||
}
|
||||
.production-tool-stack .production-tool-section .n-collapse-item__header {
|
||||
@apply min-h-12 py-0;
|
||||
}
|
||||
.production-tool-stack .production-tool-section .n-collapse-item__header-main {
|
||||
@apply text-[13px] font-medium;
|
||||
}
|
||||
.production-tool-stack .production-tool-section .n-collapse-item__content-inner {
|
||||
@apply pt-1 pb-4 pl-6;
|
||||
}
|
||||
.production-tool-stack .detail-disclosure-content {
|
||||
@apply py-3 px-4 bg-(--app-subtle);
|
||||
}
|
||||
.production-tool-stack .workspace-overview-panel {
|
||||
@apply m-0;
|
||||
}
|
||||
.production-controls {
|
||||
@apply grid grid-cols-[minmax(220px,_1.6fr)_minmax(110px,_0.5fr)_minmax(230px,_1fr)_auto] items-end gap-[18px];
|
||||
}
|
||||
@@ -733,6 +806,16 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.production-tools-intro,
|
||||
.production-section-heading {
|
||||
@apply items-start;
|
||||
}
|
||||
.production-tool-stack {
|
||||
@apply px-3;
|
||||
}
|
||||
.production-tool-stack .production-tool-section .n-collapse-item__content-inner {
|
||||
@apply pl-4;
|
||||
}
|
||||
.production-controls,
|
||||
.production-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
GenerateQualityVideosInput,
|
||||
KeyframeBatchResult,
|
||||
KeyframeReadiness,
|
||||
KeyframeGenerationPreview,
|
||||
KeyframeSpec,
|
||||
ProjectVideoStatus,
|
||||
PromptReadiness,
|
||||
@@ -20,7 +21,9 @@ import type {
|
||||
VideoQualityBatchResult,
|
||||
VideoQualityConfig,
|
||||
VideoGenerationSpec,
|
||||
VideoReadiness
|
||||
VideoReadiness,
|
||||
ProjectAssemblyReadiness,
|
||||
EpisodeAssemblyResult
|
||||
} from './types'
|
||||
|
||||
/** 项目路径统一编码,避免业务组件手工拼接 ID。 */
|
||||
@@ -35,21 +38,36 @@ function shotPath(id: string) {
|
||||
|
||||
/** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */
|
||||
export const productionApi = {
|
||||
/** 读取各集主视频是否满足顺序拼接条件,不运行 FFmpeg。 */
|
||||
assemblyReadiness: (projectId: string, signal?: AbortSignal) =>
|
||||
request<ProjectAssemblyReadiness>(`${projectPath(projectId)}/assembly/readiness`, { signal }),
|
||||
/** 将单集有效主视频按分镜顺序拼为无声成片。 */
|
||||
assembleEpisode: (episodeId: string) =>
|
||||
request<EpisodeAssemblyResult>(`/episodes/${encodeURIComponent(episodeId)}/assembly/videos`, {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
timeoutMs: 0
|
||||
}),
|
||||
/** 根据显式指定的模型查询最终输入,不能用通用规格推断授权素材是否生效。 */
|
||||
providerInputSpec: (shotId: string, provider: string, signal?: AbortSignal) =>
|
||||
request<ProviderInputSpec>(`${shotPath(shotId)}/provider-input-spec?provider=${encodeURIComponent(provider)}`, {
|
||||
signal
|
||||
}),
|
||||
/** 查询当前计划,不生成图片、视频或提示词。 */
|
||||
plan: (projectId: string, signal?: AbortSignal) =>
|
||||
request<ProductionPlan>(`${projectPath(projectId)}/production/plan`, { signal }),
|
||||
plan: (projectId: string, signal?: AbortSignal, providers?: { imageProvider?: string; videoProvider?: string }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (providers?.imageProvider) params.set('imageProvider', providers.imageProvider)
|
||||
if (providers?.videoProvider) params.set('videoProvider', providers.videoProvider)
|
||||
const query = params.size ? `?${params}` : ''
|
||||
return request<ProductionPlan>(`${projectPath(projectId)}/production/plan${query}`, { signal })
|
||||
},
|
||||
/** 读取真实主资产完成状态,不能用任务提交回执代替。 */
|
||||
status: (projectId: string, signal?: AbortSignal) =>
|
||||
request<ProjectProductionStatus>(`${projectPath(projectId)}/production/status`, { signal }),
|
||||
startPipeline: (projectId: string) =>
|
||||
startPipeline: (projectId: string, providers?: { imageProvider?: string; videoProvider?: string }) =>
|
||||
request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
body: providers ?? {},
|
||||
timeoutMs: 0
|
||||
}),
|
||||
promptReadiness: (projectId: string, force: boolean, signal?: AbortSignal) =>
|
||||
@@ -89,6 +107,9 @@ export const productionApi = {
|
||||
request<ShotKeyframe[]>(`${shotPath(shotId)}/keyframes`, { signal }),
|
||||
keyframeSpec: (shotId: string, signal?: AbortSignal) =>
|
||||
request<KeyframeSpec>(`${shotPath(shotId)}/keyframe-spec`, { signal }),
|
||||
/** 预览最终参考图规划与 Provider Prompt,不创建首帧任务。 */
|
||||
previewKeyframe: (shotId: string) =>
|
||||
request<KeyframeGenerationPreview>(`${shotPath(shotId)}/keyframe/preview`, { method: 'POST', body: {} }),
|
||||
generateKeyframe: (shotId: string, input: GenerateKeyframeInput) =>
|
||||
request<ShotKeyframe | null>(`${shotPath(shotId)}/keyframe`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -6,10 +6,10 @@ import { downloadText, formatDate } from '../../../lib/format'
|
||||
import { useAdvancedProduction } from '../useAdvancedProduction'
|
||||
import ProductionPlanPanel from './ProductionPlanPanel.vue'
|
||||
|
||||
/** 高级入口明确真实流程边界,默认折叠且不在挂载时触发预检或生成。 */
|
||||
/** 高级入口默认展示真实流程边界,但不会在挂载时触发预检或生成。 */
|
||||
const { id, operation, session, checking, error, check, blocked, preflight, start } = useAdvancedProduction()
|
||||
const receipt = computed(() => session.value.pipelineReceipt)
|
||||
const expanded = ref<string[]>([])
|
||||
const expanded = ref<string[]>(['advanced'])
|
||||
watch(
|
||||
receipt,
|
||||
value => {
|
||||
@@ -29,7 +29,7 @@ function exportReceipt() {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<NCollapse v-model:expanded-names="expanded" class="mt-5"
|
||||
<NCollapse v-model:expanded-names="expanded" class="production-tool-section"
|
||||
><NCollapseItem name="advanced" title="高级:串联生产(全项目)">
|
||||
<NAlert type="warning" :show-icon="false"
|
||||
>依次补齐形态提示词、参考图、首帧质量校验与修复、视频提示词,再提交视频任务。视觉风格、身份母版、导演设计和即时状态仍需提前准备。</NAlert
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { onScopeDispose, ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NCollapse, NCollapseItem, NProgress, NSpin } from 'naive-ui'
|
||||
import { mediaAssetUrl } from '../../../lib/assets'
|
||||
import { errorMessage } from '../../../lib/http'
|
||||
import { productionApi } from '../api'
|
||||
import type { EpisodeAssemblyResult, ProjectAssemblyReadiness } from '../types'
|
||||
|
||||
const props = defineProps<{ projectId: string }>()
|
||||
const readiness = ref<ProjectAssemblyReadiness | null>(null)
|
||||
const loading = ref(false)
|
||||
const assembling = ref('')
|
||||
const error = ref('')
|
||||
const results = ref<Record<string, EpisodeAssemblyResult>>({})
|
||||
let controller: AbortController | null = null
|
||||
|
||||
async function refresh() {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
readiness.value = await productionApi.assemblyReadiness(props.projectId, controller.signal)
|
||||
} catch (cause) {
|
||||
if (!controller.signal.aborted) error.value = errorMessage(cause)
|
||||
} finally {
|
||||
if (!controller.signal.aborted) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function assemble(episodeId: string) {
|
||||
if (assembling.value) return
|
||||
assembling.value = episodeId
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await productionApi.assembleEpisode(episodeId)
|
||||
results.value = { ...results.value, [episodeId]: result }
|
||||
await refresh()
|
||||
} catch (cause) {
|
||||
error.value = errorMessage(cause)
|
||||
} finally {
|
||||
assembling.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function resultUrl(episodeId: string) {
|
||||
const result = results.value[episodeId]
|
||||
return result ? mediaAssetUrl(result.videoUrl) : null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.projectId,
|
||||
() => {
|
||||
results.value = {}
|
||||
void refresh()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
onScopeDispose(() => controller?.abort())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCollapse class="production-tool-section" :default-expanded-names="['episode-assembly']">
|
||||
<NCollapseItem name="episode-assembly" title="单集成片拼接">
|
||||
<NSpin :show="loading">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-xs text-muted">
|
||||
按 Beat 和 Shot 顺序拼接当前有效主视频。V1 只输出无声视频,不包含对白、配乐、字幕和转场。
|
||||
</p>
|
||||
<NButton size="small" :disabled="loading" @click="refresh">刷新就绪状态</NButton>
|
||||
</div>
|
||||
<NAlert v-if="error" type="error" :show-icon="false" class="mt-3">{{ error }}</NAlert>
|
||||
<p v-if="readiness" class="mt-3 text-xs">
|
||||
已就绪 {{ readiness.ready }}/{{ readiness.total }} 集 · 阻塞 {{ readiness.blocked }} 集
|
||||
</p>
|
||||
<div v-if="readiness?.items.length" class="assembly-list mt-3">
|
||||
<article v-for="item in readiness.items" :key="item.episodeId" class="assembly-row">
|
||||
<div class="min-w-0">
|
||||
<h4 class="truncate text-sm font-semibold">
|
||||
第 {{ item.episodeNo }} 集 · {{ item.title }}
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
主视频 {{ item.readyShots }}/{{ item.totalShots }} · {{ item.totalDurationSeconds }} 秒
|
||||
</p>
|
||||
<NProgress
|
||||
class="mt-2"
|
||||
type="line"
|
||||
:show-indicator="false"
|
||||
:percentage="
|
||||
item.totalShots ? Math.round((item.readyShots / item.totalShots) * 100) : 0
|
||||
"
|
||||
:status="item.ready ? 'success' : 'default'"
|
||||
/>
|
||||
<p
|
||||
v-for="(issue, index) in item.issues.slice(0, 3)"
|
||||
:key="index"
|
||||
class="mt-1 text-xs text-muted"
|
||||
>
|
||||
<span v-if="issue.shotNo">镜头 {{ issue.shotNo }}:</span>{{ issue.reason }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<NButton
|
||||
size="small"
|
||||
:disabled="!item.ready || !!assembling"
|
||||
:loading="assembling === item.episodeId"
|
||||
@click="assemble(item.episodeId)"
|
||||
>生成无声成片</NButton
|
||||
>
|
||||
<NButton
|
||||
v-if="resultUrl(item.episodeId)"
|
||||
tag="a"
|
||||
size="small"
|
||||
:href="resultUrl(item.episodeId) || undefined"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>查看成片</NButton
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else-if="readiness && !loading" class="mt-3 text-xs text-muted">当前项目还没有可拼接的剧集。</p>
|
||||
</NSpin>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "../../../styles/styles.css";
|
||||
.assembly-list {
|
||||
@apply grid gap-2;
|
||||
}
|
||||
.assembly-row {
|
||||
@apply grid grid-cols-[minmax(0,_1fr)_auto] items-center gap-4 p-4 bg-(--app-subtle);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.assembly-row {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,11 +2,13 @@
|
||||
import AppForm from '../../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import { sizeRules } from '../../../lib/form-rules'
|
||||
import { NAlert, NButton, NCheckbox, NInputNumber } from 'naive-ui'
|
||||
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInputNumber, NTag } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { AppDialog } from '../../../components/ui'
|
||||
import { errorMessage } from '../../../lib/http'
|
||||
import { productionApi } from '../api'
|
||||
import { validOptionalSize } from '../model'
|
||||
import type { GenerateKeyframeInput, ShotKeyframe } from '../types'
|
||||
import type { GenerateKeyframeInput, KeyframeGenerationPreview, ShotKeyframe } from '../types'
|
||||
|
||||
/** 单镜头首帧表单;普通候选仍由用户明确选择,过期主首帧默认替换为新图。 */
|
||||
const props = defineProps<{
|
||||
@@ -22,6 +24,9 @@ const width = ref<number | ''>('')
|
||||
const height = ref<number | ''>('')
|
||||
const setPrimary = ref(false)
|
||||
const confirmed = ref(false)
|
||||
const preview = ref<KeyframeGenerationPreview | null>(null)
|
||||
const previewError = ref('')
|
||||
const previewPending = ref(false)
|
||||
const hasPrimary = computed(() => props.keyframes.some(item => item.isPrimary && item.status === 'completed'))
|
||||
const sizeValid = computed(() => validOptionalSize(width.value, height.value))
|
||||
const canSubmit = computed(() => !props.disabled && sizeValid.value && confirmed.value)
|
||||
@@ -32,6 +37,9 @@ function reset() {
|
||||
height.value = ''
|
||||
setPrimary.value = !!props.replacePrimary || !hasPrimary.value
|
||||
confirmed.value = false
|
||||
preview.value = null
|
||||
previewError.value = ''
|
||||
previewPending.value = false
|
||||
}
|
||||
watch([() => props.shotId, open], reset, { immediate: true })
|
||||
|
||||
@@ -47,6 +55,23 @@ function submit() {
|
||||
}
|
||||
const formModel = computed(() => ({ width: width.value, height: height.value }))
|
||||
const rules = sizeRules(() => formModel.value)
|
||||
|
||||
/** 读取后端最终参考图选择与 Prompt,不创建首帧候选。 */
|
||||
async function loadPreview() {
|
||||
if (!props.shotId || previewPending.value) return
|
||||
previewPending.value = true
|
||||
previewError.value = ''
|
||||
try {
|
||||
const result = await productionApi.previewKeyframe(props.shotId)
|
||||
if (!result || result.shotId !== props.shotId) throw new Error('预览结果与当前镜头不匹配。')
|
||||
preview.value = result
|
||||
} catch (cause) {
|
||||
preview.value = null
|
||||
previewError.value = errorMessage(cause)
|
||||
} finally {
|
||||
previewPending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,6 +91,49 @@ const rules = sizeRules(() => formModel.value)
|
||||
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
|
||||
当前主首帧使用的主体参考资产已经变化。新图成功后应设为主首帧,旧图会继续保留为历史候选。
|
||||
</NAlert>
|
||||
<div class="mt-5 flex flex-wrap items-center gap-3">
|
||||
<NButton :loading="previewPending" @click="loadPreview">预览实际生成输入</NButton>
|
||||
<span class="text-xs text-muted">只读取参考图规划与最终 Prompt,不调用图片模型。</span>
|
||||
</div>
|
||||
<NAlert v-if="previewError" type="error" :show-icon="false" class="mt-3">{{ previewError }}</NAlert>
|
||||
<div v-if="preview" class="panel mt-3 p-4 text-xs leading-6">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<strong>{{ preview.provider }} / {{ preview.model }}</strong>
|
||||
<NTag size="small" :bordered="false">
|
||||
已选 {{ preview.referencePlan.selected.length }} /
|
||||
{{ preview.referencePlan.totalCandidates }} 张
|
||||
</NTag>
|
||||
<NTag v-if="preview.referencePlan.omitted.length" size="small" type="warning" :bordered="false">
|
||||
省略 {{ preview.referencePlan.omitted.length }} 张
|
||||
</NTag>
|
||||
</div>
|
||||
<ul v-if="preview.referencePlan.selected.length" class="mt-3 space-y-1 text-muted">
|
||||
<li v-for="item in preview.referencePlan.selected" :key="`${item.role}:${item.imageId}`">
|
||||
{{ item.subjectRef }} · {{ item.role }} · <code>{{ item.imageId }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
<NCollapse class="mt-3" :default-expanded-names="['prompt']">
|
||||
<NCollapseItem name="prompt" title="最终 Provider Prompt">
|
||||
<p class="mt-3 whitespace-pre-wrap">{{ preview.providerPrompt }}</p>
|
||||
</NCollapseItem>
|
||||
<NCollapseItem
|
||||
v-if="preview.referencePlan.omitted.length"
|
||||
name="omitted"
|
||||
title="未进入模型的参考图"
|
||||
>
|
||||
<ul class="mt-3 space-y-1 text-muted">
|
||||
<li v-for="item in preview.referencePlan.omitted" :key="`${item.role}:${item.imageId}`">
|
||||
{{ item.subjectRef }} · {{ item.role }} · <code>{{ item.imageId }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
</NCollapseItem>
|
||||
<NCollapseItem name="config" title="生成配置">
|
||||
<pre class="mt-3 whitespace-pre-wrap">{{
|
||||
JSON.stringify(preview.generationConfig, null, 2)
|
||||
}}</pre>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</div>
|
||||
<div class="mt-5 grid grid-cols-2 gap-4">
|
||||
<NFormItem path="width" label="宽度(可选)"
|
||||
><NInputNumber
|
||||
|
||||
@@ -83,7 +83,7 @@ const problems = computed(() => [
|
||||
{{ plan.manualBlocked }}
|
||||
项需人工处理(同一镜头可能重复计数)。可先生成就绪的主体参考图;成功结果会保留,遇到阻塞时后续阶段可能暂停。
|
||||
</NAlert>
|
||||
<NCollapse v-if="problems.length" class="mt-3">
|
||||
<NCollapse v-if="problems.length" class="mt-3" :default-expanded-names="['issues']">
|
||||
<NCollapseItem name="issues" title="查看前置条件与阻塞原因">
|
||||
<ul class="space-y-3 text-xs">
|
||||
<li v-for="item in problems" :key="item.key">
|
||||
|
||||
@@ -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 供后端排障。 */
|
||||
|
||||
@@ -125,7 +125,7 @@ const rules = computed(() => ({
|
||||
limit: integerRule('本批镜头上限'),
|
||||
...sizeRules(() => formModel.value)
|
||||
}))
|
||||
const optionsExpanded = ref<string[]>([])
|
||||
const optionsExpanded = ref<string[]>(['options'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -277,7 +277,7 @@ const optionsExpanded = ref<string[]>([])
|
||||
session.error
|
||||
}}</NAlert>
|
||||
<QualityResult v-if="session.receipt" :receipt="session.receipt" @locate="emit('locate', $event)" />
|
||||
<NCollapse v-else-if="saved" class="mt-4"
|
||||
<NCollapse v-else-if="saved" class="mt-4" :default-expanded-names="['saved']"
|
||||
><NCollapseItem name="saved" :title="`本会话最近校验 · ${saved.passed ? '通过' : '未通过'}`"
|
||||
><p>{{ saved.summary }}</p>
|
||||
<p v-for="(issue, index) in saved.issues" :key="index" class="mt-2 text-danger">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NAlert, NButton, NCollapse, NCollapseItem } from 'naive-ui'
|
||||
import { downloadText } from '../../../lib/format'
|
||||
import type { QualityReceipt } from '../quality.types'
|
||||
|
||||
/** 摘要与失败直接显示,完整逐轮信息按需展开,不用完成请求冒充质量通过。 */
|
||||
/** 摘要、失败与完整逐轮信息默认直接显示,不用完成请求冒充质量通过。 */
|
||||
const props = defineProps<{ receipt: QualityReceipt }>()
|
||||
const emit = defineEmits<{ locate: [shotId: string] }>()
|
||||
const passed = computed(() =>
|
||||
@@ -113,7 +113,7 @@ function exportResult() {
|
||||
>
|
||||
{{ issue.frameLabel ? `${issue.frameLabel} · ` : '' }}{{ issue.description }}
|
||||
</NAlert>
|
||||
<NCollapse class="mt-4">
|
||||
<NCollapse class="mt-4" :default-expanded-names="['details']">
|
||||
<NCollapseItem name="details" title="主体检查与执行详情">
|
||||
<template v-if="receipt.kind === 'keyframe' || receipt.kind === 'video'">
|
||||
<p class="text-xs">
|
||||
|
||||
@@ -67,7 +67,7 @@ const qualityOpen = ref(false)
|
||||
const qualityTarget = ref<QualityTarget | null>(null)
|
||||
const specsOpen = ref(false)
|
||||
|
||||
/** 从实际候选打开质量面板,保留单镜头主操作,不默认展开全部校验参数。 */
|
||||
/** 从实际候选打开质量面板,保留单镜头主操作。 */
|
||||
function openQuality(kind: 'keyframe' | 'video', assetId: string) {
|
||||
qualityTarget.value = {
|
||||
kind,
|
||||
|
||||
@@ -146,6 +146,53 @@ export interface KeyframeSpec {
|
||||
references: KeyframeReference[]
|
||||
}
|
||||
|
||||
export type ImageReferenceRole =
|
||||
| 'style-overall'
|
||||
| 'style-character'
|
||||
| 'style-scene'
|
||||
| 'style-prop'
|
||||
| 'identity-anchor'
|
||||
| 'form-primary'
|
||||
|
||||
export interface KeyframePreviewReference {
|
||||
originalImageNumber: number
|
||||
subjectRef: string
|
||||
module: string
|
||||
role: ImageReferenceRole
|
||||
imageId: string
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
export interface KeyframeReferencePlanItem {
|
||||
subjectId?: string
|
||||
subjectRef: string
|
||||
module: string
|
||||
role: ImageReferenceRole
|
||||
imageId: string
|
||||
subjectFormId?: string
|
||||
}
|
||||
|
||||
/** 首帧生成前的无费用参考图规划与 Provider Prompt 预览。 */
|
||||
export interface KeyframeGenerationPreview {
|
||||
shotId: string
|
||||
episodeNo: number
|
||||
beatNo: number
|
||||
shotNo: number
|
||||
provider: string
|
||||
model: string
|
||||
generationConfig: Record<string, unknown>
|
||||
references: KeyframePreviewReference[]
|
||||
referencePlan: {
|
||||
provider: string
|
||||
maxReferenceImages: number | null
|
||||
totalCandidates: number
|
||||
strategy: 'all' | 'subject-coverage' | 'repair-aware'
|
||||
selected: KeyframeReferencePlanItem[]
|
||||
omitted: KeyframeReferencePlanItem[]
|
||||
}
|
||||
providerPrompt: string
|
||||
}
|
||||
|
||||
/** 视频生成规格中的主体参考图。 */
|
||||
export interface VideoReference {
|
||||
/** 当前目标视频模型的授权素材,不拼接模型私有地址。 */
|
||||
@@ -455,3 +502,44 @@ export interface ProviderInputSpec {
|
||||
providerAsset: { provider: string; assetId: string } | null
|
||||
})[]
|
||||
}
|
||||
|
||||
/** 单集成片拼接就绪问题。 */
|
||||
export interface EpisodeAssemblyIssue {
|
||||
shotId?: string
|
||||
beatNo?: number
|
||||
shotNo?: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** 项目中单集的成片拼接就绪状态。 */
|
||||
export interface EpisodeAssemblyReadinessItem {
|
||||
episodeId: string
|
||||
episodeNo: number
|
||||
title: string
|
||||
ready: boolean
|
||||
totalShots: number
|
||||
readyShots: number
|
||||
totalDurationSeconds: number
|
||||
issues: EpisodeAssemblyIssue[]
|
||||
}
|
||||
|
||||
/** 项目成片拼接就绪汇总;查询不会运行 FFmpeg。 */
|
||||
export interface ProjectAssemblyReadiness {
|
||||
projectId: string
|
||||
total: number
|
||||
ready: number
|
||||
blocked: number
|
||||
complete: boolean
|
||||
items: EpisodeAssemblyReadinessItem[]
|
||||
}
|
||||
|
||||
/** 单集拼接成功回执;V1 产物为无声视频。 */
|
||||
export interface EpisodeAssemblyResult {
|
||||
projectId: string
|
||||
episodeId: string
|
||||
episodeNo: number
|
||||
title: string
|
||||
shotCount: number
|
||||
durationSeconds: number
|
||||
videoUrl: string
|
||||
}
|
||||
|
||||
@@ -6,16 +6,21 @@ import { hasRunningImages } from '../subject-images/model'
|
||||
import { getOperation, runOperation } from '../workflows/operations'
|
||||
import { hasRunningWorkflow } from '../workflows/selectors'
|
||||
import { errorMessage } from '../../lib/http'
|
||||
import { generationConfigApi } from '../generation-config/api'
|
||||
import { productionApi } from './api'
|
||||
import { getProductionSession } from './model'
|
||||
|
||||
/** 使用后端计划区分可自动补齐和人工阻塞,保留项目归属及活动任务检查。 */
|
||||
export async function checkPipeline(projectId: string) {
|
||||
const profile = await generationConfigApi.profile(projectId)
|
||||
const providers = profile
|
||||
? { imageProvider: profile.imageProvider, videoProvider: profile.videoProvider }
|
||||
: undefined
|
||||
const [project, checkpoints, forms, plan, status] = await Promise.all([
|
||||
projectsApi.detail(projectId),
|
||||
projectsApi.checkpoints(projectId),
|
||||
subjectImagesApi.listForms(projectId),
|
||||
productionApi.plan(projectId),
|
||||
productionApi.plan(projectId, undefined, providers),
|
||||
productionApi.status(projectId)
|
||||
])
|
||||
if (
|
||||
@@ -26,6 +31,7 @@ export async function checkPipeline(projectId: string) {
|
||||
)
|
||||
throw new Error('预检返回了其他项目的数据,请重新读取。')
|
||||
const issues: string[] = []
|
||||
if (!profile) issues.push('尚未配置项目画布与生成模型,请先在批量生产工具中保存配置。')
|
||||
const shotIds = new Set(plan.details.keyframes.map(item => item.shotId))
|
||||
if (
|
||||
shotIds.size !== plan.keyframes.total ||
|
||||
@@ -54,7 +60,15 @@ export async function checkPipeline(projectId: string) {
|
||||
else if (![plan.subjectImages, plan.keyframes, plan.videoPrompts, plan.videos].some(stage => stage.planned > 0))
|
||||
issues.push('当前没有可执行的制作任务,请先处理计划中的阻塞项。')
|
||||
// 人工阻塞在计划中独立展示;不拦截其余 ready 形态的部分成功生产。
|
||||
return { projectId, issues, plan, status, shotCount: plan.keyframes.total, checkedAt: new Date().toISOString() }
|
||||
return {
|
||||
projectId,
|
||||
issues,
|
||||
plan,
|
||||
status,
|
||||
profile,
|
||||
shotCount: plan.keyframes.total,
|
||||
checkedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
/** 预检不付费;确认后再次预检并锁住本浏览器项目,长请求不自动重发。 */
|
||||
@@ -128,7 +142,15 @@ export function useAdvancedProduction() {
|
||||
if (result.issues.length) throw new Error('提交前预检发现条件变化,未启动生产。请处理下方问题。')
|
||||
target.pipelineReceipt = null
|
||||
submitted = true
|
||||
const receipt = await productionApi.startPipeline(projectId)
|
||||
const receipt = await productionApi.startPipeline(
|
||||
projectId,
|
||||
result.profile
|
||||
? {
|
||||
imageProvider: result.profile.imageProvider,
|
||||
videoProvider: result.profile.videoProvider
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
if (!receipt || receipt.projectId !== projectId)
|
||||
throw new Error('回执项目不匹配,请读取资产状态核对,不要直接重试。')
|
||||
target.pipelineReceipt = receipt
|
||||
|
||||
@@ -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,9 +45,7 @@ export function useProduction() {
|
||||
)
|
||||
const queryKey = computed(() => JSON.stringify([id.value, episodeNo.value, force.value]))
|
||||
/** 生产页已有刷新按钮,就绪状态改为手动更新。 */
|
||||
const query = usePolling(
|
||||
queryKey,
|
||||
async (key, signal) => {
|
||||
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([
|
||||
@@ -60,9 +58,7 @@ export function useProduction() {
|
||||
if (directions.projectId !== projectId || directions.episodeNo !== number)
|
||||
throw new Error('镜头生产查询返回了不匹配的项目或剧集,请刷新后重试。')
|
||||
return { directions, prompts, keyframes, videos, videoStatus }
|
||||
},
|
||||
false
|
||||
)
|
||||
})
|
||||
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) => {
|
||||
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)
|
||||
)
|
||||
if (!result || result.shotId !== shot || !Array.isArray(result.references) || !Array.isArray(result.missing))
|
||||
throw new Error('镜头参考素材返回不匹配,请刷新后重试。')
|
||||
return result
|
||||
},
|
||||
interval
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { NAlert, NButton, NFormItem, NInput, NPopconfirm, NSelect, NTag, NTooltip } from 'naive-ui'
|
||||
import { RefreshCw, Search, Trash2, Upload } from '@lucide/vue'
|
||||
import { AppDialog, AssetImage, EmptyState } from '../../components/ui'
|
||||
import AppForm from '../../components/ui/AppForm.vue'
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import { useQuery } from '../../composables/useQuery'
|
||||
import { fieldRule } from '../../lib/form-rules'
|
||||
import { errorMessage } from '../../lib/http'
|
||||
import { projectsApi } from '../projects/api'
|
||||
import { projectAssetsApi } from './api'
|
||||
import type { GlobalProjectAsset, ProjectAssetProject } from './types'
|
||||
|
||||
/** 后端当前允许的图片格式与大小。 */
|
||||
const ACCEPTED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024
|
||||
const categoryPattern = /^[a-z0-9_-]+$/
|
||||
const categoryLabels: Record<string, string> = {
|
||||
overall: '综合',
|
||||
character: '人物',
|
||||
scene: '场景',
|
||||
prop: '道具',
|
||||
reference: '参考素材'
|
||||
}
|
||||
|
||||
/** 全局接口一次返回全部图片及所属剧本,避免按项目产生 N 次请求。 */
|
||||
const query = useQuery(ref('all-project-assets'), (_, signal) => projectAssetsApi.listAll(signal))
|
||||
/** 上传需要列出包括尚无资产在内的全部剧本。 */
|
||||
const projectsQuery = useQuery(ref('asset-project-options'), (_, signal) => projectsApi.list(signal))
|
||||
const search = ref('')
|
||||
const projectId = ref<string | null>(null)
|
||||
const category = ref<string | null>(null)
|
||||
const busy = ref(false)
|
||||
const actionError = ref('')
|
||||
const fileError = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const uploadOpen = ref(false)
|
||||
const editOpen = ref(false)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const selectedAsset = ref<GlobalProjectAsset | null>(null)
|
||||
const overflowTitles = reactive(new Set<string>())
|
||||
const uploadForm = reactive({ projectId: '', name: '', category: 'reference' })
|
||||
const editForm = reactive({ name: '', category: '' })
|
||||
const assets = computed(() => query.data.value ?? [])
|
||||
const projectOptions = computed(() => {
|
||||
const projects = new Map<string, ProjectAssetProject>()
|
||||
assets.value.forEach(asset => projects.set(asset.project.id, asset.project))
|
||||
return [...projects.values()]
|
||||
.toSorted((a, b) => projectName(a).localeCompare(projectName(b), 'zh-CN'))
|
||||
.map(project => ({ label: projectName(project), value: project.id }))
|
||||
})
|
||||
const uploadProjectOptions = computed(() =>
|
||||
(projectsQuery.data.value ?? [])
|
||||
.toSorted((a, b) => projectName(a).localeCompare(projectName(b), 'zh-CN'))
|
||||
.map(project => ({ label: projectName(project), value: project.id }))
|
||||
)
|
||||
const categoryOptions = computed(() =>
|
||||
[...new Set(assets.value.map(asset => asset.category))]
|
||||
.toSorted()
|
||||
.map(value => ({ label: categoryLabel(value), value }))
|
||||
)
|
||||
const filteredAssets = computed(() => {
|
||||
const keyword = search.value.trim().toLowerCase()
|
||||
return assets.value.filter(asset => {
|
||||
if (projectId.value && asset.projectId !== projectId.value) return false
|
||||
if (category.value && asset.category !== category.value) return false
|
||||
if (!keyword) return true
|
||||
return [asset.name, asset.category, projectName(asset.project)].some(value =>
|
||||
value.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
})
|
||||
const formRules = {
|
||||
projectId: fieldRule(value => typeof value === 'string' && !!value, '请选择所属剧本'),
|
||||
name: fieldRule(value => typeof value === 'string' && !!value.trim(), '请输入素材名称'),
|
||||
category: fieldRule(
|
||||
value => typeof value === 'string' && categoryPattern.test(value.trim()),
|
||||
'分类只能包含小写字母、数字、下划线和连字符'
|
||||
)
|
||||
}
|
||||
|
||||
function projectName(project: Pick<ProjectAssetProject, 'title' | 'topic'>) {
|
||||
return project.title?.trim() || project.topic
|
||||
}
|
||||
|
||||
function categoryLabel(value: string) {
|
||||
return categoryLabels[value] ?? value
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value < 1024) return `${value} B`
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function metadataOriginalName(value: unknown) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return '未记录'
|
||||
const originalName = (value as Record<string, unknown>).originalName
|
||||
return typeof originalName === 'string' && originalName.trim() ? originalName : '未记录'
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
search.value = ''
|
||||
projectId.value = null
|
||||
category.value = null
|
||||
}
|
||||
|
||||
/** 同时刷新图片与上传所需的剧本选项。 */
|
||||
async function refreshAll() {
|
||||
await Promise.all([query.refresh(), projectsQuery.refresh()])
|
||||
}
|
||||
|
||||
/** 先校验本地文件,再在当前页选择剧本并确认资料。 */
|
||||
function selectFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] ?? null
|
||||
input.value = ''
|
||||
fileError.value = ''
|
||||
actionError.value = ''
|
||||
if (!file) return
|
||||
if (!ACCEPTED_TYPES.has(file.type)) {
|
||||
fileError.value = '仅支持 JPEG、PNG、WebP 图片。'
|
||||
return
|
||||
}
|
||||
if (!file.size || file.size > MAX_FILE_SIZE) {
|
||||
fileError.value = '上传图片不能为空且不能超过 20MB。'
|
||||
return
|
||||
}
|
||||
selectedFile.value = file
|
||||
uploadForm.projectId = projectId.value ?? ''
|
||||
uploadForm.name = file.name.replace(/\.[^.]+$/, '') || file.name
|
||||
uploadForm.category = 'reference'
|
||||
uploadOpen.value = true
|
||||
}
|
||||
|
||||
async function uploadAsset() {
|
||||
const file = selectedFile.value
|
||||
if (!file || busy.value) return
|
||||
busy.value = true
|
||||
actionError.value = ''
|
||||
try {
|
||||
await projectAssetsApi.upload(uploadForm.projectId, {
|
||||
file,
|
||||
name: uploadForm.name.trim(),
|
||||
category: uploadForm.category.trim()
|
||||
})
|
||||
uploadOpen.value = false
|
||||
selectedFile.value = null
|
||||
await query.refresh()
|
||||
} catch (error) {
|
||||
actionError.value = errorMessage(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(asset: GlobalProjectAsset) {
|
||||
selectedAsset.value = asset
|
||||
editForm.name = asset.name
|
||||
editForm.category = asset.category
|
||||
actionError.value = ''
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
/** 只在标题真实溢出时启用 Tooltip,短标题不产生多余浮层。 */
|
||||
function checkTitleOverflow(assetId: string, event: MouseEvent) {
|
||||
const element = event.currentTarget as HTMLElement
|
||||
if (element.scrollWidth > element.clientWidth) overflowTitles.add(assetId)
|
||||
else overflowTitles.delete(assetId)
|
||||
}
|
||||
|
||||
async function updateAsset() {
|
||||
const asset = selectedAsset.value
|
||||
if (!asset || busy.value) return
|
||||
busy.value = true
|
||||
actionError.value = ''
|
||||
try {
|
||||
await projectAssetsApi.update(asset.projectId, asset.id, {
|
||||
name: editForm.name.trim(),
|
||||
category: editForm.category.trim()
|
||||
})
|
||||
editOpen.value = false
|
||||
selectedAsset.value = null
|
||||
await query.refresh()
|
||||
} catch (error) {
|
||||
actionError.value = errorMessage(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAsset() {
|
||||
const asset = selectedAsset.value
|
||||
if (!asset || busy.value) return
|
||||
busy.value = true
|
||||
actionError.value = ''
|
||||
try {
|
||||
await projectAssetsApi.remove(asset.projectId, asset.id)
|
||||
editOpen.value = false
|
||||
selectedAsset.value = null
|
||||
await query.refresh()
|
||||
} catch (error) {
|
||||
actionError.value = errorMessage(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkspacePage compact class="asset-library-page">
|
||||
<template #header>
|
||||
<div class="page-heading asset-library-heading">
|
||||
<div>
|
||||
<h1>资产库</h1>
|
||||
<p class="page-description">汇总并管理所有剧本的人物、场景、道具和参考图片。</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
aria-label="选择全局资产图片"
|
||||
@change="selectFile"
|
||||
/>
|
||||
<NButton
|
||||
type="primary"
|
||||
:disabled="busy || !uploadProjectOptions.length"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<template #icon><Upload :size="16" /></template>上传图片
|
||||
</NButton>
|
||||
<NButton
|
||||
quaternary
|
||||
class="icon-button"
|
||||
:loading="query.loading.value || projectsQuery.loading.value"
|
||||
aria-label="刷新资产库"
|
||||
title="刷新资产库"
|
||||
@click="refreshAll"
|
||||
>
|
||||
<template #icon><RefreshCw :size="16" /></template>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<NAlert v-if="fileError" type="error" :show-icon="false" class="mb-4">{{ fileError }}</NAlert>
|
||||
<NAlert v-if="query.error.value" type="error" :show-icon="false" class="mb-4">
|
||||
{{ query.error.value }}<NButton text class="ml-3" @click="query.refresh">重新连接</NButton>
|
||||
</NAlert>
|
||||
<NAlert v-if="projectsQuery.error.value" type="warning" :show-icon="false" class="mb-4">
|
||||
剧本列表读取失败,暂时不能上传新图片。
|
||||
<NButton text class="ml-3" @click="projectsQuery.refresh">重新读取</NButton>
|
||||
</NAlert>
|
||||
|
||||
<div class="asset-library-filters">
|
||||
<NInput
|
||||
v-model:value="search"
|
||||
clearable
|
||||
placeholder="搜索图片、分类或所属剧本"
|
||||
:input-props="{ 'aria-label': '搜索资产图片' }"
|
||||
>
|
||||
<template #prefix><Search :size="15" /></template>
|
||||
</NInput>
|
||||
<NSelect
|
||||
v-model:value="projectId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部剧本"
|
||||
:options="projectOptions"
|
||||
aria-label="筛选所属剧本"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="category"
|
||||
clearable
|
||||
placeholder="全部分类"
|
||||
:options="categoryOptions"
|
||||
aria-label="筛选资产分类"
|
||||
/>
|
||||
<span class="text-xs text-muted">{{ filteredAssets.length }} / {{ assets.length }} 张</span>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredAssets.length" class="asset-library-grid">
|
||||
<article
|
||||
v-for="asset in filteredAssets"
|
||||
:key="asset.id"
|
||||
class="asset-library-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`查看 ${asset.name} 详情`"
|
||||
@click="openEdit(asset)"
|
||||
@keydown.enter.prevent="openEdit(asset)"
|
||||
@keydown.space.prevent="openEdit(asset)"
|
||||
>
|
||||
<AssetImage :src="asset.publicUrl" :alt="asset.name" class="asset-library-image" />
|
||||
<div class="asset-library-overlay">
|
||||
<NTooltip placement="top-start" trigger="hover" :disabled="!overflowTitles.has(asset.id)">
|
||||
<template #trigger>
|
||||
<h2 class="asset-library-title" @mouseenter="checkTitleOverflow(asset.id, $event)">
|
||||
{{ asset.name }}
|
||||
</h2>
|
||||
</template>
|
||||
{{ asset.name }}
|
||||
</NTooltip>
|
||||
<div class="asset-library-meta">
|
||||
<p class="truncate text-xs" :title="projectName(asset.project)">
|
||||
{{ projectName(asset.project) }}
|
||||
</p>
|
||||
<NTag size="small" :bordered="false" class="asset-library-tag">
|
||||
{{ categoryLabel(asset.category) }}
|
||||
</NTag>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="query.data.value"
|
||||
:title="assets.length ? '没有匹配的图片' : '资产库还是空的'"
|
||||
:description="assets.length ? '调整搜索、剧本或分类筛选。' : '选择剧本并上传第一张图片。'"
|
||||
>
|
||||
<NButton v-if="assets.length" @click="clearFilters">清除筛选</NButton>
|
||||
<NButton v-else type="primary" :disabled="!uploadProjectOptions.length" @click="fileInput?.click()">
|
||||
上传第一张图片
|
||||
</NButton>
|
||||
</EmptyState>
|
||||
<p v-else class="py-10 text-center text-sm text-muted" role="status">正在读取全部资产图片…</p>
|
||||
|
||||
<AppDialog
|
||||
v-model:open="uploadOpen"
|
||||
title="上传资产图片"
|
||||
description="选择所属剧本并确认名称和分类,提交后仍停留在当前资产库。"
|
||||
:busy="busy"
|
||||
>
|
||||
<NAlert v-if="actionError" type="error" :show-icon="false" class="mt-5">{{ actionError }}</NAlert>
|
||||
<AppForm :model="uploadForm" :rules="formRules" :disabled="busy" class="mt-5" @submit="uploadAsset">
|
||||
<p class="mb-4 text-sm">{{ selectedFile?.name }} · {{ formatBytes(selectedFile?.size ?? 0) }}</p>
|
||||
<NFormItem path="projectId" label="所属剧本">
|
||||
<NSelect v-model:value="uploadForm.projectId" filterable :options="uploadProjectOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem path="name" label="素材名称"><NInput v-model:value="uploadForm.name" /></NFormItem>
|
||||
<NFormItem path="category" label="分类标识">
|
||||
<NInput v-model:value="uploadForm.category" placeholder="例如 character、scene、prop" />
|
||||
</NFormItem>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<NButton :disabled="busy" @click="uploadOpen = false">取消</NButton>
|
||||
<NButton type="primary" attr-type="submit" :loading="busy">确认上传</NButton>
|
||||
</div>
|
||||
</AppForm>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
v-model:open="editOpen"
|
||||
title="素材详情与管理"
|
||||
description="查看文件详情,也可在当前页面修改名称和分类或删除素材。"
|
||||
:busy="busy"
|
||||
wide
|
||||
>
|
||||
<NAlert v-if="actionError" type="error" :show-icon="false" class="mt-5">{{ actionError }}</NAlert>
|
||||
<AppForm :model="editForm" :rules="formRules" :disabled="busy" class="mt-5" @submit="updateAsset">
|
||||
<div v-if="selectedAsset" class="asset-management-layout">
|
||||
<AssetImage
|
||||
:src="selectedAsset.publicUrl"
|
||||
:alt="selectedAsset.name"
|
||||
preview
|
||||
object-fit="contain"
|
||||
class="asset-management-preview"
|
||||
/>
|
||||
<div class="asset-management-fields">
|
||||
<NFormItem label="所属剧本">
|
||||
<NInput :value="projectName(selectedAsset.project)" disabled />
|
||||
</NFormItem>
|
||||
<NFormItem path="name" label="素材名称"><NInput v-model:value="editForm.name" /></NFormItem>
|
||||
<NFormItem path="category" label="分类标识">
|
||||
<NInput v-model:value="editForm.category" />
|
||||
</NFormItem>
|
||||
<dl class="asset-detail-grid">
|
||||
<dt>原始文件名</dt>
|
||||
<dd>{{ metadataOriginalName(selectedAsset.metadata) }}</dd>
|
||||
<dt>文件类型</dt>
|
||||
<dd>{{ selectedAsset.mimeType }} · {{ selectedAsset.extension.toUpperCase() }}</dd>
|
||||
<dt>文件大小</dt>
|
||||
<dd>{{ formatBytes(selectedAsset.size) }}</dd>
|
||||
<dt>创建时间</dt>
|
||||
<dd>{{ formatDate(selectedAsset.createdAt) }}</dd>
|
||||
<dt>更新时间</dt>
|
||||
<dd>{{ formatDate(selectedAsset.updatedAt) }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-management-footer">
|
||||
<NPopconfirm
|
||||
:positive-button-props="{ disabled: busy }"
|
||||
positive-text="确认删除"
|
||||
negative-text="取消"
|
||||
@positive-click="removeAsset"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton class="text-danger" :disabled="busy">
|
||||
<template #icon><Trash2 :size="14" /></template>删除素材
|
||||
</NButton>
|
||||
</template>
|
||||
删除后物理文件也会移除;正在被视觉风格引用的素材会由后端阻止删除。
|
||||
</NPopconfirm>
|
||||
<div class="flex gap-3">
|
||||
<NButton :disabled="busy" @click="editOpen = false">取消</NButton>
|
||||
<NButton type="primary" attr-type="submit" :loading="busy">保存修改</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppForm>
|
||||
</AppDialog>
|
||||
</WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "../../styles/styles.css";
|
||||
.asset-library-page {
|
||||
container: asset-library / inline-size;
|
||||
}
|
||||
.asset-library-heading {
|
||||
@apply flex items-start justify-between gap-4;
|
||||
}
|
||||
.asset-library-filters {
|
||||
@apply grid grid-cols-[minmax(220px,_420px)_220px_180px_auto] items-center gap-3 mb-4 p-3 bg-(--app-subtle);
|
||||
}
|
||||
.asset-library-grid {
|
||||
column-width: 240px;
|
||||
column-gap: 16px;
|
||||
}
|
||||
.asset-library-card {
|
||||
@apply relative inline-block w-full min-w-0 overflow-hidden bg-(--app-surface) align-top cursor-pointer outline-none;
|
||||
break-inside: avoid;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.asset-library-card:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent);
|
||||
}
|
||||
.asset-library-image {
|
||||
@apply block aspect-auto bg-transparent;
|
||||
}
|
||||
.asset-library-image :deep(.reveal-image),
|
||||
.asset-library-image :deep(.n-image) {
|
||||
@apply block h-auto min-h-0 bg-transparent;
|
||||
}
|
||||
.asset-library-image :deep(.n-image img) {
|
||||
@apply block w-full h-auto min-h-0 object-contain;
|
||||
}
|
||||
.asset-library-overlay {
|
||||
@apply absolute inset-0 flex flex-col justify-end p-[10px] text-white;
|
||||
background: linear-gradient(to top, rgb(0 0 0 / 0.84) 0, rgb(0 0 0 / 0.35) 42%, transparent 70%);
|
||||
}
|
||||
.asset-library-title {
|
||||
@apply block w-full truncate text-sm font-semibold;
|
||||
}
|
||||
.asset-library-meta {
|
||||
@apply flex min-w-0 items-center justify-between gap-2 mt-1.5;
|
||||
}
|
||||
.asset-library-meta p {
|
||||
color: rgb(255 255 255 / 0.76);
|
||||
}
|
||||
.asset-library-tag {
|
||||
@apply shrink-0 text-white bg-white/15 backdrop-blur-sm;
|
||||
}
|
||||
.asset-management-layout {
|
||||
@apply grid items-start gap-5;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
}
|
||||
.asset-management-preview {
|
||||
--asset-detail-image-max-height: min(56dvh, 560px);
|
||||
display: block;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
aspect-ratio: auto;
|
||||
justify-self: start;
|
||||
}
|
||||
.asset-management-preview :deep(.reveal-image),
|
||||
.asset-management-preview :deep(.n-image) {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.asset-management-preview :deep(.n-image img) {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: var(--asset-detail-image-max-height);
|
||||
object-fit: contain;
|
||||
}
|
||||
.asset-management-fields {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.asset-management-fields :deep(.n-form-item) {
|
||||
@apply mb-4;
|
||||
}
|
||||
.asset-management-fields :deep(.n-form-item-label) {
|
||||
@apply pb-2;
|
||||
}
|
||||
.asset-detail-grid {
|
||||
@apply grid grid-cols-[88px_minmax(0,_1fr)] gap-x-4 gap-y-2 mt-1 p-3 bg-(--app-subtle) text-xs;
|
||||
}
|
||||
.asset-detail-grid dt {
|
||||
@apply text-muted;
|
||||
}
|
||||
.asset-detail-grid dd {
|
||||
@apply min-w-0 break-all text-ink;
|
||||
}
|
||||
.asset-management-footer {
|
||||
@apply flex items-center justify-between gap-3 mt-5 pt-4;
|
||||
}
|
||||
@container asset-library (max-width: 840px) {
|
||||
.asset-library-filters {
|
||||
@apply grid-cols-2;
|
||||
}
|
||||
.asset-management-layout {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
.asset-management-preview {
|
||||
--asset-detail-image-max-height: min(42dvh, 420px);
|
||||
}
|
||||
}
|
||||
@container asset-library (max-width: 560px) {
|
||||
.asset-library-heading {
|
||||
@apply items-start;
|
||||
}
|
||||
.asset-library-filters {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
.asset-library-filters > span {
|
||||
@apply justify-self-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { NAlert, NButton, NFormItem, NInput, NPopconfirm, NSelect, NTag } from 'naive-ui'
|
||||
import { Pencil, RefreshCw, Search, Trash2, Upload } from '@lucide/vue'
|
||||
import { AppDialog, AssetImage, EmptyState } from '../../components/ui'
|
||||
import AppForm from '../../components/ui/AppForm.vue'
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import { fieldRule } from '../../lib/form-rules'
|
||||
import { useQuery } from '../../composables/useQuery'
|
||||
import { runOperation } from '../workflows/operations'
|
||||
import { useProjectMutationGuard } from '../workflows/useProjectMutationGuard'
|
||||
import { projectAssetsApi } from './api'
|
||||
import type { ProjectAsset } from './types'
|
||||
|
||||
/** 后端第一阶段只支持这三种图片格式,单文件最多 20MB。 */
|
||||
const ACCEPTED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024
|
||||
const categoryPattern = /^[a-z0-9_-]+$/
|
||||
const categoryLabels: Record<string, string> = {
|
||||
overall: '综合',
|
||||
character: '人物',
|
||||
scene: '场景',
|
||||
prop: '道具',
|
||||
reference: '参考素材'
|
||||
}
|
||||
|
||||
const { projectId, blocked } = useProjectMutationGuard()
|
||||
const query = useQuery(projectId, async (id, signal) => {
|
||||
const assets = await projectAssetsApi.list(id, signal)
|
||||
if (assets.some(asset => asset.projectId !== id)) throw new Error('素材列表包含其它项目的数据,请刷新后重试。')
|
||||
return assets
|
||||
})
|
||||
const assets = computed(() => query.data.value ?? [])
|
||||
const disabled = computed(() => blocked.value || !!query.error.value || !query.data.value)
|
||||
const search = ref('')
|
||||
const category = ref<string | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const uploadOpen = ref(false)
|
||||
const editOpen = ref(false)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const selectedAsset = ref<ProjectAsset | null>(null)
|
||||
const fileError = ref('')
|
||||
const uploadForm = reactive({ name: '', category: 'reference' })
|
||||
const editForm = reactive({ name: '', category: '' })
|
||||
|
||||
const categoryOptions = computed(() => {
|
||||
const values = new Set([...Object.keys(categoryLabels), ...assets.value.map(asset => asset.category)])
|
||||
return [...values].toSorted().map(value => ({ label: categoryLabel(value), value }))
|
||||
})
|
||||
const filteredAssets = computed(() => {
|
||||
const keyword = search.value.trim().toLowerCase()
|
||||
return assets.value.filter(asset => {
|
||||
if (category.value && asset.category !== category.value) return false
|
||||
if (!keyword) return true
|
||||
return [asset.name, asset.category, metadataOriginalName(asset.metadata)].some(value =>
|
||||
value.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
})
|
||||
const formRules = {
|
||||
name: fieldRule(value => typeof value === 'string' && !!value.trim(), '请输入素材名称'),
|
||||
category: fieldRule(
|
||||
value => typeof value === 'string' && categoryPattern.test(value.trim()),
|
||||
'分类只能包含小写字母、数字、下划线和连字符'
|
||||
)
|
||||
}
|
||||
|
||||
function categoryLabel(value: string) {
|
||||
return categoryLabels[value] ?? value
|
||||
}
|
||||
|
||||
function metadataOriginalName(value: unknown) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return ''
|
||||
const originalName = (value as Record<string, unknown>).originalName
|
||||
return typeof originalName === 'string' ? originalName : ''
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value < 1024) return `${value} B`
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
search.value = ''
|
||||
category.value = null
|
||||
}
|
||||
|
||||
/** 先由浏览器校验格式和大小,再打开资料确认弹窗。 */
|
||||
function selectFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] ?? null
|
||||
input.value = ''
|
||||
fileError.value = ''
|
||||
if (!file) return
|
||||
if (!ACCEPTED_TYPES.has(file.type)) {
|
||||
fileError.value = '仅支持 JPEG、PNG、WebP 图片。'
|
||||
return
|
||||
}
|
||||
if (!file.size || file.size > MAX_FILE_SIZE) {
|
||||
fileError.value = '上传图片不能为空且不能超过 20MB。'
|
||||
return
|
||||
}
|
||||
selectedFile.value = file
|
||||
uploadForm.name = file.name.replace(/\.[^.]+$/, '') || file.name
|
||||
uploadForm.category = 'reference'
|
||||
uploadOpen.value = true
|
||||
}
|
||||
|
||||
async function uploadAsset() {
|
||||
const file = selectedFile.value
|
||||
if (!file || disabled.value) return
|
||||
const id = projectId.value
|
||||
const ok = await runOperation(id, '上传项目素材', async () => {
|
||||
const asset = await projectAssetsApi.upload(id, {
|
||||
file,
|
||||
name: uploadForm.name.trim(),
|
||||
category: uploadForm.category.trim()
|
||||
})
|
||||
if (!asset || asset.projectId !== id) throw new Error('后端未返回当前项目的素材记录,请刷新核对。')
|
||||
})
|
||||
if (!ok || projectId.value !== id) return
|
||||
uploadOpen.value = false
|
||||
selectedFile.value = null
|
||||
await query.refresh()
|
||||
}
|
||||
|
||||
function openEdit(asset: ProjectAsset) {
|
||||
selectedAsset.value = asset
|
||||
editForm.name = asset.name
|
||||
editForm.category = asset.category
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
async function updateAsset() {
|
||||
const asset = selectedAsset.value
|
||||
if (!asset || disabled.value) return
|
||||
const id = projectId.value
|
||||
const ok = await runOperation(id, '更新项目素材', async () => {
|
||||
const result = await projectAssetsApi.update(id, asset.id, {
|
||||
name: editForm.name.trim(),
|
||||
category: editForm.category.trim()
|
||||
})
|
||||
if (!result || result.id !== asset.id || result.projectId !== id)
|
||||
throw new Error('后端未确认素材更新,请刷新核对。')
|
||||
})
|
||||
if (!ok || projectId.value !== id) return
|
||||
editOpen.value = false
|
||||
selectedAsset.value = null
|
||||
await query.refresh()
|
||||
}
|
||||
|
||||
async function removeAsset(asset: ProjectAsset) {
|
||||
if (disabled.value) return
|
||||
const id = projectId.value
|
||||
const ok = await runOperation(id, '删除项目素材', async () => {
|
||||
const result = await projectAssetsApi.remove(id, asset.id)
|
||||
if (!result || result.id !== asset.id || result.projectId !== id)
|
||||
throw new Error('后端未确认素材删除,请刷新核对。')
|
||||
})
|
||||
if (ok && projectId.value === id) await query.refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkspacePage compact class="project-assets-page">
|
||||
<template #header>
|
||||
<div class="workspace-toolbar project-assets-toolbar">
|
||||
<div class="min-w-0">
|
||||
<h2 class="font-semibold">项目资产库</h2>
|
||||
<p class="mt-1 text-xs text-muted">集中管理可复用图片素材,上传本身不会调用生成模型。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
aria-label="选择项目素材图片"
|
||||
@change="selectFile"
|
||||
/>
|
||||
<NButton type="primary" :disabled="disabled" @click="fileInput?.click()">
|
||||
<template #icon><Upload :size="16" /></template>上传图片
|
||||
</NButton>
|
||||
<NButton
|
||||
quaternary
|
||||
class="icon-button"
|
||||
aria-label="刷新资产库"
|
||||
title="刷新资产库"
|
||||
:loading="query.loading.value"
|
||||
@click="query.refresh"
|
||||
><template #icon><RefreshCw :size="16" /></template
|
||||
></NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<NAlert v-if="fileError" type="error" :show-icon="false" class="mb-3">{{ fileError }}</NAlert>
|
||||
<NAlert v-if="query.error.value" type="error" :show-icon="false" class="mb-3">
|
||||
{{ query.error.value }} 当前保留上次成功读取的素材,写操作已暂停。
|
||||
</NAlert>
|
||||
|
||||
<div class="asset-filter-bar">
|
||||
<NInput
|
||||
v-model:value="search"
|
||||
clearable
|
||||
placeholder="搜索名称、分类或原文件名"
|
||||
:input-props="{ 'aria-label': '搜索项目素材' }"
|
||||
>
|
||||
<template #prefix><Search :size="15" /></template>
|
||||
</NInput>
|
||||
<NSelect
|
||||
v-model:value="category"
|
||||
clearable
|
||||
placeholder="全部分类"
|
||||
:options="categoryOptions"
|
||||
aria-label="筛选素材分类"
|
||||
/>
|
||||
<span class="text-xs text-muted">{{ filteredAssets.length }} / {{ assets.length }} 项</span>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredAssets.length" class="project-asset-grid">
|
||||
<article v-for="asset in filteredAssets" :key="asset.id" class="project-asset-card">
|
||||
<AssetImage :src="asset.publicUrl" :alt="asset.name" preview class="project-asset-image" />
|
||||
<div class="project-asset-content">
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="truncate text-sm font-semibold" :title="asset.name">{{ asset.name }}</h3>
|
||||
<p v-if="metadataOriginalName(asset.metadata)" class="mt-1 truncate text-[11px] text-muted">
|
||||
{{ metadataOriginalName(asset.metadata) }}
|
||||
</p>
|
||||
</div>
|
||||
<NTag size="small" :bordered="false">{{ categoryLabel(asset.category) }}</NTag>
|
||||
</div>
|
||||
<p class="mt-3 text-[11px] text-muted">
|
||||
{{ asset.extension.toUpperCase() }} · {{ formatBytes(asset.size) }} ·
|
||||
{{ formatDate(asset.createdAt) }}
|
||||
</p>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<NButton text size="small" :disabled="disabled" @click="openEdit(asset)">
|
||||
<template #icon><Pencil :size="14" /></template>编辑
|
||||
</NButton>
|
||||
<NPopconfirm
|
||||
:positive-button-props="{ disabled }"
|
||||
positive-text="确认删除"
|
||||
negative-text="取消"
|
||||
@positive-click="removeAsset(asset)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton text size="small" class="text-danger" :disabled="disabled">
|
||||
<template #icon><Trash2 :size="14" /></template>删除
|
||||
</NButton>
|
||||
</template>
|
||||
删除后物理文件也会移除;正在被视觉风格引用的素材会由后端阻止删除。
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else-if="query.data.value"
|
||||
:title="assets.length ? '没有匹配的素材' : '资产库还是空的'"
|
||||
:description="assets.length ? '调整搜索或分类筛选。' : '上传 JPEG、PNG 或 WebP 图片,单张不超过 20MB。'"
|
||||
>
|
||||
<NButton v-if="assets.length" @click="clearFilters">清除筛选</NButton>
|
||||
<NButton v-else type="primary" :disabled="disabled" @click="fileInput?.click()">上传第一张图片</NButton>
|
||||
</EmptyState>
|
||||
<p v-else class="py-10 text-center text-sm text-muted" role="status">正在读取项目素材…</p>
|
||||
|
||||
<AppDialog
|
||||
v-model:open="uploadOpen"
|
||||
title="上传项目素材"
|
||||
description="确认名称和分类后上传。分类会用于资产筛选,并进入后端存储路径。"
|
||||
:busy="blocked"
|
||||
>
|
||||
<AppForm :model="uploadForm" :rules="formRules" :disabled="blocked" class="mt-5" @submit="uploadAsset">
|
||||
<p class="mb-4 text-sm">{{ selectedFile?.name }} · {{ formatBytes(selectedFile?.size ?? 0) }}</p>
|
||||
<NFormItem path="name" label="素材名称"><NInput v-model:value="uploadForm.name" /></NFormItem>
|
||||
<NFormItem path="category" label="分类标识">
|
||||
<NInput v-model:value="uploadForm.category" placeholder="例如 character、scene、prop" />
|
||||
</NFormItem>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<NButton :disabled="blocked" @click="uploadOpen = false">取消</NButton>
|
||||
<NButton type="primary" attr-type="submit" :loading="blocked">确认上传</NButton>
|
||||
</div>
|
||||
</AppForm>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
v-model:open="editOpen"
|
||||
title="编辑素材信息"
|
||||
description="只修改名称和分类,不会替换图片文件或修改已保存的引用地址。"
|
||||
:busy="blocked"
|
||||
>
|
||||
<AppForm :model="editForm" :rules="formRules" :disabled="blocked" class="mt-5" @submit="updateAsset">
|
||||
<NFormItem path="name" label="素材名称"><NInput v-model:value="editForm.name" /></NFormItem>
|
||||
<NFormItem path="category" label="分类标识"><NInput v-model:value="editForm.category" /></NFormItem>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<NButton :disabled="blocked" @click="editOpen = false">取消</NButton>
|
||||
<NButton type="primary" attr-type="submit" :loading="blocked">保存修改</NButton>
|
||||
</div>
|
||||
</AppForm>
|
||||
</AppDialog>
|
||||
</WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@reference "../../styles/styles.css";
|
||||
.project-assets-page {
|
||||
container: project-assets / inline-size;
|
||||
}
|
||||
.project-assets-toolbar {
|
||||
@apply items-start;
|
||||
}
|
||||
.asset-filter-bar {
|
||||
@apply grid grid-cols-[minmax(220px,_420px)_180px_auto] items-center gap-3 py-3 px-4 bg-(--app-subtle);
|
||||
}
|
||||
.project-asset-grid {
|
||||
@apply grid grid-cols-[repeat(auto-fill,_minmax(min(250px,_100%),_1fr))] gap-4 pt-4;
|
||||
}
|
||||
.project-asset-card {
|
||||
@apply min-w-0 overflow-hidden bg-(--app-surface);
|
||||
}
|
||||
.project-asset-image {
|
||||
@apply aspect-[4/3];
|
||||
}
|
||||
.project-asset-content {
|
||||
@apply p-4;
|
||||
}
|
||||
@container project-assets (max-width: 620px) {
|
||||
.asset-filter-bar {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
.asset-filter-bar > span {
|
||||
@apply justify-self-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { request } from '../../lib/http'
|
||||
import type { GlobalProjectAsset, ProjectAsset, UpdateProjectAssetInput, UploadProjectAssetInput } from './types'
|
||||
|
||||
/** 固定项目资产路径并编码正式数据库 ID。 */
|
||||
function assetPath(projectId: string, assetId?: string) {
|
||||
const base = `/projects/${encodeURIComponent(projectId)}/assets`
|
||||
return assetId ? `${base}/${encodeURIComponent(assetId)}` : base
|
||||
}
|
||||
|
||||
/** 项目素材 API;上传使用 multipart,浏览和编辑不调用生成模型。 */
|
||||
export const projectAssetsApi = {
|
||||
listAll: (signal?: AbortSignal) => request<GlobalProjectAsset[]>('/assets', { signal }),
|
||||
list: (projectId: string, signal?: AbortSignal) => request<ProjectAsset[]>(assetPath(projectId), { signal }),
|
||||
get: (projectId: string, assetId: string, signal?: AbortSignal) =>
|
||||
request<ProjectAsset>(assetPath(projectId, assetId), { signal }),
|
||||
upload: (projectId: string, input: UploadProjectAssetInput) => {
|
||||
const body = new FormData()
|
||||
body.append('file', input.file)
|
||||
body.append('name', input.name)
|
||||
body.append('category', input.category)
|
||||
return request<ProjectAsset>(assetPath(projectId), { method: 'POST', body, timeoutMs: 0 })
|
||||
},
|
||||
update: (projectId: string, assetId: string, input: UpdateProjectAssetInput) =>
|
||||
request<ProjectAsset>(assetPath(projectId, assetId), { method: 'PUT', body: input }),
|
||||
remove: (projectId: string, assetId: string) =>
|
||||
request<ProjectAsset>(assetPath(projectId, assetId), { method: 'DELETE' })
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { projectAssetsApi } from './api'
|
||||
export type { ProjectAsset, UpdateProjectAssetInput, UploadProjectAssetInput } from './types'
|
||||
@@ -0,0 +1,42 @@
|
||||
/** 项目资产库当前只接收图片;保留 type 字段以兼容后端后续扩展。 */
|
||||
export interface ProjectAsset {
|
||||
id: string
|
||||
projectId: string
|
||||
name: string
|
||||
type: string
|
||||
category: string
|
||||
mimeType: string
|
||||
extension: string
|
||||
size: number
|
||||
publicUrl: string
|
||||
metadata: unknown
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** 全局资产列表附带最小剧本信息,供筛选和来源标识使用。 */
|
||||
export interface ProjectAssetProject {
|
||||
id: string
|
||||
title: string | null
|
||||
topic: string
|
||||
status: string
|
||||
}
|
||||
|
||||
/** 全局资产接口记录。 */
|
||||
export interface GlobalProjectAsset extends ProjectAsset {
|
||||
project: ProjectAssetProject
|
||||
}
|
||||
|
||||
/** 素材编辑只修改展示信息,不替换物理文件。 */
|
||||
export interface UpdateProjectAssetInput {
|
||||
name?: string
|
||||
category?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 上传表单与后端 multipart 字段一一对应。 */
|
||||
export interface UploadProjectAssetInput {
|
||||
file: File
|
||||
name: string
|
||||
category: string
|
||||
}
|
||||
@@ -21,7 +21,10 @@ mobileMedia.addEventListener('change', syncMobile)
|
||||
onScopeDispose(() => mobileMedia.removeEventListener('change', syncMobile))
|
||||
|
||||
// 图库使用手动刷新,父布局也停止轮询,避免浏览素材时被周期性更新打断。
|
||||
const isGallery = computed(() => route.path.replace(/\/+$/, '').endsWith('/subject-images'))
|
||||
const isGallery = computed(() => {
|
||||
const path = route.path.replace(/\/+$/, '')
|
||||
return path.endsWith('/subject-images') || path.endsWith('/assets')
|
||||
})
|
||||
const context = useProjectData(
|
||||
id,
|
||||
computed(() => (isGallery.value ? false : 6000))
|
||||
@@ -30,6 +33,48 @@ provide(projectContextKey, context)
|
||||
const operation = computed(() => getOperation(id.value))
|
||||
const complete = computed(() => isProjectComplete(context.project.value, id.value))
|
||||
const isCreation = computed(() => route.path.replace(/\/+$/, '').endsWith('/create-drama'))
|
||||
type OperationGuide = { title: string; description: string; action?: string; target?: 'profile' | 'style' | 'identity' }
|
||||
const operationGuide = computed<OperationGuide | null>(() => {
|
||||
const error = operation.value.error?.trim()
|
||||
if (!error) return null
|
||||
const normalized = error.toLowerCase()
|
||||
if (normalized.includes('creative profile'))
|
||||
return {
|
||||
title: '缺少项目生成配置',
|
||||
description: '先选择图片和视频生成模型,保存后再继续当前操作。',
|
||||
action: '配置生成模型',
|
||||
target: 'profile'
|
||||
}
|
||||
if (error.includes('视觉风格') || normalized.includes('visual style'))
|
||||
return {
|
||||
title: '缺少项目视觉风格',
|
||||
description: '先完成视觉风格,再回来继续生成。',
|
||||
action: '设置视觉风格',
|
||||
target: 'style'
|
||||
}
|
||||
if (error.includes('身份') || normalized.includes('identity'))
|
||||
return {
|
||||
title: '主体身份尚未就绪',
|
||||
description: '先按主体身份页的步骤补齐文本、母版并完成锁定。',
|
||||
action: '处理主体身份',
|
||||
target: 'identity'
|
||||
}
|
||||
return { title: '操作未完成', description: error }
|
||||
})
|
||||
|
||||
/** 已知前置条件直接送达对应工作区;保留当前页面,修复后可继续原操作。 */
|
||||
function resolveOperationError() {
|
||||
const target = operationGuide.value?.target
|
||||
if (target === 'style') void router.push(`/projects/${id.value}/visual-style`)
|
||||
else if (target === 'identity') void router.push(`/projects/${id.value}/subject-identity`)
|
||||
else if (target === 'profile') {
|
||||
const onIdentityPage = route.path.replace(/\/+$/, '').endsWith('/subject-identity')
|
||||
void router.push({
|
||||
path: onIdentityPage ? route.path : `/projects/${id.value}/production`,
|
||||
query: { ...route.query, configure: 'profile' }
|
||||
})
|
||||
}
|
||||
}
|
||||
const access = inject(projectAccessKey, undefined)
|
||||
let published: ProjectAccess | null = null
|
||||
watchEffect(() => {
|
||||
@@ -65,7 +110,17 @@ onScopeDispose(() => {
|
||||
<NAlert v-if="operation.pending" :show-icon="false" role="status"
|
||||
>{{ operation.label }}。可切换页面查看结果,请勿重复提交。</NAlert
|
||||
>
|
||||
<NAlert v-if="operation.error" type="error" :show-icon="false">{{ operation.error }}</NAlert>
|
||||
<NAlert v-if="operationGuide" type="error" :show-icon="false" class="operation-guide">
|
||||
<div class="operation-guide-content">
|
||||
<div>
|
||||
<strong>{{ operationGuide.title }}</strong>
|
||||
<span class="ml-2">{{ operationGuide.description }}</span>
|
||||
</div>
|
||||
<NButton v-if="operationGuide.action" size="small" type="primary" @click="resolveOperationError">{{
|
||||
operationGuide.action
|
||||
}}</NButton>
|
||||
</div>
|
||||
</NAlert>
|
||||
<NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert>
|
||||
</NScrollbar>
|
||||
<div class="project-view">
|
||||
@@ -74,7 +129,7 @@ onScopeDispose(() => {
|
||||
v-else-if="context.project.value?.id === id"
|
||||
class="project-access-gate"
|
||||
title="请先完成剧本创作"
|
||||
:description="`剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、分镜设计与镜头生产。${isGallery ? '请手动刷新项目状态。' : '状态会自动更新。'}`"
|
||||
:description="`剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、资产库、分镜设计与镜头生产。${isGallery ? '请手动刷新项目状态。' : '状态会自动更新。'}`"
|
||||
>
|
||||
<NButton type="primary" @click="router.push(`/projects/${id}/create-drama`)">返回剧本创作</NButton>
|
||||
<NButton v-if="isGallery" class="ml-3" :loading="context.loading.value" @click="context.refresh"
|
||||
@@ -115,6 +170,9 @@ onScopeDispose(() => {
|
||||
.project-notices .n-alert {
|
||||
@apply py-[7px] px-3 text-xs;
|
||||
}
|
||||
.operation-guide-content {
|
||||
@apply flex items-center justify-between gap-4;
|
||||
}
|
||||
.project-view {
|
||||
@apply flex-1 min-h-0 overflow-hidden;
|
||||
}
|
||||
@@ -134,5 +192,8 @@ onScopeDispose(() => {
|
||||
.project-title {
|
||||
@apply text-sm;
|
||||
}
|
||||
.operation-guide-content {
|
||||
@apply items-start flex-col gap-2;
|
||||
}
|
||||
}
|
||||
</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')
|
||||
/** 接口暂未分页:对完整查询结果分批展示,滚动时追加而非切换页码。 */
|
||||
|
||||
@@ -11,12 +11,16 @@ import { errorMessage } from '../../../lib/http'
|
||||
|
||||
/** 新建剧本表单,202 成功后交由父级跳转,不在前端模拟生成。 */
|
||||
const emit = defineEmits<{ created: [projectId: string] }>()
|
||||
/** 新建剧本弹窗是否打开。 */
|
||||
const open = ref(false)
|
||||
/** 创建请求是否正在提交。 */
|
||||
const busy = ref(false)
|
||||
/** 创建请求的错误提示。 */
|
||||
const error = ref('')
|
||||
const form = reactive({ topic: '', style: '', episodeCount: 1 })
|
||||
/** 与后端 POST /projects 入参一致的创建表单。 */
|
||||
const form = reactive({ title: '', topic: '', style: '', episodeCount: 1 })
|
||||
|
||||
/** 校验正整数集数和主题,禁止双击产生重复项目。 */
|
||||
/** 校验项目标题、主题与正整数集数,禁止双击产生重复项目。 */
|
||||
async function submit() {
|
||||
if (busy.value) return
|
||||
error.value = ''
|
||||
@@ -24,10 +28,12 @@ async function submit() {
|
||||
try {
|
||||
const result = await projectsApi.create({
|
||||
...form,
|
||||
title: form.title.trim(),
|
||||
topic: form.topic.trim(),
|
||||
style: form.style.trim()
|
||||
})
|
||||
open.value = false
|
||||
form.title = ''
|
||||
form.topic = ''
|
||||
emit('created', result.projectId)
|
||||
} catch (cause) {
|
||||
@@ -36,7 +42,12 @@ async function submit() {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
const rules = { topic: requiredTextRule('故事主题'), episodeCount: integerRule('计划集数') }
|
||||
/** 创建项目表单校验规则。 */
|
||||
const rules = {
|
||||
title: requiredTextRule('项目标题'),
|
||||
topic: requiredTextRule('故事主题'),
|
||||
episodeCount: integerRule('计划集数')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -57,6 +68,15 @@ const rules = { topic: requiredTextRule('故事主题'), episodeCount: integerRu
|
||||
class="mt-7 space-y-4"
|
||||
@submit="submit"
|
||||
>
|
||||
<!-- 以下是项目基本信息模块 -->
|
||||
<NFormItem path="title" label="项目标题" :label-props="{ for: 'title' }"
|
||||
><NInput
|
||||
v-model:value="form.title"
|
||||
placeholder="例如:记忆当铺"
|
||||
:disabled="busy"
|
||||
:input-props="{ id: 'title', autocomplete: 'off' }"
|
||||
></NInput
|
||||
></NFormItem>
|
||||
<NFormItem path="topic" label="故事主题" :label-props="{ for: 'topic' }"
|
||||
><NInput
|
||||
placeholder="描述主角、故事背景,以及你希望展开的核心冲突……"
|
||||
@@ -68,6 +88,7 @@ const rules = { topic: requiredTextRule('故事主题'), episodeCount: integerRu
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput
|
||||
></NFormItem>
|
||||
<!-- 以下是生成参数模块 -->
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<NFormItem path="style" label="剧本风格" :label-props="{ for: 'style' }"
|
||||
><NInput
|
||||
|
||||
@@ -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) => {
|
||||
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 }
|
||||
},
|
||||
interval
|
||||
)
|
||||
})
|
||||
const project = computed<ProjectDetail | null>(() => query.data.value?.project ?? null)
|
||||
const checkpoints = computed<Checkpoint[]>(() => query.data.value?.checkpoints ?? [])
|
||||
return { ...query, project, checkpoints }
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
/** 后端 DramaProject.status 的原始取值。 */
|
||||
export type ProjectStatus = 'draft' | 'generating' | 'completed' | 'need_review' | 'failed'
|
||||
|
||||
/** 创建请求不包含标题,标题由后端在工作流收尾时更新。 */
|
||||
/** 创建请求参数,与后端 POST /projects 入参保持一致。 */
|
||||
export interface CreateProjectInput {
|
||||
/** 项目标题 */
|
||||
title: string
|
||||
/** 故事主题 */
|
||||
topic: string
|
||||
/** 剧本风格 */
|
||||
style: string
|
||||
/** 计划生成集数 */
|
||||
episodeCount: number
|
||||
}
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@ const rules = { concurrency: integerRule('批量并发'), maxRepairAttempts: int
|
||||
修改或覆盖上游设计后,后端不会自动使旧状态、旧提示词失效;请按顺序重生成下游内容。自动修复次数仅用于
|
||||
VisualState。
|
||||
</p>
|
||||
<NCollapse class="mt-3 pt-3"
|
||||
<NCollapse class="mt-3 pt-3" :default-expanded-names="['details']"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>视频提示词批量操作</template>
|
||||
<p class="my-3 text-xs leading-6 text-muted">
|
||||
|
||||
@@ -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,10 +38,8 @@ export function useStoryboard() {
|
||||
0
|
||||
)
|
||||
const queryKey = computed(() => JSON.stringify([id.value, episodeNo.value]))
|
||||
/** 分镜页已有刷新按钮,不再定时轮询当前剧集。 */
|
||||
const query = usePolling(
|
||||
queryKey,
|
||||
async (key, signal) => {
|
||||
/** 分镜页仅在进入、切换剧集或显式刷新时读取。 */
|
||||
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([
|
||||
@@ -57,9 +55,7 @@ export function useStoryboard() {
|
||||
throw new Error('分镜查询返回了不匹配的项目或剧集,请刷新后重试。')
|
||||
}
|
||||
return { directions, visualStates }
|
||||
},
|
||||
false
|
||||
)
|
||||
})
|
||||
const data = query.data
|
||||
const session = computed(() => getStoryboardSession(id.value))
|
||||
/** 单集重生成返回的是最新镜头正文,优先于不会随局部覆盖更新的历史 Breakdown 快照。 */
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
NTag
|
||||
} from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { RefreshCw, UserCheck } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Check, Circle, RefreshCw, UserCheck } from '@lucide/vue'
|
||||
import { AssetImage, DirectoryItem, EmptyState, StatusBadge } from '../../components/ui'
|
||||
import { downloadText } from '../../lib/format'
|
||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||
@@ -29,10 +29,12 @@ import IdentityGallery from './components/IdentityGallery.vue'
|
||||
import IdentityImageDialog from './components/IdentityImageDialog.vue'
|
||||
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
|
||||
import IdentityThumbnail from './components/IdentityThumbnail.vue'
|
||||
import CreativeProfileSettings from '../generation-config/components/CreativeProfileSettings.vue'
|
||||
import type { IdentitySubject } from './types'
|
||||
|
||||
/** 主体身份工作区按正式主体聚合,不把同一主体的多个 Form 当成不同身份。 */
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const {
|
||||
projectId,
|
||||
operation,
|
||||
@@ -42,10 +44,10 @@ const {
|
||||
catalog,
|
||||
castingQuery,
|
||||
styleQuery,
|
||||
profileQuery,
|
||||
detail,
|
||||
identity,
|
||||
images,
|
||||
castingItem,
|
||||
session,
|
||||
concurrency,
|
||||
candidateLimit,
|
||||
@@ -56,6 +58,7 @@ const {
|
||||
castingBlocked,
|
||||
detailBlocked,
|
||||
hasStyle,
|
||||
hasCreativeProfile,
|
||||
canGenerateText,
|
||||
canGenerateImage,
|
||||
canGenerateCandidate,
|
||||
@@ -64,6 +67,7 @@ const {
|
||||
save,
|
||||
generate,
|
||||
generateProject,
|
||||
generateStableAnchors,
|
||||
generateCastingCandidates,
|
||||
generateImage,
|
||||
generateCastingCandidate,
|
||||
@@ -72,6 +76,7 @@ const {
|
||||
} = useSubjectIdentity()
|
||||
const search = ref('')
|
||||
const module = ref('all')
|
||||
const identityBatchModule = ref<'all' | 'character' | 'scene' | 'prop'>('all')
|
||||
const imageOpen = ref(false)
|
||||
const castingOpen = ref(false)
|
||||
const pendingSelection = ref('')
|
||||
@@ -160,6 +165,121 @@ watch(
|
||||
)
|
||||
const formModel = computed(() => ({ concurrency: concurrency.value, candidateLimit: candidateLimit.value }))
|
||||
const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRule('本批上限') }
|
||||
|
||||
type IdentityFlowAction = 'style' | 'profile' | 'identity' | 'candidate' | 'confirm' | 'lock' | 'forms'
|
||||
const primaryCandidate = computed(() =>
|
||||
images.value.find(image => image.viewType === 'primary' && image.status === 'completed' && image.imageUrl)
|
||||
)
|
||||
const identityTextReady = computed(() => !!identity.value?.generationPrompt?.trim())
|
||||
const candidateReady = computed(() => !!anchor.value || !!primaryCandidate.value)
|
||||
const identityReady = computed(() => !!anchor.value && !!identity.value?.isLocked)
|
||||
const identityFlow = computed(() => {
|
||||
const character = subject.value?.module === 'character'
|
||||
return [
|
||||
{ label: '身份文本', done: identityTextReady.value },
|
||||
{ label: character ? '选角候选' : '身份参考图', done: candidateReady.value },
|
||||
{ label: '确认并锁定', done: identityReady.value }
|
||||
]
|
||||
})
|
||||
const currentFlowIndex = computed(() => {
|
||||
const index = identityFlow.value.findIndex(step => !step.done)
|
||||
return index < 0 ? identityFlow.value.length : index
|
||||
})
|
||||
const nextAction = computed<{ action: IdentityFlowAction; title: string; description: string; label: string }>(() => {
|
||||
if (!hasStyle.value)
|
||||
return {
|
||||
action: 'style',
|
||||
title: '先设置视觉风格',
|
||||
description: '身份生成需要项目视觉风格。',
|
||||
label: '设置视觉风格'
|
||||
}
|
||||
if (!hasCreativeProfile.value)
|
||||
return {
|
||||
action: 'profile',
|
||||
title: '先配置生成模型',
|
||||
description: '选择项目图片与视频模型后才能生图。',
|
||||
label: '配置生成模型'
|
||||
}
|
||||
if (!identityTextReady.value)
|
||||
return {
|
||||
action: 'identity',
|
||||
title: '第 1 步:生成身份文本',
|
||||
description: '生成稳定身份描述和核心提示词。',
|
||||
label: '生成身份文本'
|
||||
}
|
||||
if (!candidateReady.value)
|
||||
return subject.value?.module === 'character'
|
||||
? {
|
||||
action: 'candidate',
|
||||
title: '第 2 步:生成选角候选',
|
||||
description: '生成候选后再确认正式演员。',
|
||||
label: '生成选角候选'
|
||||
}
|
||||
: {
|
||||
action: 'candidate',
|
||||
title: '第 2 步:生成身份参考图',
|
||||
description: '生成一张可选为母版的参考图。',
|
||||
label: '生成身份参考图'
|
||||
}
|
||||
if (anchor.value && !identity.value?.isLocked)
|
||||
return {
|
||||
action: 'lock',
|
||||
title: '第 3 步:锁定身份',
|
||||
description: '勾选锁定并保存,后续生成才会稳定引用当前母版。',
|
||||
label: '前往锁定'
|
||||
}
|
||||
if (!identityReady.value)
|
||||
return {
|
||||
action: 'confirm',
|
||||
title: '第 3 步:确认并锁定',
|
||||
description: '在下方选择母版;人物会同时锁定,场景和道具还需保存锁定状态。',
|
||||
label: '前往确认母版'
|
||||
}
|
||||
return {
|
||||
action: 'forms',
|
||||
title: '主体身份已完成',
|
||||
description: '可以继续生成此主体的形态图片。',
|
||||
label: '查看形态图片'
|
||||
}
|
||||
})
|
||||
|
||||
function openProfileSettings() {
|
||||
toolsOpen.value = true
|
||||
overview.value = []
|
||||
if (route.query.configure !== 'profile') void router.replace({ query: { ...route.query, configure: 'profile' } })
|
||||
}
|
||||
|
||||
function runNextAction() {
|
||||
if (nextAction.value.action === 'style') void router.push(`/projects/${projectId.value}/visual-style`)
|
||||
else if (nextAction.value.action === 'profile') openProfileSettings()
|
||||
else if (nextAction.value.action === 'identity') void generate()
|
||||
else if (nextAction.value.action === 'candidate') {
|
||||
if (subject.value?.module === 'character') castingOpen.value = true
|
||||
else imageOpen.value = true
|
||||
} else if (nextAction.value.action === 'confirm') {
|
||||
document.querySelector('#identity-reference-gallery')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
} else if (nextAction.value.action === 'lock') {
|
||||
document.querySelector('#identity-lock')?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
} else if (nextAction.value.action === 'forms') {
|
||||
void router.push({
|
||||
path: `/projects/${projectId.value}/subject-images`,
|
||||
query: { subjectRef: subject.value?.ref }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function profileSaved() {
|
||||
operation.value.error = ''
|
||||
await profileQuery.refresh()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.configure,
|
||||
value => {
|
||||
if (value === 'profile') openProfileSettings()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -186,6 +306,7 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
title="角色选角总览与批量操作"
|
||||
:has-receipt="!!session.receipt"
|
||||
>
|
||||
<CreativeProfileSettings :project-id="projectId" @saved="profileSaved" />
|
||||
<div class="identity-tools-intro flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">主体身份</h2>
|
||||
@@ -301,6 +422,7 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
blocked ||
|
||||
!hasStyle ||
|
||||
!batchValid ||
|
||||
!candidateLimitValid ||
|
||||
dirty ||
|
||||
!casting.total
|
||||
"
|
||||
@@ -343,13 +465,63 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
/></NButton>
|
||||
</div>
|
||||
</section>
|
||||
<NCollapse class="panel mb-5 p-5"
|
||||
<section class="panel mb-5 p-5" aria-label="场景与道具身份母版">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">场景与道具稳定母版</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
为已有身份提示词但缺少有效母版的场景或道具小批量生图。成功后后端会自动设为
|
||||
Anchor 并锁定 Identity。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<ConfirmAction
|
||||
label="生成场景母版"
|
||||
:disabled="
|
||||
blocked ||
|
||||
!hasStyle ||
|
||||
!batchValid ||
|
||||
!candidateLimitValid ||
|
||||
dirty ||
|
||||
!subjects.length
|
||||
"
|
||||
acknowledgement
|
||||
:description="`最多处理 ${candidateLimit} 个场景,生成成功后自动设为身份母版并锁定。可能产生图片模型费用。`"
|
||||
@confirm="generateStableAnchors('scene')"
|
||||
/>
|
||||
<ConfirmAction
|
||||
label="生成道具母版"
|
||||
:disabled="
|
||||
blocked ||
|
||||
!hasStyle ||
|
||||
!batchValid ||
|
||||
dirty ||
|
||||
!subjects.length
|
||||
"
|
||||
acknowledgement
|
||||
:description="`最多处理 ${candidateLimit} 个道具,生成成功后自动设为身份母版并锁定。可能产生图片模型费用。`"
|
||||
@confirm="generateStableAnchors('prop')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<NCollapse class="panel mb-5 p-5" :default-expanded-names="['details']"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>高级:全部主体身份文本</template>
|
||||
<p class="mt-3 text-xs leading-6 text-muted">
|
||||
同时处理 Character、Scene 和
|
||||
Prop;角色选角建议优先使用上方专用入口。已锁定身份始终跳过。
|
||||
</p>
|
||||
<NSelect
|
||||
v-model:value="identityBatchModule"
|
||||
class="mt-4 w-48"
|
||||
aria-label="身份文本批处理范围"
|
||||
:options="[
|
||||
{ label: String('全部类型'), value: 'all' },
|
||||
{ label: String('仅人物'), value: 'character' },
|
||||
{ label: String('仅场景'), value: 'scene' },
|
||||
{ label: String('仅道具'), value: 'prop' }
|
||||
]" />
|
||||
<ConfirmAction
|
||||
class="mt-4"
|
||||
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
|
||||
@@ -358,7 +530,17 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
"
|
||||
acknowledgement
|
||||
description="生成全项目主体身份描述与提示词,不生成图片。覆盖不会自动更新旧身份图、形态图、首帧或视频。"
|
||||
@confirm="generateProject(false)" /></NCollapseItem
|
||||
@confirm="
|
||||
identityBatchModule === 'character'
|
||||
? generateProject(true)
|
||||
: generateProject(
|
||||
false,
|
||||
identityBatchModule === 'scene' ||
|
||||
identityBatchModule === 'prop'
|
||||
? identityBatchModule
|
||||
: undefined
|
||||
)
|
||||
" /></NCollapseItem
|
||||
></NCollapse>
|
||||
<section
|
||||
v-if="session.receipt"
|
||||
@@ -382,13 +564,25 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
{{ session.receipt.result.skippedLocked }})· 失败
|
||||
{{ session.receipt.result.failed }}
|
||||
</p>
|
||||
<p v-else class="mt-3 text-xs text-muted">
|
||||
<p
|
||||
v-else-if="session.receipt.kind === 'casting'"
|
||||
class="mt-3 text-xs text-muted"
|
||||
>
|
||||
共 {{ session.receipt.result.totalCharacters }} 个角色 · 缺候选
|
||||
{{ session.receipt.result.missingAnchor }} · 本批目标
|
||||
{{ session.receipt.result.targetCount }} · 已生成
|
||||
{{ session.receipt.result.generated }} · 失败
|
||||
{{ session.receipt.result.failed }}
|
||||
</p>
|
||||
<p v-else class="mt-3 text-xs text-muted">
|
||||
共 {{ session.receipt.result.total }} 个{{
|
||||
session.receipt.result.module === 'scene' ? '场景' : '道具'
|
||||
}}
|
||||
· 符合条件 {{ session.receipt.result.eligibleCount }} · 本批目标
|
||||
{{ session.receipt.result.targetCount }} · 已生成并锁定
|
||||
{{ session.receipt.result.generated }} · 失败
|
||||
{{ session.receipt.result.failed }}
|
||||
</p>
|
||||
<NAlert
|
||||
v-if="session.receipt.result.failed"
|
||||
role="alert"
|
||||
@@ -428,6 +622,17 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
当前相关操作已暂停,请刷新核对后端。
|
||||
</NAlert></NScrollbar
|
||||
>
|
||||
<NAlert
|
||||
v-if="profileQuery.data.value && !hasCreativeProfile"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
class="identity-prerequisite"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<span><strong>需要先配置项目生成模型</strong>,否则身份图和选角候选无法生成。</span>
|
||||
<NButton type="primary" @click="openProfileSettings">立即配置</NButton>
|
||||
</div>
|
||||
</NAlert>
|
||||
<div v-if="subjects.length" class="identity-workspace">
|
||||
<!-- 以下是主体目录 -->
|
||||
<aside class="panel directory-panel identity-directory">
|
||||
@@ -504,20 +709,6 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
<h3 class="font-semibold">
|
||||
{{ subject.name }} <code class="subject-ref ml-2">{{ subject.ref }}</code>
|
||||
</h3>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
<template v-if="subject.module === 'character' && castingItem">
|
||||
选角状态:{{ castingStatusLabel(castingItem.status) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
identity
|
||||
? identity.isLocked
|
||||
? '身份文本已锁定'
|
||||
: '身份文本未锁定'
|
||||
: '尚未创建身份'
|
||||
}}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<NButton :disabled="detail.loading.value" @click="detail.refresh" text size="small">
|
||||
刷新身份
|
||||
@@ -530,26 +721,40 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
正在读取主体身份…
|
||||
</p>
|
||||
<template v-if="detail.data.value">
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<ConfirmAction
|
||||
:label="identity ? 'AI 重新生成身份' : 'AI 生成身份'"
|
||||
:disabled="!canGenerateText"
|
||||
acknowledgement
|
||||
description="调用文本模型生成稳定身份描述和提示词。旧图片不会自动重生成;已锁定身份需先解锁并保存。"
|
||||
@confirm="generate"
|
||||
/><NButton
|
||||
v-if="subject.module === 'character'"
|
||||
:disabled="!canGenerateCandidate"
|
||||
@click="castingOpen = true"
|
||||
<div class="identity-flow mt-4" aria-label="主体身份操作步骤">
|
||||
<ol class="identity-flow-steps">
|
||||
<li
|
||||
v-for="(step, index) in identityFlow"
|
||||
:key="step.label"
|
||||
:class="{ done: step.done, current: index === currentFlowIndex }"
|
||||
>
|
||||
生成选角候选</NButton
|
||||
><NButton
|
||||
v-if="subject.module !== 'character' || !!anchor"
|
||||
:disabled="!canGenerateImage"
|
||||
@click="imageOpen = true"
|
||||
>{{ subject.module === 'character' ? '生成辅助身份图' : '生成身份参考图' }}</NButton
|
||||
<span class="identity-flow-marker">
|
||||
<Check v-if="step.done" :size="14" />
|
||||
<Circle v-else :size="12" />
|
||||
</span>
|
||||
<span>{{ index + 1 }}. {{ step.label }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="identity-next-action">
|
||||
<div>
|
||||
<strong>{{ nextAction.title }}</strong>
|
||||
<p>{{ nextAction.description }}</p>
|
||||
</div>
|
||||
<NButton
|
||||
type="primary"
|
||||
:disabled="
|
||||
operation.pending ||
|
||||
(nextAction.action === 'identity' && !canGenerateText) ||
|
||||
(nextAction.action === 'candidate' &&
|
||||
(subject.module === 'character'
|
||||
? !canGenerateCandidate
|
||||
: !canGenerateImage))
|
||||
"
|
||||
@click="runNextAction"
|
||||
>{{ nextAction.label }}</NButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="dirty" class="mt-3 text-xs text-muted">请先保存身份修改,再使用 AI 或生成图片。</p>
|
||||
<IdentityEditor
|
||||
:key="`${selectedId}:${editorRevision}`"
|
||||
@@ -582,6 +787,7 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
</div>
|
||||
</div>
|
||||
<IdentityGallery
|
||||
id="identity-reference-gallery"
|
||||
:key="selectedId"
|
||||
:images="images"
|
||||
:subject-name="subject.name"
|
||||
@@ -673,6 +879,38 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
.identity-workspace-page {
|
||||
container: identity-header / inline-size;
|
||||
}
|
||||
.identity-prerequisite {
|
||||
@apply shrink-0 mx-0 mb-3;
|
||||
}
|
||||
.identity-flow {
|
||||
@apply p-4 bg-(--app-subtle);
|
||||
}
|
||||
.identity-flow-steps {
|
||||
@apply flex flex-wrap items-center gap-x-6 gap-y-2;
|
||||
}
|
||||
.identity-flow-steps li {
|
||||
@apply flex items-center gap-2 text-xs text-muted;
|
||||
}
|
||||
.identity-flow-steps li.current {
|
||||
@apply text-accent font-semibold;
|
||||
}
|
||||
.identity-flow-steps li.done {
|
||||
@apply text-muted;
|
||||
}
|
||||
.identity-flow-marker {
|
||||
@apply grid place-items-center w-5 h-5 rounded-full border border-(--app-border);
|
||||
}
|
||||
.identity-flow-steps li.done .identity-flow-marker,
|
||||
.identity-flow-steps li.current .identity-flow-marker {
|
||||
@apply border-(--app-accent) text-accent;
|
||||
}
|
||||
.identity-next-action {
|
||||
@apply mt-4 pt-4 flex items-center justify-between gap-4;
|
||||
border-top: 1px solid var(--app-border);
|
||||
}
|
||||
.identity-next-action p {
|
||||
@apply mt-1 text-xs text-muted;
|
||||
}
|
||||
.toolbar-type-filter {
|
||||
@apply w-[152px] min-w-0;
|
||||
}
|
||||
@@ -768,6 +1006,9 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
|
||||
@apply shrink-0;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.identity-next-action {
|
||||
@apply items-start flex-col;
|
||||
}
|
||||
.identity-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[auto_minmax(0,_1fr)] gap-2.5;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import type {
|
||||
GenerateIdentityImageInput,
|
||||
IdentityBatchResult,
|
||||
IdentityImage,
|
||||
IdentityModule,
|
||||
SaveIdentityInput,
|
||||
StableAnchorBatchResult,
|
||||
SubjectIdentity
|
||||
} from './types'
|
||||
|
||||
@@ -29,7 +31,7 @@ export const subjectIdentityApi = {
|
||||
body: { force },
|
||||
timeoutMs: 0
|
||||
}),
|
||||
generateProject: (projectId: string, input: { force: boolean; concurrency: number }) =>
|
||||
generateProject: (projectId: string, input: { force: boolean; concurrency: number; module?: IdentityModule }) =>
|
||||
request<IdentityBatchResult>(`/projects/${encodeURIComponent(projectId)}/subject-identities/generate`, {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
@@ -56,6 +58,15 @@ export const subjectIdentityApi = {
|
||||
`/projects/${encodeURIComponent(projectId)}/character-casting/candidates/generate`,
|
||||
{ method: 'POST', body: input, timeoutMs: 0 }
|
||||
),
|
||||
/** 为场景或道具生成稳定身份母版,并由后端原子设为 Anchor、锁定 Identity。 */
|
||||
generateStableAnchors: (
|
||||
projectId: string,
|
||||
input: { module: 'scene' | 'prop'; limit: number; concurrency: number }
|
||||
) =>
|
||||
request<StableAnchorBatchResult>(
|
||||
`/projects/${encodeURIComponent(projectId)}/subject-identities/anchors/generate`,
|
||||
{ method: 'POST', body: input, timeoutMs: 0 }
|
||||
),
|
||||
listImages: (subjectId: string, signal?: AbortSignal) =>
|
||||
request<IdentityImage[]>(`${identityPath(subjectId)}/images`, { signal }),
|
||||
generateImage: (subjectId: string, input: GenerateIdentityImageInput) =>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import AppForm from '../../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import DetailDisclosure from '../../../components/ui/DetailDisclosure.vue'
|
||||
import { NButton, NCheckbox, NInput } from 'naive-ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { SaveIdentityInput, SubjectIdentity } from '../types'
|
||||
@@ -36,63 +35,42 @@ function save() {
|
||||
<template>
|
||||
<AppForm :model="form" :disabled="disabled" class="mt-5" @submit="save">
|
||||
<fieldset :disabled="disabled" class="space-y-4">
|
||||
<NFormItem
|
||||
class="block"
|
||||
path="description"
|
||||
label="稳定身份描述"
|
||||
:label-props="{ for: 'identity-description' }"
|
||||
<NFormItem class="block" path="description" label="身份描述" :label-props="{ for: 'identity-description' }"
|
||||
><NInput
|
||||
:disabled="disabled"
|
||||
placeholder="只描述跨形态不变的面部、结构、材质等特征"
|
||||
:input-props="{ id: 'identity-description' }"
|
||||
class="min-h-28"
|
||||
class="min-h-20"
|
||||
v-model:value="form.description"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
:autosize="{ minRows: 2, maxRows: 8 }"
|
||||
></NInput>
|
||||
</NFormItem>
|
||||
<NFormItem
|
||||
class="block"
|
||||
path="generationPrompt"
|
||||
label="身份核心提示词(稳定事实)"
|
||||
label="核心提示词"
|
||||
:label-props="{ for: 'identity-prompt' }"
|
||||
><NInput
|
||||
:disabled="disabled"
|
||||
placeholder="不固定某一形态的服装、伤势或临时状态"
|
||||
:input-props="{ id: 'identity-prompt' }"
|
||||
class="min-h-36"
|
||||
class="min-h-24"
|
||||
v-model:value="form.generationPrompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
:autosize="{ minRows: 2, maxRows: 8 }"
|
||||
></NInput>
|
||||
</NFormItem>
|
||||
<DetailDisclosure title="身份填写与锁定规则"
|
||||
><p class="text-xs leading-6 text-muted">
|
||||
{{
|
||||
module === 'prop'
|
||||
? '保留道具固定文字、编号、部件位置、结构与材质组合;不要泛化原有辨识细节,也不要写临时损伤或摆放状态。'
|
||||
: module === 'scene'
|
||||
? '保留建筑骨架、入口、窗户、楼梯与固定布局,不写临时天气、人群或灯光。'
|
||||
: '保留有依据的五官、骨相、发际线与体型,不固定某套服装、临时表情或伤势。'
|
||||
}}
|
||||
这里保存身份事实;背景、视角和参考图约束由后端另行编译,不是最终整段生图 Prompt。
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
人物需要同时确认母版并锁定身份,首帧才能就绪。锁定后仍可人工编辑、生成图片及切换母版。
|
||||
</p></DetailDisclosure
|
||||
>
|
||||
<NCheckbox
|
||||
:disabled="disabled"
|
||||
id="identity-lock"
|
||||
v-model:checked="form.isLocked"
|
||||
class="flex items-start gap-2 text-xs leading-6"
|
||||
>{{ module === 'character' ? '确认并锁定演员身份' : '确认并锁定身份文本' }}</NCheckbox
|
||||
>{{ module === 'character' ? '锁定演员身份' : '锁定身份' }}</NCheckbox
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="text-xs text-muted">{{
|
||||
dirty ? '有未保存修改,刷新不会覆盖草稿。' : '图片以已保存的身份提示词为准。'
|
||||
}}</span
|
||||
><NButton :disabled="disabled" type="primary" attr-type="submit">保存主体身份</NButton>
|
||||
<div class="page-form-actions">
|
||||
<NButton :disabled="disabled" type="primary" attr-type="submit">保存主体身份</NButton>
|
||||
<span v-if="dirty" class="text-xs text-muted">有未保存修改</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
</AppForm>
|
||||
|
||||
@@ -52,12 +52,6 @@ function chooseAnchor() {
|
||||
<h3 class="text-sm font-medium">身份参考图 · {{ images.length }}</h3>
|
||||
<NTag size="small" :bordered="false">{{ anchor ? '已选母版' : '尚无母版' }}</NTag>
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
身份母版确定“是谁”;形态主图确定“该造型长什么样”。切换母版仅影响之后的生成,不会替换已有形态图、分镜参考图或提示词。
|
||||
</p>
|
||||
<NAlert v-if="character && anchor" type="info" :show-icon="false" class="mt-3 text-xs leading-6">
|
||||
如果要更换演员身份,请生成新的“选角候选”并确认选角。正面、三分之四、全身等辅助身份图会继续引用当前母版,只用于保持同一演员,不会重新选择人物。
|
||||
</NAlert>
|
||||
<template v-if="selected">
|
||||
<AssetImage
|
||||
:key="selected.id"
|
||||
@@ -70,7 +64,6 @@ function chooseAnchor() {
|
||||
/>
|
||||
<div class="image-history-heading">
|
||||
<h4 class="font-medium">历史记录 · {{ images.length }} 条</h4>
|
||||
<span class="text-muted">点击缩略图切换预览,可横向滚动</span>
|
||||
</div>
|
||||
<NScrollbar
|
||||
x-scrollable
|
||||
@@ -158,7 +151,6 @@ function chooseAnchor() {
|
||||
><NButton @click="confirming = false" text size="small">取消</NButton>
|
||||
</div></NAlert
|
||||
>
|
||||
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
|
||||
</template>
|
||||
<p v-else class="py-8 text-sm text-muted">尚无身份参考图。先保存身份提示词,再生成第一张母版。</p>
|
||||
</section>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NImage } from 'naive-ui'
|
||||
import { Box, MapPin, UserRound } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { referenceImageUrl } from '../../../lib/assets'
|
||||
import { subjectIdentityApi } from '../api'
|
||||
import { currentAnchor } from '../model'
|
||||
import RevealImage from '../../../components/ui/RevealImage.vue'
|
||||
|
||||
/** source 为 undefined 时按需查询;null 表示上层已确认没有母版,不能用候选或形态图替代。 */
|
||||
const props = defineProps<{
|
||||
@@ -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) => {
|
||||
@@ -89,16 +89,7 @@ watch(
|
||||
|
||||
<template>
|
||||
<span ref="target" class="identity-thumbnail" :title="label">
|
||||
<NImage
|
||||
v-if="url && !imageFailed"
|
||||
:src="url"
|
||||
:alt="label"
|
||||
object-fit="cover"
|
||||
preview-disabled
|
||||
lazy
|
||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||||
@error="imageFailed = true"
|
||||
/>
|
||||
<RevealImage v-if="url && !imageFailed" :src="url" :alt="label" @error="imageFailed = true" />
|
||||
<span v-else class="identity-thumbnail-placeholder" role="img" :aria-label="label">
|
||||
<component :is="icon" :size="22" :stroke-width="1.4" aria-hidden="true" />
|
||||
<span aria-hidden="true">{{ kind }}</span>
|
||||
@@ -113,7 +104,8 @@ watch(
|
||||
@apply flex w-12 h-[64px] overflow-hidden bg-(--app-subtle);
|
||||
}
|
||||
.identity-thumbnail .n-image,
|
||||
.identity-thumbnail .n-image img {
|
||||
.identity-thumbnail .n-image img,
|
||||
.identity-thumbnail .reveal-image {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
.identity-thumbnail-placeholder {
|
||||
|
||||
@@ -67,6 +67,28 @@ export interface IdentityBatchResult {
|
||||
failures: { subjectId: string; subjectRef: string; error: string }[]
|
||||
}
|
||||
|
||||
export type IdentityModule = 'character' | 'scene' | 'prop'
|
||||
|
||||
export interface StableAnchorBatchResult {
|
||||
module: 'scene' | 'prop'
|
||||
total: number
|
||||
eligibleCount: number
|
||||
targetCount: number
|
||||
generated: number
|
||||
failed: number
|
||||
failures: { subjectId: string; subjectRef: string; subjectName: string; error: string }[]
|
||||
results: Array<{
|
||||
subjectId: string
|
||||
subjectRef: string
|
||||
subjectName: string
|
||||
success: boolean
|
||||
imageId?: string
|
||||
imageUrl?: string | null
|
||||
identityLocked?: boolean
|
||||
error?: string
|
||||
}>
|
||||
}
|
||||
|
||||
/** Character 的正式选角阶段状态。 */
|
||||
export type CharacterCastingStatus = 'missing_identity' | 'missing_anchor' | 'candidate_pending' | 'unlocked' | 'ready'
|
||||
|
||||
@@ -144,3 +166,4 @@ export interface ConfirmCastingResult {
|
||||
export type IdentityReceipt =
|
||||
| { kind: 'identities'; title: string; result: IdentityBatchResult }
|
||||
| { kind: 'casting'; title: string; result: CastingBatchResult }
|
||||
| { kind: 'stable-anchors'; title: string; result: StableAnchorBatchResult }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { visualStyleApi } from '../visual-style'
|
||||
import { generationConfigApi } from '../generation-config/api'
|
||||
import { subjectImagesApi } from '../subject-images/api'
|
||||
import { hasRunningImages } from '../subject-images/model'
|
||||
import { runOperation } from '../workflows/operations'
|
||||
@@ -56,6 +57,11 @@ export function useSubjectIdentity() {
|
||||
},
|
||||
false
|
||||
)
|
||||
const profileQuery = usePolling(
|
||||
projectId,
|
||||
async (id, signal) => ({ profile: await generationConfigApi.profile(id, signal) }),
|
||||
false
|
||||
)
|
||||
const castingQuery = usePolling(
|
||||
projectId,
|
||||
async (id, signal) => {
|
||||
@@ -106,11 +112,17 @@ export function useSubjectIdentity() {
|
||||
hasRunningImages(images.value)
|
||||
)
|
||||
const hasStyle = computed(() => !!styleQuery.data.value?.style)
|
||||
const hasCreativeProfile = computed(() => !!profileQuery.data.value?.profile)
|
||||
const canGenerateText = computed(
|
||||
() => !detailBlocked.value && hasStyle.value && !identity.value?.isLocked && !dirty.value
|
||||
)
|
||||
const canGenerateImage = computed(
|
||||
() => !detailBlocked.value && hasStyle.value && !!identity.value?.generationPrompt?.trim() && !dirty.value
|
||||
() =>
|
||||
!detailBlocked.value &&
|
||||
hasStyle.value &&
|
||||
hasCreativeProfile.value &&
|
||||
!!identity.value?.generationPrompt?.trim() &&
|
||||
!dirty.value
|
||||
)
|
||||
const canGenerateCandidate = computed(
|
||||
() =>
|
||||
@@ -164,13 +176,17 @@ export function useSubjectIdentity() {
|
||||
}
|
||||
|
||||
/** 批量仅生成身份文本,已锁定项由后端跳过,回执保留部分失败。 */
|
||||
async function generateProject(charactersOnly = false) {
|
||||
async function generateProject(charactersOnly = false, module?: 'scene' | 'prop') {
|
||||
if (blocked.value || !hasStyle.value || !batchValid.value || dirty.value || !subjects.value.length) return
|
||||
const id = projectId.value
|
||||
const target = getIdentitySession(id)
|
||||
const input = { concurrency: concurrency.value, force: force.value }
|
||||
const input = { concurrency: concurrency.value, force: force.value, ...(module ? { module } : {}) }
|
||||
target.receipt = null
|
||||
const title = charactersOnly ? '批量生成角色身份文本' : '批量生成全部主体身份文本'
|
||||
const title = charactersOnly
|
||||
? '批量生成角色身份文本'
|
||||
: module
|
||||
? `批量生成${module === 'scene' ? '场景' : '道具'}身份文本`
|
||||
: '批量生成全部主体身份文本'
|
||||
await runOperation(id, title, async () => {
|
||||
target.receipt = {
|
||||
kind: 'identities',
|
||||
@@ -183,6 +199,35 @@ export function useSubjectIdentity() {
|
||||
await Promise.all([detail.refresh(), castingQuery.refresh()])
|
||||
}
|
||||
|
||||
/** 场景/道具身份母版由后端独立生成、设为 Anchor 并锁定,保留逐主体失败回执。 */
|
||||
async function generateStableAnchors(module: 'scene' | 'prop') {
|
||||
if (
|
||||
blocked.value ||
|
||||
!hasStyle.value ||
|
||||
!hasCreativeProfile.value ||
|
||||
!batchValid.value ||
|
||||
!candidateLimitValid.value ||
|
||||
dirty.value
|
||||
)
|
||||
return
|
||||
const id = projectId.value
|
||||
const target = getIdentitySession(id)
|
||||
const title = `批量生成${module === 'scene' ? '场景' : '道具'}身份母版`
|
||||
target.receipt = null
|
||||
await runOperation(id, title, async () => {
|
||||
target.receipt = {
|
||||
kind: 'stable-anchors',
|
||||
title,
|
||||
result: await subjectIdentityApi.generateStableAnchors(id, {
|
||||
module,
|
||||
limit: candidateLimit.value,
|
||||
concurrency: concurrency.value
|
||||
})
|
||||
}
|
||||
})
|
||||
await Promise.all([catalog.refresh(), detail.refresh()])
|
||||
}
|
||||
|
||||
/** 小批量只为 missing_anchor 的 Character 生成候选,不自动选择演员。 */
|
||||
async function generateCastingCandidates() {
|
||||
if (
|
||||
@@ -303,6 +348,7 @@ export function useSubjectIdentity() {
|
||||
catalog,
|
||||
castingQuery,
|
||||
styleQuery,
|
||||
profileQuery,
|
||||
detail,
|
||||
identity,
|
||||
images,
|
||||
@@ -317,6 +363,7 @@ export function useSubjectIdentity() {
|
||||
castingBlocked,
|
||||
detailBlocked,
|
||||
hasStyle,
|
||||
hasCreativeProfile,
|
||||
canGenerateText,
|
||||
canGenerateImage,
|
||||
canGenerateCandidate,
|
||||
@@ -325,6 +372,7 @@ export function useSubjectIdentity() {
|
||||
save,
|
||||
generate,
|
||||
generateProject,
|
||||
generateStableAnchors,
|
||||
generateCastingCandidates,
|
||||
generateImage,
|
||||
generateCastingCandidate,
|
||||
|
||||
@@ -61,6 +61,7 @@ const {
|
||||
limit,
|
||||
promptConcurrency,
|
||||
promptForce,
|
||||
batchModule,
|
||||
generatePrompt,
|
||||
generatePrompts,
|
||||
blocked,
|
||||
@@ -145,7 +146,7 @@ watch(
|
||||
)
|
||||
const search = ref('')
|
||||
/** 切换排版复用同一份卡片和查询结果,不重新读取图库或清空筛选。 */
|
||||
const layout = ref<GalleryLayout>('grid')
|
||||
const layout = ref<GalleryLayout>('masonry')
|
||||
const module = ref('all')
|
||||
/** 形态图片状态筛选;单选下拉避免移动端复选项占据两行。 */
|
||||
type ImageStatusFilter = 'all' | 'missing'
|
||||
@@ -345,6 +346,21 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
|
||||
v-model:force="promptForce"
|
||||
@generate="generatePrompts"
|
||||
/>
|
||||
<div class="mt-5 flex flex-wrap items-center gap-3">
|
||||
<span class="text-xs text-muted">批处理范围</span>
|
||||
<NSelect
|
||||
v-model:value="batchModule"
|
||||
class="w-40"
|
||||
aria-label="形态批处理范围"
|
||||
:options="[
|
||||
{ label: String('全部类型'), value: 'all' },
|
||||
{ label: String('仅人物'), value: 'character' },
|
||||
{ label: String('仅场景'), value: 'scene' },
|
||||
{ label: String('仅道具'), value: 'prop' }
|
||||
]"
|
||||
/>
|
||||
<span class="text-xs text-muted">同时应用于批量提示词与批量生图。</span>
|
||||
</div>
|
||||
<div class="panel mt-5 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<p class="text-sm">
|
||||
@@ -355,7 +371,7 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
|
||||
<p class="mt-2 text-[11px] leading-6 text-muted">
|
||||
图库不自动刷新,可点击“刷新图库”读取最新记录。主图供分镜引用,候选图和历史失败记录均保留;过期主图不会自动删除或替换。
|
||||
</p>
|
||||
<NCollapse class="mt-4 pt-4"
|
||||
<NCollapse class="mt-4 pt-4" :default-expanded-names="['details']"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>项目批量生图</template>
|
||||
<AppForm :model="formModel" :rules="rules" :disabled="operation.pending" validate-on-change>
|
||||
@@ -700,6 +716,7 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
|
||||
<AssetImage
|
||||
:src="coverImage(form)?.imageUrl"
|
||||
:alt="`${form.subject.name} · ${form.name}`"
|
||||
:class="{ 'form-image-has-source': Boolean(coverImage(form)?.imageUrl) }"
|
||||
:object-fit="layout === 'masonry' ? 'contain' : 'cover'"
|
||||
preview
|
||||
:empty-text="hasRunningImages(form.images) ? '后台正在生成图片' : '尚未生成图片'"
|
||||
@@ -709,8 +726,9 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
|
||||
<h3 class="text-sm font-semibold">{{ form.subject.name }}</h3>
|
||||
<code class="subject-ref">{{ form.subject.ref }}</code>
|
||||
</div>
|
||||
<p class="mt-2 text-xs">
|
||||
{{ form.name }}<NTag v-if="form.isDefault" size="small" :bordered="false">默认形态</NTag>
|
||||
<p class="mt-2 flex items-center gap-2 text-xs">
|
||||
<span>{{ form.name }}</span>
|
||||
<NTag v-if="form.isDefault" size="small" :bordered="false">默认形态</NTag>
|
||||
</p>
|
||||
<p v-if="form.description" class="mt-3 line-clamp-2 text-xs leading-6 text-muted">
|
||||
{{ form.description }}
|
||||
@@ -813,6 +831,10 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
|
||||
.form-image-card {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
/* 普通网格的有图与空状态统一使用正方形图片区;瀑布流仍按原图比例展示。 */
|
||||
.form-image-grid:not(.form-image-masonry) .asset-image {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
/* 浅色卡片使用轻灰底,与白色画布区分;网格和瀑布流共用,深色保持原有层次。 */
|
||||
:root:not([data-theme='dark']) .form-image-card {
|
||||
background: var(--app-subtle);
|
||||
@@ -840,10 +862,13 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
|
||||
grid-row-end: span var(--form-image-row-span, 1);
|
||||
}
|
||||
.form-image-masonry .asset-image {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
.form-image-masonry .asset-image.form-image-has-source {
|
||||
@apply aspect-auto;
|
||||
}
|
||||
.form-image-masonry .asset-image .n-image,
|
||||
.form-image-masonry .asset-image .n-image img {
|
||||
.form-image-masonry .asset-image.form-image-has-source .n-image,
|
||||
.form-image-masonry .asset-image.form-image-has-source .n-image img {
|
||||
@apply h-auto;
|
||||
}
|
||||
.gallery-sticky-controls > .form-image-filter-region {
|
||||
@@ -888,7 +913,7 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
|
||||
@apply justify-self-end text-muted text-xs whitespace-nowrap;
|
||||
}
|
||||
.form-image-display-controls {
|
||||
@apply flex items-center justify-self-end gap-3;
|
||||
@apply flex items-center justify-self-end gap-4;
|
||||
}
|
||||
.gallery-layout-switch {
|
||||
@apply flex items-center gap-2;
|
||||
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
FormPromptResult,
|
||||
ImageBatchResult,
|
||||
SubjectFormAsset,
|
||||
SubjectImage
|
||||
SubjectImage,
|
||||
SubjectImageGenerationPreview
|
||||
} from './types'
|
||||
|
||||
/** 对正式 SubjectForm 数据库 ID 编码,不接受按名称拼出的路径。 */
|
||||
@@ -33,6 +34,9 @@ export const subjectImagesApi = {
|
||||
listForms: (projectId: string, signal?: AbortSignal) =>
|
||||
request<SubjectFormAsset[]>(`/projects/${encodeURIComponent(projectId)}/subject-forms`, { signal }),
|
||||
listImages: (formId: string, signal?: AbortSignal) => request<SubjectImage[]>(formPath(formId), { signal }),
|
||||
/** 解析正式 Prompt、参考图顺序与后端生成配置,不创建图片任务。 */
|
||||
preview: (formId: string) =>
|
||||
request<SubjectImageGenerationPreview>(`${formPath(formId)}/preview`, { method: 'POST', body: {} }),
|
||||
generate: (formId: string, input: GenerateFormImageInput) =>
|
||||
request<SubjectImage | null>(formPath(formId), { method: 'POST', body: input, timeoutMs: 0 }),
|
||||
generateProject: (projectId: string, input: GenerateProjectImagesInput) =>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import AppForm from '../../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import { sizeRules } from '../../../lib/form-rules'
|
||||
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber } from 'naive-ui'
|
||||
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NTag } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { AppDialog } from '../../../components/ui'
|
||||
import { errorMessage } from '../../../lib/http'
|
||||
import { subjectImagesApi } from '../api'
|
||||
import { primaryImage, validImageSize } from '../model'
|
||||
import type { GenerateFormImageInput, SubjectFormAsset } from '../types'
|
||||
import type { GenerateFormImageInput, SubjectFormAsset, SubjectImageGenerationPreview } from '../types'
|
||||
|
||||
/** 单形态生成确认:尺寸与自定义 Prompt 只用于本次请求。 */
|
||||
const props = defineProps<{ form: SubjectFormAsset | null; disabled: boolean }>()
|
||||
@@ -17,6 +19,9 @@ const width = ref<number | ''>('')
|
||||
const height = ref<number | ''>('')
|
||||
const setPrimary = ref(false)
|
||||
const acknowledged = ref(false)
|
||||
const preview = ref<SubjectImageGenerationPreview | null>(null)
|
||||
const previewError = ref('')
|
||||
const previewPending = ref(false)
|
||||
const dimensionsValid = computed(() => validImageSize(width.value, height.value))
|
||||
|
||||
/** 每次打开重新建立表单,避免把另一形态的 Prompt 或尺寸带入请求。 */
|
||||
@@ -27,11 +32,31 @@ watch(
|
||||
width.value = ''
|
||||
height.value = ''
|
||||
acknowledged.value = false
|
||||
preview.value = null
|
||||
previewError.value = ''
|
||||
previewPending.value = false
|
||||
setPrimary.value = props.form ? !primaryImage(props.form.images) : false
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** 预览由后端按当前正式配置编译,不提交图片生成任务。 */
|
||||
async function loadPreview() {
|
||||
if (!props.form || previewPending.value) return
|
||||
previewPending.value = true
|
||||
previewError.value = ''
|
||||
try {
|
||||
const result = await subjectImagesApi.preview(props.form.id)
|
||||
if (!result || result.subjectFormId !== props.form.id) throw new Error('预览结果与当前形态不匹配。')
|
||||
preview.value = result
|
||||
} catch (cause) {
|
||||
preview.value = null
|
||||
previewError.value = errorMessage(cause)
|
||||
} finally {
|
||||
previewPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 完成前置确认后只发出一次明确的生图请求,不触发整条生产流程。 */
|
||||
function submit() {
|
||||
if (!props.form || props.disabled || !dimensionsValid.value || !acknowledged.value) return
|
||||
@@ -68,6 +93,33 @@ const rules = { ...sizeRules(() => formModel.value) }
|
||||
人物、场景、道具均会引用已锁定身份的当前母版,保持身份、空间骨架或物件结构;身份未锁定或没有母版时不会继承该图片。更换母版不会自动更新已有形态图。
|
||||
身份母版与此处的“形态主参考图”是两种不同用途的图片。
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<NButton :loading="previewPending" :disabled="!form" @click="loadPreview">预览实际生成输入</NButton>
|
||||
<span class="text-xs text-muted">只读取后端编译结果,不调用图片模型。</span>
|
||||
</div>
|
||||
<NAlert v-if="previewError" type="error" :show-icon="false">{{ previewError }}</NAlert>
|
||||
<div v-if="preview" class="panel p-4 text-xs leading-6">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<strong>{{ preview.provider }} / {{ preview.model }}</strong>
|
||||
<NTag size="small" :bordered="false">{{ preview.references.length }} 张参考图</NTag>
|
||||
</div>
|
||||
<ul v-if="preview.references.length" class="mt-3 space-y-1 text-muted">
|
||||
<li v-for="item in preview.references" :key="`${item.imageNumber}:${item.imageId}`">
|
||||
图 {{ item.imageNumber }} · {{ item.role }} · <code>{{ item.imageId }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="mt-3 text-muted">本次没有图片参考。</p>
|
||||
<NCollapse class="mt-3" :default-expanded-names="['prompt']">
|
||||
<NCollapseItem name="prompt" title="最终 Provider Prompt">
|
||||
<p class="mt-3 whitespace-pre-wrap">{{ preview.providerPrompt }}</p>
|
||||
</NCollapseItem>
|
||||
<NCollapseItem name="config" title="生成配置">
|
||||
<pre class="mt-3 whitespace-pre-wrap">{{
|
||||
JSON.stringify(preview.generationConfig, null, 2)
|
||||
}}</pre>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</div>
|
||||
<NFormItem
|
||||
class="block"
|
||||
path="prompt"
|
||||
@@ -82,7 +134,10 @@ const rules = { ...sizeRules(() => formModel.value) }
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</NFormItem>
|
||||
<NCollapse v-if="form?.generationPrompt || form?.appearancePrompt" class="text-xs"
|
||||
<NCollapse
|
||||
v-if="form?.generationPrompt || form?.appearancePrompt"
|
||||
class="text-xs"
|
||||
:default-expanded-names="['details']"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>{{
|
||||
form?.generationPrompt ? '查看正式生图提示词' : '查看原始外观素材(不是最终提示词)'
|
||||
|
||||
@@ -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) => {
|
||||
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
|
||||
},
|
||||
false
|
||||
)
|
||||
})
|
||||
const images = computed(() => query.data.value ?? [])
|
||||
const selected = computed(
|
||||
() => images.value.find(image => image.id === selectedId.value) ?? primaryImage(images.value) ?? images.value[0]
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface GenerateFormImageInput {
|
||||
export interface GenerateProjectImagesInput {
|
||||
concurrency: number
|
||||
force: boolean
|
||||
/** 可按主体模块缩小批处理范围;省略时处理全部模块。 */
|
||||
module?: SubjectModule
|
||||
/** 仅限制本次真正生图的数量,留空表示不限制。 */
|
||||
limit?: number
|
||||
}
|
||||
@@ -83,6 +85,24 @@ export type FormPromptResult = Omit<SubjectFormAsset, 'subject' | 'images'> & {
|
||||
export interface GenerateFormPromptsInput {
|
||||
concurrency: number
|
||||
force: boolean
|
||||
module?: SubjectModule
|
||||
}
|
||||
|
||||
export type SubjectModule = 'character' | 'scene' | 'prop'
|
||||
|
||||
/** 形态生图前的无费用实际输入预览。 */
|
||||
export interface SubjectImageGenerationPreview {
|
||||
subjectFormId: string
|
||||
provider: string
|
||||
model: string
|
||||
generationConfig: Record<string, unknown>
|
||||
references: Array<{
|
||||
imageNumber: number
|
||||
role: string
|
||||
imageId: string
|
||||
imageUrl: string
|
||||
}>
|
||||
providerPrompt: string
|
||||
}
|
||||
|
||||
/** 长请求的回执按项目隔离,在会话内切换页面后仍可查看。 */
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
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) => {
|
||||
const query = useQuery(projectId, async (id, signal) => {
|
||||
if (!id) return null
|
||||
const [keyframes, videos] = await Promise.all([
|
||||
productionApi.keyframeReadiness(id, false, signal),
|
||||
@@ -21,8 +19,7 @@ export function useKeyframeImpact(projectId: Ref<string>, sourceShotId: Ref<stri
|
||||
return {
|
||||
...keyframes,
|
||||
items: keyframes.items.map(item => {
|
||||
const issues =
|
||||
byShot.get(item.shotId)?.issues.filter(issue => issue.code === 'stale_keyframe') ?? []
|
||||
const issues = byShot.get(item.shotId)?.issues.filter(issue => issue.code === 'stale_keyframe') ?? []
|
||||
return {
|
||||
...item,
|
||||
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) ?? [])
|
||||
// 不根据 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) ?? [])
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getOperation, runOperation } from '../workflows/operations'
|
||||
import { workflowCheckpoints } from '../workflows/selectors'
|
||||
import { subjectImagesApi } from './api'
|
||||
import { getImageSession, hasRunningImages } from './model'
|
||||
import type { GenerateFormImageInput } from './types'
|
||||
import type { GenerateFormImageInput, SubjectModule } from './types'
|
||||
|
||||
/** 形态图库统一读取数据库记录并管理单图/批量长请求,不调用生产流程。 */
|
||||
export function useSubjectImages() {
|
||||
@@ -17,6 +17,7 @@ export function useSubjectImages() {
|
||||
const limit = ref<number | ''>('')
|
||||
const promptConcurrency = ref(3)
|
||||
const promptForce = ref(false)
|
||||
const batchModule = ref<'all' | SubjectModule>('all')
|
||||
const query = usePolling(
|
||||
id,
|
||||
async (projectId, signal) => {
|
||||
@@ -91,7 +92,11 @@ export function useSubjectImages() {
|
||||
async function generatePrompts() {
|
||||
if (blocked.value || !promptValid.value || !forms.value.length) return
|
||||
const projectId = id.value
|
||||
const input = { concurrency: promptConcurrency.value, force: promptForce.value }
|
||||
const input = {
|
||||
concurrency: promptConcurrency.value,
|
||||
force: promptForce.value,
|
||||
...(batchModule.value === 'all' ? {} : { module: batchModule.value })
|
||||
}
|
||||
const target = getImageSession(projectId)
|
||||
target.promptReceipt = null
|
||||
await runOperation(projectId, '批量生成形态正式提示词', async () => {
|
||||
@@ -124,7 +129,8 @@ export function useSubjectImages() {
|
||||
const input = {
|
||||
concurrency: concurrency.value,
|
||||
force: force.value,
|
||||
...(limit.value === '' ? {} : { limit: limit.value })
|
||||
...(limit.value === '' ? {} : { limit: limit.value }),
|
||||
...(batchModule.value === 'all' ? {} : { module: batchModule.value })
|
||||
}
|
||||
const target = getImageSession(projectId)
|
||||
target.receipt = null
|
||||
@@ -149,6 +155,7 @@ export function useSubjectImages() {
|
||||
limit,
|
||||
promptConcurrency,
|
||||
promptForce,
|
||||
batchModule,
|
||||
promptValid,
|
||||
concurrencyValid,
|
||||
generatePrompt,
|
||||
|
||||
@@ -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 query = useQuery(projectId, async (id, signal) => {
|
||||
const style = await visualStyleApi.get(id, signal)
|
||||
assertProject(style, id)
|
||||
return { style }
|
||||
},
|
||||
false
|
||||
)
|
||||
})
|
||||
const style = computed(() => query.data.value?.style ?? null)
|
||||
const disabled = computed(() => blocked.value || !!query.error.value || !query.data.value)
|
||||
const editorRevision = ref(0)
|
||||
@@ -158,6 +154,7 @@ function removeImage(image: VisualStyleImage) {
|
||||
<p v-else-if="query.loading.value" class="py-8 text-sm text-muted" role="status">正在读取视觉风格…</p>
|
||||
<StyleImages
|
||||
:key="imageRevision"
|
||||
:project-id="projectId"
|
||||
:images="style?.images ?? []"
|
||||
:disabled="disabled || !style"
|
||||
@add="addImage"
|
||||
|
||||
@@ -145,7 +145,7 @@ const constraintsDetails = ref<InstanceType<typeof DetailDisclosure> | null>(nul
|
||||
<p class="text-xs leading-6 text-muted">
|
||||
锁定后仍可人工编辑并保存。取消勾选并保存后,才能再次使用 AI 重生成。
|
||||
</p>
|
||||
<div class="flex justify-end">
|
||||
<div class="page-form-actions">
|
||||
<NButton :disabled="disabled" type="primary" attr-type="submit"> 保存视觉风格 </NButton>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -4,12 +4,15 @@ import { NFormItem } from 'naive-ui'
|
||||
import { fieldRule, imageUrlRule } from '../../../lib/form-rules'
|
||||
import { NButton, NCheckbox, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { AssetImage } from '../../../components/ui'
|
||||
import { AppDialog, AssetImage } from '../../../components/ui'
|
||||
import { referenceImageUrl } from '../../../lib/assets'
|
||||
import { errorMessage } from '../../../lib/http'
|
||||
import { projectAssetsApi } from '../../project-assets/api'
|
||||
import type { ProjectAsset } from '../../project-assets/types'
|
||||
import type { AddStyleImageInput, StyleCategory, VisualStyleImage } from '../types'
|
||||
|
||||
/** 风格图只登记已有地址;不伪造文件上传或风格生图功能。 */
|
||||
const props = defineProps<{ images: VisualStyleImage[]; disabled: boolean }>()
|
||||
/** 风格图可关联项目资产,也保留外部地址兼容入口。 */
|
||||
const props = defineProps<{ projectId: string; images: VisualStyleImage[]; disabled: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
add: [input: AddStyleImageInput]
|
||||
toggle: [image: VisualStyleImage]
|
||||
@@ -17,6 +20,11 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
const form = reactive({ imageUrl: '', category: 'overall' as StyleCategory, sortOrder: 0, enabled: true })
|
||||
const confirmingId = ref('')
|
||||
const assetPickerOpen = ref(false)
|
||||
const assetLoading = ref(false)
|
||||
const assetError = ref('')
|
||||
const assets = ref<ProjectAsset[] | null>(null)
|
||||
const selectedAssetId = ref('')
|
||||
const categories = { overall: '整体', character: '人物', scene: '场景', prop: '道具' } as const
|
||||
|
||||
/** 仅通过校验后发出新增请求,输入框在失败时保留供修正。 */
|
||||
@@ -25,6 +33,36 @@ function add() {
|
||||
emit('add', { ...form, imageUrl: form.imageUrl.trim(), source: 'upload' })
|
||||
}
|
||||
|
||||
/** 素材仅在用户打开选择器时读取,避免风格页首屏增加重复请求。 */
|
||||
async function openAssetPicker() {
|
||||
if (props.disabled) return
|
||||
assetPickerOpen.value = true
|
||||
assetError.value = ''
|
||||
selectedAssetId.value = ''
|
||||
assetLoading.value = true
|
||||
try {
|
||||
const rows = await projectAssetsApi.list(props.projectId)
|
||||
if (rows.some(asset => asset.projectId !== props.projectId)) throw new Error('素材与当前项目不匹配。')
|
||||
assets.value = rows.filter(asset => asset.type === 'image')
|
||||
} catch (error) {
|
||||
assetError.value = errorMessage(error)
|
||||
} finally {
|
||||
assetLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addAsset() {
|
||||
if (props.disabled || !selectedAssetId.value) return
|
||||
emit('add', {
|
||||
projectAssetId: selectedAssetId.value,
|
||||
category: form.category,
|
||||
source: 'upload',
|
||||
enabled: form.enabled,
|
||||
sortOrder: form.sortOrder
|
||||
})
|
||||
assetPickerOpen.value = false
|
||||
}
|
||||
|
||||
/** 删除只移除记录,需确认且不声称删除远程文件。 */
|
||||
function remove(image: VisualStyleImage) {
|
||||
if (props.disabled || confirmingId.value !== image.id) return
|
||||
@@ -40,8 +78,8 @@ const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeIntege
|
||||
风格参考图 <span class="ml-2 text-xs text-muted">{{ images.length }} 张</span>
|
||||
</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
登记已有 HTTP(S) 或 /storage/
|
||||
图片地址。后端尚无文件上传接口;这些图片目前仅管理记录,现有身份/形态生图不会自动将风格图片传给模型。
|
||||
优先从项目资产库选择,也可登记已有 HTTP(S) 或 /storage/
|
||||
图片地址。启用的参考图会参与后续身份与形态生图,已有图片不会自动更新。
|
||||
</p>
|
||||
<AppForm :model="form" :rules="rules" :disabled="disabled" class="mt-4" @submit="add">
|
||||
<fieldset :disabled="disabled" class="form-controls style-image-controls">
|
||||
@@ -74,10 +112,13 @@ const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeIntege
|
||||
></NInputNumber
|
||||
></NFormItem>
|
||||
<NCheckbox v-model:checked="form.enabled" class="control-row-checkbox text-xs">启用</NCheckbox>
|
||||
<NButton class="style-image-submit" :disabled="disabled" attr-type="submit">登记参考图</NButton>
|
||||
<div class="style-image-submit flex gap-2">
|
||||
<NButton :disabled="disabled" @click="openAssetPicker">从资产库选择</NButton>
|
||||
<NButton :disabled="disabled" attr-type="submit">登记参考图</NButton>
|
||||
</div>
|
||||
</fieldset>
|
||||
</AppForm>
|
||||
<div v-if="images.length" class="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div v-if="images.length" class="style-image-list mt-5">
|
||||
<article v-for="image in images" :key="image.id" class="surface-inset min-w-0 p-3">
|
||||
<AssetImage
|
||||
:src="image.imageUrl"
|
||||
@@ -125,10 +166,46 @@ const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeIntege
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="mt-5 text-sm text-muted">尚无风格参考图。先保存视觉风格,再登记图片。</p>
|
||||
|
||||
<AppDialog
|
||||
v-model:open="assetPickerOpen"
|
||||
title="从项目资产库选择"
|
||||
description="选择一张项目图片作为当前分类的风格参考。这里只创建引用,不复制或删除原素材。"
|
||||
wide
|
||||
>
|
||||
<p v-if="assetError" class="mt-4 text-sm text-danger">{{ assetError }}</p>
|
||||
<p v-else-if="assetLoading" class="mt-4 text-sm text-muted" role="status">正在读取项目素材…</p>
|
||||
<div v-else-if="assets?.length" class="style-asset-picker mt-4">
|
||||
<button
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
type="button"
|
||||
class="style-asset-option"
|
||||
:class="{ 'is-selected': selectedAssetId === asset.id }"
|
||||
:aria-pressed="selectedAssetId === asset.id"
|
||||
@click="selectedAssetId = asset.id"
|
||||
>
|
||||
<AssetImage :src="asset.publicUrl" :alt="asset.name" class="aspect-[4/3]" />
|
||||
<span class="block truncate p-3 text-left text-xs">{{ asset.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="assets" class="mt-5 text-sm text-muted">
|
||||
资产库暂无图片。<RouterLink :to="`/projects/${projectId}/assets`" class="text-button"
|
||||
>前往资产库上传</RouterLink
|
||||
>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end gap-3">
|
||||
<NButton @click="assetPickerOpen = false">取消</NButton>
|
||||
<NButton type="primary" :disabled="!selectedAssetId || disabled" @click="addAsset"
|
||||
>登记为{{ categories[form.category] }}参考图</NButton
|
||||
>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@reference "../../../styles/styles.css";
|
||||
/* 按面板实际宽度布局,侧栏或移动端压缩内容时也能保持地址输入可用。 */
|
||||
.style-images-panel {
|
||||
container-type: inline-size;
|
||||
@@ -139,6 +216,26 @@ const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeIntege
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.style-image-submit {
|
||||
margin-top: 29px;
|
||||
}
|
||||
.style-image-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 260px));
|
||||
gap: 16px;
|
||||
}
|
||||
.style-asset-picker {
|
||||
@apply grid grid-cols-[repeat(auto-fill,_minmax(min(180px,_100%),_1fr))] gap-3;
|
||||
}
|
||||
.style-asset-option {
|
||||
@apply min-w-0 overflow-hidden text-left bg-(--app-subtle) border border-transparent;
|
||||
}
|
||||
.style-asset-option:hover {
|
||||
@apply bg-(--app-control-hover);
|
||||
}
|
||||
.style-asset-option.is-selected {
|
||||
@apply border-(--color-accent);
|
||||
}
|
||||
.style-image-controls .select-control {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
@@ -40,11 +40,12 @@ export type SaveVisualStyleInput = Partial<
|
||||
>
|
||||
>
|
||||
|
||||
/** 新增参考图使用已有地址,允许指定分类、启用状态及展示顺序。 */
|
||||
/** 新增参考图可使用资产库正式 ID 或外部地址,允许指定分类、启用状态及展示顺序。 */
|
||||
export interface AddStyleImageInput {
|
||||
category: StyleCategory
|
||||
source: 'upload'
|
||||
imageUrl: string
|
||||
imageUrl?: string
|
||||
projectAssetId?: string
|
||||
enabled: boolean
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ function exportReport() {
|
||||
<p class="mt-2 break-all font-mono text-[11px] text-muted">{{ node.checkpointId }}</p>
|
||||
</article>
|
||||
</div>
|
||||
<NCollapse v-else class="mt-4"
|
||||
<NCollapse v-else class="mt-4" :default-expanded-names="groups.map(group => group.phase)"
|
||||
><NCollapseItem
|
||||
v-for="group in groups"
|
||||
:key="group.phase"
|
||||
|
||||
@@ -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(' · ')
|
||||
}
|
||||
+5
-2
@@ -26,6 +26,9 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
/** 兼容普通 {data} 响应,以及 POST /projects 的顶层 202 响应。 */
|
||||
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const base = (import.meta.env.VITE_API_BASE_URL || '/api').replace(/\/$/, '')
|
||||
const multipart = typeof FormData !== 'undefined' && options.body instanceof FormData
|
||||
const body: BodyInit | undefined =
|
||||
options.body === undefined ? undefined : multipart ? (options.body as FormData) : JSON.stringify(options.body)
|
||||
const controller = new AbortController()
|
||||
const timeoutMs = options.timeoutMs ?? 30_000
|
||||
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined
|
||||
@@ -37,9 +40,9 @@ export async function request<T>(path: string, options: RequestOptions = {}): Pr
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' })
|
||||
...(options.body === undefined || multipart ? {} : { 'Content-Type': 'application/json' })
|
||||
},
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
body,
|
||||
signal: controller.signal
|
||||
})
|
||||
const text = await response.text()
|
||||
|
||||
+12
-1
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { buildDocumentTitle } from '../lib/document-title'
|
||||
|
||||
/** 按 graph 分路由,项目布局保留共享数据与长请求状态。 */
|
||||
export const router = createRouter({
|
||||
@@ -10,6 +11,11 @@ export const router = createRouter({
|
||||
component: () => import('../features/projects/ProjectsPage.vue'),
|
||||
meta: { title: '我的剧本' }
|
||||
},
|
||||
{
|
||||
path: '/assets',
|
||||
component: () => import('../features/project-assets/AssetLibraryPage.vue'),
|
||||
meta: { title: '资产库' }
|
||||
},
|
||||
{
|
||||
path: '/projects/:projectId',
|
||||
component: () => import('../features/projects/ProjectLayout.vue'),
|
||||
@@ -40,6 +46,11 @@ export const router = createRouter({
|
||||
component: () => import('../features/subject-images/SubjectImagesPage.vue'),
|
||||
meta: { title: '形态图片' }
|
||||
},
|
||||
{
|
||||
path: 'assets',
|
||||
component: () => import('../features/project-assets/ProjectAssetsPage.vue'),
|
||||
meta: { title: '资产库' }
|
||||
},
|
||||
{
|
||||
path: 'storyboard',
|
||||
component: () => import('../features/storyboard/StoryboardPage.vue'),
|
||||
@@ -62,5 +73,5 @@ export const router = createRouter({
|
||||
|
||||
/** 路由切换同步浏览器标题,不依赖页面组件主动修改。 */
|
||||
router.afterEach(to => {
|
||||
document.title = `${String(to.meta.title || '工作空间')} · 短剧工作台`
|
||||
document.title = buildDocumentTitle(to.meta.title)
|
||||
})
|
||||
|
||||
@@ -182,6 +182,14 @@
|
||||
gap: 10px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
/* 页面级表单的保存/执行操作从左侧开始,说明文字紧随其后;弹窗底部仍保持右对齐。 */
|
||||
.page-form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 9px;
|
||||
|
||||
@@ -37,8 +37,11 @@ describe('精简入口保留功能', () => {
|
||||
expect(wrapper.emitted('select')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('补充说明默认不展开,键盘可聚焦的标题可查看原文', async () => {
|
||||
it('补充说明默认展开,键盘可聚焦的标题仍可手动收起和展开', async () => {
|
||||
wrapper = mount(DetailDisclosure, { props: { title: '规则说明' }, slots: { default: '完整规则仍然保留' } })
|
||||
expect(wrapper.text()).toContain('完整规则仍然保留')
|
||||
await wrapper.get('.n-collapse-item__header-main').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('完整规则仍然保留')
|
||||
await wrapper.get('.n-collapse-item__header-main').trigger('click')
|
||||
await flushPromises()
|
||||
@@ -47,9 +50,7 @@ describe('精简入口保留功能', () => {
|
||||
|
||||
it('风格高级字段收起不丢失保存值,展开后仍可修改', async () => {
|
||||
wrapper = mount(StyleEditor, { props: { visualStyle: null, disabled: false } })
|
||||
expect(wrapper.find('#style-constraints').exists()).toBe(false)
|
||||
await wrapper.get('.n-collapse-item__header-main').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#style-constraints').exists()).toBe(true)
|
||||
await wrapper.get('#style-constraints').setValue('["保留硬约束"]')
|
||||
const fields = wrapper.findAllComponents(NInput)
|
||||
fields.find(item => item.props('placeholder')?.includes('人物质感'))!.vm.$emit('update:value', '人物风格')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NImage } from 'naive-ui'
|
||||
import AssetImage from '@/components/ui/AssetImage.vue'
|
||||
|
||||
@@ -8,6 +8,8 @@ afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('资源图片展示与原图预览', () => {
|
||||
@@ -31,6 +33,17 @@ describe('资源图片展示与原图预览', () => {
|
||||
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('contain')
|
||||
})
|
||||
|
||||
it('图片加载前显示轻量骨架占位,加载完成后直接淡入图片并移除占位', async () => {
|
||||
wrapper = mount(AssetImage, { props: { src: '/storage/reveal.png', alt: '加载中的图片' } })
|
||||
expect(wrapper.get('.reveal-image').attributes('aria-busy')).toBe('true')
|
||||
expect(wrapper.find('.reveal-image-placeholder').exists()).toBe(true)
|
||||
expect(wrapper.find('.reveal-image-grid').exists()).toBe(false)
|
||||
await wrapper.get('img').trigger('load')
|
||||
expect(wrapper.get('.reveal-image').classes()).toContain('is-loaded')
|
||||
expect(wrapper.get('.reveal-image').attributes('aria-busy')).toBe('false')
|
||||
expect(wrapper.find('.reveal-image-placeholder').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('cover 仅裁切卡片,点击使用 Naive 原生预览展示同一完整资源,遮罩可关闭', async () => {
|
||||
wrapper = mount(AssetImage, {
|
||||
attachTo: document.body,
|
||||
|
||||
@@ -29,6 +29,21 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('全站平面主题', () => {
|
||||
it('页面级表单操作统一左对齐,弹窗操作仍保持右对齐', () => {
|
||||
const css = readAllStyles()
|
||||
expect(css).toMatch(
|
||||
/\.page-form-actions\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;[^}]*align-items:\s*center;[^}]*justify-content:\s*flex-start;[^}]*gap:\s*8px 16px;/
|
||||
)
|
||||
expect(css).toMatch(/\.dialog-footer\s*\{[^}]*justify-content:\s*flex-end;/)
|
||||
for (const file of [
|
||||
'src/features/subject-identity/components/IdentityEditor.vue',
|
||||
'src/features/visual-style/components/StyleEditor.vue',
|
||||
'src/features/generation-config/components/CreativeProfileSettings.vue'
|
||||
]) {
|
||||
expect(readFileSync(file, 'utf8')).toContain('page-form-actions')
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])('模式 %s 的绿色按钮使用白字,禁用后灰底灰字且不可提交', dark => {
|
||||
localStorage.setItem('drama-studio-theme', dark ? 'dark' : 'light')
|
||||
vi.stubGlobal('matchMedia', () => ({ matches: dark, addEventListener() {}, removeEventListener() {} }))
|
||||
@@ -168,15 +183,19 @@ describe('全站平面主题', () => {
|
||||
|
||||
it('瀑布流按行填充自适应列宽,卡片与图片均保留实际高度', () => {
|
||||
const css = readAllStyles()
|
||||
expect(css).toMatch(
|
||||
/\.form-image-grid:not\(\.form-image-masonry\) \.asset-image\s*\{[^}]*aspect-ratio:\s*1 \/ 1;/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.gallery-workspace-page \.form-image-masonry\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(auto-fill, minmax\(min\(260px, 100%\), 1fr\)\);[^}]*grid-auto-flow:\s*row dense;[^}]*grid-auto-rows:\s*1px;/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.form-image-masonry > \.form-image-card\s*\{[^}]*grid-column-start:\s*var\(--form-image-column,\s*auto\);[^}]*grid-row-start:\s*var\(--form-image-row-start,\s*auto\);[^}]*grid-row-end:\s*span var\(--form-image-row-span,\s*1\);/
|
||||
)
|
||||
expect(css).toMatch(/\.form-image-masonry \.asset-image\s*\{[^}]*aspect-ratio:\s*auto;/)
|
||||
expect(css).toMatch(/\.form-image-masonry \.asset-image\s*\{[^}]*aspect-ratio:\s*1 \/ 1;/)
|
||||
expect(css).toMatch(/\.form-image-masonry \.asset-image\.form-image-has-source\s*\{[^}]*aspect-ratio:\s*auto;/)
|
||||
expect(css).toMatch(
|
||||
/\.form-image-masonry \.asset-image \.n-image,\s*\.form-image-masonry \.asset-image \.n-image img\s*\{[^}]*height:\s*auto;/
|
||||
/\.form-image-masonry \.asset-image\.form-image-has-source \.n-image,\s*\.form-image-masonry \.asset-image\.form-image-has-source \.n-image img\s*\{[^}]*height:\s*auto;/
|
||||
)
|
||||
})
|
||||
|
||||
@@ -254,6 +273,14 @@ describe('全站平面主题', () => {
|
||||
expect(readFileSync('src/components/ui/DirectoryItem.vue', 'utf8')).not.toContain('icon-button')
|
||||
})
|
||||
|
||||
it('风格参考图操作按钮与输入行对齐,缩略图在宽屏下保持紧凑', () => {
|
||||
const css = readAllStyles()
|
||||
expect(css).toMatch(/\.style-image-submit\s*\{[^}]*margin-top:\s*29px;/)
|
||||
expect(css).toMatch(
|
||||
/\.style-image-list\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(auto-fill, minmax\(min\(220px, 100%\), 260px\)\);[^}]*gap:\s*16px;/
|
||||
)
|
||||
})
|
||||
|
||||
it('业务面板、列表分组和标签以背景区分,保留键盘焦点与选中指示', () => {
|
||||
// happy-dom 不计算布局;此项保护 CSS 契约,不能替代浏览器视觉验收。
|
||||
const css = readAllStyles()
|
||||
@@ -298,4 +325,3 @@ describe('全站平面主题', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -93,8 +93,7 @@ describe('统一的 Naive UI 表单校验', () => {
|
||||
it('折叠后的非法 JSON 仍拦截保存并展开错误,修正后保留草稿正常提交', async () => {
|
||||
wrapper = mount(StyleEditor, { attachTo: document.body, props: { visualStyle: null, disabled: false } })
|
||||
await flushPromises()
|
||||
button('分类风格与硬约束').click()
|
||||
await flushPromises()
|
||||
expect(button('分类风格与硬约束').getAttribute('aria-expanded')).toBe('true')
|
||||
await input('#style-name', '手工风格')
|
||||
await input('#style-constraints', '{"invalid":true}')
|
||||
button('分类风格与硬约束').click()
|
||||
@@ -109,7 +108,10 @@ describe('统一的 Naive UI 表单校验', () => {
|
||||
})
|
||||
|
||||
it('登记参考图拦截非法地址和小数排序,允许有效存储地址与负整数排序', async () => {
|
||||
wrapper = mount(StyleImages, { attachTo: document.body, props: { images: [], disabled: false } })
|
||||
wrapper = mount(StyleImages, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'form-test-project', images: [], disabled: false }
|
||||
})
|
||||
await input('[aria-label="风格图片地址"]', 'javascript:alert(1)')
|
||||
await input('[aria-label="风格图片排序"]', '1.5')
|
||||
await submit()
|
||||
@@ -122,6 +124,55 @@ describe('统一的 Naive UI 表单校验', () => {
|
||||
expect(wrapper.emitted('add')?.[0]?.[0]).toMatchObject({ imageUrl: '/storage/style.png', sortOrder: -1 })
|
||||
})
|
||||
|
||||
it('按需读取项目资产并用正式素材 ID 登记风格参考图', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 'asset-db-1',
|
||||
projectId: 'form-test-project',
|
||||
name: '人物质感参考',
|
||||
type: 'image',
|
||||
category: 'character',
|
||||
mimeType: 'image/png',
|
||||
extension: 'png',
|
||||
size: 1024,
|
||||
publicUrl: '/storage/reference.png',
|
||||
metadata: null,
|
||||
createdAt: '2026-09-21T00:00:00Z',
|
||||
updatedAt: '2026-09-21T00:00:00Z'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(StyleImages, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'form-test-project', images: [], disabled: false }
|
||||
})
|
||||
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
button('从资产库选择').click()
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
'/api/projects/form-test-project/assets',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
)
|
||||
const option = document.querySelector<HTMLButtonElement>('.style-asset-option')!
|
||||
expect(option.textContent).toContain('人物质感参考')
|
||||
option.click()
|
||||
await flushPromises()
|
||||
button('登记为整体参考图').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('add')?.[0]?.[0]).toMatchObject({
|
||||
projectAssetId: 'asset-db-1',
|
||||
category: 'overall',
|
||||
source: 'upload'
|
||||
})
|
||||
})
|
||||
|
||||
it('异步验证期间切换对象或重复提交不会执行旧动作', async () => {
|
||||
let finish: (() => void) | undefined
|
||||
const model = reactive({ text: '有效输入' })
|
||||
|
||||
@@ -125,14 +125,16 @@ describe('管理后台组件边界', () => {
|
||||
await router.push('/projects/test/production')
|
||||
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.n-menu .n-menu-item')).toHaveLength(8)
|
||||
expect(wrapper.findAll('.n-menu .n-menu-item')).toHaveLength(9)
|
||||
expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情')
|
||||
expect(wrapper.get('.theme-toggle').text()).toBe('')
|
||||
expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu')
|
||||
expect(wrapper.getComponent(ThemeToggle).findComponent(NTooltip).exists()).toBe(false)
|
||||
expect(wrapper.get('.theme-toggle').attributes('title')).toBeUndefined()
|
||||
expect(wrapper.get('.theme-toggle svg').classes()).toContain('lucide-monitor')
|
||||
expect(wrapper.get('.admin-topbar [aria-controls="main-navigation"] svg').classes()).toContain('lucide-menu')
|
||||
expect(wrapper.get('.admin-topbar [aria-controls="main-navigation"] svg').classes()).toContain(
|
||||
'lucide-panel-left-close'
|
||||
)
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
for (const [key, label, icon] of [
|
||||
['dark', '暗黑模式', 'moon'],
|
||||
@@ -173,9 +175,14 @@ describe('管理后台组件边界', () => {
|
||||
}
|
||||
expect(firstMenuIcon().type).not.toBe(NPopover)
|
||||
expect(wrapper.get('.admin-sider-footer').findComponent(NPopover).exists()).toBe(false)
|
||||
expect(wrapper.get('[aria-label="折叠侧栏"] svg').classes()).toContain('lucide-panel-left-close')
|
||||
expect(wrapper.findAll('.studio-logo svg')).toHaveLength(2)
|
||||
expect(wrapper.find('.studio-logo .lucide-film').exists()).toBe(true)
|
||||
expect(wrapper.find('.studio-logo .lucide-sparkles').exists()).toBe(true)
|
||||
await wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[aria-label="展开侧栏"] svg').classes()).toContain('lucide-panel-left-open')
|
||||
expect(wrapper.getComponent(NMenu).props('collapsed')).toBe(false)
|
||||
expect(firstMenuIcon().type).not.toBe(NPopover)
|
||||
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 64px')
|
||||
@@ -189,6 +196,7 @@ describe('管理后台组件边界', () => {
|
||||
const labels = wrapper.findAll('.n-menu-item-content-header').map(item => item.text())
|
||||
expect(labels).toEqual([
|
||||
'我的剧本',
|
||||
'资产库',
|
||||
'剧本创作',
|
||||
'剧本拆解',
|
||||
'视觉风格',
|
||||
@@ -202,7 +210,7 @@ describe('管理后台组件边界', () => {
|
||||
await settings.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[role="dialog"][aria-label="后端连接"]')).not.toBeNull()
|
||||
expect(wrapper.findAll('.admin-nav-scroll .n-menu .n-menu-item')).toHaveLength(8)
|
||||
expect(wrapper.findAll('.admin-nav-scroll .n-menu .n-menu-item')).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('侧栏菜单独立滚动,窄屏改为不占正文宽度的全屏固定层', () => {
|
||||
@@ -215,7 +223,7 @@ describe('管理后台组件边界', () => {
|
||||
expect(css).toMatch(/\.admin-sider\.is-collapsed \.n-menu-item-content__icon\s*\{[^}]*pointer-events:\s*none/)
|
||||
expect(css).toMatch(/\.admin-sider-footer\s*\{[^}]*flex-shrink:\s*0/)
|
||||
expect(css).toMatch(/\.admin-sider\.is-collapsed \.admin-settings-label\s*\{[^}]*opacity:\s*0/)
|
||||
expect(css).toMatch(/\.admin-sider\.is-collapsed \.admin-brand span\s*\{[^}]*opacity:\s*0/)
|
||||
expect(css).toMatch(/\.admin-sider\.is-collapsed \.admin-brand-copy\s*\{[^}]*opacity:\s*0/)
|
||||
expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.admin-brand\s*\{[^}]*height:\s*48px/)
|
||||
expect(css).toContain('@media (max-width: 800px)')
|
||||
expect(css).toMatch(/\.admin-sider\s*\{[^}]*transition:\s*width\s+0\.32s/)
|
||||
@@ -295,7 +303,7 @@ describe('管理后台组件边界', () => {
|
||||
const collapsedSettings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]')
|
||||
expect(collapsedSettings.text()).toBe('后端连接')
|
||||
expect(collapsedSettings.classes()).toContain('is-collapsed')
|
||||
expect(wrapper.get('.admin-topbar [aria-label="打开主菜单"] svg').classes()).toContain('lucide-menu')
|
||||
expect(wrapper.get('.admin-topbar [aria-label="打开主菜单"] svg').classes()).toContain('lucide-panel-left-open')
|
||||
await wrapper.get('.admin-topbar [aria-label="打开主菜单"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.admin-sider')).toHaveLength(1)
|
||||
@@ -304,8 +312,11 @@ describe('管理后台组件边界', () => {
|
||||
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 100%')
|
||||
expect(wrapper.get('.admin-sider').attributes('aria-hidden')).toBeUndefined()
|
||||
expect(wrapper.find('.admin-sider-mask').exists()).toBe(false)
|
||||
expect(wrapper.get('.admin-sider').findAll('.n-menu .n-menu-item')).toHaveLength(8)
|
||||
expect(wrapper.get('.admin-sider').findAll('.n-menu .n-menu-item')).toHaveLength(9)
|
||||
expect(wrapper.get('.admin-topbar [aria-label="关闭主菜单"]').attributes('aria-expanded')).toBe('true')
|
||||
expect(wrapper.get('.admin-topbar [aria-label="关闭主菜单"] svg').classes()).toContain(
|
||||
'lucide-panel-left-close'
|
||||
)
|
||||
const expandedBrand = wrapper.get('.admin-brand')
|
||||
expect(expandedBrand.classes()).not.toContain('is-collapsed')
|
||||
expect(expandedBrand.text()).toContain('短剧工作台')
|
||||
|
||||
@@ -84,3 +84,4 @@ describe('异步轮询生命周期', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -248,7 +248,7 @@ describe('拆解页内容滚动', () => {
|
||||
['人物', 'character'],
|
||||
['场景', 'scene'],
|
||||
['道具', 'prop']
|
||||
] as const)('%s 的奇偶主体展开后均使用独立形态块,搜索重排不影响形态归属', async (label, module) => {
|
||||
] as const)('%s 的奇偶主体默认展示独立形态块,搜索重排不影响形态归属', async (label, module) => {
|
||||
const record = checkpoint()
|
||||
// 前两个主体各准备两种形态,覆盖奇偶条纹和默认标签,不调用模型接口。
|
||||
record.state.breakdownResult!.subjectForms = [0, 1].flatMap(subjectIndex =>
|
||||
@@ -267,8 +267,6 @@ describe('拆解页内容滚动', () => {
|
||||
const articles = wrapper!.findAll('.subject-record-list > article')
|
||||
for (const index of [0, 1]) {
|
||||
const article = articles[index]!
|
||||
await article.get('.n-collapse-item__header-main').trigger('click')
|
||||
await flushPromises()
|
||||
const cards = article.findAll('.subject-form-card')
|
||||
expect(cards).toHaveLength(2)
|
||||
expect(cards[0]!.get('.n-tag').text()).toBe('默认')
|
||||
|
||||
@@ -71,7 +71,14 @@ describe('连续剧本阅读器', () => {
|
||||
expect(content).toContain('padding: 32px')
|
||||
expect(content).toContain('text-align: left')
|
||||
expect(content).not.toContain('auto')
|
||||
expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.reader-content\s*\{\s*padding-inline:\s*18px/)
|
||||
expect(css).toMatch(
|
||||
/\.reader-chapter-heading\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*z-index:\s*2;[^}]*background:\s*var\(--app-surface\)/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.reader-chapter-heading\s*\{[^}]*margin-inline:\s*calc\(var\(--reader-gutter\) \* -1\);[^}]*padding:\s*14px var\(--reader-gutter\) 18px;/
|
||||
)
|
||||
expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.reader-content\s*\{[^}]*padding-inline:\s*18px/)
|
||||
expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.reader-content\s*\{[^}]*--reader-gutter:\s*18px/)
|
||||
expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.episode-reader\s*\{[^}]*72px/)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { NInputNumber, NSelect } from 'naive-ui'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import GenerationOptionFields from '../../../src/features/generation-config/components/GenerationOptionFields.vue'
|
||||
import type { GenerationOptionSchema } from '../../../src/features/generation-config/types'
|
||||
|
||||
function mountFields(schemas: GenerationOptionSchema[]) {
|
||||
return mount(GenerationOptionFields, {
|
||||
props: { schemas, modelValue: { size: '2K', durationSeconds: 4, seed: 1 } }
|
||||
})
|
||||
}
|
||||
|
||||
describe('生成模型参数字段', () => {
|
||||
it('图片尺寸使用下拉框并保留标准尺寸与当前值', () => {
|
||||
const wrapper = mountFields([
|
||||
{ key: 'size', label: '图片尺寸', type: 'size', required: true, providerDefault: 'auto' }
|
||||
])
|
||||
const select = wrapper.getComponent(NSelect)
|
||||
expect(select.props('clearable')).toBe(false)
|
||||
expect(select.props('options')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ value: '1K' }),
|
||||
expect.objectContaining({ value: '1.5K' }),
|
||||
expect.objectContaining({ value: '2K' }),
|
||||
expect.objectContaining({ value: 'auto' })
|
||||
])
|
||||
)
|
||||
expect(wrapper.findComponent(NInputNumber).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('视频时长按后端范围生成秒数选项,其他数值仍可输入', () => {
|
||||
const wrapper = mountFields([
|
||||
{
|
||||
key: 'durationSeconds',
|
||||
label: '生成时长',
|
||||
type: 'integer',
|
||||
required: true,
|
||||
min: 4,
|
||||
max: 6,
|
||||
specialValues: [{ label: '自动', value: -1 }]
|
||||
},
|
||||
{ key: 'seed', label: '随机种子', type: 'integer', required: false }
|
||||
])
|
||||
const select = wrapper.getComponent(NSelect)
|
||||
expect(select.props('options')).toEqual([
|
||||
{ label: '自动', value: -1 },
|
||||
{ label: '4 秒', value: 4 },
|
||||
{ label: '5 秒', value: 5 },
|
||||
{ label: '6 秒', value: 6 }
|
||||
])
|
||||
expect(wrapper.findAllComponents(NInputNumber)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -140,4 +140,59 @@ describe('镜头生产数据契约', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
|
||||
})
|
||||
|
||||
it('首帧生成前可预览后端最终选图规划和 Prompt', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
shotId: 'shot-1',
|
||||
episodeNo: 1,
|
||||
beatNo: 1,
|
||||
shotNo: 1,
|
||||
provider: 'seedream',
|
||||
model: 'image-model',
|
||||
generationConfig: {},
|
||||
references: [],
|
||||
referencePlan: {
|
||||
provider: 'seedream',
|
||||
maxReferenceImages: 4,
|
||||
totalCandidates: 2,
|
||||
strategy: 'subject-coverage',
|
||||
selected: [
|
||||
{
|
||||
subjectRef: '@CH0001',
|
||||
module: 'character',
|
||||
role: 'identity-anchor',
|
||||
imageId: 'anchor-1'
|
||||
}
|
||||
],
|
||||
omitted: [
|
||||
{
|
||||
subjectRef: '@SC0001',
|
||||
module: 'scene',
|
||||
role: 'form-primary',
|
||||
imageId: 'scene-1'
|
||||
}
|
||||
]
|
||||
},
|
||||
providerPrompt: '最终首帧 Prompt'
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(KeyframeDialog, {
|
||||
attachTo: document.body,
|
||||
props: { open: true, shotId: 'shot-1', shotTitle: '开场', keyframes: [], disabled: false }
|
||||
})
|
||||
await flushPromises()
|
||||
button('预览实际生成输入').click()
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot-1/keyframe/preview')
|
||||
expect(document.body.textContent).toContain('已选 1 / 2 张')
|
||||
expect(document.body.textContent).toContain('省略 1 张')
|
||||
expect(document.body.textContent).toContain('最终首帧 Prompt')
|
||||
expect(wrapper.emitted('generate')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -374,7 +374,7 @@ describe('视觉质量契约与付费边界', () => {
|
||||
})
|
||||
|
||||
describe('质量面板渐进展示', () => {
|
||||
it('默认仅校验,未确认时不可提交,打开或更改参数不产生费用', async () => {
|
||||
it('默认展示校验参数,未确认时不可提交,打开或更改参数不产生费用', async () => {
|
||||
const data = server()
|
||||
wrapper = mount(QualityDialog, {
|
||||
attachTo: document.body,
|
||||
@@ -388,7 +388,7 @@ describe('质量面板渐进展示', () => {
|
||||
await flushPromises()
|
||||
expect(validationButton().disabled).toBe(true)
|
||||
expect(document.body.textContent).toContain('更多参数与模型限制')
|
||||
expect(document.querySelector('[aria-label="额外允许的画面文字"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="额外允许的画面文字"]')).not.toBeNull()
|
||||
expect(data.fetcher).not.toHaveBeenCalled()
|
||||
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
|
||||
await flushPromises()
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { projectAssetsApi } from '@/features/project-assets/api'
|
||||
import type { ProjectAsset } from '@/features/project-assets/types'
|
||||
|
||||
const asset: ProjectAsset = {
|
||||
id: 'asset-db-1',
|
||||
projectId: 'project-db-1',
|
||||
name: '雨夜街道',
|
||||
type: 'image',
|
||||
category: 'scene',
|
||||
mimeType: 'image/png',
|
||||
extension: 'png',
|
||||
size: 2048,
|
||||
publicUrl: '/storage/project/scene.png',
|
||||
metadata: { originalName: 'scene.png' },
|
||||
createdAt: '2026-09-21T00:00:00Z',
|
||||
updatedAt: '2026-09-21T00:00:00Z'
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('项目资产 API', () => {
|
||||
it('通过单个全局接口读取全部项目素材', async () => {
|
||||
const globalAsset = {
|
||||
...asset,
|
||||
project: { id: 'project-db-1', title: '记忆当铺', topic: '记忆交易', status: 'completed' }
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({ data: [globalAsset] })))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
|
||||
await expect(projectAssetsApi.listAll()).resolves.toEqual([globalAsset])
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(fetcher).toHaveBeenCalledWith('/api/assets', expect.objectContaining({ method: 'GET' }))
|
||||
})
|
||||
|
||||
it('使用 multipart 上传文件,不手工设置 Content-Type', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ data: asset }), { status: 201 }))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const file = new File(['image'], 'scene.png', { type: 'image/png' })
|
||||
|
||||
await expect(
|
||||
projectAssetsApi.upload('project-db-1', { file, name: '雨夜街道', category: 'scene' })
|
||||
).resolves.toEqual(asset)
|
||||
|
||||
const [url, init] = fetcher.mock.calls[0]!
|
||||
expect(url).toBe('/api/projects/project-db-1/assets')
|
||||
expect(init?.method).toBe('POST')
|
||||
expect(init?.headers).toEqual({ Accept: 'application/json' })
|
||||
expect(init?.body).toBeInstanceOf(FormData)
|
||||
const body = init?.body as FormData
|
||||
expect(body.get('file')).toBe(file)
|
||||
expect(body.get('name')).toBe('雨夜街道')
|
||||
expect(body.get('category')).toBe('scene')
|
||||
})
|
||||
|
||||
it('按正式项目和素材 ID 读取、编辑及删除', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(async () => new Response(JSON.stringify({ data: asset })))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
|
||||
await projectAssetsApi.list('project/db')
|
||||
await projectAssetsApi.get('project/db', 'asset/db')
|
||||
await projectAssetsApi.update('project/db', 'asset/db', { name: '新名称', category: 'reference' })
|
||||
await projectAssetsApi.remove('project/db', 'asset/db')
|
||||
|
||||
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method ?? 'GET'])).toEqual([
|
||||
['/api/projects/project%2Fdb/assets', 'GET'],
|
||||
['/api/projects/project%2Fdb/assets/asset%2Fdb', 'GET'],
|
||||
['/api/projects/project%2Fdb/assets/asset%2Fdb', 'PUT'],
|
||||
['/api/projects/project%2Fdb/assets/asset%2Fdb', 'DELETE']
|
||||
])
|
||||
expect(fetcher.mock.calls[2]?.[1]?.body).toBe(JSON.stringify({ name: '新名称', category: 'reference' }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { NTooltip } from 'naive-ui'
|
||||
import AssetLibraryPage from '@/features/project-assets/AssetLibraryPage.vue'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('全局资产库', () => {
|
||||
it('单次读取并直接显示全部剧本图片,支持按图片与剧本搜索', async () => {
|
||||
const assets = [
|
||||
{
|
||||
id: 'asset-scene',
|
||||
projectId: 'project-one',
|
||||
name: '雨夜街道',
|
||||
type: 'image',
|
||||
category: 'scene',
|
||||
mimeType: 'image/png',
|
||||
extension: 'png',
|
||||
size: 4096,
|
||||
publicUrl: '/storage/scene.png',
|
||||
metadata: { originalName: 'rain-street-original.png' },
|
||||
createdAt: '2026-09-22T00:00:00Z',
|
||||
updatedAt: '2026-09-22T00:00:00Z',
|
||||
project: { id: 'project-one', title: '记忆当铺', topic: '记忆交易', status: 'completed' }
|
||||
},
|
||||
{
|
||||
id: 'asset-character',
|
||||
projectId: 'project-two',
|
||||
name: '林默正面照',
|
||||
type: 'image',
|
||||
category: 'character',
|
||||
mimeType: 'image/jpeg',
|
||||
extension: 'jpg',
|
||||
size: 8192,
|
||||
publicUrl: '/storage/character.jpg',
|
||||
metadata: null,
|
||||
createdAt: '2026-09-22T01:00:00Z',
|
||||
updatedAt: '2026-09-22T01:00:00Z',
|
||||
project: { id: 'project-two', title: '天降甘霖', topic: '乡村故事', status: 'completed' }
|
||||
}
|
||||
]
|
||||
const projects = assets.map(asset => ({
|
||||
...asset.project,
|
||||
style: null,
|
||||
createdAt: '',
|
||||
updatedAt: ''
|
||||
}))
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async input => {
|
||||
const url = String(input)
|
||||
return new Response(JSON.stringify({ data: url.endsWith('/projects') ? projects : assets }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/assets', component: AssetLibraryPage },
|
||||
{ path: '/projects/:projectId/assets', component: { template: '<div />' } }
|
||||
]
|
||||
})
|
||||
await router.push('/assets')
|
||||
wrapper = mount(AssetLibraryPage, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
expect(fetcher).toHaveBeenCalledWith('/api/assets', expect.objectContaining({ method: 'GET' }))
|
||||
expect(wrapper.findAll('.asset-library-card')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('雨夜街道')
|
||||
expect(wrapper.text()).toContain('林默正面照')
|
||||
expect(wrapper.text()).toContain('记忆当铺')
|
||||
expect(wrapper.findAllComponents(NTooltip)).toHaveLength(2)
|
||||
const title = wrapper.get('.asset-library-title')
|
||||
expect(title.classes()).toContain('asset-library-title')
|
||||
expect(wrapper.findComponent(NTooltip).props('disabled')).toBe(true)
|
||||
Object.defineProperties(title.element, {
|
||||
scrollWidth: { configurable: true, value: 240 },
|
||||
clientWidth: { configurable: true, value: 120 }
|
||||
})
|
||||
await title.trigger('mouseenter')
|
||||
expect(wrapper.findComponent(NTooltip).props('disabled')).toBe(false)
|
||||
expect(wrapper.get('.asset-library-card').text()).not.toContain('rain-street-original.png')
|
||||
expect(wrapper.get('.asset-library-card').text()).not.toContain('4.0 KB')
|
||||
|
||||
expect(wrapper.find('[aria-label="预览图片"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="查看素材详情"]').exists()).toBe(false)
|
||||
await wrapper.get('.asset-library-card').trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = document.querySelector('[role="dialog"][aria-label="素材详情与管理"]')!
|
||||
expect(dialog).not.toBeNull()
|
||||
expect(dialog.textContent).toContain('rain-street-original.png')
|
||||
expect(dialog.textContent).toContain('image/png')
|
||||
expect(dialog.textContent).toContain('4.0 KB')
|
||||
expect(router.currentRoute.value.path).toBe('/assets')
|
||||
|
||||
await wrapper.get('input[aria-label="搜索资产图片"]').setValue('天降甘霖')
|
||||
expect(wrapper.findAll('.asset-library-card')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('林默正面照')
|
||||
expect(wrapper.text()).not.toContain('雨夜街道')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import ProjectAssetsPage from '@/features/project-assets/ProjectAssetsPage.vue'
|
||||
import { projectContextKey } from '@/features/projects/context'
|
||||
import { testProjectContext } from '@/testing/project-context'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('项目资产库页面', () => {
|
||||
it('读取当前项目素材并支持名称、分类和原文件名筛选', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 'asset-scene',
|
||||
projectId: 'capability-test',
|
||||
name: '雨夜街道',
|
||||
type: 'image',
|
||||
category: 'scene',
|
||||
mimeType: 'image/png',
|
||||
extension: 'png',
|
||||
size: 4096,
|
||||
publicUrl: '/storage/scene.png',
|
||||
metadata: { originalName: 'street-original.png' },
|
||||
createdAt: '2026-09-21T00:00:00Z',
|
||||
updatedAt: '2026-09-21T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'asset-character',
|
||||
projectId: 'capability-test',
|
||||
name: '林默正面照',
|
||||
type: 'image',
|
||||
category: 'character',
|
||||
mimeType: 'image/jpeg',
|
||||
extension: 'jpg',
|
||||
size: 8192,
|
||||
publicUrl: '/storage/character.jpg',
|
||||
metadata: null,
|
||||
createdAt: '2026-09-21T01:00:00Z',
|
||||
updatedAt: '2026-09-21T01:00:00Z'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/projects/:projectId/assets', component: ProjectAssetsPage }]
|
||||
})
|
||||
await router.push('/projects/capability-test/assets')
|
||||
wrapper = mount(ProjectAssetsPage, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [router],
|
||||
provide: { [projectContextKey as symbol]: testProjectContext() }
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
'/api/projects/capability-test/assets',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
)
|
||||
expect(wrapper.findAll('.project-asset-card')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('雨夜街道')
|
||||
expect(wrapper.text()).toContain('林默正面照')
|
||||
|
||||
await wrapper.get('input[aria-label="搜索项目素材"]').setValue('street-original')
|
||||
expect(wrapper.findAll('.project-asset-card')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('雨夜街道')
|
||||
expect(wrapper.text()).not.toContain('林默正面照')
|
||||
|
||||
await wrapper.get('input[aria-label="搜索项目素材"]').setValue('没有结果')
|
||||
expect(wrapper.findAll('.project-asset-card')).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('没有匹配的素材')
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import { projectsApi } from '@/features/projects/api'
|
||||
import type { ProjectDetail, ProjectStatus } from '@/features/projects/types'
|
||||
import { readAllStyles } from '@/testing/styles'
|
||||
|
||||
const downstream = ['breakdown', 'visual-style', 'subject-identity', 'subject-images', 'storyboard', 'production']
|
||||
const downstream = ['breakdown', 'visual-style', 'subject-identity', 'subject-images', 'assets', 'storyboard', 'production']
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
@@ -66,21 +66,14 @@ async function openProject(initialPath: string) {
|
||||
}
|
||||
|
||||
describe('剧本完成前的下游访问限制', () => {
|
||||
it('进入图库暂停父级轮询,返回其他工作区恢复定时更新', async () => {
|
||||
it('项目工作区停留期间不重复读取项目详情和工作流记录', async () => {
|
||||
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 { router } = await openProject('/projects/manual-gallery/production')
|
||||
await router.push('/projects/manual-gallery/subject-images')
|
||||
await flushPromises()
|
||||
await openProject('/projects/manual-project/production')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(detail).toHaveBeenCalledTimes(1)
|
||||
expect(checkpoints).toHaveBeenCalledTimes(1)
|
||||
await router.push('/projects/manual-gallery/production')
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
expect(detail).toHaveBeenCalledTimes(2)
|
||||
expect(checkpoints).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('图库直接链接只读取一次项目,未完成时可手动刷新解锁', async () => {
|
||||
@@ -91,7 +84,7 @@ describe('剧本完成前的下游访问限制', () => {
|
||||
await openProject('/projects/manual-gate/subject-images')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(detail).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper!.get('.project-access-gate').text()).not.toContain('状态会自动更新')
|
||||
expect(wrapper!.get('.project-access-gate').text()).toContain('请手动刷新项目状态')
|
||||
status = 'completed'
|
||||
const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新项目状态')!
|
||||
await refresh.trigger('click')
|
||||
@@ -123,24 +116,21 @@ describe('剧本完成前的下游访问限制', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('轮询完成状态自动解锁;重新变为待审核时撤下下游面板', async () => {
|
||||
it('未完成项目停留期间不自动解锁,仅在手动刷新后进入下游页面', async () => {
|
||||
vi.useFakeTimers()
|
||||
let status: ProjectStatus = 'generating'
|
||||
vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status))
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { mounted } = await openProject('/projects/polling/production')
|
||||
const { mounted } = await openProject('/projects/manual-refresh/production')
|
||||
expect(mounted).not.toHaveBeenCalled()
|
||||
status = 'completed'
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.workspace-probe').text()).toBe('production')
|
||||
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)
|
||||
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!.findAll('.n-menu a')).toHaveLength(9)
|
||||
expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
|
||||
})
|
||||
|
||||
@@ -154,10 +144,10 @@ describe('剧本完成前的下游访问限制', () => {
|
||||
)
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { router, mounted } = await openProject('/projects/first/production')
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(8)
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(9)
|
||||
await router.push('/projects/second/production')
|
||||
await flushPromises()
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(3)
|
||||
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
|
||||
resolveSecond(project('second', 'draft'))
|
||||
await flushPromises()
|
||||
@@ -165,10 +155,10 @@ describe('剧本完成前的下游访问限制', () => {
|
||||
expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
|
||||
await router.push('/projects')
|
||||
await flushPromises()
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(1)
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('读取失败保持锁定;错误区重试成功后解锁,页头不恢复标签与刷新按钮', async () => {
|
||||
it('读取失败保持锁定;错误区重试成功后解锁且不恢复项目标题栏', async () => {
|
||||
const detail = vi
|
||||
.spyOn(projectsApi, 'detail')
|
||||
.mockRejectedValueOnce(new Error('项目读取失败'))
|
||||
@@ -176,9 +166,8 @@ describe('剧本完成前的下游访问限制', () => {
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { mounted } = await openProject('/projects/retry/storyboard')
|
||||
expect(mounted).not.toHaveBeenCalled()
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
||||
expect(wrapper!.find('.project-header .n-button').exists()).toBe(false)
|
||||
expect(wrapper!.find('.project-header .n-tag').exists()).toBe(false)
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(3)
|
||||
expect(wrapper!.find('.project-header').exists()).toBe(false)
|
||||
await wrapper!.get('.project-notices button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(detail).toHaveBeenCalledTimes(2)
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('身份图与母版契约', () => {
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('轮询新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => {
|
||||
it('刷新新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => {
|
||||
const anchor = identityImageFixture({ id: 'anchor' })
|
||||
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
|
||||
wrapper = mount(IdentityGallery, {
|
||||
|
||||
@@ -133,6 +133,15 @@ async function gallery(query = '') {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const { fetcher } = await gallery()
|
||||
const requestCount = fetcher.mock.calls.length
|
||||
@@ -140,29 +149,31 @@ describe('过期首帧到具体素材定位', () => {
|
||||
const grid = wrapper!.get('.form-image-grid').element
|
||||
const cards = wrapper!.findAll('.form-image-card').map(card => card.element)
|
||||
scroll.scrollTop = 240
|
||||
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper!.get('[aria-label="网格布局"]').classes()).toContain('layout-option-active')
|
||||
expect(wrapper!.get('[aria-label="瀑布流布局"]').classes()).not.toContain('layout-option-active')
|
||||
expect(wrapper!.get('.form-image-masonry').element).toBe(grid)
|
||||
expect(wrapper!.get('[aria-label="瀑布流布局"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper!.get('[aria-label="瀑布流布局"]').classes()).toContain('layout-option-active')
|
||||
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper!.get('[aria-label="网格布局"]').classes()).not.toContain('layout-option-active')
|
||||
expect(
|
||||
wrapper!
|
||||
.get('.gallery-layout-switch')
|
||||
.findAll('button')
|
||||
.map(button => button.attributes('aria-label'))
|
||||
).toEqual(['网格布局', '瀑布流布局', '刷新图库', '批量生图'])
|
||||
await wrapper!.get('[aria-label="瀑布流布局"]').trigger('click')
|
||||
expect(wrapper!.get('.form-image-masonry').element).toBe(grid)
|
||||
expect(wrapper!.get('[aria-label="瀑布流布局"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper!.get('[aria-label="瀑布流布局"]').classes()).toContain('layout-option-active')
|
||||
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper!.get('[aria-label="网格布局"]').classes()).not.toContain('layout-option-active')
|
||||
await wrapper!.get('[aria-label="网格布局"]').trigger('click')
|
||||
expect(wrapper!.find('.form-image-masonry').exists()).toBe(false)
|
||||
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper!.get('[aria-label="网格布局"]').classes()).toContain('layout-option-active')
|
||||
expect(wrapper!.get('[aria-label="瀑布流布局"]').attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper!.get('[aria-label="瀑布流布局"]').classes()).not.toContain('layout-option-active')
|
||||
expect(wrapper!.findAll('.form-image-card').map(card => card.element)).toEqual(cards)
|
||||
expect(scroll.scrollTop).toBe(240)
|
||||
expect(
|
||||
wrapper!.get<HTMLElement>('.form-image-card').element.style.getPropertyValue('--form-image-aspect')
|
||||
).toBe('')
|
||||
await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('雨夜')
|
||||
await wrapper!.get('[aria-label="网格布局"]').trigger('click')
|
||||
expect(wrapper!.find('.form-image-masonry').exists()).toBe(false)
|
||||
await wrapper!.get('[aria-label="瀑布流布局"]').trigger('click')
|
||||
expect(wrapper!.get('.form-image-masonry').element).toBe(grid)
|
||||
expect(wrapper!.get('.form-image-grid').element).toBe(grid)
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
|
||||
expect(wrapper!.get('.form-image-card').text()).toContain('雨夜形态')
|
||||
|
||||
@@ -70,6 +70,41 @@ describe('形态图片选择与操作', () => {
|
||||
expect(document.body.textContent).toContain('母版')
|
||||
expect(document.body.textContent).not.toContain('道具形态生图暂不自动引用')
|
||||
})
|
||||
it('按需预览正式形态的实际参考图和 Provider Prompt,不触发生图事件', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
subjectFormId: 'form-db-1',
|
||||
provider: 'seedream',
|
||||
model: 'image-model',
|
||||
generationConfig: { width: 2048 },
|
||||
references: [
|
||||
{
|
||||
imageNumber: 1,
|
||||
role: 'identity-anchor',
|
||||
imageId: 'anchor-1',
|
||||
imageUrl: '/storage/anchor.png'
|
||||
}
|
||||
],
|
||||
providerPrompt: '最终形态 Prompt'
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(GenerateImageDialog, {
|
||||
attachTo: document.body,
|
||||
props: { open: true, form: formFixture(), disabled: false }
|
||||
})
|
||||
await flushPromises()
|
||||
button('预览实际生成输入').click()
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/subject-forms/form-db-1/images/preview')
|
||||
expect(document.body.textContent).toContain('seedream / image-model')
|
||||
expect(document.body.textContent).toContain('最终形态 Prompt')
|
||||
expect(wrapper.emitted('generate')).toBeUndefined()
|
||||
})
|
||||
it('已有主图优先于新候选图和失败记录,无主图才回退到成功候选图', () => {
|
||||
const form = formFixture()
|
||||
form.images.unshift(
|
||||
|
||||
@@ -112,6 +112,14 @@ describe('形态正式提示词与批量配置', () => {
|
||||
expect(service.session.value.promptReceipt?.result.failures[0]?.error).toBe('模型拒绝')
|
||||
expect(service.session.value.receipt).toBeNull()
|
||||
})
|
||||
it('批量提示词和图片可按主体模块缩小范围', async () => {
|
||||
const { service, posts } = await setup()
|
||||
service.batchModule.value = 'scene'
|
||||
await service.generatePrompts()
|
||||
await service.generateProject()
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toMatchObject({ module: 'scene' })
|
||||
expect(JSON.parse(String(posts()[1]?.[1]?.body))).toMatchObject({ module: 'scene' })
|
||||
})
|
||||
it('图片数量上限可选,非法上限和并发阻止提交', async () => {
|
||||
const { service, posts } = await setup()
|
||||
for (const limit of [0, -1, 1.5]) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { NDataTable, NImage, NRadioGroup, NScrollbar } from 'naive-ui'
|
||||
import { NImage, NRadioGroup, NScrollbar } from 'naive-ui'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from '@/features/storyboard/testing/fixtures'
|
||||
import { getStoryboardSession } from '@/features/storyboard/model'
|
||||
import { getOperation } from '@/features/workflows/operations'
|
||||
import { readAllStyles } from '@/testing/styles'
|
||||
import SubjectImagesPage from '@/features/subject-images/SubjectImagesPage.vue'
|
||||
import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
|
||||
import { getImageSession } from '@/features/subject-images/model'
|
||||
@@ -379,7 +378,7 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
||||
expect(put[0]).toBe('/api/subjects/subject-db-1/identity')
|
||||
expect(JSON.parse(put[1]!.body as string)).toMatchObject({ description: '人工稳定身份', isLocked: true })
|
||||
expect(button('AI 重新生成身份').disabled).toBe(true)
|
||||
expect(button('生成选角候选').disabled).toBe(false)
|
||||
await wrapper!.get('#identity-description').setValue('新的未保存草稿')
|
||||
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
|
||||
expect(wrapper!.text()).toContain('切换会丢弃草稿')
|
||||
@@ -539,11 +538,10 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
expect(wrapper!.text()).toContain('2 个形态')
|
||||
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/subjects/subject-db-1/identity'))).toBe(true)
|
||||
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/identity/images'))).toBe(false)
|
||||
expect(button('生成选角候选').disabled).toBe(true)
|
||||
expect(button('AI 生成身份').disabled).toBe(false)
|
||||
expect(button('配置生成模型').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('锁定阻止身份 AI 覆盖但不阻止人工编辑和生图,生图默认省略 referenceImageId', async () => {
|
||||
it('锁定阻止身份 AI 覆盖但不阻止人工编辑,并按步骤进入后续操作', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
||||
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture({ isLocked: true }))
|
||||
@@ -553,18 +551,9 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountAssets('identity')
|
||||
expect(button('AI 重新生成身份').disabled).toBe(true)
|
||||
expect(wrapper!.text()).toContain('主体身份已完成')
|
||||
expect(button('保存主体身份').disabled).toBe(false)
|
||||
button('生成辅助身份图').click()
|
||||
await flushPromises()
|
||||
expect(button('确认生成身份图').disabled).toBe(true)
|
||||
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成身份图').click()
|
||||
await flushPromises()
|
||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||
expect(post[0]).toBe('/api/subjects/subject-db-1/identity/images')
|
||||
expect(JSON.parse(post[1]!.body as string)).toEqual({ viewType: 'front' })
|
||||
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
|
||||
})
|
||||
|
||||
it('无项目风格时禁止生成身份和图片,但仍可人工保存身份', async () => {
|
||||
@@ -578,8 +567,7 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
})
|
||||
)
|
||||
await mountAssets('identity')
|
||||
expect(button('AI 重新生成身份').disabled).toBe(true)
|
||||
expect(button('生成选角候选').disabled).toBe(true)
|
||||
expect(button('设置视觉风格').disabled).toBe(false)
|
||||
expect(button('保存主体身份').disabled).toBe(false)
|
||||
})
|
||||
|
||||
@@ -759,7 +747,7 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
||||
expect(put[0]).toBe('/api/subjects/subject-db-1/identity/images/casting-1/casting')
|
||||
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/anchor'))).toBe(false)
|
||||
expect(wrapper!.text()).toContain('选角状态:选角已完成')
|
||||
expect(wrapper!.text()).toContain('主体身份已完成')
|
||||
expect(wrapper!.text()).toContain('演员身份已确认并锁定')
|
||||
})
|
||||
|
||||
@@ -804,17 +792,19 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
||||
if (String(url).endsWith('/identity/images')) return jsonResponse([])
|
||||
if (init?.method === 'POST') throw new Error('connection lost')
|
||||
if (String(url).endsWith('/identity')) return jsonResponse(null)
|
||||
return jsonResponse(identityFixture())
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const provided = await mountAssets('identity')
|
||||
await confirmGeneration('AI 重新生成身份')
|
||||
button('生成身份文本').click()
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1)
|
||||
expect(getOperation(fixture.id).error).toContain('无法连接后端')
|
||||
provided.data.value!.project = { ...fixture, status: 'generating' }
|
||||
await flushPromises()
|
||||
expect(button('保存主体身份').disabled).toBe(true)
|
||||
expect(button('生成选角候选').disabled).toBe(true)
|
||||
expect(button('生成身份文本').disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -894,7 +884,7 @@ describe('工作台页面交互', () => {
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('形态卡片铺满并打开原生图片预览,历史管理仍从独立按钮进入', async () => {
|
||||
it('瀑布流形态卡片完整展示并打开原生图片预览,历史管理仍从独立按钮进入', async () => {
|
||||
const form = formFixture()
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
@@ -905,7 +895,7 @@ describe('工作台页面交互', () => {
|
||||
await mountSubjectImages()
|
||||
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
|
||||
const card = wrapper!.get('.form-image-card')
|
||||
expect(card.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: false })
|
||||
expect(card.getComponent(NImage).props()).toMatchObject({ objectFit: 'contain', previewDisabled: false })
|
||||
await card.get('img').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.n-image-preview')?.getAttribute('src')).toContain(
|
||||
@@ -1444,51 +1434,6 @@ describe('工作台页面交互', () => {
|
||||
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('项目列表滚动追加,搜索覆盖未展示项目且清除后回到首批', async () => {
|
||||
const projects = Array.from({ length: 45 }, (_, index) => ({
|
||||
...fixture,
|
||||
id: 'project-' + index,
|
||||
title: '剧本' + index
|
||||
}))
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () =>
|
||||
new Response(JSON.stringify({ data: projects }))
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: ProjectsPage }]
|
||||
})
|
||||
await router.push('/')
|
||||
wrapper = mount(ProjectsPage, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
const table = wrapper.getComponent(NDataTable)
|
||||
expect(table.props('data')).toHaveLength(20)
|
||||
expect(wrapper.find('.n-pagination').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('个项目')
|
||||
expect(wrapper.text()).not.toContain('更新于')
|
||||
// 远离底部不追加;抵达底部后逐批追加,末批不重复。
|
||||
const scroll = async (scrollTop: number) => {
|
||||
table.vm.$emit('scroll', { target: { scrollHeight: 1000, clientHeight: 200, scrollTop } })
|
||||
await flushPromises()
|
||||
}
|
||||
await scroll(100)
|
||||
expect(table.props('data')).toHaveLength(20)
|
||||
await scroll(800)
|
||||
expect(table.props('data')).toHaveLength(40)
|
||||
await scroll(800)
|
||||
expect(table.props('data')).toHaveLength(45)
|
||||
await scroll(800)
|
||||
expect(table.props('data')).toHaveLength(45)
|
||||
await wrapper.get('input[aria-label="搜索项目"]').setValue('剧本44')
|
||||
await flushPromises()
|
||||
expect(table.props('data')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('剧本44')
|
||||
await wrapper.get('input[aria-label="搜索项目"]').setValue('')
|
||||
await flushPromises()
|
||||
expect(table.props('data')).toHaveLength(20)
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('新建弹窗提交真实参数并返回 202 的项目 ID', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
@@ -1545,13 +1490,6 @@ describe('工作台页面交互', () => {
|
||||
expect(provided.refresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('窄屏隐藏进入拆解,选择器需压过工具栏文字按钮', () => {
|
||||
const css = readAllStyles()
|
||||
expect(css).toMatch(
|
||||
/@media \(max-width: 800px\)[\s\S]*?\.tabs-toolbar-actions \.script-next-link\s*\{[^}]*display:\s*none/
|
||||
)
|
||||
})
|
||||
|
||||
it('切换剧本标签保留阅读容器和位置,执行记录按需展开', async () => {
|
||||
wrapper = mount(CreateDramaPage, {
|
||||
attachTo: document.body,
|
||||
@@ -1559,7 +1497,8 @@ describe('工作台页面交互', () => {
|
||||
})
|
||||
const suffix = wrapper.get('.script-section .n-tabs-nav__suffix')
|
||||
expect(suffix.findAll('button').map(item => item.attributes('aria-label'))).toEqual(['剧本更多操作'])
|
||||
expect(suffix.get('router-link-stub').attributes('to')).toBe(`/projects/${fixture.id}/breakdown`)
|
||||
expect(suffix.find('router-link-stub').exists()).toBe(false)
|
||||
expect(suffix.text()).not.toContain('进入拆解')
|
||||
const reader = wrapper.get<HTMLElement>('.reader-scroll .n-scrollbar-container').element
|
||||
reader.scrollTop = 120
|
||||
expect(wrapper.find('.history-panel').exists()).toBe(false)
|
||||
|
||||
@@ -4,6 +4,8 @@ import { projectsApi } from '@/features/projects/api'
|
||||
import { breakdownApi } from '@/features/breakdown/api'
|
||||
import { storyboardApi } from '@/features/storyboard/api'
|
||||
import { subjectImagesApi } from '@/features/subject-images/api'
|
||||
import { subjectIdentityApi } from '@/features/subject-identity/api'
|
||||
import { productionApi } from '@/features/production/api'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
@@ -43,6 +45,29 @@ describe('后端 API 契约', () => {
|
||||
})
|
||||
expect(fetcher.mock.calls[4]![1]!.body).toBeUndefined()
|
||||
})
|
||||
it('新增预览、模块批处理和稳定母版接口使用正式数据库 ID', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await subjectImagesApi.preview('form/id')
|
||||
await subjectImagesApi.generatePrompts('project/id', { concurrency: 2, force: false, module: 'scene' })
|
||||
await subjectImagesApi.generateProject('project/id', { concurrency: 1, force: false, module: 'prop' })
|
||||
await subjectIdentityApi.generateProject('project/id', { concurrency: 2, force: false, module: 'scene' })
|
||||
await subjectIdentityApi.generateStableAnchors('project/id', { module: 'prop', limit: 3, concurrency: 2 })
|
||||
await productionApi.previewKeyframe('shot/id')
|
||||
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
|
||||
['/api/subject-forms/form%2Fid/images/preview', 'POST'],
|
||||
['/api/projects/project%2Fid/subject-forms/generation-prompts', 'POST'],
|
||||
['/api/projects/project%2Fid/subject-images/generate', 'POST'],
|
||||
['/api/projects/project%2Fid/subject-identities/generate', 'POST'],
|
||||
['/api/projects/project%2Fid/subject-identities/anchors/generate', 'POST'],
|
||||
['/api/storyboard-shots/shot%2Fid/keyframe/preview', 'POST']
|
||||
])
|
||||
expect(JSON.parse(fetcher.mock.calls[4]![1]!.body as string)).toEqual({
|
||||
module: 'prop',
|
||||
limit: 3,
|
||||
concurrency: 2
|
||||
})
|
||||
})
|
||||
it('分镜只调用正式项目接口,并保持 force 和零次修复参数', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
|
||||
Reference in New Issue
Block a user