feat: 新增项目资产库与形态参考素材
This commit is contained in:
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user