Files
ONE/frontend/src/admin/FilesView.vue
T
Sakurasan 1b3eb870da 文件删除前查引用:被文章/项目/站主头像用着就先挡住
- store.FileReferences(key) 反查谁在用这个文件:文章(封面 + content_md +
  content_html)、项目封面、settings.owner_avatar_key。匹配的键是 files.key 本身
  而不是完整 URL——本地 /uploads/{key}、R2 {publicBase}/{key}、缩略图
  /uploads/thumb/{key} 三种形态都以 key 结尾,换存储端后正文里的老链接照样查得到。
  草稿也算:现在没发布,删了将来发出来就是裂的。
- 按需 LIKE 现扫,不维护计数表:写入口有编辑器、外链转存、短文、项目、头像好几处,
  计数一旦漂移就再也信不过;删文件是低频操作,扫全表几十毫秒换一个永远正确的答案。
- 新端点 GET /api/admin/files/{id}/refs;DELETE 默认对在用文件返回 409(消息带引用数
  和前三个位置名),明确带 force=1 才真删。守卫放在动 blob 之前——存储端一删就没法回头。
- 后台删除弹层先把引用清单摊出来(文章《标题》(草稿)、站主头像),仍然要删才带 force。
  引用查询失败就按无引用走:真被引用时后端会 409,不会静默删掉在用文件。

顺手把另一个 agent 工具的本地草稿目录(.zcode/、.zcodeignore)加进 .gitignore。
2026-09-30 02:03:14 +08:00

321 lines
9.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)
}
}
// 引用位置的展示文案:文章/项目带标题,头像没有标题可指。
// 草稿也列出来——现在没发布,删了图将来发出来就是裂的。
function refLabel(x) {
if (x.kind === 'avatar') return '站主头像'
const head = x.kind === 'project' ? '项目' : '文章'
const name = x.title || x.slug || '未命名'
return `${head}《${name}》${x.status === 'draft' ? '(草稿)' : ''}`
}
async function remove(f) {
// 删除前查一次引用:后端默认会挡住被引用的文件(409),这里先把清单摊出来,
// 用户看完仍然要删才带 force=1 过去。
let refs = []
try {
refs = (await adminApi.fileRefs(f.id)).items || []
} catch (_) {
// 查询失败就按无引用走:真被引用时后端会返回 409,不会静默删掉在用文件
}
let tip = `删除「${f.name}」?存储里的文件会一并删除。`
if (refs.length) {
const list = refs.slice(0, 3).map(refLabel).join('、')
tip = `删除「${f.name}」?\n它正被 ${refs.length} 处引用:${list}${refs.length > 3 ? ' 等' : ''}。\n删掉这些地方会图裂。`
}
if (!window.confirm(tip)) return
try {
await adminApi.deleteFile(f.id, refs.length > 0)
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>