美化排版前端管理后台
This commit is contained in:
@@ -12,12 +12,21 @@
|
||||
</div>
|
||||
|
||||
<SplashScreen :visible="showSplash" />
|
||||
<AppToast />
|
||||
|
||||
<AppTopNav
|
||||
:logged-in="loggedIn"
|
||||
:login-url="loginUrl"
|
||||
:wishlist-count="wishlistCount"
|
||||
:has-announcement="!!announcement.content"
|
||||
@admin-unlock="openAdminModal"
|
||||
@show-announcement="showAnnouncement = true"
|
||||
/>
|
||||
|
||||
<AnnouncementModal
|
||||
:open="showAnnouncement"
|
||||
:content="announcement.content"
|
||||
@close="onCloseAnnouncement"
|
||||
/>
|
||||
|
||||
<AppAdminUnlockModal
|
||||
@@ -27,7 +36,7 @@
|
||||
@submit="submitAdminToken"
|
||||
/>
|
||||
|
||||
<div class="flex-1 flex flex-col px-[5vw] py-4 max-md:px-[2vw] max-md:py-3 min-h-0">
|
||||
<div :class="['flex-1 flex flex-col min-h-0', mergePageShell ? 'px-[5vw] py-4 max-md:px-[2vw] max-md:py-3' : '']">
|
||||
<template v-if="mergePageShell">
|
||||
<div class="page-card flex flex-col flex-1 min-h-0">
|
||||
<main class="flex-1 min-h-0">
|
||||
@@ -42,7 +51,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<main class="flex-1 py-4 min-h-0 max-md:py-3">
|
||||
<main class="flex-1 min-h-0">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
@@ -79,7 +88,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { verifyAdminToken, fetchStats, recordSiteVisit } from './modules/shared/api'
|
||||
import { verifyAdminToken, fetchStats, recordSiteVisit, fetchAnnouncement } from './modules/shared/api'
|
||||
import { authState, isLoggedIn, getLoginUrl } from './modules/shared/auth'
|
||||
import { wishlistCount, loadWishlist } from './modules/shared/useWishlist'
|
||||
import ChatWidget from './modules/chat/ChatWidget.vue'
|
||||
@@ -87,6 +96,8 @@ import SplashScreen from './modules/shared/SplashScreen.vue'
|
||||
import AppTopNav from './modules/shell/AppTopNav.vue'
|
||||
import AppAdminUnlockModal from './modules/shell/AppAdminUnlockModal.vue'
|
||||
import AppSiteFooter from './modules/shell/AppSiteFooter.vue'
|
||||
import AppToast from './modules/shell/AppToast.vue'
|
||||
import AnnouncementModal from './modules/shell/AnnouncementModal.vue'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/vue'
|
||||
|
||||
const RAND_BG_JSON_URL = 'https://randbg.api.smyhub.com/api/random?format=json'
|
||||
@@ -109,6 +120,30 @@ const statsLoaded = ref(false)
|
||||
const loggedIn = computed(() => isLoggedIn())
|
||||
const loginUrl = computed(() => getLoginUrl())
|
||||
|
||||
const announcement = ref({ content: '', version: '' })
|
||||
const showAnnouncement = ref(false)
|
||||
|
||||
const ANNOUNCEMENT_LS_KEY = 'announcement_seen_version'
|
||||
|
||||
const loadAndMaybeShowAnnouncement = async () => {
|
||||
try {
|
||||
const ann = await fetchAnnouncement()
|
||||
announcement.value = ann
|
||||
if (!ann.content || !ann.version) return
|
||||
const seen = localStorage.getItem(ANNOUNCEMENT_LS_KEY)
|
||||
if (seen !== ann.version) {
|
||||
showAnnouncement.value = true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const onCloseAnnouncement = () => {
|
||||
showAnnouncement.value = false
|
||||
if (announcement.value.version) {
|
||||
localStorage.setItem(ANNOUNCEMENT_LS_KEY, announcement.value.version)
|
||||
}
|
||||
}
|
||||
|
||||
const showAdminModal = ref(false)
|
||||
const tokenInput = ref('')
|
||||
const tokenError = ref('')
|
||||
@@ -149,6 +184,7 @@ onMounted(async () => {
|
||||
.catch(() => {})
|
||||
|
||||
await loadWishlist()
|
||||
loadAndMaybeShowAnnouncement()
|
||||
try {
|
||||
recordSiteVisit().then((result) => {
|
||||
if (result.totalVisits) stats.totalVisits = result.totalVisits
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<section class="flex glass border border-white/35 rounded-[var(--radius-default)] shadow-[var(--shadow-card)] backdrop-blur-[16px] overflow-hidden min-h-[calc(100vh-120px)] max-md:flex-col max-md:min-h-auto">
|
||||
<section class="flex glass backdrop-blur-[16px] overflow-hidden h-[calc(100vh-52px)] max-md:flex-col">
|
||||
<!-- Sidebar -->
|
||||
<nav class="w-[180px] flex-shrink-0 bg-white/65 border-r border-white/35 flex flex-col max-md:w-full max-md:border-r-0 max-md:border-b max-md:border-white/35">
|
||||
<div class="px-[18px] py-5 pb-3.5 text-base font-bold text-apptext border-b border-white/35 tracking-[0.3px] max-md:px-4 max-md:py-3 max-md:text-[15px]">
|
||||
@@ -38,10 +38,8 @@
|
||||
</div>
|
||||
|
||||
<AdminTokenRow
|
||||
:show="!token || !!message"
|
||||
:show="!token"
|
||||
v-model:token="token"
|
||||
:message="!token || message ? message : ''"
|
||||
:inline-message="token && message ? message : ''"
|
||||
/>
|
||||
|
||||
<div v-if="activeSection === 'products'">
|
||||
@@ -58,13 +56,17 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeSection === 'settings'">
|
||||
<AdminAnnouncementRow
|
||||
v-model:content="announcementContent"
|
||||
:saved="announcementSaved"
|
||||
@save="saveAnnouncement"
|
||||
/>
|
||||
<AdminMaintenanceRow
|
||||
v-model:enabled="maintenanceEnabled"
|
||||
v-model:reason="maintenanceReason"
|
||||
:message="maintenanceMsg"
|
||||
@save="saveMaintenance"
|
||||
/>
|
||||
<AdminSMTPRow :config="smtpConfig" :message="smtpMsg" @save="saveSMTPConfig" />
|
||||
<AdminSMTPRow :config="smtpConfig" @save="saveSMTPConfig" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeSection === 'status'">
|
||||
@@ -88,8 +90,10 @@ import {
|
||||
fetchAdminProducts, createProduct, updateProduct, toggleProduct, deleteProduct,
|
||||
fetchAdminOrders, deleteAdminOrder,
|
||||
fetchSiteMaintenance, setSiteMaintenance,
|
||||
fetchSMTPConfig, setSMTPConfig
|
||||
fetchSMTPConfig, setSMTPConfig,
|
||||
fetchAnnouncement, setAdminAnnouncement
|
||||
} from '../shared/api'
|
||||
import { useToast } from '../shared/useToast'
|
||||
import AdminTokenRow from './components/AdminTokenRow.vue'
|
||||
import AdminMaintenanceRow from './components/AdminMaintenanceRow.vue'
|
||||
import AdminSMTPRow from './components/AdminSMTPRow.vue'
|
||||
@@ -98,7 +102,9 @@ import AdminProductModal from './components/AdminProductModal.vue'
|
||||
import AdminOrderTable from './components/AdminOrderTable.vue'
|
||||
import AdminChatPanel from './components/AdminChatPanel.vue'
|
||||
import AdminSystemStatusPanel from './components/AdminSystemStatusPanel.vue'
|
||||
import AdminAnnouncementRow from './components/AdminAnnouncementRow.vue'
|
||||
|
||||
const { showToast } = useToast()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -116,14 +122,13 @@ const statusPanelRef = ref(null)
|
||||
const token = ref(route.query.token || '')
|
||||
const products = ref([])
|
||||
const orders = ref([])
|
||||
const message = ref('')
|
||||
const editorOpen = ref(false)
|
||||
const selectedItem = ref(null)
|
||||
const maintenanceEnabled = ref(false)
|
||||
const maintenanceReason = ref('')
|
||||
const maintenanceMsg = ref('')
|
||||
const smtpConfig = ref({})
|
||||
const smtpMsg = ref('')
|
||||
const announcementContent = ref('')
|
||||
const announcementSaved = ref(false)
|
||||
|
||||
const syncQuery = () => { if (token.value) router.replace({ query: { token: token.value } }) }
|
||||
|
||||
@@ -133,13 +138,30 @@ const refresh = async () => {
|
||||
statusPanelRef.value?.load()
|
||||
return
|
||||
}
|
||||
if (!token.value) { message.value = '请先输入 token'; return }
|
||||
if (!token.value) { showToast('请先输入 token', true); return }
|
||||
try {
|
||||
const [prods, ords] = await Promise.all([fetchAdminProducts(token.value), fetchAdminOrders(token.value)])
|
||||
products.value = prods
|
||||
orders.value = ords
|
||||
message.value = '数据已更新'
|
||||
} catch { message.value = '获取失败,请检查 token' }
|
||||
showToast('数据已更新')
|
||||
} catch { showToast('获取失败,请检查 token', true) }
|
||||
}
|
||||
|
||||
const loadAnnouncement = async () => {
|
||||
try {
|
||||
const ann = await fetchAnnouncement()
|
||||
announcementContent.value = ann.content || ''
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const saveAnnouncement = async () => {
|
||||
if (!token.value) { showToast('请先输入 token', true); return }
|
||||
try {
|
||||
await setAdminAnnouncement(token.value, announcementContent.value)
|
||||
announcementSaved.value = true
|
||||
showToast('公告已保存')
|
||||
setTimeout(() => { announcementSaved.value = false }, 2000)
|
||||
} catch { showToast('保存失败,请检查 token', true) }
|
||||
}
|
||||
|
||||
const loadMaintenance = async () => {
|
||||
@@ -147,46 +169,46 @@ const loadMaintenance = async () => {
|
||||
const { maintenance, reason } = await fetchSiteMaintenance()
|
||||
maintenanceEnabled.value = maintenance
|
||||
maintenanceReason.value = reason || ''
|
||||
} catch { maintenanceMsg.value = '加载维护状态失败' }
|
||||
} catch { showToast('加载维护状态失败', true) }
|
||||
}
|
||||
|
||||
const loadSMTPConfig = async () => {
|
||||
if (!token.value) return
|
||||
try { smtpConfig.value = await fetchSMTPConfig(token.value) } catch { smtpMsg.value = '加载 SMTP 配置失败' }
|
||||
try { smtpConfig.value = await fetchSMTPConfig(token.value) } catch { showToast('加载 SMTP 配置失败', true) }
|
||||
}
|
||||
|
||||
const saveSMTPConfig = async (cfg) => {
|
||||
if (!token.value) { smtpMsg.value = '请先输入 token'; return }
|
||||
try { await setSMTPConfig(token.value, cfg); smtpMsg.value = '配置已保存'; await loadSMTPConfig() }
|
||||
catch { smtpMsg.value = '保存失败,请检查 token' }
|
||||
if (!token.value) { showToast('请先输入 token', true); return }
|
||||
try { await setSMTPConfig(token.value, cfg); showToast('配置已保存'); await loadSMTPConfig() }
|
||||
catch { showToast('保存失败,请检查 token', true) }
|
||||
}
|
||||
|
||||
const saveMaintenance = async () => {
|
||||
if (!token.value) { maintenanceMsg.value = '请先输入 token'; return }
|
||||
if (!token.value) { showToast('请先输入 token', true); return }
|
||||
try {
|
||||
await setSiteMaintenance(token.value, maintenanceEnabled.value, maintenanceReason.value)
|
||||
maintenanceMsg.value = maintenanceEnabled.value ? '维护模式已开启' : '维护模式已关闭'
|
||||
} catch { maintenanceMsg.value = '保存失败,请检查 token' }
|
||||
showToast(maintenanceEnabled.value ? '维护模式已开启' : '维护模式已关闭')
|
||||
} catch { showToast('保存失败,请检查 token', true) }
|
||||
}
|
||||
|
||||
const handleFormSubmit = async (payload) => {
|
||||
if (!token.value) { message.value = '请先输入 token'; return }
|
||||
if (!token.value) { showToast('请先输入 token', true); return }
|
||||
try {
|
||||
if (payload.id) { await updateProduct(token.value, payload.id, payload); message.value = '已更新商品' }
|
||||
else { await createProduct(token.value, payload); message.value = '已新增商品' }
|
||||
if (payload.id) { await updateProduct(token.value, payload.id, payload); showToast('已更新商品') }
|
||||
else { await createProduct(token.value, payload); showToast('已新增商品') }
|
||||
closeEditor()
|
||||
await refresh()
|
||||
} catch { message.value = '操作失败,请检查输入' }
|
||||
} catch { showToast('操作失败,请检查输入', true) }
|
||||
}
|
||||
|
||||
const toggle = async (item) => {
|
||||
if (!token.value) { message.value = '请先输入 token'; return }
|
||||
if (!token.value) { showToast('请先输入 token', true); return }
|
||||
await toggleProduct(token.value, item.id, !item.active)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const remove = async (item) => {
|
||||
if (!token.value) { message.value = '请先输入 token'; return }
|
||||
if (!token.value) { showToast('请先输入 token', true); return }
|
||||
await deleteProduct(token.value, item.id)
|
||||
await refresh()
|
||||
}
|
||||
@@ -194,7 +216,7 @@ const remove = async (item) => {
|
||||
const removeOrder = async (orderId) => {
|
||||
if (!token.value) return
|
||||
try { await deleteAdminOrder(token.value, orderId); orders.value = orders.value.filter((o) => o.id !== orderId) }
|
||||
catch { message.value = '删除订单失败' }
|
||||
catch { showToast('删除订单失败', true) }
|
||||
}
|
||||
|
||||
const openCreate = () => { selectedItem.value = null; editorOpen.value = true }
|
||||
@@ -204,7 +226,7 @@ const closeEditor = () => { editorOpen.value = false; selectedItem.value = null
|
||||
watch(token, (val) => { syncQuery(); if (val) loadSMTPConfig() })
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMaintenance()
|
||||
await Promise.all([loadMaintenance(), loadAnnouncement()])
|
||||
if (token.value) await Promise.all([refresh(), loadSMTPConfig()])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 mb-4 px-[18px] py-4 bg-white/50 border border-white/35 rounded-lg max-md:px-3 max-md:py-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-[15px] font-semibold text-apptext">公告内容</span>
|
||||
<span class="text-xs text-muted">支持 Markdown 格式,留空则隐藏公告入口</span>
|
||||
</div>
|
||||
<textarea
|
||||
:value="content"
|
||||
@input="$emit('update:content', $event.target.value)"
|
||||
class="w-full min-h-[160px] px-3 py-2.5 rounded-lg border border-white/35 bg-white/70 text-[14px] outline-none focus:border-accent transition resize-y leading-relaxed"
|
||||
placeholder="在此输入公告内容(支持 Markdown)..."
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="primary text-sm px-4 py-1.5" type="button" @click="$emit('save')">保存公告</button>
|
||||
<span v-if="saved" class="text-[13px] text-accent2">已保存</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
content: { type: String, default: '' },
|
||||
saved: { type: Boolean, default: false }
|
||||
})
|
||||
defineEmits(['update:content', 'save'])
|
||||
</script>
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-40 flex items-center justify-center overflow-y-auto overscroll-contain p-6 bg-[rgba(30,28,32,0.35)] backdrop-blur-lg max-md:p-3"
|
||||
class="fixed inset-0 z-50"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<section
|
||||
class="flex w-[min(1080px,100%)] flex-col min-h-0 shrink-0 overflow-hidden rounded-xl border border-white/35 bg-white/95 p-[18px] shadow-[0_24px_60px_rgba(33,33,40,0.2)] max-h-[min(calc(100dvh-24px),calc(100vh-24px))] md:p-6 md:max-h-[min(74dvh,calc(100dvh-48px),calc(100vh-48px))]"
|
||||
class="absolute inset-0 flex flex-col overflow-hidden bg-white p-[18px] md:p-6"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header -->
|
||||
@@ -173,8 +173,9 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex shrink-0 gap-3 border-t border-white/35 bg-white/95 pt-3 mt-4 md:pt-4 md:mt-5">
|
||||
<button class="primary" @click="handleSubmit">{{ form.id ? '更新商品' : '新增商品' }}</button>
|
||||
<button class="ghost" @click="resetForm">重置表单</button>
|
||||
<button class="primary" @click="handleSubmit">{{ form.id ? '更新' : '添加' }}</button>
|
||||
<button class="ghost" @click="resetForm">重置</button>
|
||||
<button class="ghost" @click="$emit('close')">关闭</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -148,6 +148,16 @@ export const deleteAdminOrder = async (token, orderId) => {
|
||||
await api.delete(`/api/admin/orders/${orderId}`, { headers: adminHeaders(token) })
|
||||
}
|
||||
|
||||
export const fetchAnnouncement = async () => {
|
||||
const res = await api.get('/api/announcement')
|
||||
return unwrapData(res) || { content: '', version: '' }
|
||||
}
|
||||
|
||||
export const setAdminAnnouncement = async (token, content) => {
|
||||
const res = await api.put('/api/admin/announcement', { content }, { headers: adminHeaders(token) })
|
||||
return unwrapData(res) || {}
|
||||
}
|
||||
|
||||
export const fetchSiteMaintenance = async () => {
|
||||
const res = await api.get('/api/site/maintenance')
|
||||
return unwrapData(res) || { maintenance: false, reason: '' }
|
||||
|
||||
17
mengyastore-frontend/src/modules/shared/useToast.js
Normal file
17
mengyastore-frontend/src/modules/shared/useToast.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const msg = ref('')
|
||||
const visible = ref(false)
|
||||
const isError = ref(false)
|
||||
let _timer = null
|
||||
|
||||
export function useToast() {
|
||||
const showToast = (text, error = false) => {
|
||||
msg.value = text
|
||||
isError.value = error
|
||||
visible.value = true
|
||||
clearTimeout(_timer)
|
||||
_timer = setTimeout(() => { visible.value = false }, 2500)
|
||||
}
|
||||
return { toastMsg: msg, toastVisible: visible, toastIsError: isError, showToast }
|
||||
}
|
||||
96
mengyastore-frontend/src/modules/shell/AnnouncementModal.vue
Normal file
96
mengyastore-frontend/src/modules/shell/AnnouncementModal.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="ann-fade">
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-[rgba(30,28,32,0.4)] backdrop-blur-sm"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="relative flex flex-col w-[min(640px,100%)] max-h-[80dvh] rounded-2xl border border-white/35 bg-white/95 shadow-[0_24px_60px_rgba(33,33,40,0.2)] overflow-hidden">
|
||||
<div class="flex shrink-0 items-center justify-between gap-4 px-6 py-4 border-b border-white/35">
|
||||
<h3 class="text-base font-bold text-apptext m-0">公告</h3>
|
||||
<button class="ghost text-sm px-3 py-1" @click="$emit('close')">关闭</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 overflow-y-auto px-6 py-5 prose prose-sm max-w-none text-apptext"
|
||||
v-html="renderedContent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
const props = defineProps({
|
||||
open: Boolean,
|
||||
content: { type: String, default: '' }
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
|
||||
const md = new MarkdownIt({ breaks: true, linkify: true })
|
||||
|
||||
const renderedContent = computed(() => props.content ? md.render(props.content) : '')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ann-fade-enter-active,
|
||||
.ann-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.ann-fade-enter-from,
|
||||
.ann-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.prose :deep(h1),
|
||||
.prose :deep(h2),
|
||||
.prose :deep(h3) {
|
||||
font-weight: 700;
|
||||
margin: 1em 0 0.4em;
|
||||
color: var(--color-apptext, #1a1a1a);
|
||||
}
|
||||
.prose :deep(p) {
|
||||
margin: 0.5em 0;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.prose :deep(a) {
|
||||
color: var(--color-accent, #c2853a);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.prose :deep(ul),
|
||||
.prose :deep(ol) {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.prose :deep(li) {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
.prose :deep(code) {
|
||||
background: rgba(0,0,0,0.06);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.prose :deep(pre) {
|
||||
background: rgba(0,0,0,0.06);
|
||||
padding: 0.75em 1em;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.prose :deep(blockquote) {
|
||||
border-left: 3px solid var(--color-accent, #c2853a);
|
||||
padding-left: 1em;
|
||||
color: #666;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.prose :deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(0,0,0,0.1);
|
||||
margin: 1em 0;
|
||||
}
|
||||
</style>
|
||||
28
mengyastore-frontend/src/modules/shell/AppToast.vue
Normal file
28
mengyastore-frontend/src/modules/shell/AppToast.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<Transition name="app-toast">
|
||||
<div
|
||||
v-if="toastVisible"
|
||||
class="fixed top-[68px] left-0 right-0 mx-auto w-fit max-w-[80vw] z-[9998] px-[18px] py-2.5 rounded-[20px] text-[13px] font-medium text-white shadow-[0_4px_20px_rgba(0,0,0,0.25)] pointer-events-none select-none text-center"
|
||||
:class="toastIsError ? 'bg-[#c95a6a]' : 'bg-[#1a1a1a]/85 backdrop-blur-sm'"
|
||||
>
|
||||
{{ toastMsg }}
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useToast } from '../shared/useToast'
|
||||
const { toastMsg, toastVisible, toastIsError } = useToast()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-toast-enter-active,
|
||||
.app-toast-leave-active {
|
||||
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||
}
|
||||
.app-toast-enter-from,
|
||||
.app-toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
<div class="hidden md:flex gap-3 items-center shrink-0">
|
||||
<button type="button" class="ghost" @click="goHome">商店</button>
|
||||
<button v-if="hasAnnouncement" type="button" class="ghost" @click="$emit('show-announcement')">公告</button>
|
||||
<template v-if="loggedIn">
|
||||
<button type="button" class="ghost relative inline-flex items-center gap-1" @click="goWishlist">
|
||||
<span>☆</span>
|
||||
@@ -94,6 +95,7 @@
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button type="button" class="app-mobile-nav-btn" @click="navGoHome">商店</button>
|
||||
<button v-if="hasAnnouncement" type="button" class="app-mobile-nav-btn" @click="$emit('show-announcement'); closeMobileNav()">公告</button>
|
||||
<template v-if="loggedIn">
|
||||
<button type="button" class="app-mobile-nav-btn flex items-center justify-between gap-3" @click="navGoWishlist">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
@@ -146,10 +148,11 @@ import { authState, clearAuth } from '../shared/auth'
|
||||
defineProps({
|
||||
loggedIn: { type: Boolean, required: true },
|
||||
loginUrl: { type: String, required: true },
|
||||
wishlistCount: { type: Number, default: 0 }
|
||||
wishlistCount: { type: Number, default: 0 },
|
||||
hasAnnouncement: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['admin-unlock'])
|
||||
const emit = defineEmits(['admin-unlock', 'show-announcement'])
|
||||
|
||||
const router = useRouter()
|
||||
const mobileNavOpen = ref(false)
|
||||
|
||||
Reference in New Issue
Block a user