update: 2026-03-28 21:00
This commit is contained in:
@@ -1,494 +1,494 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user