1 changed files with 222 additions and 0 deletions
@ -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 = `` |
|||
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…
Reference in new issue