Files
qipai/admin/src/App.vue
T

257 lines
7.2 KiB
Vue

<template>
<el-config-provider>
<AdminLoginPanel
v-if="!adminSessionState.token"
@authenticated="handleAuthenticated"
/>
<main v-else-if="adminSessionState.ready && !restoreError" class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark"></div>
<div>
<h1>自助棋牌室</h1>
<p>运营后台</p>
</div>
</div>
<nav class="nav-list" aria-label="后台模块">
<RouterLink
v-for="item in visibleNavigation"
:key="item.menuKey"
:to="item.path"
class="nav-item"
active-class="active"
>
<component :is="navigationIcons[item.icon]" :size="18" />
<span>{{ item.label }}</span>
</RouterLink>
</nav>
</aside>
<section class="workspace">
<header class="topbar">
<div>
<p class="eyebrow">{{ route.meta.stage || 'M08-D' }}</p>
<h2>{{ route.meta.title || '运营后台' }}</h2>
</div>
<div class="session-box">
<div class="session-user">
<span>{{ adminSessionState.user?.nickname?.slice(0, 1) || '管' }}</span>
<div>
<strong>{{ adminSessionState.user?.nickname || '管理员' }}</strong>
<small>{{ adminSessionState.access.roles.join(' · ') || '已登录' }}</small>
</div>
</div>
<el-tooltip content="安全退出" placement="bottom">
<el-button :icon="LogOut" circle @click="handleLogout" />
</el-tooltip>
</div>
</header>
<el-alert
v-if="routeLoadError"
class="route-alert"
type="error"
show-icon
:closable="false"
title="页面资源加载失败"
:description="routeLoadError"
>
<template #default>
<el-button type="primary" plain @click="retryLazyRoute">重新加载</el-button>
</template>
</el-alert>
<RouterView v-else v-slot="{ Component }">
<Suspense>
<component
:is="Component"
:session="session"
@open-cleaning="openCleaning"
/>
<template #fallback>
<section class="route-state" aria-live="polite">
<el-skeleton :rows="8" animated />
</section>
</template>
</Suspense>
</RouterView>
</section>
</main>
<main v-else class="session-recovery" aria-live="polite">
<section v-if="restoreError" class="session-recovery-card">
<h1>暂时无法恢复后台会话</h1>
<p>{{ restoreError }}</p>
<div>
<el-button type="primary" :loading="restoring" @click="restoreSession">重试</el-button>
<el-button @click="clearSession">返回登录</el-button>
</div>
</section>
<section v-else class="session-recovery-card">
<el-skeleton :rows="4" animated />
<p>正在安全恢复会话与权限菜单</p>
</section>
</main>
</el-config-provider>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue';
import { ElMessage } from 'element-plus';
import {
ClipboardList,
Handshake,
Images,
LayoutDashboard,
LogOut,
PanelsTopLeft,
RadioTower,
ScrollText,
Sparkles,
Store,
TicketCheck,
UsersRound,
WalletCards
} from '@lucide/vue';
import { useRoute } from 'vue-router';
import AdminLoginPanel from './components/AdminLoginPanel.vue';
import {
clearStoredSession,
getAdminMe,
logoutAdmin,
storeAdminSession,
type ApiSession
} from './api';
import {
ADMIN_NAVIGATION,
firstAuthorizedPath,
isNavigationAllowed,
resolveAuthorizedPath,
type AdminMenuKey
} from './navigation.mjs';
import { router } from './router';
import {
adminSessionState,
applyAdminIdentity,
applyAdminSession,
clearAdminSessionState
} from './session';
import type { AdminSessionPayload } from './types';
const route = useRoute();
const restoring = ref(false);
const restoreError = ref('');
const routeLoadError = ref('');
const session = computed<ApiSession>(() => ({ token: adminSessionState.token }));
const visibleNavigation = computed(() => ADMIN_NAVIGATION.filter(
(item) => isNavigationAllowed(adminSessionState.access, item)
));
const navigationIcons: Record<string, Component> = {
ClipboardList,
Handshake,
Images,
LayoutDashboard,
PanelsTopLeft,
RadioTower,
ScrollText,
Sparkles,
Store,
TicketCheck,
UsersRound,
WalletCards
};
function handleAuthenticated(payload: AdminSessionPayload) {
storeAdminSession(payload);
applyAdminSession(payload);
restoreError.value = '';
void ensureAllowedRoute();
ElMessage.success('登录成功');
}
async function handleLogout() {
try {
await logoutAdmin(session.value);
} catch {
// 本地令牌清理是退出动作的最终安全边界。
}
clearSession();
}
function clearSession() {
clearStoredSession();
clearAdminSessionState();
restoreError.value = '';
routeLoadError.value = '';
void router.replace('/overview');
}
function handleRefreshed(event: Event) {
applyAdminSession((event as CustomEvent<AdminSessionPayload>).detail);
void ensureAllowedRoute();
}
async function restoreSession() {
if (!adminSessionState.token || restoring.value) return;
restoring.value = true;
restoreError.value = '';
adminSessionState.ready = false;
try {
const result = await getAdminMe(session.value);
applyAdminIdentity(result.user, result.access);
await ensureAllowedRoute();
} catch (error) {
if (!localStorage.getItem('qipai.admin.token')) {
clearAdminSessionState();
} else {
restoreError.value = error instanceof Error
? `${error.message}。请检查网络后重试,或返回登录。`
: '会话恢复失败,请检查网络后重试。';
adminSessionState.ready = true;
}
} finally {
restoring.value = false;
}
}
async function ensureAllowedRoute() {
const menuKey = route.meta.menuKey as AdminMenuKey | undefined;
const target = menuKey
? resolveAuthorizedPath(adminSessionState.access, menuKey)
: firstAuthorizedPath(adminSessionState.access);
if (target && target !== route.path) await router.replace(target);
}
function openCleaning() {
void router.push('/cleaning');
}
function retryLazyRoute() {
window.location.reload();
}
const removeRouterErrorHandler = router.onError((error) => {
routeLoadError.value = error instanceof Error
? `${error.message}。可重新加载页面恢复。`
: '路由资源加载失败,可重新加载页面恢复。';
});
const removeAfterEach = router.afterEach((_to, _from, failure) => {
if (!failure) routeLoadError.value = '';
});
onMounted(() => {
window.addEventListener('qipai:admin-refreshed', handleRefreshed);
window.addEventListener('qipai:admin-unauthorized', clearSession);
if (adminSessionState.token) void restoreSession();
});
onBeforeUnmount(() => {
window.removeEventListener('qipai:admin-refreshed', handleRefreshed);
window.removeEventListener('qipai:admin-unauthorized', clearSession);
removeRouterErrorHandler();
removeAfterEach();
});
</script>