270 lines
5.8 KiB
Vue

<script setup lang="ts">
import { Bell, ChevronDown } from "@lucide/vue";
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useRoute, useRouter } from "vue-router";
import { useAppStore } from "@/stores/app.store";
import { useAuthStore } from "@/stores/auth.store";
const appStore = useAppStore();
const { isOnline } = storeToRefs(appStore);
const auth = useAuthStore();
const { user } = storeToRefs(auth);
const router = useRouter();
const route = useRoute();
const navigation = ["对话", "看板", "供应商", "标准库", "报告", "风险项管理"];
const navigationRoutes: Record<string, string> = {
对话: "assessment",
看板: "overview",
供应商: "supplier",
标准库: "standards",
报告: "report",
风险项管理: "review",
};
const visibleNavigation = computed(() =>
navigation.filter(
(item) => item !== "风险项管理" || auth.hasPermission("risk_item.review"),
),
);
const routeChunks: Record<string, () => Promise<unknown>> = {
看板: () => import("@/pages/OverviewPage.vue"),
供应商: () => import("@/pages/SupplierProfilePage.vue"),
标准库: () => import("@/pages/StandardsPage.vue"),
报告: () => import("@/pages/ReportPage.vue"),
风险项管理: () => import("@/pages/ReviewPage.vue"),
};
const prefetched = new Set<string>();
function prefetch(item: string) {
if (prefetched.has(item)) return;
const loader = routeChunks[item];
if (!loader) return;
prefetched.add(item);
void loader();
}
</script>
<template>
<header class="app-header">
<RouterLink class="brand" :to="{ name: 'assessment' }">
<span class="brand__mark">蝶</span>
<span class="brand__name">风险预警助手</span>
<span class="engine-badge">双引擎</span>
</RouterLink>
<nav class="top-navigation" aria-label="顶部导航">
<RouterLink
v-for="item in visibleNavigation"
:key="item"
:to="{
name: navigationRoutes[item] || 'assessment',
}"
:class="{
active:
(item === '看板' && route.name === 'overview') ||
(item === '供应商' && route.name === 'supplier') ||
(item === '标准库' && route.name === 'standards') ||
(item === '报告' && route.name === 'report') ||
(item === '风险项管理' && route.name === 'review') ||
(item === '对话' && route.name === 'assessment'),
}"
@mouseenter="prefetch(item)"
@focus="prefetch(item)"
>
{{ item }}
</RouterLink>
</nav>
<div class="header-actions">
<span
class="service-dot"
:class="{ 'service-dot--online': isOnline }"
:title="isOnline ? '服务正常' : '服务离线'"
></span>
<button class="header-icon" type="button" title="通知" aria-label="通知">
<Bell :size="16" />
</button>
<button
class="user-pill"
type="button"
title="退出登录"
@click="
auth.logout();
router.push({ name: 'login' });
"
>
<span class="user-avatar">{{ user?.name?.slice(0, 1) ?? "访" }}</span>
<span>{{ user?.name ?? "访客" }}</span>
<ChevronDown :size="13" />
</button>
</div>
</header>
</template>
<style scoped>
.app-header {
z-index: 10;
display: flex;
height: var(--header-height);
flex: 0 0 var(--header-height);
align-items: center;
gap: 16px;
border-bottom: 1px solid var(--color-border);
padding: 0 20px;
background: var(--color-surface);
}
.brand {
display: flex;
align-items: center;
gap: 8px;
color: var(--color-text);
text-decoration: none;
white-space: nowrap;
}
.brand__mark {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 6px;
background: var(--color-text);
color: white;
font-size: 13px;
font-weight: 700;
}
.brand__name {
font-size: 14px;
font-weight: 650;
}
.engine-badge {
border: 1px solid var(--color-accent-soft);
border-radius: 999px;
padding: 1px 6px;
background: var(--color-accent-subtle);
color: var(--color-accent);
font-size: 9px;
font-weight: 700;
}
nav {
display: flex;
min-width: 0;
flex: 1;
gap: 2px;
margin-left: 16px;
}
nav a {
border-radius: 6px;
padding: 6px 12px;
color: var(--color-text-muted);
font-size: 13px;
text-decoration: none;
white-space: nowrap;
}
nav a:hover,
nav a.active {
background: var(--color-bg-warm);
color: var(--color-text);
}
.header-actions,
.user-pill {
display: flex;
align-items: center;
}
.header-actions {
gap: 8px;
}
.service-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-danger);
}
.service-dot--online {
background: var(--color-success);
}
.header-icon {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--color-text-muted);
}
.header-icon:hover {
background: var(--color-bg-warm);
color: var(--color-text);
}
.user-pill {
min-height: 34px;
gap: 7px;
border: 0;
border-radius: 999px;
padding: 4px 10px 4px 4px;
background: var(--color-bg-warm);
color: var(--color-text-secondary);
font-size: 12px;
}
.user-avatar {
display: grid;
width: 26px;
height: 26px;
place-items: center;
border-radius: 50%;
background: var(--color-text);
color: white;
font-size: 11px;
font-weight: 600;
}
@media (max-width: 960px) {
nav a:nth-child(n + 4) {
display: none;
}
}
@media (max-width: 680px) {
.app-header {
padding-inline: 12px;
}
.engine-badge,
.top-navigation,
.header-icon,
.user-pill > span:not(.user-avatar),
.user-pill svg {
display: none;
}
.header-actions {
margin-left: auto;
}
.brand__name {
font-size: 13px;
}
.user-pill {
padding: 4px;
}
}
</style>