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