style: 重构资产卡片覆盖层与详情弹窗
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { NAlert, NButton, NFormItem, NInput, NPopconfirm, NSelect, NTag, NTooltip } from 'naive-ui'
|
||||
import { Pencil, RefreshCw, Search, Trash2, Upload } from '@lucide/vue'
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import { NAlert, NButton, NFormItem, NImage, NInput, NPopconfirm, NSelect, NTag, NTooltip } from 'naive-ui'
|
||||
import { Eye, Info, 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 { useQuery } from '../../composables/useQuery'
|
||||
import { fieldRule } from '../../lib/form-rules'
|
||||
import { referenceImageUrl } from '../../lib/assets'
|
||||
import { errorMessage } from '../../lib/http'
|
||||
import { projectsApi } from '../projects/api'
|
||||
import { projectAssetsApi } from './api'
|
||||
@@ -39,6 +40,9 @@ const uploadOpen = ref(false)
|
||||
const editOpen = ref(false)
|
||||
const selectedFile = ref<File | 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 editForm = reactive({ name: '', category: '' })
|
||||
const assets = computed(() => query.data.value ?? [])
|
||||
@@ -167,6 +171,20 @@ function openEdit(asset: GlobalProjectAsset) {
|
||||
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() {
|
||||
const asset = selectedAsset.value
|
||||
if (!asset || busy.value) return
|
||||
@@ -280,25 +298,56 @@ async function removeAsset() {
|
||||
</div>
|
||||
|
||||
<div v-if="filteredAssets.length" class="asset-library-grid">
|
||||
<article v-for="asset in filteredAssets" :key="asset.id" class="asset-library-card">
|
||||
<AssetImage :src="asset.publicUrl" :alt="asset.name" preview class="asset-library-image" />
|
||||
<div class="asset-library-card-content">
|
||||
<NTooltip placement="top-start" trigger="hover">
|
||||
<article
|
||||
v-for="asset in filteredAssets"
|
||||
:key="asset.id"
|
||||
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>
|
||||
<h2 class="asset-library-title">{{ asset.name }}</h2>
|
||||
<h2 class="asset-library-title" @mouseenter="checkTitleOverflow(asset.id, $event)">
|
||||
{{ asset.name }}
|
||||
</h2>
|
||||
</template>
|
||||
{{ asset.name }}
|
||||
</NTooltip>
|
||||
<div class="mt-2 flex min-w-0 items-center justify-between gap-2">
|
||||
<p class="truncate text-xs text-muted" :title="projectName(asset.project)">
|
||||
<div class="asset-library-meta">
|
||||
<p class="truncate text-xs" :title="projectName(asset.project)">
|
||||
{{ projectName(asset.project) }}
|
||||
</p>
|
||||
<NTag size="small" :bordered="false">{{ categoryLabel(asset.category) }}</NTag>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<NButton text size="small" type="primary" :disabled="busy" @click="openEdit(asset)">
|
||||
<template #icon><Pencil :size="14" /></template>管理
|
||||
</NButton>
|
||||
<NTag size="small" :bordered="false" class="asset-library-tag">
|
||||
{{ categoryLabel(asset.category) }}
|
||||
</NTag>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -316,6 +365,16 @@ async function removeAsset() {
|
||||
</EmptyState>
|
||||
<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
|
||||
v-model:open="uploadOpen"
|
||||
title="上传资产图片"
|
||||
@@ -344,15 +403,27 @@ async function removeAsset() {
|
||||
title="素材详情与管理"
|
||||
description="查看文件详情,也可在当前页面修改名称和分类或删除素材。"
|
||||
:busy="busy"
|
||||
wide
|
||||
>
|
||||
<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">
|
||||
<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="所属剧本">
|
||||
<NInput :value="selectedAsset ? projectName(selectedAsset.project) : ''" disabled />
|
||||
<NInput :value="projectName(selectedAsset.project)" disabled />
|
||||
</NFormItem>
|
||||
<NFormItem path="name" label="素材名称"><NInput v-model:value="editForm.name" /></NFormItem>
|
||||
<NFormItem path="category" label="分类标识"><NInput v-model:value="editForm.category" /></NFormItem>
|
||||
<dl v-if="selectedAsset" class="asset-detail-grid">
|
||||
<NFormItem path="category" label="分类标识">
|
||||
<NInput v-model:value="editForm.category" />
|
||||
</NFormItem>
|
||||
<dl class="asset-detail-grid">
|
||||
<dt>原始文件名</dt>
|
||||
<dd>{{ metadataOriginalName(selectedAsset.metadata) }}</dd>
|
||||
<dt>文件类型</dt>
|
||||
@@ -364,7 +435,9 @@ async function removeAsset() {
|
||||
<dt>更新时间</dt>
|
||||
<dd>{{ formatDate(selectedAsset.updatedAt) }}</dd>
|
||||
</dl>
|
||||
<div class="mt-4 flex items-center justify-between gap-3">
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-management-footer">
|
||||
<NPopconfirm
|
||||
:positive-button-props="{ disabled: busy }"
|
||||
positive-text="确认删除"
|
||||
@@ -404,26 +477,57 @@ async function removeAsset() {
|
||||
column-gap: 16px;
|
||||
}
|
||||
.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;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.asset-library-card:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent);
|
||||
}
|
||||
.asset-library-image {
|
||||
@apply block aspect-auto;
|
||||
}
|
||||
.asset-library-image :deep(.reveal-image),
|
||||
.asset-library-image :deep(.n-image),
|
||||
.asset-library-image :deep(.n-image img) {
|
||||
@apply h-auto;
|
||||
@apply h-auto min-h-[160px];
|
||||
}
|
||||
.asset-library-card-content {
|
||||
@apply p-4;
|
||||
.asset-library-overlay {
|
||||
@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 {
|
||||
@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 {
|
||||
@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 {
|
||||
@apply text-muted;
|
||||
@@ -431,10 +535,19 @@ async function removeAsset() {
|
||||
.asset-detail-grid dd {
|
||||
@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) {
|
||||
.asset-library-filters {
|
||||
@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) {
|
||||
.asset-library-heading {
|
||||
|
||||
@@ -75,14 +75,24 @@ describe('全局资产库', () => {
|
||||
expect(wrapper.text()).toContain('林默正面照')
|
||||
expect(wrapper.text()).toContain('记忆当铺')
|
||||
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('4.0 KB')
|
||||
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find(button => button.text() === '管理')!
|
||||
.trigger('click')
|
||||
await wrapper.get('[aria-label="预览图片"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[role="dialog"][aria-label="素材详情与管理"]')).toBeNull()
|
||||
expect(document.querySelector('.n-image-preview-container')).not.toBeNull()
|
||||
|
||||
await wrapper.get('[aria-label="查看素材详情"]').trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = document.querySelector('[role="dialog"][aria-label="素材详情与管理"]')!
|
||||
expect(dialog).not.toBeNull()
|
||||
|
||||
Reference in New Issue
Block a user