290 lines
10 KiB
Vue
290 lines
10 KiB
Vue
<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> |