Files
edgeKey/pages/admin/+Layout.vue
2026-05-21 17:31:36 +08:00

233 lines
11 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.
<template>
<slot v-if="isLoginPage" />
<div v-else-if="needsLogin" class="flex min-h-screen items-center justify-center bg-base-200 px-4">
<section class="card w-full max-w-md bg-base-100 shadow-sm">
<div class="card-body space-y-4 text-center">
<h1 class="text-2xl font-bold">需要管理员登录</h1>
<p class="text-sm text-base-content/70">正在跳转到后台登录页如果没有自动跳转请手动点击下面按钮</p>
<AppButton variant="primary" :href="`/admin/login?redirect=${encodeURIComponent(currentPath)}`">前往登录</AppButton>
</div>
</section>
</div>
<div v-else class="drawer lg:drawer-open min-h-screen bg-base-200">
<input id="admin-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col min-w-0">
<!-- 移动端 Navbar -->
<div class="navbar bg-base-100 border-b border-base-300 w-full lg:hidden sticky top-0 z-40 shadow-sm">
<div class="flex-none">
<label for="admin-drawer" aria-label="open sidebar" class="btn btn-square btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-6 h-6 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</label>
</div>
<div class="flex-1 px-2 mx-2">
<h1 class="text-lg font-bold text-primary">edgeKey</h1>
</div>
</div>
<!-- PC端 Header -->
<header class="hidden lg:flex items-center justify-between border-b border-base-300 bg-base-100 px-8 py-4 sticky top-0 z-30 shadow-sm">
<div>
<div class="breadcrumbs text-sm text-base-content/60 mt-0.5">
<ul>
<li><a href="/admin">Home</a></li>
<li v-for="(crumb, index) in breadcrumbs" :key="index">
<a v-if="crumb.href" :href="crumb.href">{{ crumb.name }}</a>
<span v-else>{{ crumb.name }}</span>
</li>
</ul>
</div>
</div>
<ul class="menu menu-horizontal bg-base-200 rounded-box p-1 gap-1">
<li class="tooltip tooltip-bottom z-50" data-tip="语言: 中文">
<a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
</a>
</li>
<li class="tooltip tooltip-bottom z-50" data-tip="切换主题">
<label class="swap swap-rotate px-3 py-2">
<!-- this hidden checkbox controls the state -->
<input type="checkbox" class="theme-controller" value="dark" />
<!-- sun icon -->
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" /></svg>
<!-- moon icon -->
<svg class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" /></svg>
</label>
</li>
<li class="tooltip tooltip-bottom z-50" data-tip="返回前台">
<a href="/">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
</a>
</li>
</ul>
</header>
<!-- Main Content -->
<main class="flex-1 p-4 lg:p-8">
<div class="mx-auto max-w-7xl w-full">
<slot />
</div>
</main>
</div>
<div class="drawer-side z-50">
<label for="admin-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<aside class="bg-base-100 min-h-screen w-72 flex flex-col border-r border-base-300 shadow-sm">
<!-- Logo Area -->
<a
href="/admin"
class="p-6 pb-2 flex items-center gap-2 hover:bg-base-200 transition-colors block"
>
<img :src="siteLogo" height="50" width="50" alt="logo" />
<div>
<div class="text-2xl font-black text-primary tracking-tight">EK Admin</div>
<p class="text-xs font-medium text-base-content/50 mt-1 uppercase tracking-wider">
全球部署一触即达
</p>
</div>
</a>
<!-- Navigation -->
<div class="p-4 flex-1 overflow-y-auto">
<ul class="menu menu-md w-full gap-1 p-0">
<li>
<a href="/admin" :class="{'active': currentPath === '/admin'}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
仪表盘
</a>
</li>
<li v-for="group in menuGroups" :key="group.name">
<details :open="isGroupOpen(group, currentPath)">
<summary>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="group.icon" />
</svg>
{{ group.name }}
</summary>
<ul>
<li v-for="item in group.items" :key="item.href">
<a :href="item.href" :class="{'active': isItemActive(item, currentPath)}">{{ item.name }}</a>
</li>
</ul>
</details>
</li>
</ul>
</div>
<!-- Footer Area -->
<div class="p-4 border-t border-base-300 mt-auto space-y-2">
<AppButton variant="outline" block @click="handleSignOut">退出登录</AppButton>
<div class="flex items-center justify-between text-xs text-base-content/50 px-2">
<a class="cursor-default" target="_blank" href="https://github.com/34892002/edgeKey">edgeKey</a>
<div class="">
<button class="hover:text-primary transition-colors mr-2" @click="checkUpdate" :title="updateTip" >
v{{ appVersion }}-{{ gitHash }}
</button>
<div class="inline-grid *:[grid-area:1/1]">
<div class="animate-ping status" :class="statusColor"></div>
<div class="status" :class="statusColor"></div>
</div>
</div>
</div>
</div>
</aside>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import AppButton from "../../components/AppButton.vue";
import { usePageContext } from "vike-vue/usePageContext";
import { menuGroups, getBreadcrumbs, isGroupOpen, isItemActive } from "./menu";
import logoUrl from "../../assets/logo.svg";
const pageContext = usePageContext();
const currentPath = computed(() => pageContext.urlPathname ?? "");
const gitHash = __GIT_HASH__;
const appVersion = __APP_VERSION__;
const statusColor = ref('status-success');
const updateTip = ref('点击检查更新');
const lastCheckTime = ref(0);
const COOLDOWN_MS = 10 * 60 * 1000;
async function checkUpdate() {
const now = Date.now();
if (now - lastCheckTime.value < COOLDOWN_MS) {
// const remaining = Math.ceil((COOLDOWN_MS - (now - lastCheckTime.value)) / 60000);
updateTip.value = '请勿频繁查询';
return;
}
lastCheckTime.value = now;
statusColor.value = 'status-warning';
updateTip.value = '检查中...';
try {
const pkgRes = await fetch('https://raw.githubusercontent.com/34892002/edgeKey/main/package.json');
const pkg = await pkgRes.json() as { version?: string };
if (!pkg.version) throw new Error('invalid response');
// 这里只比较 package.json 中的纯版本号,不参与 git hash 展示串。
const isLatest = compareVersions(appVersion, pkg.version) >= 0;
statusColor.value = isLatest ? 'status-success' : 'status-error';
updateTip.value = isLatest ? `已是最新版本` : `有新版本 v${pkg.version}`;
} catch {
statusColor.value = 'status-error';
updateTip.value = '检查失败,请稍后重试';
}
}
// 比较 package.json 的纯版本号,例如 1.2.1 与 1.2.2。
function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
const len = Math.max(parts1.length, parts2.length);
for (let i = 0; i < len; i++) {
const num1 = parts1[i] || 0;
const num2 = parts2[i] || 0;
if (num1 > num2) return 1;
if (num1 < num2) return -1;
}
return 0;
}
async function handleSignOut() {
const form = document.createElement("form");
form.method = "POST";
form.action = "/api/auth/signout";
const csrf = document.createElement("input");
csrf.type = "hidden";
csrf.name = "csrfToken";
const token = await fetch("/api/auth/csrf").then(r => r.json()).then((d: any) => d.csrfToken);
csrf.value = token;
form.appendChild(csrf);
document.body.appendChild(form);
form.submit();
}
const isLoginPage = computed(() => (pageContext.urlPathname ?? "") === "/admin/login");
const isAdminUser = computed(() => pageContext.session?.user?.role === "admin");
const needsLogin = computed(() => !isLoginPage.value && !isAdminUser.value);
const siteLogo = computed(() => pageContext.site?.logo || logoUrl);
const breadcrumbs = computed(() => getBreadcrumbs(currentPath.value));
onMounted(() => {
if (needsLogin.value) {
window.location.href = `/admin/login?redirect=${encodeURIComponent(currentPath.value)}`;
}
});
</script>