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

290 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { toastOk, toastErr } from './toast'
import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { formatDateShort, relativeDate } from '../utils'
const route = useRoute()
const router = useRouter()
const items = ref([])
const total = ref(0)
const loading = ref(true)
const error = ref('')
const allTags = ref([])
// 筛选状态——初始值从 URL 读,变化时 push 回 URL
const status = ref(String(route.query.status || ''))
const kind = ref(String(route.query.kind || ''))
const tagFilter = ref(String(route.query.tag || ''))
const sort = ref(String(route.query.sort || 'newest'))
const q = ref(String(route.query.q || ''))
const page = ref(Number(route.query.page || 1))
const size = 20
const selected = ref(new Set())
const orderMap = {
newest: 'published_at DESC',
oldest: 'published_at ASC',
updated: 'updated_at DESC',
longest: 'reading_minutes DESC'
}
// 快速切换筛选/翻页时只采纳最新一次请求的结果(序号防竞态覆盖)
let seq = 0
async function load() {
const my = ++seq
loading.value = true
error.value = ''
try {
const params = { page: page.value, size, order: orderMap[sort.value] || orderMap.newest }
if (status.value) params.status = status.value
if (kind.value) params.kind = kind.value
if (tagFilter.value) params.tag = tagFilter.value
if (q.value.trim()) params.q = q.value.trim()
const data = await adminApi.posts(params)
if (my !== seq) return
items.value = data.items || []
total.value = data.total || 0
selected.value = new Set()
} catch (e) {
if (my !== seq) return
error.value = e.message || '加载失败'
} finally {
if (my === seq) loading.value = false
}
}
async function loadTags() {
try {
const data = await adminApi.tags()
allTags.value = data.tags || data || []
} catch (e) {
/* ignore */
}
}
onMounted(() => {
loadTags()
load()
})
// 数据变化 → 同步 URL(用 replace,不污染历史栈)
function syncQuery() {
const q2 = { ...route.query }
const setOrDel = (k, v, def = '') => {
if (v && v !== def) q2[k] = v
else delete q2[k]
}
setOrDel('status', status.value)
setOrDel('kind', kind.value)
setOrDel('tag', tagFilter.value)
setOrDel('sort', sort.value, 'newest')
setOrDel('q', q.value.trim())
if (page.value > 1) q2.page = String(page.value)
else delete q2.page
router.replace({ path: '/admin/posts', query: q2 })
}
watch([status, kind, tagFilter, sort, page], () => {
syncQuery()
load()
})
let searchTimer
function onSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
page.value = 1
syncQuery()
load()
}, 300)
}
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
const allSelected = computed(() => items.value.length && selected.value.size === items.value.length)
function toggle(id) {
const s = new Set(selected.value)
if (s.has(id)) s.delete(id)
else s.add(id)
selected.value = s
}
function toggleAll() {
if (allSelected.value) selected.value = new Set()
else selected.value = new Set(items.value.map((p) => p.id))
}
async function bulk(action) {
if (!selected.value.size) return
if (action === 'delete' && !window.confirm(`删除 ${selected.value.size} 篇文章?此操作不可撤销。`)) return
try {
await adminApi.bulkPosts([...selected.value], action)
await load()
} catch (e) {
toastErr(e.message || '操作失败')
}
}
async function remove(id, title) {
if (!window.confirm(`删除《${title}》?此操作不可撤销。`)) return
try {
await adminApi.deletePost(id)
await load()
} catch (e) {
toastErr(e.message || '删除失败')
}
}
function edit(id) {
router.push(`/admin/${id}`)
}
function clearFilters() {
status.value = ''
kind.value = ''
tagFilter.value = ''
q.value = ''
page.value = 1
syncQuery()
load()
}
</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>
<RouterLink to="/admin/new" class="btn btn-primary">+ 写新的</RouterLink>
</header>
<div class="posts-toolbar">
<input
v-model="q"
class="input grow"
placeholder="搜索标题 / 摘要 / 正文"
aria-label="搜索文章"
type="search"
spellcheck="false"
@input="onSearch"
/>
<select v-model="sort" class="select" style="width: auto; min-width: 130px;" aria-label="排序方式">
<option value="newest">最新发布</option>
<option value="oldest">最早发布</option>
<option value="updated">最近修改</option>
<option value="longest">字数最多</option>
</select>
</div>
<div class="posts-toolbar" style="margin-bottom: 16px;">
<span style="font-size: 12px; color: var(--admin-muted); margin-right: 4px;">状态</span>
<button class="chip" :class="{ on: status === '' }" @click="status = ''">全部</button>
<button class="chip" :class="{ on: status === 'published' }" @click="status = 'published'">已发布</button>
<button class="chip" :class="{ on: status === 'draft' }" @click="status = 'draft'">草稿</button>
<span style="font-size: 12px; color: var(--admin-muted); margin: 0 4px 0 12px;">类型</span>
<button class="chip" :class="{ on: kind === '' }" @click="kind = ''">全部</button>
<button class="chip" :class="{ on: kind === 'long' }" @click="kind = 'long'">长文</button>
<button class="chip" :class="{ on: kind === 'short' }" @click="kind = 'short'">短文</button>
<span v-if="allTags.length" style="font-size: 12px; color: var(--admin-muted); margin: 0 4px 0 12px;">标签</span>
<button class="chip" :class="{ on: tagFilter === '' }" @click="tagFilter = ''">全部</button>
<button
v-for="t in allTags"
:key="t.id"
class="chip"
:class="{ on: tagFilter === t.name }"
@click="tagFilter = t.name"
>
<span class="tag-dot" :style="{ background: t.color || 'var(--admin-accent)' }" aria-hidden="true"></span>
{{ t.name }}
</button>
<button v-if="status || kind || tagFilter || q" class="chip" style="margin-left: auto;" @click="clearFilters">清除筛选 ✕</button>
</div>
<div v-if="selected.size" class="bulk-bar">
<span>已选 <strong>{{ selected.size }}</strong> 篇</span>
<span class="grow"></span>
<button @click="bulk('publish')">批量发布</button>
<button @click="bulk('draft')">转为草稿</button>
<button @click="bulk('delete')">批量删除</button>
<button class="x" @click="selected = new Set(); load()" aria-label="清除选择">×</button>
</div>
<div v-if="loading" class="loading">载入中…</div>
<div v-else-if="error" class="empty">{{ error }}</div>
<div v-else-if="!items.length" class="empty">
没有匹配的文章。<RouterLink to="/admin/new">写一篇 →</RouterLink>
</div>
<template v-else>
<div style="margin-bottom: 8px;">
<label style="font-size: 13px; color: var(--admin-muted); cursor: pointer;">
<input type="checkbox" :checked="allSelected" @change="toggleAll" style="margin-right: 6px;" />
全选当前页
</label>
</div>
<div
v-for="p in items"
:key="p.id"
class="post-card has-check"
:class="{ selected: selected.has(p.id) }"
@click.self="edit(p.id)"
>
<input
type="checkbox"
:checked="selected.has(p.id)"
class="check"
:aria-label="`选择《${p.title || '无题'}》`"
@click.stop="toggle(p.id)"
/>
<div class="cover">
<img
v-if="p.cover_url"
:src="p.cover_url"
alt=""
referrerpolicy="no-referrer"
width="96"
height="64"
loading="lazy"
decoding="async"
/>
<span v-else class="ph" aria-hidden="true">{{ p.kind === 'short' ? '短' : '长' }}</span>
</div>
<div class="body">
<div class="title">{{ p.title || '无题' }}</div>
<div v-if="p.summary" class="summary">{{ p.summary }}</div>
<div class="meta">
<span class="dot" :class="p.status" aria-hidden="true"></span>
<span>{{ p.status === 'published' ? '已发布' : '草稿' }}</span>
<span style="margin: 0 6px;" aria-hidden="true">·</span>
<span>{{ p.kind === 'short' ? '短文' : '长文' }}</span>
<span style="margin: 0 6px;" aria-hidden="true">·</span>
<span :title="formatDateShort(p.published_at)">{{ relativeDate(p.published_at) }}</span>
<template v-if="p.tags && p.tags.length">
<span style="margin: 0 6px;" aria-hidden="true">·</span>
<span v-for="t in p.tags" :key="t" class="chip-static" style="font-size: 11px;">{{ t }}</span>
</template>
</div>
</div>
<div class="stats" aria-hidden="true">
<div>{{ p.reading_minutes || 1 }}′</div>
<div>{{ p.content_len || 0 }} 字</div>
</div>
<div class="actions">
<a href="#" @click.prevent.stop="edit(p.id)">编辑</a>
<a v-if="p.status === 'published'" :href="`/post/${p.slug}`" target="_blank" @click.stop>查看</a>
<a href="#" class="danger" @click.prevent.stop="remove(p.id, p.title)">删除</a>
</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="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="page = page + 1" aria-label="下一页">下一页 →</button>
</nav>
</template>
</section>
</template>