299 lines
8.3 KiB
Vue
299 lines
8.3 KiB
Vue
<script setup>
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { toastOk, toastErr } from './toast'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { adminApi } from '../api'
|
|
import { relativeDate } from '../utils'
|
|
|
|
// 文件管理:上传(点击 / 拖拽,多选)、网格缩略图、复制链接、删除。
|
|
// 缩略图与链接都用后端解析好的 f.url(R2 直链或 /uploads 流式路由)。
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const items = ref([])
|
|
const total = ref(0)
|
|
const loading = ref(true)
|
|
const error = ref('')
|
|
const page = computed(() => Number(route.query.page || 1))
|
|
const size = 24
|
|
|
|
const uploading = ref(false)
|
|
const uploadError = ref('')
|
|
const dragOver = ref(false)
|
|
const copiedId = ref(0)
|
|
const fileInput = ref(null)
|
|
|
|
let seq = 0
|
|
async function load() {
|
|
const my = ++seq
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
const data = await adminApi.files({ page: page.value, q: String(route.query.q || '') })
|
|
if (my !== seq) return
|
|
items.value = data.items || []
|
|
total.value = data.total || 0
|
|
} catch (e) {
|
|
if (my !== seq) return
|
|
error.value = e.message || '加载失败'
|
|
items.value = []
|
|
} finally {
|
|
if (my === seq) loading.value = false
|
|
}
|
|
}
|
|
onMounted(load)
|
|
watch(() => route.query.page, load)
|
|
|
|
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
|
|
|
|
// ---------- 上传 ----------
|
|
async function upload(files) {
|
|
if (!files || !files.length || uploading.value) return
|
|
uploading.value = true
|
|
uploadError.value = ''
|
|
try {
|
|
const fd = new FormData()
|
|
for (const f of files) fd.append('file', f)
|
|
await adminApi.uploadFiles(fd)
|
|
toastOk(`已上传 ${files.length} 个文件`)
|
|
await load()
|
|
} catch (e) {
|
|
toastErr(e.message || '上传失败')
|
|
uploadError.value = e.message || '上传失败'
|
|
} finally {
|
|
uploading.value = false
|
|
}
|
|
}
|
|
function onPick(e) {
|
|
upload(e.target.files)
|
|
e.target.value = '' // 允许重复选同一个文件
|
|
}
|
|
function onDrop(e) {
|
|
dragOver.value = false
|
|
upload(e.dataTransfer.files)
|
|
}
|
|
|
|
// ---------- 操作 ----------
|
|
async function copyUrl(f) {
|
|
try {
|
|
await navigator.clipboard.writeText(f.url)
|
|
toastOk('链接已复制')
|
|
copiedId.value = f.id
|
|
setTimeout(() => {
|
|
if (copiedId.value === f.id) copiedId.value = 0
|
|
}, 2000)
|
|
} catch (_) {
|
|
// 非 https 环境没有 clipboard API:退回手动复制
|
|
window.prompt('复制此链接:', f.url)
|
|
}
|
|
}
|
|
|
|
async function remove(f) {
|
|
if (!window.confirm(`删除「${f.name}」?存储里的文件会一并删除,引用它的文章将失效。`)) return
|
|
try {
|
|
await adminApi.deleteFile(f.id)
|
|
toastOk(`已删除「${f.name}」`)
|
|
// 当前页删空时退一页,其余直接重载
|
|
if (items.value.length === 1 && page.value > 1) router.replace({ query: { ...route.query, page: page.value - 1 } })
|
|
else load()
|
|
} catch (e) {
|
|
toastErr(e.message || '删除失败')
|
|
error.value = e.message || '删除失败'
|
|
}
|
|
}
|
|
|
|
// ---------- 展示助手 ----------
|
|
function humanSize(n) {
|
|
if (n < 1024) return n + ' B'
|
|
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'
|
|
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'
|
|
return (n / 1073741824).toFixed(2) + ' GB'
|
|
}
|
|
function ext(name) {
|
|
const i = name.lastIndexOf('.')
|
|
return i < 0 ? '文件' : name.slice(i + 1).toUpperCase()
|
|
}
|
|
|
|
// 拖拽悬停态在离开窗口时复位
|
|
function onWindowDragEnd() {
|
|
dragOver.value = false
|
|
}
|
|
onMounted(() => window.addEventListener('dragend', onWindowDragEnd))
|
|
onBeforeUnmount(() => window.removeEventListener('dragend', onWindowDragEnd))
|
|
</script>
|
|
|
|
<template>
|
|
<section>
|
|
<header style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px;">
|
|
<h1 style="font-family: var(--serif); font-size: 22px;">
|
|
文件 <span style="font-family: var(--sans); font-size: 13px; color: var(--admin-muted); font-weight: 400;">{{ total }}</span>
|
|
</h1>
|
|
</header>
|
|
|
|
<div
|
|
class="dropzone"
|
|
:class="{ over: dragOver, uploading }"
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="上传文件"
|
|
@click="fileInput && fileInput.click()"
|
|
@keydown.enter="fileInput && fileInput.click()"
|
|
@dragover.prevent="dragOver = true"
|
|
@dragleave.prevent="dragOver = false"
|
|
@drop.prevent="onDrop"
|
|
>
|
|
<input ref="fileInput" type="file" multiple hidden accept=".jpg,.jpeg,.png,.webp,.gif,.avif,.pdf,.zip,.txt" @change="onPick" />
|
|
<span v-if="uploading">上传中…</span>
|
|
<span v-else>点击选择或把文件拖到这里上传 · 图片 / pdf / zip / txt · 单文件 ≤ 50MB</span>
|
|
</div>
|
|
<p v-if="uploadError" class="upload-error" role="alert">{{ uploadError }}</p>
|
|
|
|
<div v-if="loading" class="loading">载入中…</div>
|
|
<div v-else-if="error" class="empty">{{ error }}</div>
|
|
<div v-else-if="!items.length" class="empty">还没有上传过文件。</div>
|
|
|
|
<template v-else>
|
|
<div class="file-grid">
|
|
<div v-for="f in items" :key="f.id" class="file-card">
|
|
<div class="thumb">
|
|
<img
|
|
v-if="f.mime.startsWith('image/')"
|
|
:src="f.url"
|
|
alt=""
|
|
loading="lazy"
|
|
decoding="async"
|
|
referrerpolicy="no-referrer"
|
|
/>
|
|
<span v-else class="ph" aria-hidden="true">{{ ext(f.name) }}</span>
|
|
</div>
|
|
<div class="name" :title="f.name">{{ f.name }}</div>
|
|
<div class="meta">
|
|
<span>{{ humanSize(f.size) }}</span>
|
|
<span style="margin: 0 5px;" aria-hidden="true">·</span>
|
|
<span :title="f.created_at">{{ relativeDate(f.created_at) }}</span>
|
|
</div>
|
|
<div class="actions">
|
|
<a href="#" @click.prevent="copyUrl(f)">{{ copiedId === f.id ? '已复制 ✓' : '复制链接' }}</a>
|
|
<a :href="f.url" target="_blank" rel="noopener">打开</a>
|
|
<a href="#" class="danger" @click.prevent="remove(f)">删除</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<nav
|
|
v-if="pageCount > 1"
|
|
aria-label="分页"
|
|
style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px;"
|
|
>
|
|
<button class="btn" :disabled="page <= 1" @click="router.replace({ query: { ...route.query, page: page - 1 } })" aria-label="上一页">
|
|
← 上一页
|
|
</button>
|
|
<span style="font-size: 13px; color: var(--admin-muted);" aria-live="polite">第 {{ page }} / {{ pageCount }} 页</span>
|
|
<button class="btn" :disabled="page >= pageCount" @click="router.replace({ query: { ...route.query, page: page + 1 } })" aria-label="下一页">
|
|
下一页 →
|
|
</button>
|
|
</nav>
|
|
</template>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.dropzone {
|
|
border: 1.5px dashed var(--admin-line);
|
|
border-radius: 6px;
|
|
padding: 22px;
|
|
text-align: center;
|
|
color: var(--admin-muted);
|
|
font-size: 13px;
|
|
margin-bottom: 16px;
|
|
cursor: pointer;
|
|
transition: border-color 0.2s, background-color 0.2s;
|
|
}
|
|
|
|
.dropzone:hover,
|
|
.dropzone.over {
|
|
border-color: var(--admin-accent);
|
|
background: color-mix(in srgb, var(--admin-accent) 6%, transparent);
|
|
}
|
|
|
|
.dropzone.uploading {
|
|
cursor: wait;
|
|
}
|
|
|
|
.upload-error {
|
|
color: var(--admin-danger);
|
|
font-size: 13px;
|
|
margin: -8px 0 12px;
|
|
}
|
|
|
|
.file-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.file-card {
|
|
border: 1px solid var(--admin-line);
|
|
border-radius: 6px;
|
|
background: var(--admin-card);
|
|
padding: 10px;
|
|
}
|
|
|
|
.thumb {
|
|
height: 110px;
|
|
border-radius: 4px;
|
|
background: var(--admin-paper-sunken);
|
|
display: grid;
|
|
place-items: center;
|
|
overflow: hidden;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.thumb img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.thumb .ph {
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.05em;
|
|
color: var(--admin-faint);
|
|
}
|
|
|
|
.name {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--admin-ink);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.meta {
|
|
font-size: 11.5px;
|
|
color: var(--admin-muted);
|
|
margin: 3px 0 8px;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
font-size: 12.5px;
|
|
}
|
|
|
|
.actions a {
|
|
color: var(--admin-muted);
|
|
}
|
|
|
|
.actions a:hover {
|
|
color: var(--admin-accent);
|
|
}
|
|
|
|
.actions a.danger:hover {
|
|
color: var(--admin-danger);
|
|
}
|
|
</style>
|