mirror of
https://github.com/opsre/LiteOps.git
synced 2026-02-24 09:00:47 +08:00
前端开源初始化提交
This commit is contained in:
30
web/src/components/layout/Content.vue
Normal file
30
web/src/components/layout/Content.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<a-layout-content>
|
||||
<div class="content-container">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
min-height: 360px;
|
||||
border-radius: 4px;
|
||||
overflow-y: auto;
|
||||
/* 设置最大高度,内容超出时可以滚动 */
|
||||
max-height: calc(100vh - 112px);
|
||||
}
|
||||
|
||||
:deep(.ant-layout-content) {
|
||||
margin: 24px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
14
web/src/components/layout/Footer.vue
Normal file
14
web/src/components/layout/Footer.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<a-layout-footer style="text-align: center; background-color: #fff;">
|
||||
LiteOps ©2024 Created by 胡图图
|
||||
</a-layout-footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-layout-footer) {
|
||||
padding: 24px 50px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
font-size: 14px;
|
||||
/* background: #f0f2f5; */
|
||||
}
|
||||
</style>
|
||||
297
web/src/components/layout/Header.vue
Normal file
297
web/src/components/layout/Header.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<a-layout-header style="background: #fff; padding: 0; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<a-breadcrumb>
|
||||
<template v-if="breadcrumbItems.length">
|
||||
<a-breadcrumb-item v-for="item in breadcrumbItems" :key="item.path || item.title">
|
||||
<router-link v-if="item.clickable" :to="item.path">{{ item.title }}</router-link>
|
||||
<span v-else>{{ item.title }}</span>
|
||||
</a-breadcrumb-item>
|
||||
</template>
|
||||
<a-breadcrumb-item v-else>
|
||||
<router-link to="/dashboard">首页</router-link>
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-dropdown>
|
||||
<a class="ant-dropdown-link" @click.prevent>
|
||||
<UserOutlined /> {{ userName }}
|
||||
</a>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="profile" @click="handleProfile">个人信息</a-menu-item>
|
||||
<a-menu-item key="logout" @click="handleLogout">退出登录</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<!-- 个人信息弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="profileModalVisible"
|
||||
title="个人信息"
|
||||
width="500px"
|
||||
:footer="null"
|
||||
:maskClosable="true"
|
||||
>
|
||||
<a-spin :spinning="loading">
|
||||
<div class="user-profile-info">
|
||||
<div class="info-item">
|
||||
<span class="info-label">用户名</span>
|
||||
<span class="info-value">{{ userInfo.username }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">姓名</span>
|
||||
<span class="info-value">{{ userInfo.name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">邮箱</span>
|
||||
<span class="info-value">{{ userInfo.email }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">状态</span>
|
||||
<span class="info-value">
|
||||
{{ userInfo.status === 1 ? '正常' : '禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">最后登录</span>
|
||||
<span class="info-value">{{ userInfo.login_time || '暂无记录' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">创建时间</span>
|
||||
<span class="info-value">{{ userInfo.create_time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider /> <!-- 分割线 -->
|
||||
|
||||
<div class="user-profile-roles" v-if="userInfo.roles && userInfo.roles.length > 0">
|
||||
<div class="info-item">
|
||||
<span class="info-label">角色</span>
|
||||
<span class="info-value">
|
||||
<a-space>
|
||||
<span v-for="role in userInfo.roles" :key="role.role_id">
|
||||
{{ role.name }}
|
||||
</span>
|
||||
</a-space>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-profile-roles" v-else>
|
||||
<div class="info-item">
|
||||
<span class="info-label">角色</span>
|
||||
<span class="info-value">暂无角色信息</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { UserOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// 弹窗相关状态
|
||||
const profileModalVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const userInfo = ref({
|
||||
user_id: '',
|
||||
username: '',
|
||||
name: '',
|
||||
email: '',
|
||||
status: 1,
|
||||
roles: [],
|
||||
login_time: '',
|
||||
create_time: '',
|
||||
update_time: ''
|
||||
});
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
const items = [];
|
||||
const matched = route.matched;
|
||||
|
||||
// 如果当前路由是首页,不显示面包屑项
|
||||
if (route.path === '/dashboard' || route.path === '/') {
|
||||
return items;
|
||||
}
|
||||
|
||||
// 首页可以被点击
|
||||
items.push({
|
||||
title: '首页',
|
||||
path: '/dashboard',
|
||||
clickable: true
|
||||
});
|
||||
|
||||
// 定义一级菜单
|
||||
const parentMenus = ['/projects', '/build', '/logs', '/user', '/environments', '/system'];
|
||||
|
||||
matched.forEach((item) => {
|
||||
if (item.path !== '/' && item.meta && item.meta.title) {
|
||||
// 判断是否为一级菜单
|
||||
const isParentMenu = parentMenus.includes(item.path);
|
||||
|
||||
items.push({
|
||||
title: item.meta.title,
|
||||
path: item.path,
|
||||
clickable: !isParentMenu // 只有一级菜单不可点击
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
// 获取用户名
|
||||
const userName = computed(() => {
|
||||
const userInfo = localStorage.getItem('user_info');
|
||||
if (userInfo) {
|
||||
return JSON.parse(userInfo).name;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// 获取用户信息
|
||||
const fetchUserProfile = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get('/api/user/profile/', {
|
||||
headers: {
|
||||
'Authorization': token
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.code === 200) {
|
||||
userInfo.value = response.data.data;
|
||||
} else {
|
||||
message.error(response.data.message || '获取用户信息失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
message.error('获取用户信息失败,请稍后重试');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 显示个人信息弹窗
|
||||
const handleProfile = () => {
|
||||
profileModalVisible.value = true;
|
||||
fetchUserProfile();
|
||||
};
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.post('/api/logout/', {}, {
|
||||
headers: {
|
||||
'Authorization': token
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.code === 200) {
|
||||
message.success('退出成功');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user_info');
|
||||
router.push('/login');
|
||||
} else {
|
||||
message.error(response.data.message || '退出失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('退出失败,请稍后重试');
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
:deep(.ant-layout-header) {
|
||||
padding: 0;
|
||||
height: 64px;
|
||||
line-height: 64px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
|
||||
:deep(.ant-dropdown-link) {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.ant-breadcrumb a) {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
:deep(.ant-breadcrumb a:hover) {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
:deep(.ant-breadcrumb span) {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* 弹窗相关样式 */
|
||||
:deep(.ant-divider) {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
:deep(.ant-modal-body) {
|
||||
padding: 24px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.user-profile-info,
|
||||
.user-profile-roles {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
width: 80px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
28
web/src/components/layout/MainLayout.vue
Normal file
28
web/src/components/layout/MainLayout.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<a-layout style="min-height: 100vh">
|
||||
<Sidebar />
|
||||
<a-layout>
|
||||
<Header />
|
||||
<Content />
|
||||
<Footer />
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Sidebar from './Sidebar.vue';
|
||||
import Header from './Header.vue';
|
||||
import Content from './Content.vue';
|
||||
import Footer from './Footer.vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-layout) {
|
||||
min-height: 100vh;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
.site-layout .site-layout-background {
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
241
web/src/components/layout/Sidebar.vue
Normal file
241
web/src/components/layout/Sidebar.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<a-layout-sider v-model:collapsed="collapsed" theme="light" collapsible>
|
||||
<div class="logo">
|
||||
<img v-if="collapsed" src="../../assets/image/liteops.png" alt="Logo" />
|
||||
<img v-else src="../../assets/image/liteops-sidebar.png" alt="Logo" />
|
||||
</div>
|
||||
<div class="menu-container">
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:openKeys="openKeys"
|
||||
mode="inline"
|
||||
@click="handleMenuClick"
|
||||
v-if="permissionStore.initialized"
|
||||
>
|
||||
<!-- 首页 -->
|
||||
<a-menu-item key="/dashboard" v-if="hasMenuPermission('/dashboard')">
|
||||
<template #icon>
|
||||
<DashboardOutlined />
|
||||
</template>
|
||||
<span>首页</span>
|
||||
</a-menu-item>
|
||||
|
||||
<!-- 项目管理 -->
|
||||
<a-sub-menu key="/projects" v-if="hasAnySubMenuPermission('/projects')">
|
||||
<template #icon>
|
||||
<ProjectOutlined />
|
||||
</template>
|
||||
<template #title>项目管理</template>
|
||||
<a-menu-item key="/projects/list" v-if="hasMenuPermission('/projects/list')">项目列表</a-menu-item>
|
||||
</a-sub-menu>
|
||||
|
||||
<!-- 构建与部署 -->
|
||||
<a-sub-menu key="/build" v-if="hasAnySubMenuPermission('/build')">
|
||||
<template #icon>
|
||||
<BuildOutlined />
|
||||
</template>
|
||||
<template #title>构建与部署</template>
|
||||
<a-menu-item key="/build/tasks" v-if="hasMenuPermission('/build/tasks')">构建任务</a-menu-item>
|
||||
<a-menu-item key="/build/history" v-if="hasMenuPermission('/build/history')">构建历史</a-menu-item>
|
||||
</a-sub-menu>
|
||||
|
||||
<!-- 日志与监控 -->
|
||||
<a-sub-menu key="/logs" v-if="hasAnySubMenuPermission('/logs')">
|
||||
<template #icon>
|
||||
<FileSearchOutlined />
|
||||
</template>
|
||||
<template #title>日志与监控</template>
|
||||
<a-menu-item key="/logs/login" v-if="hasMenuPermission('/logs/login')">登陆日志</a-menu-item>
|
||||
</a-sub-menu>
|
||||
|
||||
<!-- 用户与权限管理 -->
|
||||
<a-sub-menu key="/user" v-if="hasAnySubMenuPermission('/user')">
|
||||
<template #icon>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
<template #title>用户与权限</template>
|
||||
<a-menu-item key="/user/list" v-if="hasMenuPermission('/user/list')">用户管理</a-menu-item>
|
||||
<a-menu-item key="/user/role" v-if="hasMenuPermission('/user/role')">角色管理</a-menu-item>
|
||||
</a-sub-menu>
|
||||
|
||||
<!-- 凭证管理 -->
|
||||
<a-menu-item key="/credentials" v-if="hasMenuPermission('/credentials')">
|
||||
<template #icon>
|
||||
<KeyOutlined />
|
||||
</template>
|
||||
<span>凭证管理</span>
|
||||
</a-menu-item>
|
||||
|
||||
<!-- 环境配置 -->
|
||||
<a-sub-menu key="/environments" v-if="hasAnySubMenuPermission('/environments')">
|
||||
<template #icon>
|
||||
<CloudServerOutlined />
|
||||
</template>
|
||||
<template #title>环境配置</template>
|
||||
<a-menu-item key="/environments/list" v-if="hasMenuPermission('/environments/list')">环境列表</a-menu-item>
|
||||
</a-sub-menu>
|
||||
|
||||
|
||||
|
||||
<!-- 系统配置 -->
|
||||
<a-sub-menu key="/system" v-if="hasAnySubMenuPermission('/system')">
|
||||
<template #icon>
|
||||
<SettingOutlined />
|
||||
</template>
|
||||
<template #title>系统配置</template>
|
||||
<a-menu-item key="/system/basic" v-if="hasMenuPermission('/system/basic')">基本设置</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</a-menu>
|
||||
<div v-else class="menu-loading">
|
||||
<a-spin tip="加载菜单权限中..." />
|
||||
</div>
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { hasMenuPermission, hasAnySubMenuPermission, permissionStore } from '../../utils/permission';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
ProjectOutlined,
|
||||
BuildOutlined,
|
||||
FileSearchOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
KeyOutlined,
|
||||
CloudServerOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const collapsed = ref(false);
|
||||
const selectedKeys = ref(['/dashboard']);
|
||||
const openKeys = ref([]);
|
||||
|
||||
// 获取当前路由的父级路径
|
||||
const getParentPath = (path) => {
|
||||
const pathParts = path.split('/');
|
||||
return pathParts.length > 2 ? `/${pathParts[1]}` : path;
|
||||
};
|
||||
|
||||
// 初始化菜单状态
|
||||
onMounted(() => {
|
||||
const currentPath = route.path;
|
||||
selectedKeys.value = [currentPath];
|
||||
|
||||
if (currentPath !== '/dashboard') {
|
||||
const parentPath = getParentPath(currentPath);
|
||||
openKeys.value = [parentPath];
|
||||
}
|
||||
});
|
||||
|
||||
// 监听路由变化
|
||||
watch(() => route.path, (newPath) => {
|
||||
selectedKeys.value = [newPath];
|
||||
const parentPath = getParentPath(newPath);
|
||||
|
||||
if (!openKeys.value.includes(parentPath) && newPath !== '/dashboard') {
|
||||
openKeys.value = [parentPath];
|
||||
}
|
||||
});
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
router.push(key);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logo {
|
||||
height: 65px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/* padding: 8px; */
|
||||
/* overflow: hidden; */
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 65px;
|
||||
/* height: auto; */
|
||||
/* max-height: 50px; */
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
height: calc(100vh - 64px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-layout-sider) {
|
||||
box-shadow: 2px 0 8px 0 rgba(29, 35, 41, 0.05);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
:deep(.ant-layout-sider-collapsed .logo) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
:deep(.ant-layout-sider-collapsed .logo img) {
|
||||
max-width: 32px;
|
||||
}
|
||||
|
||||
:deep(.ant-layout-sider-collapsed .ant-menu-item .anticon),
|
||||
:deep(.ant-layout-sider-collapsed .ant-menu-submenu-title .anticon) {
|
||||
margin-right: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item) {
|
||||
height: 40px !important;
|
||||
line-height: 40px !important;
|
||||
margin: 4px 0 !important;
|
||||
padding-left: 24px !important;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-submenu-title) {
|
||||
height: 40px !important;
|
||||
line-height: 40px !important;
|
||||
margin: 4px 0 !important;
|
||||
padding-left: 24px !important;
|
||||
}
|
||||
|
||||
/* 子菜单项的缩进一致 */
|
||||
:deep(.ant-menu-sub .ant-menu-item) {
|
||||
padding-left: 48px !important;
|
||||
}
|
||||
|
||||
/* 图标对齐 */
|
||||
:deep(.ant-menu-item .anticon),
|
||||
:deep(.ant-menu-submenu-title .anticon) {
|
||||
min-width: 14px;
|
||||
margin-right: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.menu-container::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.menu-container::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.menu-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.menu-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user