后台统一状态提示:共享 toast 模块(ToastStack 挂后台布局与登录页),登录成功/失败、文章/标签/作品/文件/评论/设置操作全部有反馈,替换掉原生 alert;编辑器局部 toast 并入共享实现
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import ToastStack from './ToastStack.vue'
|
||||
import { useRoute, useRouter, RouterLink, RouterView } from 'vue-router'
|
||||
import { checkAuth } from './auth'
|
||||
import { adminApi, session } from '../api'
|
||||
@@ -96,6 +97,7 @@ watch(() => route.path, () => loadCounts())
|
||||
|
||||
<template>
|
||||
<div v-if="ready" class="admin-shell">
|
||||
<ToastStack />
|
||||
<aside class="admin-side">
|
||||
<div class="brand">
|
||||
<RouterLink to="/admin" class="name">{{ site.site_title || 'ONE' }}</RouterLink>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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 { relativeDate, stripTags } from '../utils'
|
||||
@@ -46,12 +47,14 @@ function setStatus(v) {
|
||||
|
||||
async function approve(id) {
|
||||
await adminApi.approveComment(id)
|
||||
toastOk('已通过,前台可见')
|
||||
load()
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!window.confirm('删除这条评论?正文清空,楼层保留为「已删除」。')) return
|
||||
await adminApi.deleteComment(id)
|
||||
toastOk('已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
@@ -59,6 +62,7 @@ async function banToggle(c) {
|
||||
const banned = !c.user?.banned
|
||||
if (!window.confirm(banned ? `禁言「${c.user?.name || '该用户'}」?禁言后其无法再发表评论。` : '解除禁言?')) return
|
||||
await adminApi.setReaderBanned(c.user?.id, banned)
|
||||
toastOk(banned ? '已禁言' : '已解除禁言')
|
||||
c.user = { ...(c.user || {}), banned }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
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 { adminApi } from '../api'
|
||||
import { site } from '../site'
|
||||
@@ -625,39 +626,8 @@ function commitImage() {
|
||||
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)。
|
||||
const uploadingImage = ref(false)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { adminApi } from '../api'
|
||||
import { relativeDate } from '../utils'
|
||||
@@ -55,8 +56,10 @@ async function upload(files) {
|
||||
const fd = new FormData()
|
||||
for (const f of files) fd.append('file', f)
|
||||
await adminApi.uploadFiles(fd)
|
||||
toastOk(`已上传 ${files.length} 个文件`)
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '上传失败')
|
||||
uploadError.value = e.message || '上传失败'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
@@ -75,6 +78,7 @@ function onDrop(e) {
|
||||
async function copyUrl(f) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(f.url)
|
||||
toastOk('链接已复制')
|
||||
copiedId.value = f.id
|
||||
setTimeout(() => {
|
||||
if (copiedId.value === f.id) copiedId.value = 0
|
||||
@@ -89,10 +93,12 @@ async function remove(f) {
|
||||
if (!window.confirm(`删除「${f.name}」?存储里的文件会一并删除,引用它的文章将失效。`)) return
|
||||
try {
|
||||
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 } })
|
||||
else load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '删除失败')
|
||||
error.value = e.message || '删除失败'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import ToastStack from './ToastStack.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { adminApi, session } from '../api'
|
||||
import { site } from '../site'
|
||||
@@ -17,8 +19,10 @@ async function submit() {
|
||||
const data = await adminApi.login(username.value, password.value)
|
||||
session.user = username.value
|
||||
void data
|
||||
toastOk('登录成功')
|
||||
router.push('/admin')
|
||||
} catch (e) {
|
||||
toastErr('用户名或密码不对')
|
||||
error.value = '用户名或密码不对'
|
||||
} finally {
|
||||
busy.value = false
|
||||
@@ -27,6 +31,7 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastStack />
|
||||
<div class="page">
|
||||
<form class="card" @submit.prevent="submit">
|
||||
<p class="eyebrow">后台</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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'
|
||||
@@ -125,7 +126,7 @@ async function bulk(action) {
|
||||
await adminApi.bulkPosts([...selected.value], action)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '操作失败')
|
||||
toastErr(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +136,7 @@ async function remove(id, title) {
|
||||
await adminApi.deletePost(id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '删除失败')
|
||||
toastErr(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { adminApi } from '../api'
|
||||
|
||||
const projects = ref([])
|
||||
@@ -40,7 +41,7 @@ onMounted(load)
|
||||
|
||||
async function create() {
|
||||
if (!form.value.title.trim()) {
|
||||
alert('请填写作品名称')
|
||||
toastErr('请填写作品名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -56,7 +57,7 @@ async function create() {
|
||||
form.value = blank()
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '创建失败')
|
||||
toastErr(e.message || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +82,7 @@ function cancelEdit() {
|
||||
|
||||
async function saveEdit() {
|
||||
if (!draft.value.title.trim()) {
|
||||
alert('请填写作品名称')
|
||||
toastErr('请填写作品名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -97,7 +98,7 @@ async function saveEdit() {
|
||||
editing.value = null
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '保存失败')
|
||||
toastErr(e.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +108,7 @@ async function toggleStatus(p) {
|
||||
await adminApi.updateProject(p.id, { status: next })
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '更新失败')
|
||||
toastErr(e.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +118,7 @@ async function remove(p) {
|
||||
await adminApi.deleteProject(p.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '删除失败')
|
||||
toastErr(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { adminApi } from '../api'
|
||||
import { SKIN_CONSTANTS, UI_CONSTANTS } from '../site'
|
||||
import { SECTIONS } from '../ui/sections'
|
||||
@@ -186,7 +187,9 @@ async function save() {
|
||||
}
|
||||
}
|
||||
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
toastOk('设置已保存')
|
||||
} catch (e) {
|
||||
toastErr('保存失败:' + (e.message || ''))
|
||||
error.value = e.message || '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { adminApi } from '../api'
|
||||
|
||||
const tags = ref([])
|
||||
@@ -30,7 +31,7 @@ async function create() {
|
||||
newColor.value = ''
|
||||
await load()
|
||||
} 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 load()
|
||||
} catch (e) {
|
||||
alert(e.message || '更新失败')
|
||||
toastErr(e.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +50,7 @@ async function remove(t) {
|
||||
await adminApi.deleteTag(t.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '删除失败')
|
||||
toastErr(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +70,7 @@ function cancelMerge() {
|
||||
async function doMerge(fromTag) {
|
||||
const toId = Number(mergeInto.value)
|
||||
if (!toId || toId === fromTag.id) {
|
||||
alert('选一个不同的目标标签')
|
||||
toastErr('选一个不同的目标标签')
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`把「${fromTag.name}」合并到选中的标签?此操作不可撤销。`)) return
|
||||
@@ -78,7 +79,7 @@ async function doMerge(fromTag) {
|
||||
cancelMerge()
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '合并失败')
|
||||
toastErr(e.message || '合并失败')
|
||||
}
|
||||
}
|
||||
</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