Browse Source

feat(ui): add media library page at /me/media

Made-with: Cursor
tags/邮箱功能前置
npmrun 3 weeks ago
parent
commit
a2b99afcfb
  1. 222
      app/pages/me/media/index.vue

222
app/pages/me/media/index.vue

@ -0,0 +1,222 @@
<script setup lang="ts">
definePageMeta({ title: '媒体库' })
type MediaAssetRow = {
id: number
storageKey: string
publicPath: string
mime: string
sizeBytes: number
createdAt: string
refCount: number
}
const toast = useToast()
const { fetchData, getApiErrorMessage } = useClientApi()
const page = ref(1)
const pageSize = ref(20)
const items = ref<MediaAssetRow[]>([])
const total = ref(0)
const loading = ref(true)
const uploading = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const pageSizeItems = [
{ label: '每页 10', value: 10 },
{ label: '每页 20', value: 20 },
{ label: '每页 50', value: 50 },
]
function formatBytes(n: number): string {
if (n < 1024) {
return `${n} B`
}
if (n < 1024 * 1024) {
return `${(n / 1024).toFixed(1)} KB`
}
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
function formatDt(iso: string | null): string {
if (!iso) {
return '—'
}
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN')
}
async function load() {
loading.value = true
try {
const q = new URLSearchParams({
page: String(page.value),
pageSize: String(pageSize.value),
})
const body = await fetchData<{ items: MediaAssetRow[]; total: number }>(
`/api/me/media/assets?${q.toString()}`,
)
items.value = body.items
total.value = body.total
} catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' })
} finally {
loading.value = false
}
}
watch(pageSize, () => {
page.value = 1
})
watch(
[page, pageSize],
() => {
void load()
},
{ immediate: true },
)
function openFilePicker() {
fileInput.value?.click()
}
async function onFileChange(ev: Event) {
const input = ev.target as HTMLInputElement
const files = input.files ? Array.from(input.files) : []
input.value = ''
if (!files.length) {
return
}
uploading.value = true
try {
const form = new FormData()
for (const f of files) {
form.append('file', f)
}
await fetchData<{ files: { url: string }[] }>('/api/file/upload', {
method: 'POST',
body: form,
notify: false,
})
toast.add({
title: files.length === 1 ? '上传成功' : `已上传 ${files.length} 个文件`,
color: 'success',
})
await load()
} catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' })
} finally {
uploading.value = false
}
}
async function copyUrl(item: MediaAssetRow) {
if (!import.meta.client) {
return
}
const absoluteUrl = `${window.location.origin}${item.publicPath}`
try {
await navigator.clipboard.writeText(absoluteUrl)
toast.add({ title: '已复制 URL', color: 'success' })
} catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' })
}
}
async function copyMarkdown(item: MediaAssetRow) {
if (!import.meta.client) {
return
}
const absoluteUrl = `${window.location.origin}${item.publicPath}`
const md = `![](${absoluteUrl})`
try {
await navigator.clipboard.writeText(md)
toast.add({ title: '已复制 Markdown', color: 'success' })
} catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' })
}
}
</script>
<template>
<div class="space-y-6 w-full max-w-[min(100%,88rem)] px-0 sm:px-0">
<div>
<h2 class="text-lg font-medium">
资源库
</h2>
<p class="text-sm text-muted mt-1">
浏览已上传图片复制外链或 Markdown用于文章与资料
</p>
</div>
<div class="flex flex-wrap items-end gap-x-5 gap-y-3">
<input
ref="fileInput"
type="file"
class="sr-only"
accept="image/png,image/jpeg,image/jpg,image/webp"
multiple
@change="onFileChange"
>
<UButton
color="primary"
:loading="uploading"
:disabled="uploading"
@click="openFilePicker"
>
选择图片上传
</UButton>
<UFormField label="每页" class="w-40 shrink-0">
<USelect v-model="pageSize" :items="pageSizeItems" value-key="value" class="w-full" />
</UFormField>
</div>
<div v-if="loading" class="text-muted">
加载中
</div>
<UEmpty
v-else-if="!items.length"
title="暂无图片"
description="上传 PNG / JPEG / WebP 后,将在此列出并可直接复制链接。"
/>
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<UCard v-for="item in items" :key="item.id">
<div class="space-y-3">
<img
:src="item.publicPath"
:alt="item.storageKey"
class="w-full aspect-video object-cover rounded-md border border-default"
loading="lazy"
>
<div
class="truncate font-mono text-xs text-default"
:title="item.storageKey"
>
{{ item.storageKey }}
</div>
<div class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted tabular-nums">
<span>{{ formatBytes(item.sizeBytes) }}</span>
<span>{{ formatDt(item.createdAt) }}</span>
</div>
<p class="text-xs text-default">
引用数: {{ item.refCount }}
</p>
<div class="flex flex-wrap gap-2">
<UButton size="xs" variant="soft" @click="copyUrl(item)">
复制 URL
</UButton>
<UButton size="xs" variant="soft" @click="copyMarkdown(item)">
复制 Markdown
</UButton>
</div>
</div>
</UCard>
</div>
<div v-if="!loading && total > pageSize" class="flex justify-end">
<UPagination v-model:page="page" :total="total" :items-per-page="pageSize" size="sm" />
</div>
</div>
</template>
Loading…
Cancel
Save