后台统一状态提示:共享 toast 模块(ToastStack 挂后台布局与登录页),登录成功/失败、文章/标签/作品/文件/评论/设置操作全部有反馈,替换掉原生 alert;编辑器局部 toast 并入共享实现
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 || '删除失败'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 || '删除失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user