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
+2
View File
@@ -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>
+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"
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%;
+3 -2
View File
@@ -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
View File
@@ -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()
+5
View File
@@ -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'),