Files
mengyastore/mengyastore-frontend/src/modules/admin/AdminPage.vue

495 lines
13 KiB
Vue

<template>
<section class="admin-layout">
<!-- Sidebar -->
<nav class="admin-sidebar">
<div class="sidebar-brand">
<span>管理后台</span>
</div>
<div class="sidebar-nav">
<button
v-for="item in NAV_ITEMS"
:key="item.id"
:class="['nav-item', activeSection === item.id ? 'nav-item--active' : '']"
@click="activeSection = item.id"
type="button"
>
<span class="nav-icon" v-html="item.icon"></span>
<span class="nav-label">{{ item.label }}</span>
</button>
</div>
</nav>
<!-- Main content -->
<div class="admin-content">
<!-- Top bar -->
<div class="admin-topbar">
<div class="topbar-title">{{ currentNavLabel }}</div>
<div class="topbar-actions">
<button class="ghost small-btn" @click="refresh">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
刷新
</button>
<button v-if="activeSection === 'products'" class="primary small-btn" @click="openCreate">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
添加商品
</button>
</div>
</div>
<!-- Token row (always visible) -->
<AdminTokenRow
:show="!token || !!message"
v-model:token="token"
:message="!token || message ? message : ''"
:inline-message="token && message ? message : ''"
/>
<!-- Section: Products -->
<div v-if="activeSection === 'products'">
<p class="section-tip tag"> {{ products.length }} 件商品</p>
<AdminProductTable
:products="products"
@edit="openEdit"
@toggle="toggle"
@remove="remove"
/>
</div>
<!-- Section: Orders -->
<div v-else-if="activeSection === 'orders'">
<AdminOrderTable :orders="orders" @remove="removeOrder" />
</div>
<!-- Section: Chat -->
<div v-else-if="activeSection === 'chat'">
<AdminChatPanel :admin-token="token" />
</div>
<!-- Section: Settings -->
<div v-else-if="activeSection === 'settings'">
<AdminMaintenanceRow
v-model:enabled="maintenanceEnabled"
v-model:reason="maintenanceReason"
:message="maintenanceMsg"
@save="saveMaintenance"
/>
<AdminSMTPRow
:config="smtpConfig"
:message="smtpMsg"
@save="saveSMTPConfig"
/>
</div>
</div>
</section>
<AdminProductModal
:open="editorOpen"
:edit-item="selectedItem"
@close="closeEditor"
@submit="handleFormSubmit"
/>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
fetchAdminProducts,
createProduct,
updateProduct,
toggleProduct,
deleteProduct,
fetchAdminOrders,
deleteAdminOrder,
fetchSiteMaintenance,
setSiteMaintenance,
fetchSMTPConfig,
setSMTPConfig
} from '../shared/api'
import AdminTokenRow from './components/AdminTokenRow.vue'
import AdminMaintenanceRow from './components/AdminMaintenanceRow.vue'
import AdminSMTPRow from './components/AdminSMTPRow.vue'
import AdminProductTable from './components/AdminProductTable.vue'
import AdminProductModal from './components/AdminProductModal.vue'
import AdminOrderTable from './components/AdminOrderTable.vue'
import AdminChatPanel from './components/AdminChatPanel.vue'
const route = useRoute()
const router = useRouter()
const NAV_ITEMS = [
{
id: 'products',
label: '商品管理',
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>'
},
{
id: 'orders',
label: '订单记录',
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>'
},
{
id: 'chat',
label: '用户消息',
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'
},
{
id: 'settings',
label: '站点设置',
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>'
}
]
const activeSection = ref('products')
const currentNavLabel = computed(() => NAV_ITEMS.find(i => i.id === activeSection.value)?.label || '')
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 syncQuery = () => {
if (token.value) {
router.replace({ query: { token: token.value } })
}
}
const refresh = async () => {
if (!token.value) {
message.value = '请先输入 token'
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'
}
}
const loadMaintenance = async () => {
try {
const { maintenance, reason } = await fetchSiteMaintenance()
maintenanceEnabled.value = maintenance
maintenanceReason.value = reason || ''
} catch {
maintenanceMsg.value = '加载维护状态失败'
}
}
const loadSMTPConfig = async () => {
if (!token.value) return
try {
smtpConfig.value = await fetchSMTPConfig(token.value)
} catch {
smtpMsg.value = '加载 SMTP 配置失败'
}
}
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'
}
}
const saveMaintenance = async () => {
if (!token.value) {
maintenanceMsg.value = '请先输入 token'
return
}
try {
await setSiteMaintenance(token.value, maintenanceEnabled.value, maintenanceReason.value)
maintenanceMsg.value = maintenanceEnabled.value ? '维护模式已开启' : '维护模式已关闭'
} catch {
maintenanceMsg.value = '保存失败,请检查 token'
}
}
const handleFormSubmit = async (payload) => {
if (!token.value) {
message.value = '请先输入 token'
return
}
try {
if (payload.id) {
await updateProduct(token.value, payload.id, payload)
message.value = '已更新商品'
} else {
await createProduct(token.value, payload)
message.value = '已新增商品'
}
closeEditor()
await refresh()
} catch {
message.value = '操作失败,请检查输入'
}
}
const toggle = async (item) => {
if (!token.value) {
message.value = '请先输入 token'
return
}
await toggleProduct(token.value, item.id, !item.active)
await refresh()
}
const remove = async (item) => {
if (!token.value) {
message.value = '请先输入 token'
return
}
await deleteProduct(token.value, item.id)
await refresh()
}
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 = '删除订单失败'
}
}
const openCreate = () => {
selectedItem.value = null
editorOpen.value = true
}
const openEdit = (item) => {
selectedItem.value = item
editorOpen.value = true
}
const closeEditor = () => {
editorOpen.value = false
selectedItem.value = null
}
watch(token, (val) => {
syncQuery()
if (val) loadSMTPConfig()
})
onMounted(async () => {
await loadMaintenance()
if (token.value) {
await Promise.all([refresh(), loadSMTPConfig()])
}
})
</script>
<style scoped>
/* ── Layout ── */
.admin-layout {
display: flex;
min-height: calc(100vh - 120px);
gap: 0;
background: var(--glass);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
overflow: hidden;
}
/* ── Sidebar ── */
.admin-sidebar {
width: 180px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.65);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
}
.sidebar-brand {
padding: 20px 18px 14px;
font-size: 16px;
font-weight: 700;
color: var(--text);
border-bottom: 1px solid var(--line);
letter-spacing: 0.3px;
}
.sidebar-nav {
display: flex;
flex-direction: column;
padding: 10px 0;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
font-size: 15px;
font-family: inherit;
font-weight: 500;
color: var(--muted);
background: none;
border: none;
cursor: pointer;
text-align: left;
border-radius: 0;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.nav-item:hover {
background: rgba(180, 154, 203, 0.1);
color: var(--text);
}
.nav-item--active {
background: rgba(180, 154, 203, 0.18);
color: var(--text);
font-weight: 700;
border-right: 3px solid var(--accent);
}
.nav-icon {
display: flex;
align-items: center;
opacity: 0.7;
flex-shrink: 0;
}
.nav-item--active .nav-icon {
opacity: 1;
}
/* ── Content area ── */
.admin-content {
flex: 1;
min-width: 0;
padding: 22px 24px;
overflow: auto;
}
/* ── Top bar ── */
.admin-topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.topbar-title {
font-size: 20px;
font-weight: 700;
color: var(--text);
}
.topbar-actions {
display: flex;
gap: 8px;
align-items: center;
}
.small-btn {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 14px;
padding: 7px 13px;
}
.section-tip {
font-size: 13px;
color: var(--muted);
margin-bottom: 10px;
}
.tag {
font-size: 14px;
color: var(--muted);
}
/* ── Mobile: sidebar becomes top tabs ── */
@media (max-width: 900px) {
.admin-layout {
flex-direction: column;
min-height: auto;
}
.admin-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--line);
}
.sidebar-brand {
padding: 12px 16px 10px;
font-size: 15px;
}
.sidebar-nav {
flex-direction: row;
padding: 0;
gap: 0;
overflow-x: auto;
scrollbar-width: none;
}
.sidebar-nav::-webkit-scrollbar {
display: none;
}
.nav-item {
flex-direction: column;
gap: 3px;
padding: 8px 14px;
font-size: 12px;
border-right: none;
border-bottom: 3px solid transparent;
flex-shrink: 0;
align-items: center;
}
.nav-item--active {
border-right: none;
border-bottom: 3px solid var(--accent);
background: rgba(180, 154, 203, 0.12);
}
.admin-content {
padding: 14px;
}
.admin-topbar {
margin-bottom: 12px;
}
.topbar-title {
font-size: 16px;
}
}
</style>