feat: 新增项目资产库与形态参考素材

This commit is contained in:
GouJ
2026-09-21 19:54:51 +08:00
parent e6d02a20a7
commit b834d1d259
18 changed files with 737 additions and 41 deletions
+8
View File
@@ -44,3 +44,11 @@
- 浅色主画布统一白色,输入框使用浅灰,Tab 导航、辅助区和图库使用统一主题变量,首屏 theme-color 与应用一致。深色视频播放背景保留。 - 浅色主画布统一白色,输入框使用浅灰,Tab 导航、辅助区和图库使用统一主题变量,首屏 theme-color 与应用一致。深色视频播放背景保留。
验证使用模拟 API,不调用真实生成模型。浏览器无法访问当前本地预览地址,尚未完成浏览器截图验收或真实后端联调。 验证使用模拟 API,不调用真实生成模型。浏览器无法访问当前本地预览地址,尚未完成浏览器截图验收或真实后端联调。
## 2026-09-21 项目资产接口
- `POST /projects/:projectId/assets` 使用 multipart 的 `file``name``category`,浏览器负责生成 boundary,前端不手工设置 `Content-Type`
- GETPUT/DELETE 项目资产接口全部使用正式项目 ID 与资产 ID;编辑只修改名称和分类,不替换物理文件。
- 素材公开字段包含 `publicUrl`、类型、MIME、扩展名、大小、分类、元数据和时间;页面只读取 `metadata.originalName` 作为辅助搜索,不依赖内部存储路径。
- 视觉风格新增参考图可发送 `projectAssetId`,后端从所属项目素材解析 `imageUrl`;外部 `imageUrl` 入口继续兼容。资产关联关系不在公开风格图 DTO 中推断。
- 删除仍由后端执行引用保护;前端不先删风格记录,也不将失败响应显示为已删除。
+6
View File
@@ -13,6 +13,7 @@
| 拆解与恢复 | 拆解设置与恢复 | `breakdown-preview``breakdown/start``retry``resume-shots``resume-storyboard` | 保留正式剧集分组、模块选择、预览、失败恢复与 JSON 导出 | | 拆解与恢复 | 拆解设置与恢复 | `breakdown-preview``breakdown/start``retry``resume-shots``resume-storyboard` | 保留正式剧集分组、模块选择、预览、失败恢复与 JSON 导出 |
| 运行诊断 | 创作/拆解的执行记录 → 查看完整运行诊断 | GET `metrics``timeline/grouped`、共享 `checkpoints` | 新增全量时间线、阶段分组、工作流与节点筛选、独立失败提示、JSON 导出 | | 运行诊断 | 创作/拆解的执行记录 → 查看完整运行诊断 | GET `metrics``timeline/grouped`、共享 `checkpoints` | 新增全量时间线、阶段分组、工作流与节点筛选、独立失败提示、JSON 导出 |
| 视觉风格 | 视觉风格 | GETPUTPOST `visual-style` 及图片登记、启停、删除 | 保留锁定、分类提示词、硬约束、AI 生成与图片记录 | | 视觉风格 | 视觉风格 | GETPUTPOST `visual-style` 及图片登记、启停、删除 | 保留锁定、分类提示词、硬约束、AI 生成与图片记录 |
| 项目资产库 | 资产库/视觉风格参考图选择器 | `/projects/:id/assets` 上传、列表、详情、编辑、删除 | 新增 JPEG/PNG/WebP 上传、名称与分类管理、筛选预览;被视觉风格引用时由后端阻止删除 |
| 稳定身份 | 主体身份 | `subjects/:id/identity`、单个/项目/角色文本生成 | 保留人工修改、锁定及批量文本生成 | | 稳定身份 | 主体身份 | `subjects/:id/identity`、单个/项目/角色文本生成 | 保留人工修改、锁定及批量文本生成 |
| 角色选角 | 主体身份角色模块 | `character-casting/readiness`、批量/单个 candidates、图片 `casting` | 保留候选、检查、确认母版并原子锁定 | | 角色选角 | 主体身份角色模块 | `character-casting/readiness`、批量/单个 candidates、图片 `casting` | 保留候选、检查、确认母版并原子锁定 |
| 身份参考图 | 主体身份图库 | `identity/images`、图片 `anchor` | 保留完整预览、历史、辅助视角与母版切换;修正道具继承说明 | | 身份参考图 | 主体身份图库 | `identity/images`、图片 `anchor` | 保留完整预览、历史、辅助视角与母版切换;修正道具继承说明 |
@@ -77,3 +78,8 @@
- 已通过 lint、格式、TypeScript、Vitest(含质量链路及菜单/折叠交互回归)和生产构建;定位回归见 `src/features/subject-images/asset-impact.test.ts`。测试不触发真实模型或生产任务。 - 已通过 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,也不把资产库图片自动设为形态主图;已有形态图不会因资产或风格参考变化被客户端静默替换。
+2
View File
@@ -14,6 +14,7 @@ import {
type MenuOption type MenuOption
} from 'naive-ui' } from 'naive-ui'
import { import {
Archive,
Clapperboard, Clapperboard,
Camera, Camera,
Images, Images,
@@ -107,6 +108,7 @@ const workflowItems = [
['visual-style', '视觉风格', Palette], ['visual-style', '视觉风格', Palette],
['subject-identity', '主体身份', Fingerprint], ['subject-identity', '主体身份', Fingerprint],
['subject-images', '形态图片', Images], ['subject-images', '形态图片', Images],
['assets', '资产库', Archive],
['storyboard', '分镜设计', Camera], ['storyboard', '分镜设计', Camera],
['production', '镜头生产', Clapperboard] ['production', '镜头生产', Clapperboard]
] as const ] as const
@@ -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>
+26
View File
@@ -0,0 +1,26 @@
import { request } from '../../lib/http'
import type { 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 = {
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' })
}
+2
View File
@@ -0,0 +1,2 @@
export { projectAssetsApi } from './api'
export type { ProjectAsset, UpdateProjectAssetInput, UploadProjectAssetInput } from './types'
+29
View File
@@ -0,0 +1,29 @@
/** 项目资产库当前只接收图片;保留 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 UpdateProjectAssetInput {
name?: string
category?: string
metadata?: Record<string, unknown>
}
/** 上传表单与后端 multipart 字段一一对应。 */
export interface UploadProjectAssetInput {
file: File
name: string
category: string
}
+1 -1
View File
@@ -65,7 +65,7 @@ onScopeDispose(() => {
v-else-if="context.project.value?.id === id" v-else-if="context.project.value?.id === id"
class="project-access-gate" class="project-access-gate"
title="请先完成剧本创作" title="请先完成剧本创作"
description="剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、分镜设计与镜头生产。请手动刷新项目状态。" description="剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、资产库、分镜设计与镜头生产。请手动刷新项目状态。"
> >
<NButton type="primary" @click="router.push(`/projects/${id}/create-drama`)">返回剧本创作</NButton> <NButton type="primary" @click="router.push(`/projects/${id}/create-drama`)">返回剧本创作</NButton>
<NButton class="ml-3" :loading="context.loading.value" @click="context.refresh">刷新项目状态</NButton> <NButton class="ml-3" :loading="context.loading.value" @click="context.refresh">刷新项目状态</NButton>
@@ -154,6 +154,7 @@ function removeImage(image: VisualStyleImage) {
<p v-else-if="query.loading.value" class="py-8 text-sm text-muted" role="status">正在读取视觉风格</p> <p v-else-if="query.loading.value" class="py-8 text-sm text-muted" role="status">正在读取视觉风格</p>
<StyleImages <StyleImages
:key="imageRevision" :key="imageRevision"
:project-id="projectId"
:images="style?.images ?? []" :images="style?.images ?? []"
:disabled="disabled || !style" :disabled="disabled || !style"
@add="addImage" @add="addImage"
@@ -4,12 +4,15 @@ import { NFormItem } from 'naive-ui'
import { fieldRule, imageUrlRule } from '../../../lib/form-rules' import { fieldRule, imageUrlRule } from '../../../lib/form-rules'
import { NButton, NCheckbox, NInput, NInputNumber, NSelect } from 'naive-ui' import { NButton, NCheckbox, NInput, NInputNumber, NSelect } from 'naive-ui'
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { AssetImage } from '../../../components/ui' import { AppDialog, AssetImage } from '../../../components/ui'
import { referenceImageUrl } from '../../../lib/assets' 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' 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<{ const emit = defineEmits<{
add: [input: AddStyleImageInput] add: [input: AddStyleImageInput]
toggle: [image: VisualStyleImage] toggle: [image: VisualStyleImage]
@@ -17,6 +20,11 @@ const emit = defineEmits<{
}>() }>()
const form = reactive({ imageUrl: '', category: 'overall' as StyleCategory, sortOrder: 0, enabled: true }) const form = reactive({ imageUrl: '', category: 'overall' as StyleCategory, sortOrder: 0, enabled: true })
const confirmingId = ref('') 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 const categories = { overall: '整体', character: '人物', scene: '场景', prop: '道具' } as const
/** 仅通过校验后发出新增请求,输入框在失败时保留供修正。 */ /** 仅通过校验后发出新增请求,输入框在失败时保留供修正。 */
@@ -25,6 +33,36 @@ function add() {
emit('add', { ...form, imageUrl: form.imageUrl.trim(), source: 'upload' }) 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) { function remove(image: VisualStyleImage) {
if (props.disabled || confirmingId.value !== image.id) return 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> 风格参考图 <span class="ml-2 text-xs text-muted">{{ images.length }} </span>
</h3> </h3>
<p class="mt-2 text-xs leading-6 text-muted"> <p class="mt-2 text-xs leading-6 text-muted">
登记已有 HTTP(S) /storage/ 优先从项目资产库选择也可登记已有 HTTP(S) /storage/
图片地址后端尚无文件上传接口这些图片目前仅管理记录现有身份形态生图不会自动将风格图片传给模型 图片地址启用的参考图会参与后续身份与形态生图已有图片不会自动更新
</p> </p>
<AppForm :model="form" :rules="rules" :disabled="disabled" class="mt-4" @submit="add"> <AppForm :model="form" :rules="rules" :disabled="disabled" class="mt-4" @submit="add">
<fieldset :disabled="disabled" class="form-controls style-image-controls"> <fieldset :disabled="disabled" class="form-controls style-image-controls">
@@ -74,7 +112,10 @@ const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeIntege
></NInputNumber ></NInputNumber
></NFormItem> ></NFormItem>
<NCheckbox v-model:checked="form.enabled" class="control-row-checkbox text-xs">启用</NCheckbox> <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> </fieldset>
</AppForm> </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="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
@@ -125,10 +166,46 @@ const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeIntege
</article> </article>
</div> </div>
<p v-else class="mt-5 text-sm text-muted">尚无风格参考图先保存视觉风格再登记图片</p> <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> </section>
</template> </template>
<style> <style>
@reference "../../../styles/styles.css";
/* 按面板实际宽度布局,侧栏或移动端压缩内容时也能保持地址输入可用。 */ /* 按面板实际宽度布局,侧栏或移动端压缩内容时也能保持地址输入可用。 */
.style-images-panel { .style-images-panel {
container-type: inline-size; container-type: inline-size;
@@ -139,6 +216,18 @@ const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeIntege
gap: 12px; gap: 12px;
min-width: 0; min-width: 0;
} }
.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 { .style-image-controls .select-control {
min-width: 0; min-width: 0;
width: 100%; width: 100%;
+3 -2
View File
@@ -40,11 +40,12 @@ export type SaveVisualStyleInput = Partial<
> >
> >
/** 新增参考图使用已有地址,允许指定分类、启用状态及展示顺序。 */ /** 新增参考图使用资产库正式 ID 或外部地址,允许指定分类、启用状态及展示顺序。 */
export interface AddStyleImageInput { export interface AddStyleImageInput {
category: StyleCategory category: StyleCategory
source: 'upload' source: 'upload'
imageUrl: string imageUrl?: string
projectAssetId?: string
enabled: boolean enabled: boolean
sortOrder: number sortOrder: number
} }
+5 -2
View File
@@ -26,6 +26,9 @@ function isRecord(value: unknown): value is Record<string, unknown> {
/** 兼容普通 {data} 响应,以及 POST /projects 的顶层 202 响应。 */ /** 兼容普通 {data} 响应,以及 POST /projects 的顶层 202 响应。 */
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> { export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const base = (import.meta.env.VITE_API_BASE_URL || '/api').replace(/\/$/, '') 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 controller = new AbortController()
const timeoutMs = options.timeoutMs ?? 30_000 const timeoutMs = options.timeoutMs ?? 30_000
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined 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', method: options.method ?? 'GET',
headers: { headers: {
Accept: 'application/json', 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 signal: controller.signal
}) })
const text = await response.text() const text = await response.text()
+5
View File
@@ -41,6 +41,11 @@ export const router = createRouter({
component: () => import('../features/subject-images/SubjectImagesPage.vue'), component: () => import('../features/subject-images/SubjectImagesPage.vue'),
meta: { title: '形态图片' } meta: { title: '形态图片' }
}, },
{
path: 'assets',
component: () => import('../features/project-assets/ProjectAssetsPage.vue'),
meta: { title: '资产库' }
},
{ {
path: 'storyboard', path: 'storyboard',
component: () => import('../features/storyboard/StoryboardPage.vue'), component: () => import('../features/storyboard/StoryboardPage.vue'),
+53 -23
View File
@@ -40,27 +40,6 @@ async function submit() {
} }
describe('统一的 Naive UI 表单校验', () => { describe('统一的 Naive UI 表单校验', () => {
it('新建剧本要求填写项目标题,并将清理后的标题提交给后端', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response('{"projectId":"created","status":"generating"}', { status: 202 }))
vi.stubGlobal('fetch', fetcher)
wrapper = mount(CreateProjectDialog, { attachTo: document.body })
button('新建剧本').click()
await flushPromises()
expect(document.querySelector('#title')).toBeInstanceOf(HTMLInputElement)
await input('#topic', '雨夜来信')
await submit()
expect(feedback()).toContain('请填写项目标题')
expect(fetcher).not.toHaveBeenCalled()
await input('#title', ' 记忆当铺 ')
await submit()
expect(JSON.parse(fetcher.mock.calls[0]![1]!.body as string)).toMatchObject({
title: '记忆当铺',
topic: '雨夜来信'
})
})
it('新建剧本使用字段反馈拦截空白主题和小数集数,修正后只发送一次请求', async () => { it('新建剧本使用字段反馈拦截空白主题和小数集数,修正后只发送一次请求', async () => {
const fetcher = vi const fetcher = vi
.fn<typeof fetch>() .fn<typeof fetch>()
@@ -71,7 +50,6 @@ describe('统一的 Naive UI 表单校验', () => {
await flushPromises() await flushPromises()
expect(document.querySelector('form')!.noValidate).toBe(true) expect(document.querySelector('form')!.noValidate).toBe(true)
expect(document.querySelector('[required]')).toBeNull() expect(document.querySelector('[required]')).toBeNull()
await input('#title', '测试剧本')
await input('#topic', ' ') await input('#topic', ' ')
const focus = vi.spyOn(HTMLTextAreaElement.prototype, 'focus') const focus = vi.spyOn(HTMLTextAreaElement.prototype, 'focus')
await submit() await submit()
@@ -131,7 +109,10 @@ describe('统一的 Naive UI 表单校验', () => {
}) })
it('登记参考图拦截非法地址和小数排序,允许有效存储地址与负整数排序', async () => { 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="风格图片地址"]', 'javascript:alert(1)')
await input('[aria-label="风格图片排序"]', '1.5') await input('[aria-label="风格图片排序"]', '1.5')
await submit() await submit()
@@ -144,6 +125,55 @@ describe('统一的 Naive UI 表单校验', () => {
expect(wrapper.emitted('add')?.[0]?.[0]).toMatchObject({ imageUrl: '/storage/style.png', sortOrder: -1 }) 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 () => { it('异步验证期间切换对象或重复提交不会执行旧动作', async () => {
let finish: (() => void) | undefined let finish: (() => void) | undefined
const model = reactive({ text: '有效输入' }) const model = reactive({ text: '有效输入' })
+4 -3
View File
@@ -125,7 +125,7 @@ describe('管理后台组件边界', () => {
await router.push('/projects/test/production') await router.push('/projects/test/production')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } }) wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises() 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('#main-content .workspace-page').text()).toBe('镜头详情')
expect(wrapper.get('.theme-toggle').text()).toBe('') expect(wrapper.get('.theme-toggle').text()).toBe('')
expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu') expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu')
@@ -194,6 +194,7 @@ describe('管理后台组件边界', () => {
'视觉风格', '视觉风格',
'主体身份', '主体身份',
'形态图片', '形态图片',
'资产库',
'分镜设计', '分镜设计',
'镜头生产' '镜头生产'
]) ])
@@ -202,7 +203,7 @@ describe('管理后台组件边界', () => {
await settings.trigger('click') await settings.trigger('click')
await flushPromises() await flushPromises()
expect(document.querySelector('[role="dialog"][aria-label="后端连接"]')).not.toBeNull() 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('侧栏菜单独立滚动,窄屏改为不占正文宽度的全屏固定层', () => { it('侧栏菜单独立滚动,窄屏改为不占正文宽度的全屏固定层', () => {
@@ -304,7 +305,7 @@ describe('管理后台组件边界', () => {
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 100%') expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 100%')
expect(wrapper.get('.admin-sider').attributes('aria-hidden')).toBeUndefined() expect(wrapper.get('.admin-sider').attributes('aria-hidden')).toBeUndefined()
expect(wrapper.find('.admin-sider-mask').exists()).toBe(false) 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="关闭主菜单"]').attributes('aria-expanded')).toBe('true')
const expandedBrand = wrapper.get('.admin-brand') const expandedBrand = wrapper.get('.admin-brand')
expect(expandedBrand.classes()).not.toContain('is-collapsed') expect(expandedBrand.classes()).not.toContain('is-collapsed')
+64
View File
@@ -0,0 +1,64 @@
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('使用 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,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('没有匹配的素材')
})
})
+4 -4
View File
@@ -10,7 +10,7 @@ import { projectsApi } from '@/features/projects/api'
import type { ProjectDetail, ProjectStatus } from '@/features/projects/types' import type { ProjectDetail, ProjectStatus } from '@/features/projects/types'
import { readAllStyles } from '@/testing/styles' 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 let wrapper: VueWrapper | undefined
afterEach(() => { afterEach(() => {
wrapper?.unmount() wrapper?.unmount()
@@ -107,7 +107,7 @@ describe('剧本完成前的下游访问限制', () => {
expect(wrapper!.find(`.n-menu a[href="/projects/unfinished/${path}"]`).exists()).toBe(false) expect(wrapper!.find(`.n-menu a[href="/projects/unfinished/${path}"]`).exists()).toBe(false)
} }
expect(mounted).not.toHaveBeenCalled() expect(mounted).not.toHaveBeenCalled()
expect(wrapper!.findAll('.n-menu [aria-disabled="true"]')).toHaveLength(6) expect(wrapper!.findAll('.n-menu [aria-disabled="true"]')).toHaveLength(7)
await wrapper!.get('.project-access-gate button').trigger('click') await wrapper!.get('.project-access-gate button').trigger('click')
await flushPromises() await flushPromises()
expect(router.currentRoute.value.path).toBe('/projects/unfinished/create-drama') expect(router.currentRoute.value.path).toBe('/projects/unfinished/create-drama')
@@ -130,7 +130,7 @@ describe('剧本完成前的下游访问限制', () => {
await wrapper!.get('.project-access-gate button:nth-of-type(2)').trigger('click') await wrapper!.get('.project-access-gate button:nth-of-type(2)').trigger('click')
await flushPromises() await flushPromises()
expect(wrapper!.get('.workspace-probe').text()).toBe('production') expect(wrapper!.get('.workspace-probe').text()).toBe('production')
expect(wrapper!.findAll('.n-menu a')).toHaveLength(8) expect(wrapper!.findAll('.n-menu a')).toHaveLength(9)
expect(mounted).toHaveBeenCalledExactlyOnceWith('production') expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
}) })
@@ -144,7 +144,7 @@ describe('剧本完成前的下游访问限制', () => {
) )
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const { router, mounted } = await openProject('/projects/first/production') 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 router.push('/projects/second/production')
await flushPromises() await flushPromises()
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2) expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)