Files
ONE/frontend/src/admin/FilesView.vue
T

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>