Files
console-dev/app/layouts/default.vue
T

711 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
/**
* Deni 应用外壳适配:保留会员权限、真实通知和 Nuxt 路由,只迁移布局与视觉组合。
* 布局来源:OpenVue Foundation Deni,MIT;完整归属见项目第三方许可说明。
*/
import "~/assets/styles/ConsoleLegalFooter.DAYWmXIB.css";
import "~/assets/styles/BackendNavigation.CEj4LnzI.css";
import "~/assets/styles/BackendNavEntry.Bx6IPica.css";
import "~/assets/styles/deni.css";
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from "vue";
import { useHead, useRoute, useRouter } from "#app";
import { Icon, NuxtLink } from "#components";
import Avatar from "openvue/avatar";
import Badge from "openvue/badge";
import Dialog from "openvue/dialog";
import Menu from "openvue/menu";
import BackendNavEntry from "~/components/BackendNavEntry.vue";
import BackendNavigation from "~/components/BackendNavigation.vue";
import ConsoleLegalFooter from "~/components/ConsoleLegalFooter.vue";
import { useApi, useAuth } from "~/lib/auth.js";
import {
a as mergeMenuSections,
c as routeMatches,
l as normalizeMenu,
r as configuredRoute,
s as filterStaticMenu,
t as menuBreadcrumb,
} from "~/lib/backend-menu.js";
import { r as formatTime } from "~/lib/dict.js";
import { t as useNavigationState } from "~/lib/navigation-state.js";
import { t as useMailbox } from "~/lib/useMailbox.js";
import { t as useSiteConfig } from "~/lib/useSiteConfig.js";
import { t as useTheme } from "~/lib/useTheme.js";
import { logoUrl } from "~/utils/brand.js";
const auth = useAuth();
const router = useRouter();
const route = useRoute();
const theme = useTheme();
const site = useSiteConfig();
const maintenanceBanner = ref(null);
const bannerHeight = ref(0);
let bannerObserver;
// 维护条可能换行或加载新字体,业务吸顶和 Teleport 浮层共用实测偏移。
watch(
maintenanceBanner,
(element) => {
bannerObserver?.disconnect();
const measure = () => {
bannerHeight.value = element
? Math.ceil(element.getBoundingClientRect().height)
: 0;
};
measure();
if (element) {
bannerObserver = new ResizeObserver(measure);
bannerObserver.observe(element);
}
},
{ flush: "post" },
);
onBeforeUnmount(() => bannerObserver?.disconnect());
const staticSections = [
{
label: "",
items: [
{ label: "首页", icon: "lucide:house", to: "/" },
{ label: "订购", icon: "lucide:shopping-cart", to: "/products" },
{ label: "购物车", icon: "lucide:shopping-basket", to: "/cart" },
],
},
{
label: "资源管理",
items: [
{
label: "服务器",
icon: "lucide:server",
children: [
{ label: "云服务器", to: "/cloud-servers" },
{ label: "物理服务器", to: "/dedicated-servers" },
],
},
{
label: "安全",
icon: "lucide:shield",
children: [
{ label: "安全组", to: "/security/groups" },
{ label: "SSH 密钥", to: "/security/ssh-keys" },
{ label: "域名白名单", to: "/security/domains" },
],
},
{ label: "实例转移", icon: "lucide:truck", to: "/transfer" },
],
},
{
label: "费用与优惠",
items: [
{ label: "财务", icon: "lucide:wallet", to: "/finance" },
{ label: "订单", icon: "lucide:clipboard-list", to: "/orders" },
{ label: "发票", icon: "lucide:receipt", to: "/invoices" },
{ label: "推介", icon: "lucide:gift", to: "/recommend" },
{ label: "代金券", icon: "lucide:ticket", to: "/vouchers" },
],
},
{
label: "账户",
items: [
{ label: "账户", icon: "lucide:user", to: "/account" },
{ label: "消息", icon: "lucide:mail", to: "/messages" },
{ label: "操作日志", icon: "lucide:scroll-text", to: "/logbook" },
],
},
{
label: "服务与支持",
items: [
{ label: "工单", icon: "lucide:headphones", to: "/tickets" },
{ label: "帮助", icon: "lucide:circle-help", to: "/help" },
{ label: "公告", icon: "lucide:megaphone", to: "/notices" },
{ label: "新闻", icon: "lucide:newspaper", to: "/news" },
],
},
];
const navigation = useNavigationState(auth, useApi());
const backendEntries = navigation.entries;
const authoritative = navigation.authoritative;
const coveredPaths = ref([]);
const availableSections = computed(() =>
authoritative.value
? filterStaticMenu(staticSections, backendEntries.value)
: staticSections
.map((section) => ({
...section,
items: section.items.flatMap((item) => {
if (item.children) {
const children = item.children.filter(
(child) => !coveredPaths.value.includes(child.to),
);
return children.length ? [{ ...item, children }] : [];
}
return item.to && coveredPaths.value.includes(item.to)
? []
: [item];
}),
}))
.filter((section) => section.items.length),
);
const sections = computed(() =>
mergeMenuSections(
staticSections,
availableSections.value,
backendEntries.value,
),
);
// 后端菜单有权威性时不补回被关闭入口;匹配仍使用同一个路径/查询协议。
function loadNavigation(entries, isAuthoritative) {
backendEntries.value = normalizeMenu(entries);
authoritative.value = isAuthoritative;
const paths = [];
const collect = (items) => {
for (const item of items) {
if (item.to) paths.push(item.to);
if (item.children) collect(item.children);
}
};
collect(backendEntries.value);
coveredPaths.value = paths;
}
watch(
() => [backendEntries.value, route.path, route.query],
() => {
const target = configuredRoute(backendEntries.value, route);
if (target)
router.replace({
path: target.path,
query: { ...route.query, m: target.query.m },
});
},
);
const expandedGroups = ref({});
function isActive(to, siblings = []) {
return routeMatches(to, route, siblings);
}
function groupActive(item) {
return route.path === "/servers/server" && item.label === "服务器"
? true
: (item.children?.some((child) => isActive(child.to, item.children)) ??
false);
}
function groupExpanded(item) {
return expandedGroups.value[item.label] ?? groupActive(item);
}
function toggleGroup(item) {
expandedGroups.value[item.label] = !groupExpanded(item);
}
const userMenu = ref();
const userItems = computed(() => [
{
label: `${auth.overview?.username || "会员"} #${auth.overview?.id || ""}`,
disabled: true,
},
{ separator: true },
{
label: "账户管理",
icon: "pi pi-user",
command: () => router.push("/account"),
},
{
label: "钱包",
icon: "pi pi-wallet",
command: () => router.push("/finance"),
},
{
label: "购物车",
icon: "pi pi-shopping-cart",
command: () => router.push("/cart"),
},
{
label: "消息",
icon: "pi pi-bell",
command: () => router.push("/messages"),
},
{ separator: true },
{
label: "退出登录",
icon: "pi pi-sign-out",
command: async () => {
await auth.logout();
router.push("/login");
},
},
]);
const {
count: unreadCount,
recent: recentMessages,
refresh: refreshMailbox,
} = useMailbox();
const mailboxOpen = ref(false);
async function openMailbox() {
mailboxOpen.value = true;
await refreshMailbox();
}
function openMessage(id) {
mailboxOpen.value = false;
router.push({ path: "/messages", query: { id: String(id) } });
}
function openAllMessages() {
mailboxOpen.value = false;
router.push("/messages");
}
const drawerOpen = ref(false);
const sidebar = ref(null);
const navigationTrigger = ref(null);
const isMobile = ref(
typeof window !== "undefined" && window.innerWidth <= 1024,
);
let media;
let previousOverflow = "";
function syncViewport() {
isMobile.value = media?.matches ?? false;
if (!isMobile.value) drawerOpen.value = false;
}
onMounted(() => {
site.load().catch(() => {});
if (auth.isLoggedIn && !auth.overview) auth.fetchOverview().catch(() => {});
refreshMailbox();
media = window.matchMedia("(max-width: 1024px)");
syncViewport();
media.addEventListener("change", syncViewport);
});
onBeforeUnmount(() => {
media?.removeEventListener("change", syncViewport);
if (drawerOpen.value) document.body.style.overflow = previousOverflow;
});
watch(
() => route.fullPath,
() => {
drawerOpen.value = false;
},
);
watch(drawerOpen, async (open) => {
if (open) {
previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
await nextTick();
sidebar.value?.querySelector(".nav-close")?.focus();
} else {
document.body.style.overflow = previousOverflow;
await nextTick();
if (isMobile.value) navigationTrigger.value?.focus();
}
});
function trapNavigationFocus(event) {
if (!isMobile.value || !drawerOpen.value) return;
if (event.key === "Escape") {
event.preventDefault();
drawerOpen.value = false;
return;
}
if (event.key !== "Tab") return;
const controls = Array.from(
sidebar.value?.querySelectorAll(
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])",
) ?? [],
).filter((element) => element.getClientRects().length > 0);
const first = controls[0];
const last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
const detailTitles = {
"/products": "商品详情",
"/products/cloud-server": "云服务器",
"/products/dedicated-server": "物理服务器",
"/products/dedicated-server/customize": "配置物理服务器",
"/orders/order/pay": "订单支付",
"/orders/order/paid": "支付结果",
"/invoices/request": "申请发票",
"/invoices/invoice": "发票详情",
"/account/realname/personal": "个人实名认证",
"/account/realname/company": "组织机构认证",
"/wallet": "钱包",
"/security/domain-whitelist": "域名白名单",
"/servers/server": "实例详情",
"/tickets/create": "创建工单",
"/tickets": "工单详情",
"/orders": "订单详情",
"/bills": "账单详情",
"/invoices/settings": "发票设置",
"/account/realname": "实名认证",
"/security/groups": "安全组规则",
"/notices": "公告详情",
"/account/api-keys": "API 密钥",
"/account/connections": "关联账号",
"/resources": "资源中心",
};
const breadcrumbs = computed(() => {
const path = route.path;
const title = detailTitles[path];
if (path === "/servers/server") return ["服务器", "实例详情"];
const configured = menuBreadcrumb(backendEntries.value, route);
if (configured.length) {
const containsPath = (items) =>
items.some(
(item) =>
item.to?.split("?")[0] === path ||
(item.children && containsPath(item.children)),
);
if (!containsPath(backendEntries.value)) {
const detail =
title ||
Object.entries(detailTitles)
.filter(([prefix]) => path.startsWith(`${prefix}/`))
.sort(([a], [b]) => b.length - a.length)[0]?.[1] ||
"详情";
return [...configured, detail];
}
return configured;
}
const active = staticSections
.flatMap((section) =>
section.items.flatMap((item) =>
item.children
? item.children.map((child) => ({
...child,
group: item.label,
siblings: item.children,
}))
: [
{
to: item.to || "/",
label: item.label,
group: section.label,
siblings: undefined,
},
],
),
)
.filter((item) => isActive(item.to, item.siblings))
.sort((a, b) => b.to.split("?")[0].length - a.to.split("?")[0].length)[0];
if (!active) return title ? [title] : ["首页"];
const result =
active.group && active.group !== active.label
? [active.group, active.label]
: [active.label];
const base = active.to.split("?")[0];
if (path !== base) result.push(title || detailTitles[base] || "详情");
return result;
});
useHead(() => ({
title: `${breadcrumbs.value.at(-1) || "会员中心"} - ${site.siteName.value}`,
htmlAttrs: {
style: {
"--console-header-height": "56px",
"--console-banner-height": `${site.maintenance.value ? bannerHeight.value : 0}px`,
"--console-top-offset":
"calc(var(--console-header-height) + var(--console-banner-height))",
},
},
}));
</script>
<template>
<div
class="layout deni-layout"
:class="{ 'layout-overlay-open': drawerOpen }"
>
<a href="#main-content" class="skip-link">跳转到主要内容</a>
<div
v-if="site.maintenance.value"
ref="maintenanceBanner"
class="maintenance-banner"
>
<Icon name="lucide:triangle-alert" aria-hidden="true" />
<span>{{
site.config.value?.maintenance_mode_message ||
"站点维护中,部分功能可能不可用。"
}}</span>
</div>
<aside
id="console-navigation"
ref="sidebar"
class="sidebar"
:inert="isMobile && !drawerOpen"
:role="isMobile && drawerOpen ? 'dialog' : undefined"
:aria-modal="isMobile && drawerOpen ? true : undefined"
aria-label="主导航"
@keydown="trapNavigationFocus"
>
<div class="sidebar-brand">
<NuxtLink to="/" class="brand" aria-label="破碎工坊首页">
<img :src="logoUrl" class="brand-logo" alt="破碎工坊云" />
</NuxtLink>
<button
type="button"
class="nav-close"
aria-label="关闭菜单"
@click="drawerOpen = false"
>
<Icon name="lucide:x" aria-hidden="true" />
</button>
</div>
<nav class="sidebar-nav" aria-label="会员中心导航">
<BackendNavigation @loaded="loadNavigation">
<section
v-for="(section, index) in sections"
:key="index"
class="sidebar-section"
>
<p v-if="section.label" class="sidebar-section-label">
{{ section.label }}
</p>
<div class="sidebar-list">
<template v-for="item in section.items" :key="item.label">
<BackendNavEntry
v-if="item.backend"
:entry="item.backend"
:siblings="
section.items.flatMap((entry) =>
entry.backend ? [entry.backend] : [],
)
"
/>
<div v-else-if="item.children" class="nav-group">
<button
type="button"
class="nav-item"
:class="{ active: groupActive(item) }"
:aria-expanded="groupExpanded(item)"
:aria-controls="`nav-group-${item.label}`"
@click="toggleGroup(item)"
>
<Icon
:name="item.icon"
class="nav-icon"
aria-hidden="true"
/>
<span class="nav-label">{{ item.label }}</span>
<Icon
name="lucide:chevron-right"
class="nav-arrow"
:class="{ open: groupExpanded(item) }"
aria-hidden="true"
/>
</button>
<div
:id="`nav-group-${item.label}`"
class="sub-nav"
:class="{ open: groupExpanded(item) }"
:inert="!groupExpanded(item)"
:aria-hidden="!groupExpanded(item)"
>
<div class="sub-nav-clip">
<div class="sub-nav-items">
<NuxtLink
v-for="child in item.children"
:key="child.to"
:to="child.to"
class="sub-item"
:class="{ active: isActive(child.to, item.children) }"
:aria-current="
isActive(child.to, item.children)
? 'page'
: undefined
"
>{{ child.label }}</NuxtLink
>
</div>
</div>
</div>
</div>
<NuxtLink
v-else-if="item.to"
:to="item.to"
class="nav-item"
:class="{ active: isActive(item.to) }"
:aria-current="isActive(item.to) ? 'page' : undefined"
>
<Icon :name="item.icon" class="nav-icon" aria-hidden="true" />
<span class="nav-label">{{ item.label }}</span>
</NuxtLink>
</template>
</div>
</section>
</BackendNavigation>
</nav>
<div class="sidebar-footer">
<NuxtLink to="/account" class="sidebar-account">
<Avatar
:label="(auth.overview?.username || '会员').slice(0, 1)"
shape="circle"
aria-hidden="true"
/>
<span class="sidebar-account-text">
<span class="sidebar-account-name">{{
auth.overview?.username || "会员"
}}</span>
<span class="sidebar-account-id"
>#{{ auth.overview?.id || "" }}</span
>
</span>
<Icon name="lucide:settings" aria-hidden="true" />
</NuxtLink>
</div>
</aside>
<button
v-if="drawerOpen"
class="layout-mask"
type="button"
aria-label="关闭导航遮罩"
tabindex="-1"
@click="drawerOpen = false"
/>
<div class="layout-main main" :inert="isMobile && drawerOpen">
<header class="topbar">
<div class="topbar-left">
<button
ref="navigationTrigger"
class="nav-toggle topbar-action"
type="button"
aria-label="打开菜单"
:aria-expanded="drawerOpen"
aria-controls="console-navigation"
@click="drawerOpen = true"
>
<Icon name="lucide:menu" aria-hidden="true" />
</button>
<NuxtLink to="/" class="mobile-brand" aria-label="破碎工坊首页"
><img :src="logoUrl" class="brand-logo" alt="破碎工坊云"
/></NuxtLink>
<nav class="crumbs" aria-label="面包屑">
<NuxtLink to="/" class="crumb-home" aria-label="首页"
><Icon name="lucide:house" aria-hidden="true"
/></NuxtLink>
<template v-for="(crumb, index) in breadcrumbs" :key="index">
<Icon
name="lucide:chevron-right"
class="crumb-sep"
aria-hidden="true"
/>
<span
class="crumb"
:class="{ current: index === breadcrumbs.length - 1 }"
>{{ crumb }}</span
>
</template>
</nav>
</div>
<div class="topbar-right">
<button
class="topbar-action mail-btn"
type="button"
title="消息"
aria-label="查看消息"
@click="openMailbox"
>
<Icon name="lucide:bell" aria-hidden="true" />
<Badge
v-if="unreadCount > 0"
:value="unreadCount > 99 ? '99+' : String(unreadCount)"
severity="danger"
class="mail-badge"
/>
</button>
<button
class="topbar-action"
type="button"
:title="`主题:${theme.label.value}(点击切换)`"
:aria-label="`切换主题,当前${theme.label.value}`"
@click="theme.cycle()"
>
<Icon
:name="
theme.mode.value === 'light'
? 'lucide:sun'
: theme.mode.value === 'dark'
? 'lucide:moon'
: 'lucide:monitor'
"
aria-hidden="true"
/>
</button>
<span class="topbar-divider" aria-hidden="true" />
<button
class="topbar-user"
type="button"
aria-label="用户菜单"
aria-haspopup="menu"
@click="userMenu.toggle($event)"
>
<Avatar
:label="(auth.overview?.username || '会员').slice(0, 1)"
shape="circle"
aria-hidden="true"
/>
<span class="topbar-user-name">{{
auth.overview?.username || "会员"
}}</span>
<Icon name="lucide:chevron-down" aria-hidden="true" />
</button>
</div>
<Menu ref="userMenu" :model="userItems" popup />
</header>
<Dialog
v-model:visible="mailboxOpen"
header="事件通知"
modal
:style="{ width: '672px' }"
:draggable="false"
>
<div class="mail-pop">
<div v-if="recentMessages.length" class="mail-pop-list">
<button
v-for="message in recentMessages"
:key="message.id"
type="button"
class="mail-pop-item"
:class="{ unread: message.read === 0 }"
@click="openMessage(message.id)"
>
<span class="mail-pop-title"
><span v-if="message.read === 0" class="mail-pop-dot" />
{{ message.title }}</span
>
<span class="mail-pop-time">{{
formatTime(message.create_time)
}}</span>
</button>
</div>
<div v-else class="mail-pop-empty">
<Icon name="lucide:bell" aria-hidden="true" />
<strong>暂无事件通知</strong>
<span>目前没有待处理的消息通知</span>
</div>
<button type="button" class="mail-pop-foot" @click="openAllMessages">
查看全部
</button>
</div>
</Dialog>
<main id="main-content" class="layout-content content" tabindex="-1">
<slot />
</main>
<ConsoleLegalFooter />
</div>
</div>
</template>