feat: 新增项目资产库与形态参考素材
This commit is contained in:
@@ -44,3 +44,11 @@
|
||||
- 浅色主画布统一白色,输入框使用浅灰,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 中推断。
|
||||
- 删除仍由后端执行引用保护;前端不先删风格记录,也不将失败响应显示为已删除。
|
||||
|
||||
@@ -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,8 @@
|
||||
- 已通过 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,也不把资产库图片自动设为形态主图;已有形态图不会因资产或风格参考变化被客户端静默替换。
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type MenuOption
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
Archive,
|
||||
Clapperboard,
|
||||
Camera,
|
||||
Images,
|
||||
@@ -107,6 +108,7 @@ const workflowItems = [
|
||||
['visual-style', '视觉风格', Palette],
|
||||
['subject-identity', '主体身份', Fingerprint],
|
||||
['subject-images', '形态图片', Images],
|
||||
['assets', '资产库', Archive],
|
||||
['storyboard', '分镜设计', Camera],
|
||||
['production', '镜头生产', Clapperboard]
|
||||
] 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>
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { projectAssetsApi } from './api'
|
||||
export type { ProjectAsset, UpdateProjectAssetInput, UploadProjectAssetInput } from './types'
|
||||
@@ -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
|
||||
}
|
||||
@@ -65,7 +65,7 @@ onScopeDispose(() => {
|
||||
v-else-if="context.project.value?.id === id"
|
||||
class="project-access-gate"
|
||||
title="请先完成剧本创作"
|
||||
description="剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、分镜设计与镜头生产。请手动刷新项目状态。"
|
||||
description="剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、资产库、分镜设计与镜头生产。请手动刷新项目状态。"
|
||||
>
|
||||
<NButton type="primary" @click="router.push(`/projects/${id}/create-drama`)">返回剧本创作</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>
|
||||
<StyleImages
|
||||
:key="imageRevision"
|
||||
:project-id="projectId"
|
||||
:images="style?.images ?? []"
|
||||
:disabled="disabled || !style"
|
||||
@add="addImage"
|
||||
|
||||
@@ -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,7 +112,10 @@ 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">
|
||||
@@ -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,18 @@ const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeIntege
|
||||
gap: 12px;
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
+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()
|
||||
|
||||
@@ -41,6 +41,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'),
|
||||
|
||||
@@ -40,27 +40,6 @@ async function submit() {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
@@ -71,7 +50,6 @@ describe('统一的 Naive UI 表单校验', () => {
|
||||
await flushPromises()
|
||||
expect(document.querySelector('form')!.noValidate).toBe(true)
|
||||
expect(document.querySelector('[required]')).toBeNull()
|
||||
await input('#title', '测试剧本')
|
||||
await input('#topic', ' ')
|
||||
const focus = vi.spyOn(HTMLTextAreaElement.prototype, 'focus')
|
||||
await submit()
|
||||
@@ -131,7 +109,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()
|
||||
@@ -144,6 +125,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,7 +125,7 @@ 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')
|
||||
@@ -194,6 +194,7 @@ describe('管理后台组件边界', () => {
|
||||
'视觉风格',
|
||||
'主体身份',
|
||||
'形态图片',
|
||||
'资产库',
|
||||
'分镜设计',
|
||||
'镜头生产'
|
||||
])
|
||||
@@ -202,7 +203,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('侧栏菜单独立滚动,窄屏改为不占正文宽度的全屏固定层', () => {
|
||||
@@ -304,7 +305,7 @@ 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')
|
||||
const expandedBrand = wrapper.get('.admin-brand')
|
||||
expect(expandedBrand.classes()).not.toContain('is-collapsed')
|
||||
|
||||
@@ -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('没有匹配的素材')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -107,7 +107,7 @@ describe('剧本完成前的下游访问限制', () => {
|
||||
expect(wrapper!.find(`.n-menu a[href="/projects/unfinished/${path}"]`).exists()).toBe(false)
|
||||
}
|
||||
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 flushPromises()
|
||||
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 flushPromises()
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -144,7 +144,7 @@ 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)
|
||||
|
||||
Reference in New Issue
Block a user