Files
BYW-VoiceHub/app/components/Admin/ChangelogManager.vue
T
Henry_Du 4ce4649aaf
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s
初次提交
2026-09-06 08:19:02 +08:00

278 lines
10 KiB
Vue

<template>
<div class="max-w-[1200px] mx-auto space-y-6 pb-24 px-2">
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">更新日志</h2>
<p class="text-xs text-zinc-500 mt-1 font-medium">管理前台“更新日志”页面的发布内容</p>
</div>
<div class="flex gap-3">
<button
class="flex items-center gap-2 px-5 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-xs font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading"
@click="load"
>
<RefreshCw :size="14" /> 刷新
</button>
<button
class="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95"
@click="openCreate"
>
<Plus :size="14" /> 新建
</button>
</div>
</div>
<div v-if="loading" class="flex flex-col items-center justify-center py-20">
<div class="w-8 h-8 border-4 border-zinc-800 border-t-blue-500 rounded-full animate-spin mb-4" />
<p class="text-zinc-500 text-sm">加载中...</p>
</div>
<div v-else class="space-y-3">
<div
v-for="item in entries"
:key="item.id"
class="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-5 flex flex-col md:flex-row md:items-center md:justify-between gap-4"
>
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span
class="px-2.5 py-1 rounded-lg text-[10px] font-black uppercase tracking-widest"
:class="item.published ? 'bg-emerald-500/10 text-emerald-300' : 'bg-zinc-800/50 text-zinc-400'"
>
{{ item.published ? '已发布' : '草稿' }}
</span>
<span class="px-2.5 py-1 rounded-lg bg-blue-500/10 text-blue-300 text-[10px] font-black">
v{{ item.version }}
</span>
<p class="text-sm font-black text-zinc-100 truncate">{{ item.title }}</p>
</div>
<p class="text-[10px] text-zinc-500 mt-2">
{{ formatDate(item.publishedAt || item.createdAt) }}
</p>
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-xs font-bold"
@click="openEdit(item)"
>
<Pencil :size="14" class="inline-block mr-1" /> 编辑
</button>
<button
class="px-3 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-xs font-bold"
:disabled="savingId === item.id"
@click="togglePublish(item)"
>
<UploadCloud :size="14" class="inline-block mr-1" />
{{ item.published ? '取消发布' : '发布' }}
</button>
<button
class="px-3 py-2 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 text-xs font-bold disabled:opacity-50"
:disabled="savingId === item.id"
@click="remove(item)"
>
<Trash2 :size="14" class="inline-block mr-1" /> 删除
</button>
</div>
</div>
<div v-if="entries.length === 0" class="py-20 text-center text-zinc-500">
暂无更新日志
</div>
</div>
<Teleport to="body">
<div v-if="showModal" class="fixed inset-0 z-[80] bg-black/60 backdrop-blur-sm p-4 flex items-end sm:items-center justify-center">
<div class="w-full max-w-2xl bg-zinc-950 border border-zinc-800 rounded-2xl overflow-hidden">
<div class="px-5 py-4 border-b border-zinc-800 flex items-center justify-between">
<p class="text-sm font-black text-zinc-100">{{ editingId ? '编辑更新日志' : '新建更新日志' }}</p>
<button
class="h-9 w-9 rounded-lg border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900 transition-colors"
@click="closeModal"
>
<X :size="16" class="mx-auto" />
</button>
</div>
<div class="p-5 space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2">版本号</label>
<input v-model="form.version" type="text" class="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-2.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all" placeholder="如:2.4.1" >
</div>
<div>
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2">标题</label>
<input v-model="form.title" type="text" class="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-2.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all" placeholder="如:投稿体验优化" >
</div>
</div>
<div>
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2">内容</label>
<textarea
v-model="form.content"
:rows="10"
class="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all font-mono"
placeholder="支持纯文本(建议用分点)。"
/>
</div>
<div class="flex items-center justify-between gap-4 p-3 rounded-xl border border-zinc-800 bg-zinc-900/40">
<div>
<p class="text-xs font-bold text-zinc-200">立即发布</p>
<p class="text-[10px] text-zinc-500 mt-0.5">发布后前台更新日志页面可见</p>
</div>
<input v-model="form.published" type="checkbox" class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer" >
</div>
</div>
<div class="px-5 py-4 border-t border-zinc-800 flex items-center justify-end gap-3">
<button
class="px-4 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-sm font-bold"
@click="closeModal"
>
取消
</button>
<button
class="px-5 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-black disabled:opacity-50"
:disabled="savingModal"
@click="save"
>
{{ savingModal ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { Plus, RefreshCw, Pencil, UploadCloud, Trash2, X } from '@lucide/vue'
import { useToast } from '~/composables/useToast'
const { showToast } = useToast()
const loading = ref(true)
const entries = ref([])
const savingId = ref(null)
const showModal = ref(false)
const savingModal = ref(false)
const editingId = ref(null)
const form = ref({ version: '', title: '', content: '', published: false })
const formatDate = (value) => {
if (!value) return ''
const d = new Date(value)
if (Number.isNaN(d.getTime())) return ''
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const load = async () => {
try {
loading.value = true
const data = await $fetch('/api/admin/changelog', { credentials: 'include', query: { t: Date.now() } })
entries.value = Array.isArray(data) ? data : []
} catch (e) {
showToast('加载更新日志失败', 'error')
} finally {
loading.value = false
}
}
const openCreate = () => {
editingId.value = null
form.value = { version: '', title: '', content: '', published: false }
showModal.value = true
}
const openEdit = (item) => {
editingId.value = item.id
form.value = {
version: item.version || '',
title: item.title || '',
content: item.content || '',
published: !!item.published
}
showModal.value = true
}
const closeModal = () => {
showModal.value = false
}
const save = async () => {
const payload = {
version: (form.value.version || '').trim(),
title: (form.value.title || '').trim(),
content: (form.value.content || '').trim(),
published: !!form.value.published
}
if (!payload.version || !payload.title || !payload.content) {
showToast('请填写版本号、标题与内容', 'error')
return
}
try {
savingModal.value = true
if (editingId.value) {
await $fetch(`/api/admin/changelog/${editingId.value}`, {
method: 'PUT',
credentials: 'include',
body: payload
})
showToast('已保存', 'success')
} else {
await $fetch('/api/admin/changelog', {
method: 'POST',
credentials: 'include',
body: payload
})
showToast('已创建', 'success')
}
showModal.value = false
await load()
} catch (e) {
showToast('保存失败', 'error')
} finally {
savingModal.value = false
}
}
const togglePublish = async (item) => {
try {
savingId.value = item.id
await $fetch(`/api/admin/changelog/${item.id}`, {
method: 'PUT',
credentials: 'include',
body: { published: !item.published }
})
await load()
} catch {
showToast('更新发布状态失败', 'error')
} finally {
savingId.value = null
}
}
const remove = async (item) => {
try {
savingId.value = item.id
await $fetch(`/api/admin/changelog/${item.id}`, {
method: 'DELETE',
credentials: 'include'
})
await load()
showToast('已删除', 'success')
} catch {
showToast('删除失败', 'error')
} finally {
savingId.value = null
}
}
onMounted(load)
</script>