美化排版前端管理后台

This commit is contained in:
2026-06-29 16:59:56 +08:00
parent f0dce7ad91
commit ec7ee7ba56
122 changed files with 18418 additions and 38 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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: '' }

View 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 }
}

View 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>

View 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>

View File

@@ -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)