style: 重构资产卡片覆盖层与详情弹窗

This commit is contained in:
GouJ
2026-09-22 11:34:27 +08:00
parent 2240a90424
commit f3c9f6ca41
2 changed files with 167 additions and 44 deletions
+138 -25
View File
@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref } from 'vue' import { computed, nextTick, reactive, ref } from 'vue'
import { NAlert, NButton, NFormItem, NInput, NPopconfirm, NSelect, NTag, NTooltip } from 'naive-ui' import { NAlert, NButton, NFormItem, NImage, NInput, NPopconfirm, NSelect, NTag, NTooltip } from 'naive-ui'
import { Pencil, RefreshCw, Search, Trash2, Upload } from '@lucide/vue' import { Eye, Info, RefreshCw, Search, Trash2, Upload } from '@lucide/vue'
import { AppDialog, AssetImage, EmptyState } from '../../components/ui' import { AppDialog, AssetImage, EmptyState } from '../../components/ui'
import AppForm from '../../components/ui/AppForm.vue' import AppForm from '../../components/ui/AppForm.vue'
import WorkspacePage from '../../components/ui/WorkspacePage.vue' import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { useQuery } from '../../composables/useQuery' import { useQuery } from '../../composables/useQuery'
import { fieldRule } from '../../lib/form-rules' import { fieldRule } from '../../lib/form-rules'
import { referenceImageUrl } from '../../lib/assets'
import { errorMessage } from '../../lib/http' import { errorMessage } from '../../lib/http'
import { projectsApi } from '../projects/api' import { projectsApi } from '../projects/api'
import { projectAssetsApi } from './api' import { projectAssetsApi } from './api'
@@ -39,6 +40,9 @@ const uploadOpen = ref(false)
const editOpen = ref(false) const editOpen = ref(false)
const selectedFile = ref<File | null>(null) const selectedFile = ref<File | null>(null)
const selectedAsset = ref<GlobalProjectAsset | null>(null) const selectedAsset = ref<GlobalProjectAsset | null>(null)
const previewAsset = ref<GlobalProjectAsset | null>(null)
const previewImage = ref<{ showPreview: () => void } | null>(null)
const overflowTitles = reactive(new Set<string>())
const uploadForm = reactive({ projectId: '', name: '', category: 'reference' }) const uploadForm = reactive({ projectId: '', name: '', category: 'reference' })
const editForm = reactive({ name: '', category: '' }) const editForm = reactive({ name: '', category: '' })
const assets = computed(() => query.data.value ?? []) const assets = computed(() => query.data.value ?? [])
@@ -167,6 +171,20 @@ function openEdit(asset: GlobalProjectAsset) {
editOpen.value = true editOpen.value = true
} }
/** 只在标题真实溢出时启用 Tooltip,短标题不产生多余浮层。 */
function checkTitleOverflow(assetId: string, event: MouseEvent) {
const element = event.currentTarget as HTMLElement
if (element.scrollWidth > element.clientWidth) overflowTitles.add(assetId)
else overflowTitles.delete(assetId)
}
/** 预览按钮独立打开 Naive 图片查看器,不触发卡片详情。 */
async function openPreview(asset: GlobalProjectAsset) {
previewAsset.value = asset
await nextTick()
previewImage.value?.showPreview()
}
async function updateAsset() { async function updateAsset() {
const asset = selectedAsset.value const asset = selectedAsset.value
if (!asset || busy.value) return if (!asset || busy.value) return
@@ -280,25 +298,56 @@ async function removeAsset() {
</div> </div>
<div v-if="filteredAssets.length" class="asset-library-grid"> <div v-if="filteredAssets.length" class="asset-library-grid">
<article v-for="asset in filteredAssets" :key="asset.id" class="asset-library-card"> <article
<AssetImage :src="asset.publicUrl" :alt="asset.name" preview class="asset-library-image" /> v-for="asset in filteredAssets"
<div class="asset-library-card-content"> :key="asset.id"
<NTooltip placement="top-start" trigger="hover"> class="asset-library-card"
role="button"
tabindex="0"
:aria-label="`查看 ${asset.name} 详情`"
@click="openEdit(asset)"
@keydown.enter.prevent="openEdit(asset)"
@keydown.space.prevent="openEdit(asset)"
>
<AssetImage :src="asset.publicUrl" :alt="asset.name" class="asset-library-image" />
<div class="asset-library-overlay">
<div class="asset-library-actions">
<NButton
quaternary
circle
class="asset-overlay-button"
aria-label="预览图片"
title="预览图片"
@click.stop="openPreview(asset)"
>
<template #icon><Eye :size="16" /></template>
</NButton>
<NButton
quaternary
circle
class="asset-overlay-button"
aria-label="查看素材详情"
title="查看素材详情"
@click.stop="openEdit(asset)"
>
<template #icon><Info :size="16" /></template>
</NButton>
</div>
<NTooltip placement="top-start" trigger="hover" :disabled="!overflowTitles.has(asset.id)">
<template #trigger> <template #trigger>
<h2 class="asset-library-title">{{ asset.name }}</h2> <h2 class="asset-library-title" @mouseenter="checkTitleOverflow(asset.id, $event)">
{{ asset.name }}
</h2>
</template> </template>
{{ asset.name }} {{ asset.name }}
</NTooltip> </NTooltip>
<div class="mt-2 flex min-w-0 items-center justify-between gap-2"> <div class="asset-library-meta">
<p class="truncate text-xs text-muted" :title="projectName(asset.project)"> <p class="truncate text-xs" :title="projectName(asset.project)">
{{ projectName(asset.project) }} {{ projectName(asset.project) }}
</p> </p>
<NTag size="small" :bordered="false">{{ categoryLabel(asset.category) }}</NTag> <NTag size="small" :bordered="false" class="asset-library-tag">
</div> {{ categoryLabel(asset.category) }}
<div class="mt-3 flex justify-end"> </NTag>
<NButton text size="small" type="primary" :disabled="busy" @click="openEdit(asset)">
<template #icon><Pencil :size="14" /></template>管理
</NButton>
</div> </div>
</div> </div>
</article> </article>
@@ -316,6 +365,16 @@ async function removeAsset() {
</EmptyState> </EmptyState>
<p v-else class="py-10 text-center text-sm text-muted" role="status">正在读取全部资产图片</p> <p v-else class="py-10 text-center text-sm text-muted" role="status">正在读取全部资产图片</p>
<NImage
v-if="previewAsset"
ref="previewImage"
class="sr-only"
:src="referenceImageUrl(previewAsset.publicUrl) ?? undefined"
:preview-src="referenceImageUrl(previewAsset.publicUrl) ?? undefined"
:alt="previewAsset.name"
:previewed-img-props="{ alt: previewAsset.name, referrerpolicy: 'no-referrer' }"
/>
<AppDialog <AppDialog
v-model:open="uploadOpen" v-model:open="uploadOpen"
title="上传资产图片" title="上传资产图片"
@@ -344,15 +403,27 @@ async function removeAsset() {
title="素材详情与管理" title="素材详情与管理"
description="查看文件详情,也可在当前页面修改名称和分类或删除素材。" description="查看文件详情,也可在当前页面修改名称和分类或删除素材。"
:busy="busy" :busy="busy"
wide
> >
<NAlert v-if="actionError" type="error" :show-icon="false" class="mt-5">{{ actionError }}</NAlert> <NAlert v-if="actionError" type="error" :show-icon="false" class="mt-5">{{ actionError }}</NAlert>
<AppForm :model="editForm" :rules="formRules" :disabled="busy" class="mt-5" @submit="updateAsset"> <AppForm :model="editForm" :rules="formRules" :disabled="busy" class="mt-5" @submit="updateAsset">
<div v-if="selectedAsset" class="asset-management-layout">
<AssetImage
:src="selectedAsset.publicUrl"
:alt="selectedAsset.name"
preview
object-fit="contain"
class="asset-management-preview"
/>
<div class="asset-management-fields">
<NFormItem label="所属剧本"> <NFormItem label="所属剧本">
<NInput :value="selectedAsset ? projectName(selectedAsset.project) : ''" disabled /> <NInput :value="projectName(selectedAsset.project)" disabled />
</NFormItem> </NFormItem>
<NFormItem path="name" label="素材名称"><NInput v-model:value="editForm.name" /></NFormItem> <NFormItem path="name" label="素材名称"><NInput v-model:value="editForm.name" /></NFormItem>
<NFormItem path="category" label="分类标识"><NInput v-model:value="editForm.category" /></NFormItem> <NFormItem path="category" label="分类标识">
<dl v-if="selectedAsset" class="asset-detail-grid"> <NInput v-model:value="editForm.category" />
</NFormItem>
<dl class="asset-detail-grid">
<dt>原始文件名</dt> <dt>原始文件名</dt>
<dd>{{ metadataOriginalName(selectedAsset.metadata) }}</dd> <dd>{{ metadataOriginalName(selectedAsset.metadata) }}</dd>
<dt>文件类型</dt> <dt>文件类型</dt>
@@ -364,7 +435,9 @@ async function removeAsset() {
<dt>更新时间</dt> <dt>更新时间</dt>
<dd>{{ formatDate(selectedAsset.updatedAt) }}</dd> <dd>{{ formatDate(selectedAsset.updatedAt) }}</dd>
</dl> </dl>
<div class="mt-4 flex items-center justify-between gap-3"> </div>
</div>
<div class="asset-management-footer">
<NPopconfirm <NPopconfirm
:positive-button-props="{ disabled: busy }" :positive-button-props="{ disabled: busy }"
positive-text="确认删除" positive-text="确认删除"
@@ -404,26 +477,57 @@ async function removeAsset() {
column-gap: 16px; column-gap: 16px;
} }
.asset-library-card { .asset-library-card {
@apply inline-block w-full min-w-0 overflow-hidden bg-(--app-surface) align-top; @apply relative inline-block w-full min-w-0 overflow-hidden bg-(--app-surface) align-top cursor-pointer outline-none;
break-inside: avoid; break-inside: avoid;
margin-bottom: 16px; margin-bottom: 16px;
} }
.asset-library-card:focus-visible {
box-shadow: inset 0 0 0 2px var(--color-accent);
}
.asset-library-image { .asset-library-image {
@apply block aspect-auto; @apply block aspect-auto;
} }
.asset-library-image :deep(.reveal-image), .asset-library-image :deep(.reveal-image),
.asset-library-image :deep(.n-image), .asset-library-image :deep(.n-image),
.asset-library-image :deep(.n-image img) { .asset-library-image :deep(.n-image img) {
@apply h-auto; @apply h-auto min-h-[160px];
} }
.asset-library-card-content { .asset-library-overlay {
@apply p-4; @apply absolute inset-0 flex flex-col justify-end p-[10px] text-white;
background: linear-gradient(to top, rgb(0 0 0 / 0.84) 0, rgb(0 0 0 / 0.35) 42%, transparent 70%);
}
.asset-library-actions {
@apply flex self-end items-center gap-1 mb-[10px];
}
.asset-overlay-button {
@apply text-white bg-black/35 backdrop-blur-sm;
}
.asset-overlay-button:hover {
@apply text-white bg-black/60;
} }
.asset-library-title { .asset-library-title {
@apply block w-full truncate text-sm font-semibold; @apply block w-full truncate text-sm font-semibold;
} }
.asset-library-meta {
@apply flex min-w-0 items-center justify-between gap-2 mt-1.5;
}
.asset-library-meta p {
color: rgb(255 255 255 / 0.76);
}
.asset-library-tag {
@apply shrink-0 text-white bg-white/15 backdrop-blur-sm;
}
.asset-management-layout {
@apply grid grid-cols-[minmax(280px,_0.9fr)_minmax(0,_1.1fr)] items-start gap-5;
}
.asset-management-preview {
@apply h-[min(56dvh,_560px)] min-h-[280px] aspect-auto bg-(--app-subtle);
}
.asset-management-fields {
@apply min-w-0;
}
.asset-detail-grid { .asset-detail-grid {
@apply grid grid-cols-[96px_minmax(0,_1fr)] gap-x-4 gap-y-2 mt-2 p-3 bg-(--app-subtle) text-xs; @apply grid grid-cols-[88px_minmax(0,_1fr)] gap-x-4 gap-y-2 mt-1 p-3 bg-(--app-subtle) text-xs;
} }
.asset-detail-grid dt { .asset-detail-grid dt {
@apply text-muted; @apply text-muted;
@@ -431,10 +535,19 @@ async function removeAsset() {
.asset-detail-grid dd { .asset-detail-grid dd {
@apply min-w-0 break-all text-ink; @apply min-w-0 break-all text-ink;
} }
.asset-management-footer {
@apply flex items-center justify-between gap-3 mt-5 pt-4 border-t border-(--app-border);
}
@container asset-library (max-width: 840px) { @container asset-library (max-width: 840px) {
.asset-library-filters { .asset-library-filters {
@apply grid-cols-2; @apply grid-cols-2;
} }
.asset-management-layout {
@apply grid-cols-1;
}
.asset-management-preview {
@apply h-[min(42dvh,_420px)] min-h-[220px];
}
} }
@container asset-library (max-width: 560px) { @container asset-library (max-width: 560px) {
.asset-library-heading { .asset-library-heading {
+15 -5
View File
@@ -75,14 +75,24 @@ describe('全局资产库', () => {
expect(wrapper.text()).toContain('林默正面照') expect(wrapper.text()).toContain('林默正面照')
expect(wrapper.text()).toContain('记忆当铺') expect(wrapper.text()).toContain('记忆当铺')
expect(wrapper.findAllComponents(NTooltip)).toHaveLength(2) expect(wrapper.findAllComponents(NTooltip)).toHaveLength(2)
expect(wrapper.get('.asset-library-title').classes()).toContain('asset-library-title') const title = wrapper.get('.asset-library-title')
expect(title.classes()).toContain('asset-library-title')
expect(wrapper.findComponent(NTooltip).props('disabled')).toBe(true)
Object.defineProperties(title.element, {
scrollWidth: { configurable: true, value: 240 },
clientWidth: { configurable: true, value: 120 }
})
await title.trigger('mouseenter')
expect(wrapper.findComponent(NTooltip).props('disabled')).toBe(false)
expect(wrapper.get('.asset-library-card').text()).not.toContain('rain-street-original.png') expect(wrapper.get('.asset-library-card').text()).not.toContain('rain-street-original.png')
expect(wrapper.get('.asset-library-card').text()).not.toContain('4.0 KB') expect(wrapper.get('.asset-library-card').text()).not.toContain('4.0 KB')
await wrapper await wrapper.get('[aria-label="预览图片"]').trigger('click')
.findAll('button') await flushPromises()
.find(button => button.text() === '管理')! expect(document.querySelector('[role="dialog"][aria-label="素材详情与管理"]')).toBeNull()
.trigger('click') expect(document.querySelector('.n-image-preview-container')).not.toBeNull()
await wrapper.get('[aria-label="查看素材详情"]').trigger('click')
await flushPromises() await flushPromises()
const dialog = document.querySelector('[role="dialog"][aria-label="素材详情与管理"]')! const dialog = document.querySelector('[role="dialog"][aria-label="素材详情与管理"]')!
expect(dialog).not.toBeNull() expect(dialog).not.toBeNull()