后台统一状态提示:共享 toast 模块(ToastStack 挂后台布局与登录页),登录成功/失败、文章/标签/作品/文件/评论/设置操作全部有反馈,替换掉原生 alert;编辑器局部 toast 并入共享实现

This commit is contained in:
Sakurasan
2026-09-28 23:52:16 +08:00
parent f7a2e83727
commit c84a383e5c
11 changed files with 96 additions and 45 deletions
+2
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue' import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import ToastStack from './ToastStack.vue'
import { useRoute, useRouter, RouterLink, RouterView } from 'vue-router' import { useRoute, useRouter, RouterLink, RouterView } from 'vue-router'
import { checkAuth } from './auth' import { checkAuth } from './auth'
import { adminApi, session } from '../api' import { adminApi, session } from '../api'
@@ -96,6 +97,7 @@ watch(() => route.path, () => loadCounts())
<template> <template>
<div v-if="ready" class="admin-shell"> <div v-if="ready" class="admin-shell">
<ToastStack />
<aside class="admin-side"> <aside class="admin-side">
<div class="brand"> <div class="brand">
<RouterLink to="/admin" class="name">{{ site.site_title || 'ONE' }}</RouterLink> <RouterLink to="/admin" class="name">{{ site.site_title || 'ONE' }}</RouterLink>
+4
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { toastOk, toastErr } from './toast'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api' import { adminApi } from '../api'
import { relativeDate, stripTags } from '../utils' import { relativeDate, stripTags } from '../utils'
@@ -46,12 +47,14 @@ function setStatus(v) {
async function approve(id) { async function approve(id) {
await adminApi.approveComment(id) await adminApi.approveComment(id)
toastOk('已通过,前台可见')
load() load()
} }
async function remove(id) { async function remove(id) {
if (!window.confirm('删除这条评论?正文清空,楼层保留为「已删除」。')) return if (!window.confirm('删除这条评论?正文清空,楼层保留为「已删除」。')) return
await adminApi.deleteComment(id) await adminApi.deleteComment(id)
toastOk('已删除')
load() load()
} }
@@ -59,6 +62,7 @@ async function banToggle(c) {
const banned = !c.user?.banned const banned = !c.user?.banned
if (!window.confirm(banned ? `禁言「${c.user?.name || '该用户'}」?禁言后其无法再发表评论。` : '解除禁言?')) return if (!window.confirm(banned ? `禁言「${c.user?.name || '该用户'}」?禁言后其无法再发表评论。` : '解除禁言?')) return
await adminApi.setReaderBanned(c.user?.id, banned) await adminApi.setReaderBanned(c.user?.id, banned)
toastOk(banned ? '已禁言' : '已解除禁言')
c.user = { ...(c.user || {}), banned } c.user = { ...(c.user || {}), banned }
} }
+2 -32
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { toast, toastOk, toastErr, toastUpdate, toastDone } from './toast'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api' import { adminApi } from '../api'
import { site } from '../site' import { site } from '../site'
@@ -625,39 +626,8 @@ function commitImage() {
showImageInput.value = false showImageInput.value = false
} }
// ---------- toast ----------
// 编辑器内的轻提示:上传进度、转存结果、校验失败。自动消失,错误多留一会儿。
const toasts = reactive([])
let toastSeq = 0
function toast(text, type = 'info', ttl = 2600) {
const id = ++toastSeq
toasts.push({ id, text, type })
setTimeout(() => {
const i = toasts.findIndex((t) => t.id === id)
if (i >= 0) toasts.splice(i, 1)
}, ttl)
return id
}
function toastUpdate(id, text, type = 'info') {
const t = toasts.find((x) => x.id === id)
if (t) {
t.text = text
t.type = type
}
}
function toastDone(id, text) {
toastUpdate(id, text, 'ok')
const idx = toasts.findIndex((t) => t.id === id)
if (idx >= 0) {
const t = toasts[idx]
setTimeout(() => {
const i = toasts.indexOf(t)
if (i >= 0) toasts.splice(i, 1)
}, 1800)
}
}
// ---------- 上传(封面 / 编辑器插图共用) ---------- // ---------- 上传(封面 / 编辑器插图共用) ----------
// toast 用共享的 ./toast(ToastStack 渲染在 AdminLayout)。
// 后台「高级 → 自定义 JS」无关;走 /api/admin/files(FormData)。 // 后台「高级 → 自定义 JS」无关;走 /api/admin/files(FormData)。
const uploadingImage = ref(false) const uploadingImage = ref(false)
+6
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { toastOk, toastErr } from './toast'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api' import { adminApi } from '../api'
import { relativeDate } from '../utils' import { relativeDate } from '../utils'
@@ -55,8 +56,10 @@ async function upload(files) {
const fd = new FormData() const fd = new FormData()
for (const f of files) fd.append('file', f) for (const f of files) fd.append('file', f)
await adminApi.uploadFiles(fd) await adminApi.uploadFiles(fd)
toastOk(`已上传 ${files.length} 个文件`)
await load() await load()
} catch (e) { } catch (e) {
toastErr(e.message || '上传失败')
uploadError.value = e.message || '上传失败' uploadError.value = e.message || '上传失败'
} finally { } finally {
uploading.value = false uploading.value = false
@@ -75,6 +78,7 @@ function onDrop(e) {
async function copyUrl(f) { async function copyUrl(f) {
try { try {
await navigator.clipboard.writeText(f.url) await navigator.clipboard.writeText(f.url)
toastOk('链接已复制')
copiedId.value = f.id copiedId.value = f.id
setTimeout(() => { setTimeout(() => {
if (copiedId.value === f.id) copiedId.value = 0 if (copiedId.value === f.id) copiedId.value = 0
@@ -89,10 +93,12 @@ async function remove(f) {
if (!window.confirm(`删除「${f.name}」?存储里的文件会一并删除,引用它的文章将失效。`)) return if (!window.confirm(`删除「${f.name}」?存储里的文件会一并删除,引用它的文章将失效。`)) return
try { try {
await adminApi.deleteFile(f.id) 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 } }) if (items.value.length === 1 && page.value > 1) router.replace({ query: { ...route.query, page: page.value - 1 } })
else load() else load()
} catch (e) { } catch (e) {
toastErr(e.message || '删除失败')
error.value = e.message || '删除失败' error.value = e.message || '删除失败'
} }
} }
+5
View File
@@ -1,5 +1,7 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { toastOk, toastErr } from './toast'
import ToastStack from './ToastStack.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { adminApi, session } from '../api' import { adminApi, session } from '../api'
import { site } from '../site' import { site } from '../site'
@@ -17,8 +19,10 @@ async function submit() {
const data = await adminApi.login(username.value, password.value) const data = await adminApi.login(username.value, password.value)
session.user = username.value session.user = username.value
void data void data
toastOk('登录成功')
router.push('/admin') router.push('/admin')
} catch (e) { } catch (e) {
toastErr('用户名或密码不对')
error.value = '用户名或密码不对' error.value = '用户名或密码不对'
} finally { } finally {
busy.value = false busy.value = false
@@ -27,6 +31,7 @@ async function submit() {
</script> </script>
<template> <template>
<ToastStack />
<div class="page"> <div class="page">
<form class="card" @submit.prevent="submit"> <form class="card" @submit.prevent="submit">
<p class="eyebrow">后台</p> <p class="eyebrow">后台</p>
+3 -2
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { toastOk, toastErr } from './toast'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api' import { adminApi } from '../api'
import { formatDateShort, relativeDate } from '../utils' import { formatDateShort, relativeDate } from '../utils'
@@ -125,7 +126,7 @@ async function bulk(action) {
await adminApi.bulkPosts([...selected.value], action) await adminApi.bulkPosts([...selected.value], action)
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '操作失败') toastErr(e.message || '操作失败')
} }
} }
@@ -135,7 +136,7 @@ async function remove(id, title) {
await adminApi.deletePost(id) await adminApi.deletePost(id)
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '删除失败') toastErr(e.message || '删除失败')
} }
} }
+7 -6
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { toastOk, toastErr } from './toast'
import { adminApi } from '../api' import { adminApi } from '../api'
const projects = ref([]) const projects = ref([])
@@ -40,7 +41,7 @@ onMounted(load)
async function create() { async function create() {
if (!form.value.title.trim()) { if (!form.value.title.trim()) {
alert('请填写作品名称') toastErr('请填写作品名称')
return return
} }
try { try {
@@ -56,7 +57,7 @@ async function create() {
form.value = blank() form.value = blank()
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '创建失败') toastErr(e.message || '创建失败')
} }
} }
@@ -81,7 +82,7 @@ function cancelEdit() {
async function saveEdit() { async function saveEdit() {
if (!draft.value.title.trim()) { if (!draft.value.title.trim()) {
alert('请填写作品名称') toastErr('请填写作品名称')
return return
} }
try { try {
@@ -97,7 +98,7 @@ async function saveEdit() {
editing.value = null editing.value = null
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '保存失败') toastErr(e.message || '保存失败')
} }
} }
@@ -107,7 +108,7 @@ async function toggleStatus(p) {
await adminApi.updateProject(p.id, { status: next }) await adminApi.updateProject(p.id, { status: next })
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '更新失败') toastErr(e.message || '更新失败')
} }
} }
@@ -117,7 +118,7 @@ async function remove(p) {
await adminApi.deleteProject(p.id) await adminApi.deleteProject(p.id)
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '删除失败') toastErr(e.message || '删除失败')
} }
} }
</script> </script>
+3
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { toastOk, toastErr } from './toast'
import { adminApi } from '../api' import { adminApi } from '../api'
import { SKIN_CONSTANTS, UI_CONSTANTS } from '../site' import { SKIN_CONSTANTS, UI_CONSTANTS } from '../site'
import { SECTIONS } from '../ui/sections' import { SECTIONS } from '../ui/sections'
@@ -186,7 +187,9 @@ async function save() {
} }
} }
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false }) savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
toastOk('设置已保存')
} catch (e) { } catch (e) {
toastErr('保存失败:' + (e.message || ''))
error.value = e.message || '保存失败' error.value = e.message || '保存失败'
} finally { } finally {
saving.value = false saving.value = false
+6 -5
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { toastOk, toastErr } from './toast'
import { adminApi } from '../api' import { adminApi } from '../api'
const tags = ref([]) const tags = ref([])
@@ -30,7 +31,7 @@ async function create() {
newColor.value = '' newColor.value = ''
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '创建失败') toastErr(e.message || '创建失败')
} }
} }
@@ -39,7 +40,7 @@ async function update(t) {
await adminApi.updateTag(t.id, { name: t.name, color: t.color }) await adminApi.updateTag(t.id, { name: t.name, color: t.color })
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '更新失败') toastErr(e.message || '更新失败')
} }
} }
@@ -49,7 +50,7 @@ async function remove(t) {
await adminApi.deleteTag(t.id) await adminApi.deleteTag(t.id)
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '删除失败') toastErr(e.message || '删除失败')
} }
} }
@@ -69,7 +70,7 @@ function cancelMerge() {
async function doMerge(fromTag) { async function doMerge(fromTag) {
const toId = Number(mergeInto.value) const toId = Number(mergeInto.value)
if (!toId || toId === fromTag.id) { if (!toId || toId === fromTag.id) {
alert('选一个不同的目标标签') toastErr('选一个不同的目标标签')
return return
} }
if (!window.confirm(`把「${fromTag.name}」合并到选中的标签?此操作不可撤销。`)) return if (!window.confirm(`把「${fromTag.name}」合并到选中的标签?此操作不可撤销。`)) return
@@ -78,7 +79,7 @@ async function doMerge(fromTag) {
cancelMerge() cancelMerge()
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '合并失败') toastErr(e.message || '合并失败')
} }
} }
</script> </script>
+11
View File
@@ -0,0 +1,11 @@
<script setup>
// 后台共享 toast 的渲染层:挂在 AdminLayout(所有管理页)与 LoginView。
// 样式在全局 styles.css(.toast-stack / .toast),与编辑器共用一份。
import { toasts } from './toast'
</script>
<template>
<div class="toast-stack" aria-live="polite">
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.type">{{ t.text }}</div>
</div>
</template>
+47
View File
@@ -0,0 +1,47 @@
import { reactive } from 'vue'
// 后台共享的轻提示:所有视图的操作反馈(成功/失败/进度)从这里发,
// ToastStack.vue 负责渲染(挂在 AdminLayout 与 LoginView)。
// EditorView 的上传/转存也走它——原先那套局部实现已并入。
export const toasts = reactive([])
let seq = 0
export function toast(text, type = 'info', ttl = 2600) {
const id = ++seq
toasts.push({ id, text, type })
setTimeout(() => {
const i = toasts.findIndex((t) => t.id === id)
if (i >= 0) toasts.splice(i, 1)
}, ttl)
return id
}
export function toastOk(text) {
return toast(text, 'ok', 2200)
}
export function toastErr(text) {
return toast(text, 'err', 4200)
}
// 更新一条已存在的 toast(上传进度用)
export function toastUpdate(id, text, type = 'info') {
const t = toasts.find((x) => x.id === id)
if (t) {
t.text = text
t.type = type
}
}
// 标记成功并很快消失(进度条收尾)
export function toastDone(id, text) {
toastUpdate(id, text, 'ok')
const idx = toasts.findIndex((t) => t.id === id)
if (idx >= 0) {
const t = toasts[idx]
setTimeout(() => {
const i = toasts.indexOf(t)
if (i >= 0) toasts.splice(i, 1)
}, 1600)
}
}