feat: ui优化、二级菜单

This commit is contained in:
ggyy
2026-05-21 17:31:36 +08:00
parent 4d69df2f37
commit b79bd34adc
4 changed files with 240 additions and 76 deletions

View File

@@ -32,9 +32,10 @@
<div class="breadcrumbs text-sm text-base-content/60 mt-0.5">
<ul>
<li><a href="/admin">Home</a></li>
<li v-if="breadcrumbs?.length > 0"><a :href="breadcrumbs[0].href">{{ breadcrumbs[0].name }}</a>
<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>
<li v-if="breadcrumbs?.length > 1">{{ breadcrumbs[1].name }}</li>
</ul>
</div>
</div>
@@ -95,18 +96,30 @@
<!-- 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'}">仪表盘</a></li>
<li><a href="/admin/categories" :class="{'active': currentPath?.startsWith('/admin/categories')}">分类管理</a></li>
<li><a href="/admin/products" :class="{'active': currentPath?.startsWith('/admin/products')}">商品管理</a></li>
<li><a href="/admin/cards" :class="{'active': currentPath?.startsWith('/admin/cards')}">卡密管理</a></li>
<li><a href="/admin/orders" :class="{'active': currentPath?.startsWith('/admin/orders')}">订单管理</a></li>
<li><a href="/admin/discount-codes" :class="{'active': currentPath?.startsWith('/admin/discount-codes')}">折扣码管理</a></li>
<li><a href="/admin/payments" :class="{'active': currentPath?.startsWith('/admin/payments')}">支付配置</a></li>
<li><a href="/admin/email" :class="{'active': currentPath?.startsWith('/admin/email')}">邮件管理</a></li>
<li><a href="/admin/media" :class="{'active': currentPath?.startsWith('/admin/media')}">文件管理</a></li>
<li><a href="/admin/settings" :class="{'active': currentPath?.startsWith('/admin/settings')}">站点设置</a></li>
<!-- <li><a href="/admin/security" :class="{'active': currentPath?.startsWith('/admin/security')}">安全配置</a></li> -->
<li><a href="/admin/profile" :class="{'active': currentPath?.startsWith('/admin/profile')}">个人资料</a></li>
<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>
@@ -135,6 +148,7 @@
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";
@@ -208,35 +222,7 @@ const isAdminUser = computed(() => pageContext.session?.user?.role === "admin");
const needsLogin = computed(() => !isLoginPage.value && !isAdminUser.value);
const siteLogo = computed(() => pageContext.site?.logo || logoUrl);
type Crumb = { name: string; href?: string };
const BREADCRUMB_ROUTES: { pattern: string; crumbs: Crumb[] }[] = [
{ pattern: "/admin/products/new", crumbs: [{ name: "商品管理", href: "/admin/products" }, { name: "新建商品" }] },
{ pattern: "/admin/products/:id/edit", crumbs: [{ name: "商品管理", href: "/admin/products" }, { name: "编辑商品" }] },
{ pattern: "/admin/products", crumbs: [{ name: "商品管理" }] },
{ pattern: "/admin/orders/:id", crumbs: [{ name: "订单管理", href: "/admin/orders" }, { name: "订单详情" }] },
{ pattern: "/admin/orders", crumbs: [{ name: "订单管理" }] },
{ pattern: "/admin/categories", crumbs: [{ name: "分类管理" }] },
{ pattern: "/admin/cards", crumbs: [{ name: "卡密管理" }] },
{ pattern: "/admin/discount-codes", crumbs: [{ name: "折扣码管理" }] },
{ pattern: "/admin/payments", crumbs: [{ name: "支付配置" }] },
{ pattern: "/admin/email", crumbs: [{ name: "邮件管理" }] },
{ pattern: "/admin/media", crumbs: [{ name: "文件管理" }] },
{ pattern: "/admin/settings", crumbs: [{ name: "站点设置" }] },
{ pattern: "/admin/security", crumbs: [{ name: "安全配置" }] },
{ pattern: "/admin/profile", crumbs: [{ name: "个人资料" }] },
];
function matchRoute(pattern: string, path: string) {
const re = new RegExp("^" + pattern.replace(/:[^/]+/g, "[^/]+") + "(/.*)?$");
return re.test(path);
}
const breadcrumbs = computed((): Crumb[] => {
const path = currentPath.value ?? "";
const route = BREADCRUMB_ROUTES.find(r => matchRoute(r.pattern, path));
return route ? route.crumbs : [];
});
const breadcrumbs = computed(() => getBreadcrumbs(currentPath.value));
onMounted(() => {
if (needsLogin.value) {

104
pages/admin/menu.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* 后台菜单和路由配置
* 集中管理页面路径、菜单显示、面包屑导航
*/
export interface MenuItem {
name: string
href: string
}
export interface MenuGroup {
icon: string // SVG path
name: string
items: MenuItem[]
}
export interface Crumb {
name: string
href?: string
}
// 菜单组配置
export const menuGroups: MenuGroup[] = [
{
icon: "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",
name: "商品与订单",
items: [
{ name: "分类管理", href: "/admin/categories" },
{ name: "商品管理", href: "/admin/products" },
{ name: "卡密管理", href: "/admin/cards" },
{ name: "订单管理", href: "/admin/orders" },
{ name: "折扣码管理", href: "/admin/discount-codes" },
],
},
{
icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065zM15 12a3 3 0 11-6 0 3 3 0 016 0z",
name: "系统配置",
items: [
{ name: "支付配置", href: "/admin/payments" },
{ name: "邮件管理", href: "/admin/email" },
{ name: "站点设置", href: "/admin/settings" },
{ name: "安全设置", href: "/admin/security" },
],
},
{
icon: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z",
name: "内容管理",
items: [
{ name: "文件管理", href: "/admin/media" },
],
},
{
icon: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z",
name: "账户",
items: [
{ name: "个人资料", href: "/admin/profile" },
],
},
]
// 面包屑配置(特殊路由)
export const breadcrumbRoutes: { pattern: string; crumbs: Crumb[] }[] = [
{ pattern: "/admin/products/new", crumbs: [{ name: "商品与订单", href: "/admin/products" }, { name: "新建商品" }] },
{ pattern: "/admin/products/:id/edit", crumbs: [{ name: "商品与订单", href: "/admin/products" }, { name: "编辑商品" }] },
{ pattern: "/admin/orders/:id", crumbs: [{ name: "商品与订单", href: "/admin/orders" }, { name: "订单详情" }] },
]
// 根据路径获取面包屑
export function getBreadcrumbs(path: string): Crumb[] {
// 先匹配特殊路由
for (const route of breadcrumbRoutes) {
if (matchRoute(route.pattern, path)) {
return route.crumbs
}
}
// 再匹配菜单项
for (const group of menuGroups) {
for (const item of group.items) {
if (path === item.href || path.startsWith(item.href + "/")) {
return [{ name: group.name }, { name: item.name }]
}
}
}
return []
}
// 判断菜单组是否应该展开
export function isGroupOpen(group: MenuGroup, currentPath: string): boolean {
return group.items.some(item =>
currentPath === item.href || currentPath.startsWith(item.href + "/")
)
}
// 判断菜单项是否激活
export function isItemActive(item: MenuItem, currentPath: string): boolean {
return currentPath === item.href || currentPath.startsWith(item.href + "/")
}
function matchRoute(pattern: string, path: string): boolean {
const re = new RegExp("^" + pattern.replace(/:[^/]+/g, "[^/]+") + "(/.*)?$")
return re.test(path)
}

View File

@@ -1,42 +1,102 @@
<template>
<section class="grid gap-6 lg:grid-cols-2">
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title">认证密钥</h2>
<p class="text-sm text-base-content/70">
生产环境必须配置 `AUTH_SECRET` `NEXTAUTH_SECRET`本地开发可以通过 `.env` 注入Cloudflare 生产环境建议使用 `wrangler secret put`
<section class="card bg-base-100 shadow-sm">
<div class="card-body space-y-6">
<div>
<h1 class="card-title text-2xl">Cloudflare Turnstile 登录验证</h1>
<p class="text-sm text-base-content/70 mt-2">
在管理员登录页接入 Cloudflare Turnstile 小组件用于拦截自动化爆破登录
</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title">登录限流</h2>
<p class="text-sm text-base-content/70">
当前管理员登录已启用基础请求限流你也应在 Cloudflare 后台为 `/api/auth/callback/credentials` 配置 Rate Limiting / WAF 规则
</p>
<!-- 当前状态 -->
<div class="alert" :class="turnstileEnabled ? 'alert-success' : 'alert-warning'">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span v-if="turnstileEnabled">Turnstile 已启用 - 登录页已显示人机验证</span>
<span v-else>Turnstile 未启用 - 请按下方步骤配置</span>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title">密码安全</h2>
<p class="text-sm text-base-content/70">
请在首次初始化完成后立即前往"个人资料"修改默认管理员密码并定期轮换高权限账号口令
</p>
<AppButton href="/admin/profile" variant="primary" size="sm">前往个人资料</AppButton>
<div class="divider"></div>
<!-- 配置步骤 -->
<div class="space-y-6">
<h2 class="text-lg font-semibold">配置步骤</h2>
<!-- 步骤 1 -->
<div class="flex gap-4">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-primary text-primary-content flex items-center justify-center font-bold">1</div>
<div class="flex-1 space-y-2">
<h3 class="font-medium">创建 Turnstile 站点</h3>
<p class="text-sm text-base-content/70">
前往
<a href="https://dash.cloudflare.com/turnstile" target="_blank" class="link link-primary">Cloudflare Dashboard Turnstile</a>
创建站点获取以下两个密钥
</p>
<div class="mockup-code text-sm">
<pre><code>TURNSTILE_SITE_KEY = 前端小组件站点 Key</code></pre>
<pre><code>TURNSTILE_SECRET_KEY = 服务端校验 Secret Key</code></pre>
</div>
</div>
</div>
<!-- 步骤 2 -->
<div class="flex gap-4">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-primary text-primary-content flex items-center justify-center font-bold">2</div>
<div class="flex-1 space-y-2">
<h3 class="font-medium">配置环境变量</h3>
<p class="text-sm text-base-content/70">
使用 wrangler 命令将密钥配置到 Cloudflare Workers
</p>
<div class="mockup-code text-sm">
<pre data-prefix="$"><code>wrangler secret put TURNSTILE_SITE_KEY</code></pre>
<pre data-prefix="$"><code>wrangler secret put TURNSTILE_SECRET_KEY</code></pre>
</div>
<div class="alert alert-info text-sm">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>本地开发时可在 <code>.env</code> 文件中直接配置这两个变量</span>
</div>
</div>
</div>
<!-- 步骤 3 -->
<div class="flex gap-4">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-primary text-primary-content flex items-center justify-center font-bold">3</div>
<div class="flex-1 space-y-2">
<h3 class="font-medium">完成</h3>
<p class="text-sm text-base-content/70">
配置完成后刷新此页面查看状态变化后台登录页会自动显示 Turnstile 小组件并在服务端强制校验
</p>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title">支付安全</h2>
<p class="text-sm text-base-content/70">
支付日志会保留业务信息用于排查但会对 `md5``key``secret``signature` 等敏感字段做脱敏处理启用支付前请先在"站点设置"配置网站地址
</p>
<div class="flex gap-3">
<AppButton href="/admin/settings" variant="outline" size="sm">站点设置</AppButton>
<AppButton href="/admin/payments" variant="primary" size="sm">支付配置</AppButton>
<div class="divider"></div>
<!-- 状态说明 -->
<div class="space-y-3">
<h2 class="text-lg font-semibold">状态说明</h2>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>配置状态</th>
<th>行为</th>
</tr>
</thead>
<tbody>
<tr :class="{'bg-base-200': !turnstileEnabled}">
<td><span class="badge" :class="turnstileEnabled ? 'badge-ghost' : 'badge-warning'">两个变量都未配置</span></td>
<td>Turnstile 默认关闭不影响现有登录流程</td>
</tr>
<tr :class="{'bg-base-200': turnstileEnabled}">
<td><span class="badge" :class="turnstileEnabled ? 'badge-success' : 'badge-ghost'">两个变量都已配置</span></td>
<td>登录页自动显示 Turnstile 小组件服务端强制校验</td>
</tr>
<tr>
<td><span class="badge badge-ghost">只配置了一个变量</span></td>
<td>系统自动视为未启用避免半配置状态</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -44,5 +104,8 @@
</template>
<script setup lang="ts">
import AppButton from "../../../components/AppButton.vue";
</script>
import { useData } from "vike-vue/useData";
import type { Data } from "./+data";
const { turnstileEnabled } = useData<Data>();
</script>

View File

@@ -0,0 +1,11 @@
import type { PrismaClient } from "../../../generated/prisma/client";
import { getTurnstileConfig } from "../../../server/turnstile";
export type Data = ReturnType<typeof data>;
export async function data(pageContext: { prisma: PrismaClient }) {
const config = getTurnstileConfig();
return {
turnstileEnabled: config.enabled,
};
}