style: 重构资产卡片覆盖层与详情弹窗
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user