Merge branch 'main' of github.com:cedar-v/license-manager

This commit is contained in:
wood911010 committed 2025-08-08 20:34:10 +08:00
commit 183f5e7896
18 files changed
+1405 -70

No files matched your search

+9 -1
View File
@@ -1,8 +1,16 @@
###
# @Author: 13895237362 2205451508@qq.com
# @Date: 2025-07-31 14:30:20
# @LastEditors: 13895237362 2205451508@qq.com
# @LastEditTime: 2025-08-08 09:18:54
# @FilePath: /frontend/.env.development
# @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
###
# 开发环境
NODE_ENV = "development"
# 开发环境接口地址
VITE_API_BASE_URL = '/devApi'
VITE_API_BASE_URL = ''
# # 开发环境打包名称
# VITE_OUTDIR='dev'
+1 -1
View File
@@ -2,7 +2,7 @@
NODE_ENV = "production"
# 正式环境接口地址
VITE_API_BASE_URL = '/proApi'
VITE_API_BASE_URL = ''
# 正式环境打包名称
VITE_OUTDIR='production'
+9 -1
View File
@@ -1,8 +1,16 @@
###
# @Author: 13895237362 2205451508@qq.com
# @Date: 2025-07-31 14:30:20
# @LastEditors: 13895237362 2205451508@qq.com
# @LastEditTime: 2025-08-08 10:07:33
# @FilePath: /frontend/.env.test
# @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
###
# 测试环境
NODE_ENV = "test"
# 测试环境接口地址
VITE_API_BASE_URL = '/testApi'
VITE_API_BASE_URL = ''
# 测试环境打包名称
VITE_OUTDIR='test'
+4 -3
View File
@@ -8,6 +8,7 @@
"name": "vue3_vite",
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"amfe-flexible": "^2.2.1",
"axios": "^1.3.6",
"element-plus": "^2.10.5",
@@ -85,9 +86,9 @@
}
},
"node_modules/@element-plus/icons-vue": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.1.tgz",
"integrity": "sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.2.0"
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"amfe-flexible": "^2.2.1",
"axios": "^1.3.6",
"element-plus": "^2.10.5",
Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+1 -1
View File
@@ -2,7 +2,7 @@
"login": {
"title": "Authorization Management Platform",
"subtitle": "Sign in to your account",
"usernamePlaceholder": "Username",
"usernamePlaceholder": "Please enter Username",
"passwordPlaceholder": "Enter password",
"remember": "Remember password",
"forgotPassword": "Forgot password?",
+1 -1
View File
@@ -2,7 +2,7 @@
"login": {
"title": "権限管理プラットフォーム",
"subtitle": "アカウントにログイン",
"usernamePlaceholder": "ユーザー名",
"usernamePlaceholder": "ユーザー名を入力してください",
"passwordPlaceholder": "パスワードを入力してください",
"remember": "パスワードを記憶する",
"forgotPassword": "パスワードをお忘れですか?",
+1 -1
View File
@@ -2,7 +2,7 @@
"login": {
"title": "授权管理平台",
"subtitle": "登录您的账户",
"usernamePlaceholder": "用户名",
"usernamePlaceholder": "请输入用户名",
"passwordPlaceholder": "请输入密码",
"remember": "记住密码",
"forgotPassword": "忘记密码?",
+6
View File
@@ -15,11 +15,17 @@ import './assets/styles/global.css'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import i18n, { getCurrentElementLocale } from './i18n'
const app = createApp(App)
// 全局注册Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 正确配置Element Plus语言包
app.use(ElementPlus, {
locale: getCurrentElementLocale()
+3 -2
View File
@@ -2,7 +2,7 @@
* @Author: 13895237362 2205451508@qq.com
* @Date: 2025-08-01 09:32:42
* @LastEditors: 13895237362 2205451508@qq.com
* @LastEditTime: 2025-08-06 16:40:56
* @LastEditTime: 2025-08-08 11:03:44
* @FilePath: /frontend/src/router/index.ts
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
@@ -22,7 +22,8 @@ const routes: Array<RouteRecordRaw> = [
{
path: "/dashboard",
component: () => import("@/views/Dashboard.vue"),
meta: { title: "仪表板", requiresAuth: true }
// meta: { title: "仪表板", requiresAuth: true }
meta: { title: "仪表板"}
},
];
+34 -45
View File
@@ -1,53 +1,42 @@
<!--
* @Author: 13895237362 2205451508@qq.com
* @Date: 2025-08-01 09:32:42
* @LastEditors: 13895237362 2205451508@qq.com
* @LastEditTime: 2025-08-08 14:08:31
* @FilePath: /frontend/src/views/Dashboard.vue
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
-->
<template>
<div class="dashboard">
<h1>欢迎来到授权管理平台</h1>
<p>您已成功登录!</p>
<button @click="logout" class="logout-btn">退出登录</button>
</div>
<Layout app-name="License Manager" :nav-items="navigationItems" :breadcrumb-items="breadcrumbs" page-title="仪表盘"
@nav-click="handleNavigation">
<!-- 页面内容 -->
<div>
<h1>欢迎来到授权管理平台</h1>
<p>您已成功登录!</p>
</div>
</Layout>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import { ref } from 'vue'
import { useRouter } from 'vue-router';
import Layout from '@/views/components/layout/Layout.vue';
import type { NavItem, BreadcrumbItem } from '@/views/components/layout/index';
const router = useRouter();
function logout() {
localStorage.removeItem("token");
router.push("/login");
}
const navigationItems = ref<NavItem[]>([
{ id: "dashboard", label: "仪表盘", href: "/dashboard", icon: "Dashboard", active: true },
{ id: "customers", label: "客户管理", href: "/customers", icon: "User" },
{ id: "licenses", label: "授权管理", href: "/licenses", icon: "Key" },
{ id: "roles", label: "角色权限", href: "/roles", icon: "UserFilled" },
{ id: "users", label: "系统用户", href: "/users", icon: "Avatar" }
]);
const breadcrumbs = ref<BreadcrumbItem[]>([
{ title: "仪表盘", path: "/dashboard" }
]);
const handleNavigation = (item: NavItem) => {
router.push(item.href);
};
</script>
<style scoped>
.dashboard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: #f5f5f5;
}
h1 {
color: #333;
margin-bottom: 20px;
}
p {
color: #666;
margin-bottom: 30px;
}
.logout-btn {
padding: 10px 20px;
background: #2E69F7;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.logout-btn:hover {
background: #1557E5;
}
</style>
<style scoped></style>
+9 -14
View File
@@ -29,10 +29,9 @@
<!-- 登录区域 -->
<div class="login-section">
<div class="login-container">
<el-card class="login-card" :body-style="{ padding: '2rem' }">
<div class="login-card" >
<h1 class="title">{{ t('login.title') }}</h1>
<p class="subtitle">{{ t('login.subtitle') }}</p>
<el-form
ref="loginFormRef"
:model="loginForm"
@@ -94,7 +93,7 @@
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</div>
</div>
</div>
@@ -306,11 +305,7 @@ async function handleLogin() {
}
.login-card {
border: none;
border-radius: 20px;
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37);
backdrop-filter: blur(4px);
background: rgba(255, 255, 255, 0.95);
padding: '2rem'
}
.title {
@@ -341,7 +336,7 @@ async function handleLogin() {
.login-input :deep(.el-input__wrapper) {
height: clamp(45px, 8vh, 55px);
border-radius: 10px;
border: 1px solid #019C7C;
/* border: 1px solid #019C7C; */
background-color: #ffffff;
box-shadow: none;
transition: all 0.3s ease;
@@ -425,7 +420,7 @@ async function handleLogin() {
transition: all 0.3s ease;
}
.language-switcher :deep(.el-input__inner):focus {
.language-switcher :deep(.el-input__inner):hover {
border-color: #019C7C;
box-shadow: 0 0 0 2px rgba(1, 156, 124, 0.2);
}
@@ -483,7 +478,7 @@ async function handleLogin() {
max-width: 90vw;
}
.login-card :deep(.el-card__body) {
.login-card {
padding: 1.5rem !important;
}
@@ -507,7 +502,7 @@ async function handleLogin() {
max-width: 100%;
}
.login-card :deep(.el-card__body) {
.login-card {
padding: 1rem !important;
}
@@ -538,7 +533,7 @@ async function handleLogin() {
margin-bottom: 1.5rem;
}
.login-card :deep(.el-card__body) {
.login-card {
padding: 1.5rem !important;
}
}
@@ -548,7 +543,7 @@ async function handleLogin() {
padding-top: 3rem;
}
.login-card :deep(.el-card__body) {
.login-card {
padding: 1rem !important;
}
@@ -0,0 +1,375 @@
<!--
/**
* 主布局组件
* 提供完整的后台管理系统布局结构,包括侧边栏、顶部导航和主内容区域
* 支持响应式设计和侧边栏收起/展开功能
*/
-->
<template>
<!-- 主布局容器 -->
<div class="app-layout" :class="layoutClasses">
<!-- 遮罩层 - 移动端使用 -->
<div
v-if="isMobile && showSidebar"
class="layout-overlay"
@click="closeSidebar"
></div>
<!-- 侧边栏 -->
<Sidebar
:app-name="appName"
:nav-items="navItems"
:collapsible="sidebarCollapsible"
:default-collapsed="sidebarCollapsed"
:class="{
'sidebar--mobile-open': isMobile && showSidebar,
'sidebar--collapsed': sidebarCollapsed
}"
@nav-click="handleNavClick"
@toggle="handleSidebarToggle"
>
<template #logo>
<slot name="sidebar-logo">
<span class="logo-text">{{ appName }}</span>
</slot>
</template>
<template #nav-items>
<slot name="sidebar-nav"></slot>
</template>
<template #footer>
<slot name="sidebar-footer"></slot>
</template>
</Sidebar>
<!-- 主内容区域 -->
<div class="layout-main" :class="mainContentClasses">
<!-- 顶部导航 -->
<NavContent
:breadcrumb-items="breadcrumbItems"
:page-title="pageTitle"
:show-search="showSearch"
:search-placeholder="searchPlaceholder"
:show-notifications="showNotifications"
:notification-count="notificationCount"
:show-settings="showSettings"
:show-user-avatar="showUserAvatar"
:user-avatar="userAvatar"
:user-name="userName"
:show-menu-button="isMobile"
:class="{ 'nav-content--sidebar-collapsed': sidebarCollapsed }"
@search="handleSearch"
@breadcrumb-click="handleBreadcrumbClick"
@notification-click="handleNotificationClick"
@settings-click="handleSettingsClick"
@user-click="handleUserClick"
@menu-toggle="toggleSidebar"
>
<template #breadcrumb>
<slot name="nav-breadcrumb"></slot>
</template>
<template #actions>
<slot name="nav-actions"></slot>
</template>
</NavContent>
<!-- 页面内容 -->
<main class="layout-content">
<div class="content-container">
<slot name="default">
<router-view />
</slot>
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import Sidebar from './Sidebar.vue'
import NavContent from './NavContent.vue'
// 导航项接口定义
interface NavItem {
id: string // 导航项唯一标识
label: string // 显示文本
href: string // 链接地址
icon?: string // 图标类名(可选)
active?: boolean // 是否为当前活跃项(可选)
children?: NavItem[] // 子菜单项(可选)
}
// 面包屑导航项接口定义
interface BreadcrumbItem {
title: string // 显示标题
path?: string // 链接路径(可选,最后一项通常不需要链接)
}
// 组件属性接口定义
interface Props {
appName?: string // 应用名称
navItems?: NavItem[] // 导航菜单项列表
sidebarCollapsible?: boolean // 侧边栏是否可折叠
defaultSidebarCollapsed?: boolean // 侧边栏默认是否折叠
breadcrumbItems?: BreadcrumbItem[] // 面包屑导航项列表
pageTitle?: string // 当前页面标题
showSearch?: boolean // 是否显示搜索框
searchPlaceholder?: string // 搜索框占位符文本
showNotifications?: boolean // 是否显示通知按钮
notificationCount?: number // 未读通知数量
showSettings?: boolean // 是否显示设置按钮
showUserAvatar?: boolean // 是否显示用户头像
userAvatar?: string // 用户头像URL
userName?: string // 用户名称
}
// 定义组件属性和默认值
const props = withDefaults(defineProps<Props>(), {
appName: 'Cedar',
navItems: () => [],
sidebarCollapsible: true,
defaultSidebarCollapsed: false,
breadcrumbItems: () => [],
pageTitle: '',
showSearch: true,
searchPlaceholder: '搜索...',
showNotifications: true,
notificationCount: 0,
showSettings: true,
showUserAvatar: true,
userName: ''
})
// 定义组件事件
const emit = defineEmits<{
navClick: [item: NavItem, event: Event]
search: [query: string]
breadcrumbClick: [item: BreadcrumbItem, event: Event]
notificationClick: []
settingsClick: []
userClick: []
}>()
// 响应式状态管理
const isMobile = ref(false)
const showSidebar = ref(false)
const sidebarCollapsed = ref(props.defaultSidebarCollapsed)
// 计算布局样式类
const layoutClasses = computed(() => ({
'app-layout--mobile': isMobile.value,
'app-layout--sidebar-collapsed': sidebarCollapsed.value
}))
// 计算主内容区样式类
const mainContentClasses = computed(() => ({
'layout-main--sidebar-collapsed': sidebarCollapsed.value,
'layout-main--mobile': isMobile.value
}))
// 处理导航点击事件
const handleNavClick = (item: NavItem, event: Event) => {
if (isMobile.value) {
showSidebar.value = false
}
emit('navClick', item, event)
}
// 处理侧边栏收起/展开
const handleSidebarToggle = (collapsed: boolean) => {
sidebarCollapsed.value = collapsed
}
// 切换侧边栏显示状态
const toggleSidebar = () => {
if (isMobile.value) {
showSidebar.value = !showSidebar.value
} else {
sidebarCollapsed.value = !sidebarCollapsed.value
}
}
// 关闭侧边栏(移动端)
const closeSidebar = () => {
showSidebar.value = false
}
// 处理搜索事件
const handleSearch = (query: string) => {
emit('search', query)
}
// 处理面包屑导航点击
const handleBreadcrumbClick = (item: BreadcrumbItem, event: Event) => {
emit('breadcrumbClick', item, event)
}
// 处理通知按钮点击
const handleNotificationClick = () => {
emit('notificationClick')
}
// 处理设置按钮点击
const handleSettingsClick = () => {
emit('settingsClick')
}
// 处理用户头像点击
const handleUserClick = () => {
emit('userClick')
}
// 检测是否为移动设备
const checkMobile = () => {
isMobile.value = window.innerWidth <= 1024
if (!isMobile.value) {
showSidebar.value = false
}
}
// 组件挂载时初始化
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
// 组件卸载时清理
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
</script>
<style scoped>
.app-layout {
position: relative;
width: 100vw;
height: 100vh;
background: #F7F8FA;
overflow: hidden;
}
/* 遮罩层 */
.layout-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
backdrop-filter: blur(2px);
}
/* 主内容区域 */
.layout-main {
margin-left: 280px;
height: 100vh;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
}
.layout-main--sidebar-collapsed {
margin-left: 64px;
}
.layout-main--mobile {
margin-left: 0;
}
/* 页面内容 */
.layout-content {
flex: 1;
padding-top: 80px;
overflow-y: auto;
position: relative;
}
.content-container {
min-height: calc(100vh - 80px);
padding: 24px;
max-width: 1600px;
margin: 0 auto;
}
/* 移动端适配 */
@media (max-width: 1024px) {
.layout-main {
margin-left: 0;
}
}
@media (max-width: 768px) {
.content-container {
padding: 16px;
}
}
@media (max-width: 480px) {
.content-container {
padding: 12px;
}
}
/* 滚动条样式 */
.layout-content::-webkit-scrollbar {
width: 6px;
}
.layout-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.layout-content::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.layout-content::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* 动画效果 */
.app-layout * {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 打印样式 */
@media print {
.app-layout {
background: white;
}
.layout-main {
margin-left: 0;
}
.layout-content {
padding-top: 0;
}
.content-container {
max-width: none;
padding: 0;
}
}
/* 高对比度模式支持 */
@media (prefers-contrast: high) {
.app-layout {
background: white;
}
}
/* 减少动画模式 */
@media (prefers-reduced-motion: reduce) {
.app-layout *,
.layout-main,
.layout-content {
transition: none !important;
}
}
</style>
@@ -0,0 +1,530 @@
<!--
/**
* 顶部导航栏组件
* 提供面包屑导航、页面标题、搜索框、通知、设置和用户信息
* 支持响应式设计和移动端适配
*/
-->
<template>
<!-- 顶部导航栏容器 -->
<header class="nav-content" :class="{ 'nav-content--mobile': isMobile }">
<!-- 移动端菜单按钮 -->
<button
v-if="showMenuButton"
class="nav-content__menu-btn"
@click="handleMenuToggle"
:aria-label="'打开菜单'"
>
<i class="el-icon-s-unfold"></i>
</button>
<!-- 面包屑导航区域 -->
<div class="nav-content__breadcrumb">
<div class="breadcrumb-container">
<slot name="breadcrumb">
<nav class="breadcrumb" v-if="breadcrumbItems.length">
<el-icon><SetUp /></el-icon>
<span
v-for="(item, index) in breadcrumbItems"
:key="item.path || index"
class="breadcrumb-item"
>
<a
v-if="item.path && index < breadcrumbItems.length - 1"
:href="item.path"
class="breadcrumb-link"
@click="handleBreadcrumbClick(item, $event)"
>
{{ item.title }}
</a>
<span
v-else
class="breadcrumb-current"
>
{{ item.title }}
</span>
<i
v-if="index < breadcrumbItems.length - 1"
class="breadcrumb-separator"
>
/
</i>
</span>
</nav>
</slot>
</div>
</div>
<!-- 操作按钮区域 -->
<div class="nav-content__actions">
<slot name="actions">
<div class="actions-container">
<!-- 搜索框 -->
<div v-if="showSearch" class="search-box">
<input
v-model="searchQuery"
type="text"
:placeholder="searchPlaceholder"
class="search-input"
@input="handleSearch"
/>
<i class="search-icon el-icon-search"></i>
</div>
<!-- 通知按钮 -->
<button
v-if="showNotifications"
class="action-btn notification-btn"
@click="handleNotificationClick"
:aria-label="'通知'"
>
<i class="el-icon-bell"></i>
<span v-if="notificationCount" class="notification-badge">
{{ notificationCount > 99 ? '99+' : notificationCount }}
</span>
</button>
<!-- 设置按钮 -->
<button
v-if="showSettings"
class="action-btn settings-btn"
@click="handleSettingsClick"
:aria-label="'设置'"
>
<i class="el-icon-setting"></i>
</button>
<!-- 用户头像 -->
<div v-if="showUserAvatar" class="user-avatar" @click="handleUserClick">
<img
v-if="userAvatar"
:src="userAvatar"
:alt="userName || '用户头像'"
class="avatar-img"
/>
<div v-else class="avatar-placeholder">
{{ userInitials }}
</div>
</div>
</div>
</slot>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
// 面包屑导航项接口定义
interface BreadcrumbItem {
title: string // 显示标题
path?: string // 链接路径(可选,最后一项通常不需要链接)
}
// 组件属性接口定义
interface Props {
breadcrumbItems?: BreadcrumbItem[] // 面包屑导航项列表
pageTitle?: string // 当前页面标题
showSearch?: boolean // 是否显示搜索框
searchPlaceholder?: string // 搜索框占位符文本
showNotifications?: boolean // 是否显示通知按钮
notificationCount?: number // 未读通知数量
showSettings?: boolean // 是否显示设置按钮
showUserAvatar?: boolean // 是否显示用户头像
userAvatar?: string // 用户头像URL
userName?: string // 用户名称
showMenuButton?: boolean // 是否显示菜单按钮(移动端使用)
}
// 定义组件属性和默认值
const props = withDefaults(defineProps<Props>(), {
breadcrumbItems: () => [],
pageTitle: '',
showSearch: true,
searchPlaceholder: '搜索...',
showNotifications: true,
notificationCount: 0,
showSettings: true,
showUserAvatar: true,
userName: '',
showMenuButton: false
})
// 定义组件事件
const emit = defineEmits<{
search: [query: string]
breadcrumbClick: [item: BreadcrumbItem, event: Event]
notificationClick: []
settingsClick: []
userClick: []
menuToggle: []
}>()
// 响应式状态管理
const searchQuery = ref('')
const isMobile = ref(false)
// 计算用户姓名缩写
const userInitials = computed(() => {
if (!props.userName) return 'U'
return props.userName
.split(' ')
.map(name => name.charAt(0))
.join('')
.toUpperCase()
.slice(0, 2)
})
// 处理搜索事件
const handleSearch = () => {
emit('search', searchQuery.value)
}
// 处理面包屑导航点击
const handleBreadcrumbClick = (item: BreadcrumbItem, event: Event) => {
emit('breadcrumbClick', item, event)
}
// 处理通知按钮点击
const handleNotificationClick = () => {
emit('notificationClick')
}
// 处理设置按钮点击
const handleSettingsClick = () => {
emit('settingsClick')
}
// 处理用户头像点击
const handleUserClick = () => {
emit('userClick')
}
// 处理菜单按钮点击(移动端)
const handleMenuToggle = () => {
emit('menuToggle')
}
// 检测是否为移动设备
const checkMobile = () => {
isMobile.value = window.innerWidth <= 1024
}
// 组件挂载时初始化
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
// 组件卸载时清理
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
</script>
<style scoped>
.nav-content {
position: fixed;
top: 0;
left: 280px;
right: 0;
height: 80px;
background: #FFFFFF;
border-bottom: 1px solid rgba(29, 29, 29, 0.12);
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
z-index: 999;
transition: all 0.3s ease;
}
.nav-content--mobile {
left: 0;
padding: 16px 20px;
}
/* 移动端菜单按钮 */
.nav-content__menu-btn {
display: none;
width: 40px;
height: 40px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
color: #666;
font-size: 18px;
align-items: center;
justify-content: center;
margin-right: 12px;
transition: all 0.2s;
}
.nav-content__menu-btn:hover {
background: rgba(1, 156, 124, 0.1);
color: #019C7C;
}
/* 面包屑区域 */
.nav-content__breadcrumb {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.breadcrumb-container {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #888;
flex-wrap: wrap;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.breadcrumb-link {
color: #019C7C;
text-decoration: none;
transition: all 0.2s;
}
.breadcrumb-link:hover {
color: #017A63;
}
.breadcrumb-current {
color: #333;
font-weight: 500;
}
.breadcrumb-separator {
color: #ccc;
font-style: normal;
margin: 0 4px;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 操作按钮区域 */
.nav-content__actions {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.actions-container {
display: flex;
align-items: center;
gap: 16px;
}
/* 搜索框 */
.search-box {
position: relative;
min-width: 200px;
}
.search-input {
width: 100%;
height: 36px;
padding: 0 40px 0 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
background: #fafafa;
transition: all 0.2s;
}
.search-input:focus {
outline: none;
border-color: #019C7C;
background: #ffffff;
box-shadow: 0 0 0 2px rgba(1, 156, 124, 0.1);
}
.search-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #888;
font-size: 16px;
pointer-events: none;
}
/* 操作按钮 */
.action-btn {
position: relative;
width: 40px;
height: 40px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
color: #666;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.action-btn:hover {
background: rgba(1, 156, 124, 0.1);
color: #019C7C;
}
.notification-badge {
position: absolute;
top: 6px;
right: 6px;
min-width: 16px;
height: 16px;
background: #ff4757;
color: white;
font-size: 10px;
font-weight: 600;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
line-height: 1;
}
/* 用户头像 */
.user-avatar {
width: 40px;
height: 40px;
border-radius: 20px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
}
.user-avatar:hover {
border-color: #019C7C;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #019C7C, #017A63);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.nav-content {
left: 0;
padding: 16px 20px;
}
.nav-content__menu-btn {
display: flex;
}
.search-box {
min-width: 150px;
}
}
@media (max-width: 768px) {
.nav-content {
padding: 12px 16px;
gap: 12px;
}
.actions-container {
gap: 12px;
}
.search-box {
min-width: 120px;
}
.search-input {
height: 32px;
font-size: 13px;
}
.action-btn {
width: 36px;
height: 36px;
font-size: 16px;
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 18px;
}
.breadcrumb {
font-size: 11px;
}
.page-title {
font-size: 16px;
}
}
@media (max-width: 480px) {
.nav-content {
padding: 10px 12px;
}
.search-box {
display: none;
}
.breadcrumb {
display: none;
}
.page-title {
font-size: 14px;
}
}
/* 与侧边栏收起状态的配合 */
.nav-content--sidebar-collapsed {
left: 64px;
}
</style>
@@ -0,0 +1,394 @@
<!--
/**
* 侧边栏组件
* 提供应用导航菜单,支持折叠/展开功能和响应式设计
* 包含Logo区域、导航菜单和底部区域
*/
-->
<template>
<!-- 侧边栏容器 -->
<aside
class="sidebar"
:class="{ 'sidebar--collapsed': isCollapsed }"
>
<!-- Logo区域 -->
<div class="sidebar__header">
<div class="sidebar__logo">
<div class="logo-container">
<div class="logo-icon">
<svg width="41" height="40" viewBox="0 0 41 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g class="logo-icon-group">
<path d="M0 0H29.74V40H0V0Z" fill="#019C7C"/>
<path d="M24.59 20.16H41V40H24.59V20.16Z" fill="#146B59"/>
</g>
</svg>
</div>
<span v-show="!isCollapsed" class="logo-text">Cedar-V</span>
</div>
</div>
<button
v-if="collapsible"
class="sidebar__toggle"
@click="toggleSidebar"
:aria-label="isCollapsed ? '展开侧边栏' : '收起侧边栏'"
>
<i :class="isCollapsed ? 'el-icon-s-unfold' : 'el-icon-s-fold'"></i>
</button>
</div>
<!-- 导航菜单 -->
<nav class="sidebar__nav">
<div class="nav-section">
<slot name="nav-items">
<div class="nav-item" v-for="item in navItems" :key="item.id">
<a
:href="item.href"
class="nav-link"
:class="{ 'nav-link--active': item.active }"
@click="handleNavClick(item, $event)"
>
<div class="nav-icon-wrapper">
<el-icon v-if="item.icon" class="nav-icon">
<component :is="item.icon" />
</el-icon>
</div>
<span v-show="!isCollapsed" class="nav-text">{{ item.label }}</span>
</a>
<!-- 子菜单 -->
<div v-if="item.children && !isCollapsed" class="nav-submenu">
<a
v-for="child in item.children"
:key="child.id"
:href="child.href"
class="nav-sublink"
:class="{ 'nav-sublink--active': child.active }"
@click="handleNavClick(child, $event)"
>
<span class="nav-subtext">{{ child.label }}</span>
</a>
</div>
</div>
</slot>
</div>
</nav>
<!-- 底部区域 -->
<div class="sidebar__footer">
<slot name="footer">
<!-- 用户信息等 -->
</slot>
</div>
</aside>
</template>
<script setup lang="ts">
import { ref } from 'vue'
// 导航项接口定义
interface NavItem {
id: string // 导航项唯一标识
label: string // 显示文本
href: string // 链接地址
icon?: string // 图标类名(可选)
active?: boolean // 是否为当前活跃项(可选)
children?: NavItem[] // 子菜单项(可选)
}
// 组件属性接口定义
interface Props {
appName?: string // 应用名称,显示在Logo区域
navItems?: NavItem[] // 导航菜单项列表
collapsible?: boolean // 是否允许折叠侧边栏
defaultCollapsed?: boolean // 默认是否为折叠状态
}
// 定义组件属性和默认值
const props = withDefaults(defineProps<Props>(), {
appName: 'Cedar',
navItems: () => [],
collapsible: true,
defaultCollapsed: false
})
// 定义组件事件
const emit = defineEmits<{
navClick: [item: NavItem, event: Event]
toggle: [collapsed: boolean]
}>()
// 侧边栏折叠状态管理
const isCollapsed = ref(props.defaultCollapsed)
// 切换侧边栏折叠状态
const toggleSidebar = () => {
isCollapsed.value = !isCollapsed.value
emit('toggle', isCollapsed.value)
}
// 处理导航项点击事件
const handleNavClick = (item: NavItem, event: Event) => {
emit('navClick', item, event)
}
</script>
<style scoped>
.sidebar {
position: fixed;
left: 0;
top: 0;
width: 280px;
height: 100vh;
background: linear-gradient(180deg, #F5F7FA 0%, #FFFFFF 100%);
border-right: 1px solid rgba(29, 29, 29, 0.12);
display: flex;
flex-direction: column;
transition: all 0.3s ease;
z-index: 1000;
}
.sidebar--collapsed {
width: 64px;
}
/* Header 区域 */
.sidebar__header {
height: 80px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid rgba(29, 29, 29, 0.06);
}
.sidebar__logo {
display: flex;
align-items: center;
flex: 1;
}
.logo-container {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 41px;
height: 40px;
flex-shrink: 0;
}
.logo-text {
font-family: 'Swis721 BlkCn BT', sans-serif;
font-size: 30px;
font-weight: 400;
color: #333333;
white-space: nowrap;
overflow: hidden;
line-height: 1.2;
}
.sidebar__toggle {
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #B2B8C2;
transition: all 0.2s;
}
.sidebar__toggle:hover {
background: rgba(1, 156, 124, 0.1);
color: #019C7C;
}
/* 导航区域 */
.sidebar__nav {
flex: 1;
padding: 8px 0;
overflow-y: auto;
}
.nav-section {
padding: 8px 16px;
}
.nav-item {
margin-bottom: 4px;
}
.nav-link {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
color: #1D1D1D;
text-decoration: none;
border-radius: 28px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 16px;
font-family: 'OPPOSans', sans-serif;
font-weight: 400;
line-height: 1.3;
margin-bottom: 10px;
}
.nav-link:hover {
background: rgba(1, 156, 124, 0.08);
color: #019C7C;
}
.nav-link--active {
background: linear-gradient(90deg, #39CC68 0%, #A6E852 100%);
color: #019C7C;
font-weight: 700;
box-shadow: 0px 2px 32px 0px rgba(0, 0, 0, 0.02);
}
.nav-link--active .nav-text {
color: #019C7C;
}
.nav-icon-wrapper {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: #FFFFFF;
border-radius: 4px;
flex-shrink: 0;
}
.nav-icon {
width: 20px;
height: 20px;
font-size: 16px;
color: #B2B8C2;
}
.nav-link--active .nav-icon {
color: #019C7C;
}
.nav-text {
white-space: nowrap;
overflow: hidden;
}
/* 子菜单 */
.nav-submenu {
margin-left: 32px;
margin-top: 4px;
}
.nav-sublink {
display: block;
padding: 8px 16px;
color: #888;
text-decoration: none;
border-radius: 6px;
font-size: 13px;
transition: all 0.2s;
margin-bottom: 2px;
}
.nav-sublink:hover {
background: rgba(1, 156, 124, 0.06);
color: #019C7C;
}
.nav-sublink--active {
background: rgba(1, 156, 124, 0.08);
color: #019C7C;
font-weight: 500;
}
.nav-subtext {
white-space: nowrap;
overflow: hidden;
}
/* 底部区域 */
.sidebar__footer {
padding: 16px;
border-top: 1px solid rgba(29, 29, 29, 0.06);
}
/* 响应式设计 */
@media (max-width: 1024px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar--mobile-open {
transform: translateX(0);
}
.sidebar--collapsed {
width: 280px;
}
}
@media (max-width: 768px) {
.sidebar {
width: 100vw;
max-width: 320px;
}
.sidebar--collapsed {
width: 100vw;
max-width: 320px;
}
}
/* 收起状态下的样式调整 */
.sidebar--collapsed .sidebar__logo {
justify-content: center;
}
.sidebar--collapsed .logo-container {
justify-content: center;
}
.sidebar--collapsed .logo-text {
display: none;
}
.sidebar--collapsed .nav-link {
justify-content: center;
padding: 24px 12px;
}
.sidebar--collapsed .nav-text {
display: none;
}
.sidebar--collapsed .nav-submenu {
display: none;
}
/* 滚动条样式 */
.sidebar__nav::-webkit-scrollbar {
width: 4px;
}
.sidebar__nav::-webkit-scrollbar-track {
background: transparent;
}
.sidebar__nav::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
.sidebar__nav::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
</style>
@@ -0,0 +1,27 @@
/**
* Layout布局组件导出文件
* 统一管理布局相关组件和类型定义的导出
*/
// 布局组件导出
export { default as Layout } from './Layout.vue'
export { default as Sidebar } from './Sidebar.vue'
export { default as NavContent } from './NavContent.vue'
// 类型定义导出
// 导航菜单项类型定义
export interface NavItem {
id: string // 导航项唯一标识
label: string // 显示文本
href: string // 链接地址
icon?: string // 图标类名(可选)
active?: boolean // 是否为当前活跃项(可选)
children?: NavItem[] // 子菜单项(可选)
}
// 面包屑导航项类型定义
export interface BreadcrumbItem {
title: string // 显示标题
path?: string // 链接路径(可选,最后一项通常不需要链接)
}