You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

226 lines
9.6 KiB

<template>
<div class="flex min-h-screen">
<!-- ── 侧边栏 ── -->
<aside
class="fixed top-0 left-0 bottom-0 z-30 overflow-y-auto flex flex-col"
style="width:256px; background:#1E293B;"
>
<!-- Logo 区 -->
<div style="padding:20px 20px 16px; border-bottom:1px solid rgba(255,255,255,0.06);">
<div style="display:flex; align-items:center; gap:12px;">
<div style="width:40px; height:40px; background:linear-gradient(135deg,#4F46E5,#7C3AED); border-radius:12px; display:flex; align-items:center; justify-content:center; box-shadow:0 6px 20px rgba(79,70,229,0.35); flex-shrink:0;">
<svg style="width:22px; height:22px; color:#fff;" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<div style="color:#fff; font-weight:700; font-size:15px; letter-spacing:-0.02em;">{{ auth.siteTitle || 'Admin' }}</div>
<div style="color:#64748B; font-size:11px; font-weight:500;">Admin Console · v1.0.1</div>
</div>
</div>
</div>
<!-- 导航 -->
<nav style="padding:12px 0; flex:1;">
<template v-for="group in menuGroups">
<div v-if="hasVisibleItems(group)" :key="group.label" class="group-title">{{ group.label }}</div>
<template v-for="item in group.items">
<NuxtLink
v-if="auth.hasAccess(item.id)"
:key="item.id"
:to="item.to"
class="sidebar-link"
active-class="active"
>
<svg class="icon" width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="item.icon" />
</svg>
{{ item.label }}
</NuxtLink>
</template>
</template>
</nav>
<!-- 底部账号信息卡 -->
<div style="margin-top:auto; padding:14px 18px; border-top:1px solid rgba(255,255,255,0.06);">
<div style="display:flex; align-items:center; gap:10px;">
<div style="width:32px; height:32px; border-radius:9px; background:linear-gradient(135deg,#4F46E5,#7C3AED); color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; flex-shrink:0;">
{{ avatarChar }}
</div>
<div style="min-width:0;">
<div style="color:#E2E8F0; font-size:13px; font-weight:600; line-height:1.2; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ auth.account || '-' }}</div>
<div style="color:#64748B; font-size:11px; margin-top:2px;">{{ identityLabel }}</div>
</div>
</div>
</div>
</aside>
<!-- ── 主内容区 ── -->
<main class="flex-1" style="margin-left:256px;">
<!-- 顶部栏 -->
<header class="bg-white shadow-sm sticky top-0 z-20 h-16 flex items-center justify-between px-6">
<!-- 面包屑 -->
<div class="flex items-center gap-2 text-sm">
<span class="text-slate-400">首页</span>
<span class="text-slate-300">/</span>
<span class="text-slate-700 font-medium">{{ pageTitle }}</span>
</div>
<!-- 右侧操作 -->
<div class="flex items-center gap-2">
<!-- 通知 -->
<div class="dropdown dropdown-end">
<div tabindex="0" class="btn btn-ghost btn-sm btn-circle relative">
<svg class="h-5 w-5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</div>
<div tabindex="0" class="dropdown-content z-[2] shadow-xl bg-white rounded-xl w-80 border" style="max-height:400px;">
<div class="flex items-center justify-between px-4 py-3 border-b">
<span class="text-sm font-semibold text-slate-700">系统通知</span>
</div>
<div class="px-3 py-6 text-center text-slate-400 text-xs">暂无新通知</div>
</div>
</div>
<!-- 用户菜单 -->
<div class="dropdown dropdown-end">
<div tabindex="0" class="flex items-center gap-2 cursor-pointer hover:bg-slate-50 rounded-lg px-3 py-1.5">
<div class="avatar placeholder">
<div class="bg-indigo-600 text-white rounded-full w-8">
<span class="text-sm">{{ avatarChar }}</span>
</div>
</div>
<div>
<div class="text-sm text-slate-700 leading-tight">{{ auth.account }}</div>
<div class="text-[10px] text-slate-400">{{ identityLabelShort }}</div>
</div>
</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-lg bg-white rounded-lg w-44 border">
<li><a class="text-sm cursor-pointer" @click="openPwd">修改密码</a></li>
<li><a class="text-sm text-red-500 cursor-pointer" @click="logout">退出登录</a></li>
</ul>
</div>
</div>
</header>
<!-- 修改密码(自助改自己的密码;引导超管的密码在部署环境文件里,后端会拒绝并提示)-->
<div v-if="pwdOpen" class="modal modal-open">
<div class="modal-box max-w-sm">
<h3 class="font-bold text-base mb-3">修改密码</h3>
<div class="space-y-3">
<div>
<label class="text-xs text-slate-500">原密码</label>
<input v-model="pwd.old" type="password" class="input input-bordered input-sm w-full" autocomplete="current-password" />
</div>
<div>
<label class="text-xs text-slate-500">新密码(至少 6 位)</label>
<input v-model="pwd.next" type="password" class="input input-bordered input-sm w-full" autocomplete="new-password" />
</div>
<div>
<label class="text-xs text-slate-500">确认新密码</label>
<input v-model="pwd.confirm" type="password" class="input input-bordered input-sm w-full" autocomplete="new-password" />
</div>
</div>
<p class="text-xs text-slate-400 mt-3">改完需要重新登录。</p>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" @click="pwdOpen = false">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="pwdSubmitting" @click="submitPwd">确认修改</button>
</div>
</div>
</div>
<!-- Toast -->
<AppToast />
<!-- 全局处理中遮罩(写操作期间显示,防重复点击) -->
<AppLoading />
<!-- 页面内容 -->
<div class="p-6">
<slot />
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted } from 'vue'
import { useAuthStore } from '~/stores/auth'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { MENU_GROUPS } from '~/utils/menus'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const { consoleApi } = useApi()
const { success, error } = useToast()
function logout() {
auth.logout()
router.push('/')
}
// ── 自助改密 ──
const pwdOpen = ref(false)
const pwdSubmitting = ref(false)
const pwd = reactive({ old: '', next: '', confirm: '' })
function openPwd() {
pwd.old = ''
pwd.next = ''
pwd.confirm = ''
pwdOpen.value = true
}
async function submitPwd() {
if (!pwd.old || !pwd.next) { error('原密码和新密码必填'); return }
if (pwd.next.length < 6) { error('新密码至少 6 位'); return }
if (pwd.next !== pwd.confirm) { error('两次输入的新密码不一致'); return }
pwdSubmitting.value = true
try {
await consoleApi('accounts/changepwd', { old_password: pwd.old, new_password: pwd.next })
pwdOpen.value = false
success('密码已修改,请重新登录')
// 旧 token 还有效,但让用户用新密码登一次,避免「以为没改成」
setTimeout(logout, 800)
} catch (e: any) {
error(e?.message ?? '修改失败')
} finally {
pwdSubmitting.value = false
}
}
// 身份标签(侧栏用全称,顶栏用简称,与 common.js 一致)
const identityLabel = computed(() => {
const map: Record<number, string> = { 1: '超级管理员', 2: '管理员', 3: '代理', 4: '运营' }
return map[auth.identity] ?? ''
})
const identityLabelShort = computed(() => {
const map: Record<number, string> = { 1: '超管', 2: '管理员', 3: '代理', 4: '运营' }
return map[auth.identity] ?? ''
})
const avatarChar = computed(() => (auth.account || 'A').charAt(0).toUpperCase())
// 菜单分组:统一取自 utils/menus.ts(权限编辑器/路由守卫共用同一份目录)
const menuGroups = MENU_GROUPS
function hasVisibleItems(group: typeof menuGroups[number]) {
return group.items.some((item) => auth.hasAccess(item.id))
}
const pageTitle = computed(() => {
if (route.meta?.title) return route.meta.title as string
for (const g of menuGroups) {
const item = g.items.find((m) => route.path.startsWith(m.to))
if (item) return item.label
}
return 'Console'
})
onMounted(() => {
if (!auth.token) router.push('/')
})
</script>