731 lines
23 KiB
Vue
731 lines
23 KiB
Vue
<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,
|
||
status: mailboxStatus,
|
||
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 class="maintenance-message">{{
|
||
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="mailboxStatus === 'loading' || mailboxStatus === 'idle'"
|
||
class="mail-pop-empty"
|
||
role="status"
|
||
>
|
||
正在加载通知…
|
||
</div>
|
||
<div
|
||
v-else-if="mailboxStatus === 'error'"
|
||
class="mail-pop-empty"
|
||
role="alert"
|
||
>
|
||
<Icon name="lucide:triangle-alert" aria-hidden="true" />
|
||
<strong>通知暂时不可用</strong>
|
||
<span>加载失败,请重试</span>
|
||
<button type="button" class="mail-pop-foot" @click="refreshMailbox">
|
||
重新加载通知
|
||
</button>
|
||
</div>
|
||
<div v-else-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>
|