diff --git a/docs/api-contract-sync.md b/docs/api-contract-sync.md index 3aee5e1..de4a0d4 100644 --- a/docs/api-contract-sync.md +++ b/docs/api-contract-sync.md @@ -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 中推断。 +- 删除仍由后端执行引用保护;前端不先删风格记录,也不将失败响应显示为已删除。 diff --git a/docs/backend-coverage.md b/docs/backend-coverage.md index 272d5e6..52aebbc 100644 --- a/docs/backend-coverage.md +++ b/docs/backend-coverage.md @@ -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,也不把资产库图片自动设为形态主图;已有形态图不会因资产或风格参考变化被客户端静默替换。 diff --git a/src/App.vue b/src/App.vue index 1cc0a8f..36e9215 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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 diff --git a/src/features/project-assets/ProjectAssetsPage.vue b/src/features/project-assets/ProjectAssetsPage.vue new file mode 100644 index 0000000..4321d1a --- /dev/null +++ b/src/features/project-assets/ProjectAssetsPage.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/src/features/project-assets/api.ts b/src/features/project-assets/api.ts new file mode 100644 index 0000000..6aafee2 --- /dev/null +++ b/src/features/project-assets/api.ts @@ -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(assetPath(projectId), { signal }), + get: (projectId: string, assetId: string, signal?: AbortSignal) => + request(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(assetPath(projectId), { method: 'POST', body, timeoutMs: 0 }) + }, + update: (projectId: string, assetId: string, input: UpdateProjectAssetInput) => + request(assetPath(projectId, assetId), { method: 'PUT', body: input }), + remove: (projectId: string, assetId: string) => + request(assetPath(projectId, assetId), { method: 'DELETE' }) +} diff --git a/src/features/project-assets/index.ts b/src/features/project-assets/index.ts new file mode 100644 index 0000000..302ca19 --- /dev/null +++ b/src/features/project-assets/index.ts @@ -0,0 +1,2 @@ +export { projectAssetsApi } from './api' +export type { ProjectAsset, UpdateProjectAssetInput, UploadProjectAssetInput } from './types' diff --git a/src/features/project-assets/types.ts b/src/features/project-assets/types.ts new file mode 100644 index 0000000..5f6970c --- /dev/null +++ b/src/features/project-assets/types.ts @@ -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 +} + +/** 上传表单与后端 multipart 字段一一对应。 */ +export interface UploadProjectAssetInput { + file: File + name: string + category: string +} diff --git a/src/features/projects/ProjectLayout.vue b/src/features/projects/ProjectLayout.vue index 8642ffd..2df3ede 100644 --- a/src/features/projects/ProjectLayout.vue +++ b/src/features/projects/ProjectLayout.vue @@ -65,7 +65,7 @@ onScopeDispose(() => { v-else-if="context.project.value?.id === id" class="project-access-gate" title="请先完成剧本创作" - description="剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、分镜设计与镜头生产。请手动刷新项目状态。" + description="剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、资产库、分镜设计与镜头生产。请手动刷新项目状态。" > 返回剧本创作 刷新项目状态 diff --git a/src/features/visual-style/VisualStylePage.vue b/src/features/visual-style/VisualStylePage.vue index ded53d2..210682a 100644 --- a/src/features/visual-style/VisualStylePage.vue +++ b/src/features/visual-style/VisualStylePage.vue @@ -154,6 +154,7 @@ function removeImage(image: VisualStyleImage) {

正在读取视觉风格…

() +/** 风格图可关联项目资产,也保留外部地址兼容入口。 */ +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(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 风格参考图 {{ images.length }} 张

- 登记已有 HTTP(S) 或 /storage/ - 图片地址。后端尚无文件上传接口;这些图片目前仅管理记录,现有身份/形态生图不会自动将风格图片传给模型。 + 优先从项目资产库选择,也可登记已有 HTTP(S) 或 /storage/ + 图片地址。启用的参考图会参与后续身份与形态生图,已有图片不会自动更新。

@@ -74,7 +112,10 @@ const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeIntege > 启用 - 登记参考图 +
+ 从资产库选择 + 登记参考图 +
@@ -125,10 +166,46 @@ const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeIntege

尚无风格参考图。先保存视觉风格,再登记图片。

+ + +

{{ assetError }}

+

正在读取项目素材…

+
+ +
+
+ 资产库暂无图片。前往资产库上传 +
+
+ 取消 + 登记为{{ categories[form.category] }}参考图 +
+