495 lines
13 KiB
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>
|