前端开源初始化提交

This commit is contained in:
hukdoesn
2025-06-30 11:57:32 +08:00
parent 00090a0aca
commit fa52e9e571
50 changed files with 12349 additions and 12 deletions

2
.gitignore vendored
View File

@@ -40,4 +40,4 @@ dist-ssr
*.xml
# web
/web/
# /web/

View File

@@ -166,7 +166,7 @@ LiteOps主要适用于以下场景
### 前置要求
在开始部署之前,请确保的系统满足以下要求:
在开始部署之前,请确保的系统满足以下要求:
- **操作系统**Linux (推荐 Ubuntu 20.04+、CentOS 7+)
- **Docker**:版本 20.0+
@@ -179,7 +179,7 @@ LiteOps主要适用于以下场景
#### 1. 获取部署文件
需要获取以下部署文件:
需要获取以下部署文件:
- `start-containers.sh` - 一键部署脚本
- `liteops_init.sql` - 数据库初始化文件
@@ -226,7 +226,7 @@ chmod +x start-containers.sh
#### 5. 验证部署
部署完成后,可以通过以下方式验证:
部署完成后,可以通过以下方式验证:
```bash
# 检查容器状态
@@ -257,7 +257,7 @@ docker logs liteops-mysql
### 访问应用
部署成功后,可以通过以下地址访问:
部署成功后,可以通过以下地址访问:
- **前端界面**http://localhost
- **后端API**http://localhost:8900/api/
@@ -284,7 +284,7 @@ LiteOps目前处于未完善状态虽然核心功能已经初步实现
## 📞 联系我
如果对LiteOps有任何建议、问题或需求欢迎通过以下方式联系我
如果对LiteOps有任何建议、问题或需求欢迎通过以下方式联系我
- **邮箱**hukdoesn@163.com
- **GitHub Issues**[提交问题或建议](https://github.com/hukdoesn/liteops/issues)

View File

@@ -819,7 +819,7 @@ class BuildExecuteView(View):
history_id=generate_id(),
task=task,
build_number=build_number,
branch=branch if branch else '', # 对于预发布和生产环境,分支可能为空
branch=branch if branch else '', # 对于预发布和生产环境,分支为空
commit_id=commit_id,
version=version if version else None, # 对于预发布和生产环境,使用传入的版本号
status='pending', # 初始状态为等待中

View File

@@ -319,7 +319,7 @@ class NotificationTestView(View):
# 准备测试消息
timestamp = str(int(time.time() * 1000))
test_message = "这是一条测试消息,如果收到了这条消息,说明机器人配置正确。"
test_message = "这是一条测试消息,如果收到了这条消息,说明机器人配置正确。"
# 根据不同类型的机器人发送测试消息
try:

View File

@@ -194,6 +194,6 @@ STATIC_URL = 'static/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# 构建相关配置
#BUILD_ROOT = Path('/data/liteops/build') # 修改为指定目录
BUILD_ROOT = Path('/data')
BUILD_ROOT = Path('/Users/huk/Downloads/data') # 修改为指定目录
# BUILD_ROOT = Path('/data')
BUILD_ROOT.mkdir(exist_ok=True, parents=True) # 确保目录存在,包括父目录

View File

@@ -1,7 +1,7 @@
[client]
#host = 127.0.0.1
host = 127.0.0.1
#host = mysql
host = liteops-mysql
#host = liteops-mysql
port = 3306
database = liteops
user = root

2
web/.env.development Normal file
View File

@@ -0,0 +1,2 @@
# 在开发环境中使用完整路径
VITE_API_URL=http://localhost:8900

3
web/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1
web/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

36
web/src/App.vue Normal file
View File

@@ -0,0 +1,36 @@
<template>
<a-config-provider :locale="zhCN" :theme="theme">
<router-view></router-view>
</a-config-provider>
</template>
<script setup>
import zhCN from "ant-design-vue/es/locale/zh_CN";
// 配置 Ant Design Vue 主题,强制使用自定义 PingFang 字体
const theme = {
token: {
fontFamily: "'PingFangCustom', Arial, sans-serif",
},
};
</script>
<style>
#app {
width: 100%;
height: 100vh;
overflow: hidden;
font-family: 'PingFangCustom', Arial, sans-serif !important;
}
body {
margin: 0;
padding: 0;
background-color: #f0f2f5;
font-family: 'PingFangCustom', Arial, sans-serif !important;
}
.ant-config-provider {
font-family: 'PingFangCustom', Arial, sans-serif !important;
}
</style>

View File

@@ -0,0 +1,125 @@
/* 定义自定义PingFang字体 - 强制使用项目中的字体文件 */
@font-face {
font-family: 'PingFangCustom';
src: url('../font/PingFang.ttc') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'PingFangCustom';
src: url('../font/PingFang.ttc') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'PingFangCustom';
src: url('../font/PingFang.ttc') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'PingFangCustom';
src: url('../font/PingFang.ttc') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'PingFangCustom';
src: url('../font/PingFang.ttc') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
* {
font-family: 'PingFangCustom', Arial, sans-serif !important;
}
body, html {
font-family: 'PingFangCustom', Arial, sans-serif !important;
margin: 0;
padding: 0;
}
:root {
font-family: 'PingFangCustom', Arial, sans-serif !important;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.ant-typography,
.ant-btn,
.ant-input,
.ant-select,
.ant-table,
.ant-menu,
.ant-dropdown,
.ant-modal,
.ant-message,
.ant-notification,
.ant-form,
.ant-card,
.ant-tabs,
.ant-breadcrumb,
.ant-pagination,
.ant-steps,
.ant-tree,
.ant-list,
.ant-drawer,
.ant-popover,
.ant-tooltip,
.ant-alert,
.ant-badge,
.ant-tag,
.ant-progress,
.ant-spin,
.ant-switch,
.ant-radio,
.ant-checkbox,
.ant-rate,
.ant-slider,
.ant-upload,
.ant-calendar,
.ant-date-picker,
.ant-time-picker,
.ant-config-provider,
.ant-app {
font-family: 'PingFangCustom', Arial, sans-serif !important;
}
[class*="ant-"] {
font-family: 'PingFangCustom', Arial, sans-serif !important;
}
input, textarea, select, button {
font-family: 'PingFangCustom', Arial, sans-serif !important;
}
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number,
.ant-select-selector,
.ant-cascader-picker,
.ant-picker,
.ant-mentions,
.ant-checkbox-wrapper,
.ant-radio-wrapper {
font-family: 'PingFangCustom', Arial, sans-serif !important;
}

View File

@@ -0,0 +1,43 @@
/* global.css */
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

View File

Binary file not shown.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

1
web/src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View 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>

View 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>

View 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>

View 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>

View 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>

24
web/src/main.js Normal file
View File

@@ -0,0 +1,24 @@
import { createApp } from 'vue'
// import './style.css'
import App from './App.vue'
import Antd from 'ant-design-vue';
import './assets/css/reset.css';
import './assets/css/global.css';
import axios from 'axios'
import router from './router'
// import permissionDirective from './directives/permission';
// 配置 axios
// 使用环境变量中的API URL
axios.defaults.baseURL = import.meta.env.VITE_API_URL;
const app = createApp(App);
// 使用 Ant Design Vue 和 Vue Router
// app.use(router).use(Antd).use(permissionDirective);
app.use(router).use(Antd)
// 将 axios 添加到 Vue 实例的全局属性中
app.config.globalProperties.$axios = axios;
app.mount('#app')

246
web/src/router/index.js Normal file
View File

@@ -0,0 +1,246 @@
import { createRouter, createWebHistory } from 'vue-router';
import MainLayout from '../components/layout/MainLayout.vue';
import axios from 'axios';
import { permissionStore, initUserPermissions, hasMenuPermission, hasAnySubMenuPermission } from '../utils/permission';
import { message } from 'ant-design-vue';
// 添加axios响应拦截器
axios.interceptors.response.use(
response => response,
error => {
if (error.response && error.response.status === 401) {
// 清除登录信息
localStorage.removeItem('token');
localStorage.removeItem('user_info');
// 跳转到登录页
router.push('/login');
}
return Promise.reject(error);
}
);
const routes = [
{
path: '/login',
name: 'login',
component: () => import('../views/login/LoginView.vue'),
meta: { title: '登录' }
},
{
path: '/',
component: MainLayout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'dashboard',
component: () => import('../views/dashboard/Dashboard.vue'),
meta: { title: '首页', permission: '/dashboard' }
},
// 项目管理
{
path: 'projects',
name: 'projects',
meta: { title: '项目管理', permission: '/projects' },
redirect: '/projects/list',
children: [
{
path: 'list',
name: 'project-list',
component: () => import('../views/projects/ProjectList.vue'),
meta: { title: '项目列表', permission: '/projects/list' }
},
{
path: 'detail',
name: 'project-detail',
component: () => import('../views/projects/ProjectDetail.vue'),
meta: { title: '项目详情', permission: '/projects/list' }
}
]
},
// 构建与部署
{
path: 'build',
name: 'build',
meta: { title: '构建与部署', permission: '/build' },
redirect: '/build/tasks',
children: [
{
path: 'tasks',
name: 'build-tasks',
component: () => import('../views/build/BuildTasks.vue'),
meta: { title: '构建任务', permission: '/build/tasks' }
},
{
path: 'tasks/detail',
name: 'build-task-detail',
component: () => import('../views/build/BuildTaskDetail.vue'),
meta: { title: '任务详情', permission: '/build/tasks' }
},
{
path: 'tasks/create',
name: 'build-task-create',
component: () => import('../views/build/BuildTaskEdit.vue'),
meta: { title: '新建构建任务', permission: '/build/tasks' }
},
{
path: 'tasks/edit',
name: 'build-task-edit',
component: () => import('../views/build/BuildTaskEdit.vue'),
meta: { title: '编辑构建任务', permission: '/build/tasks' }
},
{
path: 'history',
name: 'build-history',
component: () => import('../views/build/BuildHistory.vue'),
meta: { title: '构建历史', permission: '/build/history' }
}
]
},
// 日志与监控
{
path: 'logs',
name: 'logs',
meta: { title: '日志与监控', permission: '/logs' },
redirect: '/logs/login',
children: [
{
path: 'login',
name: 'login-logs',
component: () => import('../views/logs/LoginLogs.vue'),
meta: { title: '登陆日志', permission: '/logs/login' }
},
{
path: 'login/detail',
name: 'login-log-detail',
component: () => import('../views/logs/LoginLogDetail.vue'),
meta: { title: '登录日志详情', permission: '/logs/login' }
}
]
},
// 用户与权限
{
path: 'user',
name: 'user',
meta: { title: '用户与权限', permission: '/user' },
redirect: '/user/list',
children: [
{
path: 'list',
name: 'user-list',
component: () => import('../views/user/UserList.vue'),
meta: { title: '用户管理', permission: '/user/list' }
},
{
path: 'role',
name: 'user-role',
component: () => import('../views/user/UserRole.vue'),
meta: { title: '角色管理', permission: '/user/role' }
}
]
},
// 凭证管理
{
path: 'credentials',
name: 'credentials',
component: () => import('../views/credentials/CredentialsList.vue'),
meta: { title: '凭证管理', permission: '/credentials' }
},
// 环境配置
{
path: 'environments',
name: 'environments',
meta: { title: '环境配置', permission: '/environments' },
redirect: '/environments/list',
children: [
{
path: 'list',
name: 'environment-list',
component: () => import('../views/environments/EnvironmentList.vue'),
meta: { title: '环境列表', permission: '/environments/list' }
},
{
path: 'detail',
name: 'environment-detail',
component: () => import('../views/environments/EnvironmentDetail.vue'),
meta: { title: '环境详情', permission: '/environments/list' }
}
]
},
// 系统配置
{
path: 'system',
name: 'system',
meta: { title: '系统配置', permission: '/system' },
redirect: '/system/basic',
children: [
{
path: 'basic',
name: 'system-basic',
component: () => import('../views/system/BasicSettings.vue'),
meta: { title: '基本设置', permission: '/system/basic' }
}
]
},
]
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
// 权限初始化标志
let permissionInitialized = false;
// 路由守卫
router.beforeEach(async (to, from, next) => {
// 如果是登录页,直接通过
if (to.path === '/login') {
return next();
}
// 检查是否已登录
const token = localStorage.getItem('token');
if (!token) {
return next('/login');
}
// 初始化权限
if (!permissionInitialized && !permissionStore.initialized) {
permissionInitialized = true;
const success = await initUserPermissions();
// 如果权限初始化失败,跳转到登录页
if (!success) {
console.error('权限初始化失败,重定向到登录页');
localStorage.removeItem('token');
localStorage.removeItem('user_info');
return next('/login');
}
}
// 检查菜单权限
if (to.meta && to.meta.permission) {
const permissionPath = to.meta.permission;
const pathParts = permissionPath.split('/');
const isSubMenu = pathParts.length > 2;
// 如果是子菜单,同时检查直接权限和父菜单的子权限
const hasPermission = isSubMenu ?
(hasMenuPermission(permissionPath) || hasAnySubMenuPermission(permissionPath)) :
hasMenuPermission(permissionPath);
if (!hasPermission) {
console.warn(`用户无权访问路由: ${to.path}, 所需权限: ${to.meta.permission}`);
message.error(`你没有访问${to.meta.title || '该页面'}的权限`);
return next('/dashboard');
}
}
next();
});
export default router;

75
web/src/style.css Normal file
View File

@@ -0,0 +1,75 @@
:root {
font-family: 'PingFangCustom', Arial, sans-serif !important;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
width: 100%;
height: 100vh;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

222
web/src/utils/permission.js Normal file
View File

@@ -0,0 +1,222 @@
import axios from 'axios';
import { ref, reactive } from 'vue';
import { message } from 'ant-design-vue';
// 权限数据存储
export const permissionStore = reactive({
initialized: false,
menuPermissions: [],
functionPermissions: {},
dataPermissions: {
project_scope: 'all',
project_ids: [],
environment_scope: 'all',
environment_types: []
}
});
// 初始化用户权限
export const initUserPermissions = async () => {
try {
// 从本地存储获取用户信息
const userInfo = JSON.parse(localStorage.getItem('user_info') || '{}');
if (!userInfo.user_id) {
console.error('用户信息不存在,权限初始化失败');
return false;
}
// 获取用户的角色权限
const token = localStorage.getItem('token');
const response = await axios.get('/api/user/permissions', {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
const permissions = response.data.data;
// 存储权限信息
permissionStore.menuPermissions = permissions.menu || [];
permissionStore.functionPermissions = permissions.function || {};
permissionStore.dataPermissions = permissions.data || {
project_scope: 'all',
project_ids: [],
environment_scope: 'all',
environment_types: []
};
permissionStore.initialized = true;
return true;
} else {
console.error('获取用户权限失败:', response.data.message);
return false;
}
} catch (error) {
console.error('初始化用户权限失败:', error);
return false;
}
};
// 检查菜单权限
export const hasMenuPermission = (menuPath) => {
if (!permissionStore.initialized) {
console.warn('权限尚未初始化,拒绝所有菜单权限', menuPath);
return false;
}
const userInfo = JSON.parse(localStorage.getItem('user_info') || '{}');
if (userInfo.is_admin) {
console.log(`用户是管理员,自动拥有菜单权限: ${menuPath}`);
return true;
}
const hasPermission = permissionStore.menuPermissions.includes(menuPath);
return hasPermission;
};
// 检查是否有子菜单权限
export const hasAnySubMenuPermission = (parentPath) => {
if (!permissionStore.initialized) {
return false;
}
// 管理员拥有所有权限
const userInfo = JSON.parse(localStorage.getItem('user_info') || '{}');
if (userInfo.is_admin) {
return true;
}
// 检查是否直接拥有父菜单权限
if (permissionStore.menuPermissions.includes(parentPath)) {
return true;
}
// 检查是否拥有任何以父菜单路径开头的子菜单权限
return permissionStore.menuPermissions.some(permission =>
permission !== parentPath && permission.startsWith(`${parentPath}/`)
);
};
// 检查功能权限
export const hasFunctionPermission = (module, action) => {
if (!permissionStore.initialized) {
return false;
}
const modulePermissions = permissionStore.functionPermissions[module] || [];
return modulePermissions.includes(action);
};
// 检查项目数据权限
export const hasProjectPermission = (projectId) => {
if (!permissionStore.initialized) {
return false;
}
// 如果有所有项目的权限
if (permissionStore.dataPermissions.project_scope === 'all') {
return true;
}
return permissionStore.dataPermissions.project_ids.includes(projectId);
};
// 检查环境数据权限
export const hasEnvironmentPermission = (environmentType) => {
if (!permissionStore.initialized) {
return false;
}
// 如果有所有环境的权限
if (permissionStore.dataPermissions.environment_scope === 'all') {
return true;
}
return permissionStore.dataPermissions.environment_types.includes(environmentType);
};
export const getPermittedProjectIds = () => {
if (!permissionStore.initialized) {
return [];
}
if (permissionStore.dataPermissions.project_scope === 'all') {
return null;
}
return permissionStore.dataPermissions.project_ids || [];
};
// 获取有权限的环境类型
export const getPermittedEnvironmentTypes = () => {
if (!permissionStore.initialized) {
return [];
}
if (permissionStore.dataPermissions.environment_scope === 'all') {
return null;
}
return permissionStore.dataPermissions.environment_types || [];
};
// 统一的权限错误提示
export const showPermissionError = (module, action) => {
let errorMsg = '你没有权限执行此操作';
if (module && action) {
const actionText = {
'view': '查看',
'create': '创建',
'edit': '编辑',
'delete': '删除',
'execute': '执行',
'deploy': '部署',
'rollback': '回滚',
'approve': '审批',
'test': '测试',
'view_log': '查看日志',
'disable': '禁用/启用',
}[action] || action;
const moduleText = {
'project': '项目',
'build': '构建任务',
'build_task': '构建任务',
'build_history': '构建历史',
'environment': '环境',
'credential': '凭证',
'user': '用户',
'role': '角色',
'notification': '通知'
}[module] || module;
errorMsg = `你没有${moduleText}${actionText}权限`;
}
message.error(errorMsg);
return false;
};
// 检查功能权限和数据权限
export const checkPermission = (module, action, entityId = null, entityType = 'project') => {
if (!hasFunctionPermission(module, action)) {
showPermissionError(module, action);
return false;
}
if (entityId) {
if (entityType === 'project' && !hasProjectPermission(entityId)) {
message.error('你没有该项目的访问权限');
return false;
} else if (entityType === 'environment' && !hasEnvironmentPermission(entityId)) {
message.error('你没有该环境的访问权限');
return false;
}
}
return true;
};

View File

@@ -0,0 +1,682 @@
<template>
<div class="build-history">
<div class="page-header">
<a-row justify="space-between" align="middle">
<a-col>
<h2>构建历史</h2>
</a-col>
<a-col>
<a-space>
<a-select
v-model:value="projectId"
style="width: 200px"
placeholder="选择项目"
:loading="projectsLoading"
@change="handleProjectChange"
>
<a-select-option value="all">全部项目</a-select-option>
<a-select-option
v-for="project in projects"
:key="project.project_id"
:value="project.project_id"
>
{{ project.name }}
</a-select-option>
</a-select>
<a-select
v-model:value="environmentId"
style="width: 200px"
placeholder="选择环境"
:loading="environmentsLoading"
@change="handleEnvironmentChange"
>
<a-select-option value="all">全部环境</a-select-option>
<a-select-option
v-for="env in environments"
:key="env.environment_id"
:value="env.environment_id"
>
{{ env.name }}
</a-select-option>
</a-select>
<a-input
v-model:value="taskName"
placeholder="搜索任务名称"
style="width: 200px"
allow-clear
@pressEnter="handleSearch"
>
<template #prefix>
<SearchOutlined style="color: rgba(0, 0, 0, 0.25)" />
</template>
</a-input>
<a-button type="primary" :loading="loading" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
</a-space>
</a-col>
</a-row>
</div>
<a-card>
<a-timeline>
<a-timeline-item
v-for="record in buildRecords"
:key="record.id"
:color="getStatusColor(record.status)"
>
<template #dot>
<CheckCircleOutlined v-if="record.status === 'success'" />
<CloseCircleOutlined v-if="record.status === 'failed'" />
<StopOutlined v-if="record.status === 'terminated'" />
<LoadingOutlined v-if="record.status === 'running' || record.status === 'pending'" />
</template>
<div class="history-item">
<div class="history-header">
<div class="build-info">
<span class="build-id">构建 #{{ record.build_number }}</span>
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
<span class="build-branch">{{ record.branch }}</span>
</div>
<div class="build-meta">
<span>构建时间: {{ record.startTime }}</span>
<span>总耗时: {{ record.duration }}</span>
</div>
</div>
<div class="history-content">
<a-descriptions :column="2">
<a-descriptions-item label="构建任务">
<a-space>
<span class="build-branch">{{ record.task.name }}</span>
</a-space>
</a-descriptions-item>
<a-descriptions-item label="Git Commit">
<span class="build-branch">{{ record.commit }}</span>
</a-descriptions-item>
<a-descriptions-item label="构建版本">
<span class="build-branch">{{ record.version }}</span>
</a-descriptions-item>
<a-descriptions-item label="构建环境">
<span class="build-branch">{{ record.environment }}</span>
</a-descriptions-item>
<a-descriptions-item label="构建人">
<span class="build-branch">{{ record.operator }}</span>
</a-descriptions-item>
<a-descriptions-item label="构建需求">
<span class="build-branch">{{ record.requirement }}</span>
</a-descriptions-item>
</a-descriptions>
<div class="stages-info">
<div class="stages-header">
<span class="stages-title">构建阶段</span>
<span class="total-duration">
总耗时: {{ record.duration }}
</span>
</div>
<div class="stages-timeline">
<a-timeline>
<a-timeline-item
v-for="stage in record.stages"
:key="stage.name"
:color="getStatusColor(stage.status)"
>
<template #dot>
<template v-if="stage.status === 'success'">
<CheckCircleOutlined />
</template>
<template v-else-if="stage.status === 'failed'">
<CloseCircleOutlined />
</template>
<template v-else-if="stage.status === 'terminated'">
<StopOutlined />
</template>
</template>
<div class="stage-info">
<div class="stage-header">
<span class="stage-name">{{ stage.name }}</span>
<a-tag :color="getStatusColor(stage.status)" :bordered="false">
{{ getStageStatusText(stage.status) }}
</a-tag>
</div>
<div class="stage-details">
<span class="stage-time">
<ClockCircleOutlined /> 开始时间: {{ stage.startTime }}
</span>
<a-divider type="vertical" />
<span class="stage-duration">
耗时: {{ stage.duration }}
</span>
<a-button
type="link"
size="small"
@click="handleViewStageLog(record, stage)"
>
查看日志
</a-button>
</div>
</div>
</a-timeline-item>
</a-timeline>
</div>
</div>
<div class="action-buttons">
<a-space>
<a-button type="primary" @click="handleViewLog(record)">
查看日志
</a-button>
<a-button
type="primary"
danger
@click="handleRollback(record)"
>
回滚到此版本
</a-button>
</a-space>
</div>
</div>
</div>
</a-timeline-item>
</a-timeline>
<!-- 分页器 -->
<div class="pagination" v-if="total > 0">
<a-pagination
v-model:current="page"
:total="total"
:pageSize="pageSize"
show-quick-jumper
show-size-changer
:pageSizeOptions="['10', '20', '50', '100']"
@change="handlePageChange"
@showSizeChange="handleSizeChange"
/>
</div>
</a-card>
<!-- 日志查看弹窗 -->
<a-modal
v-model:open="logModalVisible"
title="构建日志"
width="1000px"
:footer="null"
>
<div class="log-content">
<FullscreenLogViewer
:logContent="selectedLog"
title="构建日志"
/>
</div>
</a-modal>
<a-modal
v-model:open="rollbackModalVisible"
title="确认回滚"
@ok="confirmRollback"
:confirmLoading="rollbackLoading"
okText="确认回滚"
cancelText="取消"
>
<p>确定要回滚 {{ selectedTaskName }}任务 到版本 {{ selectedVersion }} </p>
<p style="color: #ff4d4f;">注意回滚操作不可逆请谨慎操作</p>
</a-modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import axios from 'axios';
import {
SearchOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
ClockCircleOutlined,
InfoCircleOutlined,
DownloadOutlined,
StopOutlined,
} from '@ant-design/icons-vue';
import FullscreenLogViewer from './components/FullscreenLogViewer.vue';
// 状态变量
const loading = ref(false);
const projectsLoading = ref(false);
const environmentsLoading = ref(false);
const projects = ref([]);
const environments = ref([]);
const projectId = ref('all');
const environmentId = ref('all');
const taskName = ref('');
const buildRecords = ref([]);
const logModalVisible = ref(false);
const selectedLog = ref('');
const rollbackModalVisible = ref(false);
const rollbackLoading = ref(false);
const selectedVersion = ref('');
const selectedTaskName = ref('');
const selectedHistoryId = ref('');
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
// 获取项目列表
const loadProjects = async () => {
try {
projectsLoading.value = true;
const token = localStorage.getItem('token');
const response = await axios.get('/api/projects/', {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
projects.value = response.data.data;
}
} catch (error) {
message.error('加载项目列表失败');
} finally {
projectsLoading.value = false;
}
};
// 获取环境列表
const loadEnvironments = async () => {
try {
environmentsLoading.value = true;
const token = localStorage.getItem('token');
const response = await axios.get('/api/environments/', {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
environments.value = response.data.data;
}
} catch (error) {
message.error('加载环境列表失败');
} finally {
environmentsLoading.value = false;
}
};
// 加载构建历史
const loadBuildHistory = async () => {
try {
loading.value = true;
const token = localStorage.getItem('token');
// 构建查询参数
const params = {
page: page.value,
page_size: pageSize.value
};
if (projectId.value && projectId.value !== 'all') {
params.project_id = projectId.value;
}
if (environmentId.value && environmentId.value !== 'all') {
params.environment_id = environmentId.value;
}
if (taskName.value) {
params.task_name = taskName.value;
}
const response = await axios.get('/api/build/history/', {
params,
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
buildRecords.value = response.data.data;
total.value = response.data.total;
}
} catch (error) {
console.error('Load build history error:', error);
message.error('加载构建历史失败');
} finally {
loading.value = false;
}
};
// 获取构建日志
const fetchBuildLog = async (historyId) => {
try {
const token = localStorage.getItem('token');
const response = await axios.get(`/api/build/history/log/${historyId}/`, {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
return response.data.data.log;
}
return '获取日志失败';
} catch (error) {
return '获取日志失败: ' + error.response.data.message;
}
};
// 获取阶段日志
const fetchStageLog = async (historyId, stageName) => {
try {
const token = localStorage.getItem('token');
const response = await axios.get(`/api/build/history/stage-log/${historyId}/${stageName}/`, {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
return response.data.data.log;
}
return '获取阶段日志失败';
} catch (error) {
return '获取阶段日志失败: ' + error.response.data.message;
}
};
const executeRollback = async () => {
try {
rollbackLoading.value = true;
const token = localStorage.getItem('token');
const response = await axios.post('/api/build/history/', {
history_id: selectedHistoryId.value
}, {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
message.success('开始回滚');
rollbackModalVisible.value = false;
} else {
throw new Error(response.data.message);
}
} catch (error) {
console.error('Rollback error:', error);
message.error('回滚失败: ' + error.response.data.message);
} finally {
rollbackLoading.value = false;
}
};
// 事件处理函数
const handleProjectChange = () => {
page.value = 1;
loadBuildHistory();
};
const handleEnvironmentChange = () => {
page.value = 1;
loadBuildHistory();
};
const handleSearch = () => {
page.value = 1;
loadBuildHistory();
};
const handleViewLog = async (record) => {
selectedHistoryId.value = record.id;
selectedLog.value = '正在加载日志...';
logModalVisible.value = true;
selectedLog.value = await fetchBuildLog(record.id);
};
const handleViewStageLog = async (record, stage) => {
selectedLog.value = '正在加载日志...';
logModalVisible.value = true;
selectedLog.value = await fetchStageLog(record.id, stage.name);
};
const handleRollback = (record) => {
selectedVersion.value = record.version;
selectedTaskName.value = record.task.name
selectedHistoryId.value = record.id;
rollbackModalVisible.value = true;
};
const confirmRollback = () => {
executeRollback();
};
const handlePageChange = (current) => {
page.value = current;
loadBuildHistory();
};
const handleSizeChange = (current, size) => {
page.value = 1;
pageSize.value = size;
loadBuildHistory();
};
// 处理日志弹窗关闭
const handleLogModalClose = () => {
logModalVisible.value = false;
selectedLog.value = '';
selectedHistoryId.value = '';
};
// 工具函数
const getStatusColor = (status) => {
const statusMap = {
'success': 'rgba(135,208,104,0.8)',
'failed': 'rgba(255,77,79,0.8)',
'running': 'processing',
'pending': 'warning',
'terminated': 'rgba(128, 128, 128, 0.8)' // 灰色
};
return statusMap[status] || 'default';
};
const getStatusText = (status) => {
const statusMap = {
'success': '成功',
'failed': '失败',
'running': '构建中',
'pending': '等待中',
'terminated': '已终止'
};
return statusMap[status] || status;
};
const getStageStatusText = (status) => {
const statusMap = {
'success': '成功',
'failed': '失败',
'running': '构建中',
'pending': '等待中',
'terminated': '已终止'
};
return statusMap[status] || status;
};
// 生命周期钩子
onMounted(() => {
// 检查是否有URL查询参数
const urlParams = new URLSearchParams(window.location.search);
const taskIdParam = urlParams.get('task_id');
const taskNameParam = urlParams.get('task_name');
if (taskNameParam) {
taskName.value = taskNameParam;
}
loadProjects();
loadEnvironments();
// 延迟加载构建历史
setTimeout(() => {
loadBuildHistory();
}, 100);
});
</script>
<style scoped>
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
}
:deep(.ant-card) {
border-radius: 4px;
}
.history-item {
padding: 16px;
background: #fafafa;
border-radius: 4px;
margin: 8px 0;
}
.history-header {
margin-bottom: 16px;
}
.build-info {
margin-bottom: 8px;
}
.build-id {
font-weight: 500;
margin-right: 8px;
}
.build-branch {
margin-left: 8px;
color: #666;
}
.build-meta {
display: flex;
gap: 24px;
color: rgba(0, 0, 0, 0.45);
}
.history-content {
background: #fff;
padding: 16px;
border-radius: 4px;
}
.action-buttons {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.log-content {
display: flex;
flex-direction: column;
height: 650px;
}
.log-body {
flex: 1;
overflow-y: auto;
background: #1e1e1e;
padding: 16px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
}
.log-body pre {
margin: 0;
color: #fff;
white-space: pre-wrap;
word-wrap: break-word;
}
.log-footer {
padding: 12px;
background: #f5f5f5;
border-top: 1px solid #e8e8e8;
}
.log-controls {
display: flex;
justify-content: flex-end;
align-items: center;
}
.stages-info {
margin: 16px 0;
padding: 16px;
background: #fafafa;
border-radius: 4px;
}
.stages-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.stages-title {
font-size: 14px;
font-weight: 500;
}
.total-duration {
color: rgba(0, 0, 0, 0.45);
}
.stages-timeline {
background: #fff;
padding: 16px;
border-radius: 4px;
}
.stage-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.stage-header {
display: flex;
align-items: center;
gap: 8px;
}
.stage-name {
font-weight: 500;
font-size: 14px;
}
.stage-details {
display: flex;
align-items: center;
gap: 8px;
color: rgba(0, 0, 0, 0.45);
font-size: 13px;
}
:deep(.ant-timeline-item-content) {
margin-left: 28px;
}
:deep(.ant-timeline-item) {
padding-bottom: 20px;
}
:deep(.ant-timeline-item-last) {
padding-bottom: 0;
}
.pagination {
margin-top: 24px;
text-align: right;
}
</style>

View File

@@ -0,0 +1,300 @@
<template>
<div class="build-task-detail">
<div class="page-header">
<a-row justify="space-between" align="middle">
<a-col>
<h2>构建任务详情</h2>
</a-col>
<a-col>
<a-space>
<a-button @click="handleBack">
<template #icon><ArrowLeftOutlined /></template>
返回
</a-button>
<a-button type="primary" @click="handleEdit">
<template #icon><EditOutlined /></template>
编辑
</a-button>
</a-space>
</a-col>
</a-row>
</div>
<a-card v-loading="loading">
<a-descriptions
title="基本信息"
bordered
:column="2"
>
<a-descriptions-item label="任务ID">
{{ taskDetail.task_id }}
</a-descriptions-item>
<a-descriptions-item label="任务名称">
{{ taskDetail.name }}
</a-descriptions-item>
<a-descriptions-item label="任务状态">
{{ getStatusText(taskDetail.status) }}
</a-descriptions-item>
<a-descriptions-item label="所属项目">
{{ taskDetail.project?.name }}
</a-descriptions-item>
<a-descriptions-item label="构建环境">
{{ taskDetail.environment?.name }}
</a-descriptions-item>
<a-descriptions-item label="环境类型">
{{ taskDetail.environment?.type }}
</a-descriptions-item>
<a-descriptions-item label="Git仓库">
{{ taskDetail.project?.repository }}
</a-descriptions-item>
<a-descriptions-item label="默认分支">
<template v-if="taskDetail.branch">
{{ taskDetail.branch }}
<!-- <a-tag color="blue" style="margin-left: 8px">推荐分支</a-tag> -->
</template>
<template v-else>
<span style="color: rgba(0,0,0,.45)">未设置将使用仓库默认分支</span>
</template>
</a-descriptions-item>
<a-descriptions-item label="Git凭证">
{{ taskDetail.git_token?.name }}
</a-descriptions-item>
<a-descriptions-item label="最新构建号">
{{ taskDetail.version || '暂无' }}
</a-descriptions-item>
<a-descriptions-item label="构建版本">
{{ taskDetail.version || '暂无' }}
</a-descriptions-item>
<a-descriptions-item label="构建需求">
{{ taskDetail.requirement || '暂无' }}
</a-descriptions-item>
<a-descriptions-item label="创建者">
{{ taskDetail.creator?.name }}
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ taskDetail.create_time }}
</a-descriptions-item>
<a-descriptions-item label="最近更新">
{{ taskDetail.update_time }}
</a-descriptions-item>
<a-descriptions-item label="任务描述" :span="2">
{{ taskDetail.description || '暂无描述' }}
</a-descriptions-item>
</a-descriptions>
<a-divider />
<div class="notification-settings">
<h3>通知设置</h3>
<div v-if="robotList.length === 0 && !loading" class="empty-notification">
<a-empty description="暂无通知设置" />
</div>
<div v-else-if="taskDetail.notification_channels && taskDetail.notification_channels.length > 0" class="notification-list">
<a-tag
v-for="robotId in taskDetail.notification_channels"
:key="robotId"
:color="getRobotTagColor(getRobotTypeById(robotId))"
class="robot-tag"
>
<template #icon>
<DingdingOutlined v-if="getRobotTypeById(robotId) === 'dingtalk'" />
<WechatOutlined v-else-if="getRobotTypeById(robotId) === 'wecom'" />
<RocketOutlined v-else-if="getRobotTypeById(robotId) === 'feishu'" />
<MailOutlined v-else />
</template>
{{ getRobotNameById(robotId) }}
</a-tag>
</div>
<div v-else-if="!loading" class="empty-notification">
<a-empty description="未配置通知机器人" />
</div>
</div>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message, Empty } from 'ant-design-vue';
import axios from 'axios';
import {
ArrowLeftOutlined,
EditOutlined,
DingdingOutlined,
WechatOutlined,
MailOutlined,
RocketOutlined,
} from '@ant-design/icons-vue';
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const robotsLoading = ref(false);
const taskDetail = ref({
notification_channels: []
});
const robotList = ref([]);
// 获取任务详情
const loadTaskDetail = async () => {
try {
loading.value = true;
const taskId = route.query.task_id;
const token = localStorage.getItem('token');
const response = await axios.get(`/api/build/tasks/${taskId}`, {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
taskDetail.value = response.data.data;
if (!taskDetail.value.notification_channels) {
taskDetail.value.notification_channels = [];
}
// 加载机器人列表
await loadRobotList();
} else {
message.error(response.data.message || '获取任务详情失败');
}
} catch (error) {
console.error('Load task detail error:', error);
message.error('获取任务详情失败');
} finally {
loading.value = false;
}
};
// 加载通知机器人列表
const loadRobotList = async () => {
try {
robotsLoading.value = true;
const token = localStorage.getItem('token');
const response = await axios.get('/api/notification/robots/', {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
robotList.value = response.data.data || [];
} else {
message.error(response.data.message || '获取通知机器人列表失败');
}
} catch (error) {
console.error('Load robot list error:', error);
message.error('获取通知机器人列表失败');
} finally {
robotsLoading.value = false;
}
};
// 根据机器人ID获取机器人类型
const getRobotTypeById = (robotId) => {
const robot = robotList.value.find(r => r.robot_id === robotId);
return robot ? robot.type : '';
};
// 根据机器人ID获取机器人名称
const getRobotNameById = (robotId) => {
const robot = robotList.value.find(r => r.robot_id === robotId);
return robot ? robot.name : robotId;
};
// 获取机器人标签颜色
const getRobotTagColor = (type) => {
const colors = {
'dingtalk': '#1890ff',
'wecom': '#07c160',
'feishu': '#722ed1',
};
return colors[type] || '#2db7f5';
};
// 获取状态文本
const getStatusText = (status) => {
const texts = {
created: '正常',
disabled: '已禁用',
running: '运行中',
success: '成功',
failed: '失败',
};
return texts[status] || status;
};
// 返回列表页
const handleBack = () => {
router.push('/build/tasks');
};
// 跳转到编辑页
const handleEdit = () => {
router.push({
name: 'build-task-edit',
query: { task_id: route.query.task_id }
});
};
onMounted(() => {
loadTaskDetail();
});
</script>
<style scoped>
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
}
:deep(.ant-card) {
border-radius: 4px;
}
:deep(.ant-descriptions) {
margin-bottom: 24px;
}
:deep(.ant-descriptions-title) {
font-size: 16px;
font-weight: 500;
}
.notification-settings {
margin-top: 24px;
}
.notification-settings h3 {
margin-bottom: 16px;
font-size: 16px;
font-weight: 500;
}
:deep(.ant-checkbox-group) {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.notification-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
}
.robot-tag {
display: flex;
align-items: center;
padding: 6px 12px;
font-size: 14px;
}
.empty-notification {
padding: 24px 0;
text-align: center;
}
</style>

View File

@@ -0,0 +1,879 @@
<template>
<div class="build-task-edit">
<div class="page-header">
<a-page-header
:title="isEdit ? '编辑构建任务' : '新建构建任务'"
@back="handleBack"
/>
</div>
<a-form
:model="formState"
:rules="rules"
ref="formRef"
layout="vertical"
>
<a-card title="基本信息" class="card-wrapper">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="任务名称" name="name" required>
<a-input v-model:value="formState.name" placeholder="请输入任务名称" />
<div class="form-item-help">任务名称将作为 Jenkins Job 名称不能包含特殊字符</div>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="所属项目" name="project_id" required>
<a-select
v-model:value="formState.project_id"
placeholder="请选择项目"
:options="projectOptions"
@change="handleProjectChange"
show-search
:filter-option="filterOption"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="构建环境" name="environment_id" required>
<a-select
v-model:value="formState.environment_id"
placeholder="请选择环境"
:options="environmentOptions"
show-search
:filter-option="filterOption"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="描述" name="description">
<a-textarea
v-model:value="formState.description"
placeholder="请输入任务描述"
:rows="4"
/>
</a-form-item>
</a-col>
</a-row>
</a-card>
<a-card title="源码配置" class="card-wrapper">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="默认分支" name="branch">
<a-input
v-model:value="formState.branch"
placeholder="可选设置默认显示的分支例如main、master、develop"
>
<template #prefix>
<BranchesOutlined />
</template>
</a-input>
<div class="form-item-help">设置构建时默认选中的分支留空则默认选择仓库的默认分支实际构建时将使用用户选择的分支进行构建</div>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="Git Token" name="git_token_id" required>
<a-select
v-model:value="formState.git_token_id"
placeholder="请选择Git Token"
:loading="gitCredentialsLoading"
:options="gitCredentials"
show-search
:filter-option="filterOption"
>
<template #suffixIcon>
<ReloadOutlined
:spin="gitCredentialsLoading"
@click="loadGitCredentials"
/>
</template>
</a-select>
<div class="form-item-help">
用于访问Git仓库的Token凭证如果没有合适的凭证请先在凭证管理中添加
</div>
</a-form-item>
</a-col>
</a-row>
</a-card>
<a-card class="card-wrapper">
<template #title>
<div class="card-title-with-action">
<span>构建配置</span>
<SystemVariablesList />
</div>
</template>
<!-- 外部脚本库配置 -->
<div class="external-script-config">
<a-form-item>
<a-checkbox v-model:checked="formState.use_external_script" @change="handleExternalScriptChange">
<LinkOutlined style="color: #1890ff; margin-right: 4px;" />
使用外部脚本库
</a-checkbox>
<div class="config-description">从Git仓库拉取构建和部署脚本在Shell脚本中调用</div>
</a-form-item>
<div v-if="formState.use_external_script && formState.external_script_repo_url" class="external-script-summary">
<a-descriptions size="small" :column="1" bordered>
<a-descriptions-item label="仓库地址">
<a-tooltip :title="formState.external_script_repo_url">
<LinkOutlined /> {{ truncateUrl(formState.external_script_repo_url) }}
</a-tooltip>
</a-descriptions-item>
<a-descriptions-item label="存放目录">
<FolderOutlined /> {{ formState.external_script_directory }}
</a-descriptions-item>
<a-descriptions-item v-if="formState.external_script_branch" label="分支">
<BranchesOutlined /> {{ formState.external_script_branch }}
</a-descriptions-item>
</a-descriptions>
<a-button type="link" size="small" @click="openExternalScriptModal">
<EditOutlined /> 修改配置
</a-button>
</div>
</div>
<div class="stages-list">
<div v-for="(stage, index) in formState.stages" :key="index" class="stage-item">
<div class="stage-header">
<span class="stage-number">
<BuildOutlined /> 阶段 {{ index + 1 }}
</span>
<a-space>
<a-tooltip title="上移">
<a-button
v-if="index > 0"
type="text"
@click="moveStage(index, 'up')"
:disabled="index === 0"
>
<UpOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="下移">
<a-button
v-if="index < formState.stages.length - 1"
type="text"
@click="moveStage(index, 'down')"
>
<DownOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="删除">
<a-button
v-if="formState.stages.length > 1"
type="text"
danger
@click="removeStage(index)"
>
<DeleteOutlined />
</a-button>
</a-tooltip>
</a-space>
</div>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item
:name="['stages', index, 'name']"
:rules="[{ required: true, message: '请输入阶段名称' }]"
>
<a-input v-model:value="stage.name" placeholder="阶段名称">
<template #prefix>
<TagOutlined />
</template>
</a-input>
</a-form-item>
</a-col>
<a-col :span="16">
<a-form-item
:name="['stages', index, 'script']"
:rules="[{ required: true, message: '请输入执行脚本' }]"
>
<CodeEditor
v-model="stage.script"
:title="`Shell Script - ${stage.name || '未命名阶段'}`"
:placeholder="getShellScriptPlaceholder()"
:max-height="400"
/>
</a-form-item>
</a-col>
</a-row>
</div>
</div>
<div class="stage-actions">
<a-button type="dashed" block @click="addStage">
<PlusOutlined /> 添加构建阶段
</a-button>
</div>
</a-card>
<BuildNotification v-model="formState.notification_channels" />
<div class="form-footer">
<a-space>
<a-button @click="handleBack">取消</a-button>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">
保存
</a-button>
</a-space>
</div>
</a-form>
<a-modal
v-model:open="externalScriptModalVisible"
title="配置外部脚本库"
width="600px"
@ok="handleExternalScriptModalOk"
@cancel="handleExternalScriptModalCancel"
:maskClosable="false"
>
<a-form
:model="externalScriptForm"
:rules="externalScriptRules"
ref="externalScriptFormRef"
layout="vertical"
>
<a-form-item
label="Git仓库地址"
name="repo_url"
>
<a-input
v-model:value="externalScriptForm.repo_url"
placeholder="请输入Git仓库地址例如https://github.com/example/scripts.git"
>
<template #prefix>
<LinkOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item
label="存放目录"
name="directory"
>
<a-input
v-model:value="externalScriptForm.directory"
placeholder="例如:/data/scripts"
>
<template #prefix>
<FolderOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item
label="分支名称"
name="branch"
>
<a-input
v-model:value="externalScriptForm.branch"
placeholder="请输入分支名称例如main、master、develop"
>
<template #prefix>
<BranchesOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item
label="Git Token私有仓库"
name="token_id"
>
<a-select
v-model:value="externalScriptForm.token_id"
placeholder="如果是私有仓库请选择Git Token凭证"
:options="gitCredentials"
allow-clear
show-search
:filter-option="filterOption"
>
<template #suffixIcon>
<ReloadOutlined
:spin="gitCredentialsLoading"
@click="loadGitCredentials"
/>
</template>
</a-select>
</a-form-item>
<a-alert
message="配置说明"
description="外部脚本库将在构建时被克隆到指定的绝对路径目录中你可以在Shell脚本中通过绝对路径调用这些脚本文件。例如/data/scripts/deploy.sh"
type="info"
show-icon
style="margin-top: 16px;"
/>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import {
PlusOutlined,
DeleteOutlined,
ReloadOutlined,
QuestionCircleOutlined,
BuildOutlined,
DeploymentUnitOutlined,
BranchesOutlined,
CodeOutlined,
LinkOutlined,
ClockCircleOutlined,
FieldTimeOutlined,
TagOutlined,
UpOutlined,
DownOutlined,
ThunderboltOutlined,
CopyOutlined,
LockOutlined,
FolderOutlined,
InfoCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
MailOutlined,
KeyOutlined,
EditOutlined,
} from '@ant-design/icons-vue';
import axios from 'axios';
import CodeEditor from './components/CodeEditor.vue';
import BuildNotification from './components/BuildNotification.vue';
import SystemVariablesList from './components/SystemVariablesList.vue';
const router = useRouter();
const route = useRoute();
const formRef = ref();
const isEdit = ref(false);
const submitLoading = ref(false);
const loading = ref(false);
const projectOptions = ref([]);
const environmentOptions = ref([]);
const gitCredentials = ref([]);
const gitCredentialsLoading = ref(false);
// 外部脚本仓库相关状态
const externalScriptModalVisible = ref(false);
const currentStageIndex = ref(-1);
const externalScriptFormRef = ref();
// 外部脚本仓库表单
const externalScriptForm = reactive({
repo_url: '',
directory: '',
branch: '',
token_id: undefined,
});
// 外部脚本仓库表单验证规则
const externalScriptRules = {
repo_url: [
{ required: true, message: '请输入Git仓库地址', trigger: 'blur' }
],
directory: [
{ required: true, message: '请输入存放目录', trigger: 'blur' },
{ pattern: /^\/[a-zA-Z0-9/_-]+$/, message: '请输入正确的绝对路径,必须以/开头,只能包含字母、数字、下划线、连字符和斜杠', trigger: 'blur' }
],
branch: [
{ required: true, message: '请输入分支名称', trigger: 'blur' },
{ min: 1, max: 100, message: '分支名称长度应在 1-100 个字符之间', trigger: 'blur' }
]
};
// 表单状态
const formState = reactive({
task_id: '',
name: '',
project_id: undefined,
environment_id: undefined,
description: '',
branch: '',
git_token_id: undefined,
use_external_script: false,
external_script_repo_url: '',
external_script_directory: '',
external_script_branch: '',
external_script_token_id: undefined,
stages: [
{
name: '构建',
script: '',
}
],
notification_channels: [],
});
// 表单校验规则
const rules = {
name: [
{ required: true, message: '请输入任务名称', trigger: 'blur' },
{ min: 2, max: 50, message: '任务名称长度应在 2-50 个字符之间', trigger: 'blur' },
],
project_id: [
{ required: true, message: '请选择项目', trigger: 'change' },
],
environment_id: [
{ required: true, message: '请选择环境', trigger: 'change' },
],
git_token_id: [
{ required: true, message: '请选择Git Token', trigger: 'change' },
],
};
// 加载项目列表
const loadProjects = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/projects/', {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
projectOptions.value = response.data.data.map(item => ({
label: item.name,
value: item.project_id
}));
}
} catch (error) {
console.error('Load projects error:', error);
message.error('加载项目列表失败');
}
};
// 加载环境列表
const loadEnvironments = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/environments/', {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
environmentOptions.value = response.data.data.map(item => ({
label: item.name,
value: item.environment_id
}));
}
} catch (error) {
console.error('Load environments error:', error);
message.error('加载环境列表失败');
}
};
// 加载Git Token凭证列表
const loadGitCredentials = async () => {
try {
gitCredentialsLoading.value = true;
const token = localStorage.getItem('token');
const response = await axios.get('/api/credentials/', {
params: { type: 'gitlab_token' },
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
gitCredentials.value = response.data.data.map(item => ({
label: item.name,
value: item.credential_id,
description: item.description
}));
}
} catch (error) {
console.error('Load git credentials error:', error);
message.error('加载Git Token凭证失败');
} finally {
gitCredentialsLoading.value = false;
}
};
// 处理项目变更
const handleProjectChange = async (value) => {
const project = projectOptions.value.find(item => item.value === value);
if (project) {
// 根据项目信息加载其他相关数据
}
};
// 添加构建阶段
const addStage = () => {
formState.stages.push({
name: '',
script: '',
});
};
const removeStage = (index) => {
formState.stages.splice(index, 1);
};
// 处理返回
const handleBack = () => {
router.back();
};
// 处理提交
const handleSubmit = async () => {
try {
await formRef.value.validate();
submitLoading.value = true;
const token = localStorage.getItem('token');
const method = isEdit.value ? 'put' : 'post';
// 构建提交数据
const submitData = { ...formState };
if (!submitData.use_external_script) {
submitData.external_script_repo_url = '';
submitData.external_script_directory = '';
submitData.external_script_branch = '';
submitData.external_script_token_id = undefined;
}
if (!isEdit.value) {
delete submitData.task_id;
}
const response = await axios[method]('/api/build/tasks/', submitData, {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
message.success(`${isEdit.value ? '更新' : '创建'}构建任务成功`);
router.push('/build/tasks');
} else {
throw new Error(response.data.message);
}
} catch (error) {
console.error('Submit task error:', error);
message.error(error.message || `${isEdit.value ? '更新' : '创建'}构建任务失败`);
} finally {
submitLoading.value = false;
}
};
// 加载任务详情
const loadTaskDetail = async (taskId) => {
try {
loading.value = true;
const token = localStorage.getItem('token');
const response = await axios.get(`/api/build/tasks/${taskId}`, {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
formState.task_id = response.data.data.task_id;
formState.name = response.data.data.name;
formState.description = response.data.data.description;
formState.branch = response.data.data.branch;
// 外部脚本库配置
formState.use_external_script = response.data.data.use_external_script || false;
formState.external_script_repo_url = response.data.data.external_script_repo_url || '';
formState.external_script_directory = response.data.data.external_script_directory || '';
formState.external_script_branch = response.data.data.external_script_branch || '';
formState.external_script_token_id = response.data.data.external_script_token_id || undefined;
const stages = response.data.data.stages || [];
formState.stages = stages.map(stage => ({
name: stage.name || '',
script: stage.script || '',
}));
if (formState.stages.length === 0) {
formState.stages.push({
name: '构建',
script: '',
});
}
formState.notification_channels = response.data.data.notification_channels || [];
if (response.data.data.project) {
formState.project_id = response.data.data.project.project_id;
}
if (response.data.data.environment) {
formState.environment_id = response.data.data.environment.environment_id;
}
if (response.data.data.git_token) {
formState.git_token_id = response.data.data.git_token.credential_id;
}
// 加载相关选项数据
await Promise.all([
loadProjects(),
loadEnvironments(),
loadGitCredentials()
]);
} else {
message.error(response.data.message || '加载任务详情失败');
}
} catch (error) {
console.error('加载任务详情失败:', error);
message.error('加载任务详情失败');
} finally {
loading.value = false;
}
};
// 过滤选项方法
const filterOption = (input, option) => {
return (
option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0 ||
option.description?.toLowerCase().indexOf(input.toLowerCase()) >= 0
);
};
const moveStage = (index, direction) => {
const stages = formState.stages;
if (direction === 'up' && index > 0) {
[stages[index], stages[index - 1]] = [stages[index - 1], stages[index]];
} else if (direction === 'down' && index < stages.length - 1) {
[stages[index], stages[index + 1]] = [stages[index + 1], stages[index]];
}
};
// 获取Shell脚本占位符
const getShellScriptPlaceholder = () => {
let placeholder = `#!/bin/bash
# 在这里输入shell脚本
# 支持所有shell命令和Jenkins环境变量
# 例如:
# echo $JOB_NAME
# echo $BUILD_NUMBER
# echo $WORKSPACE`;
if (formState.use_external_script && formState.external_script_directory) {
placeholder += `\n\n# 调用外部脚本库中的脚本示例:\n# ${formState.external_script_directory}/deploy.sh\n# ${formState.external_script_directory}/build.sh`;
}
return placeholder;
};
// 处理外部脚本库变更
const handleExternalScriptChange = (checked) => {
if (!checked) {
// 取消使用外部脚本,清空相关配置
formState.external_script_repo_url = '';
formState.external_script_directory = '';
formState.external_script_branch = '';
formState.external_script_token_id = undefined;
} else if (!formState.external_script_repo_url) {
openExternalScriptModal();
}
};
// 打开外部脚本库配置Modal
const openExternalScriptModal = () => {
// 填充表单数据
externalScriptForm.repo_url = formState.external_script_repo_url || '';
externalScriptForm.directory = formState.external_script_directory || '';
externalScriptForm.branch = formState.external_script_branch || '';
externalScriptForm.token_id = formState.external_script_token_id || undefined;
externalScriptModalVisible.value = true;
};
const handleExternalScriptModalOk = async () => {
try {
await externalScriptFormRef.value.validate();
// 保存配置到formState
formState.external_script_repo_url = externalScriptForm.repo_url;
formState.external_script_directory = externalScriptForm.directory;
formState.external_script_branch = externalScriptForm.branch;
formState.external_script_token_id = externalScriptForm.token_id;
externalScriptModalVisible.value = false;
message.success('外部脚本库配置成功');
} catch (error) {
console.error('External script form validation failed:', error);
}
};
// 处理外部脚本库Modal取消
const handleExternalScriptModalCancel = () => {
if (!formState.external_script_repo_url) {
formState.use_external_script = false;
}
externalScriptModalVisible.value = false;
};
// 截断URL显示
const truncateUrl = (url) => {
if (!url) return '';
return url.length > 50 ? url.substring(0, 47) + '...' : url;
};
onMounted(async () => {
const taskId = route.query.task_id;
if (taskId) {
isEdit.value = true;
await loadTaskDetail(taskId);
} else {
loadProjects();
loadEnvironments();
loadGitCredentials();
}
});
</script>
<style scoped>
.page-header {
margin-bottom: 24px;
background: #fff;
border-radius: 4px;
}
:deep(.ant-page-header) {
padding: 16px 24px;
}
.card-wrapper {
margin-bottom: 24px;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
:deep(.ant-card-head) {
border-bottom: 1px solid #f0f0f0;
padding: 0 24px;
}
:deep(.ant-card-head-title) {
font-size: 16px;
font-weight: 500;
}
:deep(.ant-card-body) {
padding: 24px;
}
.form-item-help {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 4px;
}
.stages-list {
margin-top: 16px;
}
.stage-item {
padding: 16px;
background: transparent;
border-radius: 4px;
margin-bottom: 16px;
border: 1px solid #e8e8e8;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.stage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #e8e8e8;
}
.stage-number {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
}
.stage-actions {
margin-top: 16px;
}
.form-footer {
margin-top: 24px;
text-align: center;
background: #fff;
padding: 16px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
:deep(.ant-radio-button-wrapper) {
margin-right: 8px;
}
:deep(.ant-checkbox-group) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
:deep(.ant-form-item-label) {
font-weight: 500;
}
ul {
margin: 0;
padding-left: 16px;
}
li {
color: rgba(0, 0, 0, 0.45);
}
.channel-icon {
width: 16px;
height: 16px;
vertical-align: -0.125em;
}
.notification-template-vars {
margin-top: 8px;
padding: 8px;
background-color: transparent;
border-radius: 4px;
border: 1px solid #e8e8e8;
}
.card-title-with-action {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.external-script-config {
margin-bottom: 24px;
padding: 16px;
border: 1px solid #e8e8e8;
border-radius: 6px;
background-color: transparent;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.config-description {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 4px;
margin-left: 24px;
}
.external-script-summary {
margin-top: 12px;
padding: 12px;
background-color: transparent;
border-radius: 4px;
border: 1px solid #e8e8e8;
}
.external-script-summary :deep(.ant-descriptions-item-label) {
font-weight: 500;
color: #595959;
width: 100px;
}
.external-script-summary :deep(.ant-descriptions-item-content) {
color: #262626;
}
</style>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
<template>
<a-card title="构建后操作" class="card-wrapper">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="通知方式" name="notification_channels">
<a-select
:value="modelValue"
mode="multiple"
placeholder="请选择通知方式"
style="width: 100%"
:options="robotList"
:field-names="{
label: 'label',
value: 'robot_id',
}"
@change="handleChannelsChange"
/>
<div class="form-item-help">
选择构建完成后的通知方式无论构建成功或失败都会发送通知
</div>
</a-form-item>
</a-col>
</a-row>
</a-card>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { message } from 'ant-design-vue';
import axios from 'axios';
const props = defineProps({
modelValue: {
type: Array,
required: true,
default: () => [],
},
});
const emit = defineEmits(['update:modelValue']);
// 机器人列表
const robotList = ref([]);
// 获取机器人类型文本
const getRobotTypeText = (type) => {
const types = {
dingtalk: '钉钉',
wecom: '企业微信',
feishu: '飞书',
};
return types[type] || type;
};
// 处理通知方式变更
const handleChannelsChange = (value) => {
emit('update:modelValue', value);
};
// 加载机器人列表
const loadRobotList = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/build/tasks/', {
params: { get_robots: true },
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
robotList.value = response.data.data.map(robot => ({
label: `${getRobotTypeText(robot.type)} - ${robot.name}`,
robot_id: robot.robot_id,
type: robot.type,
name: robot.name,
}));
} else {
message.error(response.data.message || '获取通知机器人列表失败');
}
} catch (error) {
console.error('Load robot list error:', error);
message.error('获取通知机器人列表失败');
}
};
// 页面加载时获取机器人列表
onMounted(() => {
loadRobotList();
});
</script>
<style scoped>
.card-wrapper {
margin-bottom: 24px;
}
.form-item-help {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,123 @@
<template>
<div class="code-editor">
<div class="editor-header">
<span>{{ title }}</span>
</div>
<div class="editor-content">
<Codemirror
ref="editorRef"
v-model="code"
:placeholder="placeholder"
:autofocus="false"
:indent-with-tab="true"
:tab-size="2"
:extensions="extensions"
/>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import { Codemirror } from 'vue-codemirror';
import { StreamLanguage } from '@codemirror/language';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view';
// import "codemirror/mode/shell/shell.js";
const props = defineProps({
modelValue: {
type: String,
default: '',
},
title: {
type: String,
required: true,
},
placeholder: {
type: String,
default: '',
},
maxHeight: {
type: Number,
default: 400, // 默认最大高度为400px
},
});
const emit = defineEmits(['update:modelValue']);
const editorRef = ref(null);
const code = ref(props.modelValue);
// 编辑器扩展
const extensions = [
StreamLanguage.define(shell),
oneDark,
EditorView.lineWrapping,
EditorView.theme({
"&": {
maxHeight: `${props.maxHeight}px`,
height: "auto"
},
".cm-scroller": {
overflow: "auto"
},
".cm-content": {
minHeight: "100px"
}
})
];
watch(code, (newValue) => {
emit('update:modelValue', newValue);
});
watch(() => props.modelValue, (newValue) => {
if (code.value !== newValue) {
code.value = newValue;
}
});
</script>
<style scoped>
.code-editor {
border: 1px solid #d9d9d9;
border-radius: 4px;
overflow: hidden;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #fafafa;
color: rgba(0, 0, 0, 0.85);
border-bottom: 1px solid #d9d9d9;
font-size: 14px;
font-weight: 500;
}
.editor-content {
position: relative;
}
:deep(.cm-editor) {
height: 100%;
}
:deep(.cm-editor.cm-focused) {
outline: none;
}
:deep(.cm-scroller) {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 14px;
line-height: 1.5;
padding: 8px;
}
:deep(.cm-content) {
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,311 @@
<template>
<div class="fullscreen-log-viewer">
<div
class="fullscreen-log-container"
:class="{ 'fullscreen-mode': isFullscreen }"
>
<div class="log-header">
<div class="log-title">{{ title }}</div>
<div class="log-actions">
<a-button
type="text"
:title="isFullscreen ? '退出全屏' : '全屏显示'"
@click="toggleFullscreen"
>
<template #icon>
<FullscreenExitOutlined v-if="isFullscreen" />
<FullscreenOutlined v-else />
</template>
</a-button>
</div>
</div>
<div class="log-body" ref="logBodyRef" @scroll="handleScroll">
<pre v-if="logContent" v-html="formattedLog"></pre>
<div v-else class="log-empty">暂无日志内容</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import {
FullscreenOutlined,
FullscreenExitOutlined,
} from '@ant-design/icons-vue';
const props = defineProps({
logContent: {
type: String,
required: true,
},
title: {
type: String,
default: '构建日志',
},
autoScroll: {
type: Boolean,
default: false
}
});
const isFullscreen = ref(false);
const logBodyRef = ref(null);
const isUserScrolling = ref(false);
const scrollTimeout = ref(null);
const lastScrollTop = ref(0);
const formattedLog = computed(() => {
if (!props.logContent) return '';
let formatted = props.logContent
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return formatted;
});
// 切换全屏模式
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
};
// 监听ESC键用于退出全屏
const handleKeyDown = (event) => {
if (event.key === 'Escape' && isFullscreen.value) {
isFullscreen.value = false;
}
};
// 处理用户滚动事件
const handleScroll = () => {
if (!logBodyRef.value) return;
const element = logBodyRef.value;
const currentScrollTop = element.scrollTop;
// 检测用户是否在向上滚动
if (currentScrollTop < lastScrollTop.value) {
isUserScrolling.value = true;
}
// 检测是否滚动到底部
const isAtBottom = element.scrollHeight - element.scrollTop - element.clientHeight <= 5;
if (isAtBottom) {
isUserScrolling.value = false;
}
lastScrollTop.value = currentScrollTop;
if (scrollTimeout.value) {
clearTimeout(scrollTimeout.value);
}
// 设置定时器如果用户停止滚动3秒后重新启用自动滚动
scrollTimeout.value = setTimeout(() => {
const isStillAtBottom = element.scrollHeight - element.scrollTop - element.clientHeight <= 5;
if (isStillAtBottom) {
isUserScrolling.value = false;
}
}, 3000);
};
// 平滑滚动到底部
const scrollToBottom = (smooth = false) => {
if (!logBodyRef.value) return;
const element = logBodyRef.value;
if (smooth) {
// 平滑滚动
element.scrollTo({
top: element.scrollHeight,
behavior: 'smooth'
});
} else {
// 立即滚动
element.scrollTop = element.scrollHeight;
}
};
// 强制滚动到底部
const forceScrollToBottom = () => {
isUserScrolling.value = false;
scrollToBottom();
};
defineExpose({
logBodyRef,
scrollToBottom,
forceScrollToBottom
});
// 防抖函数
const debounce = (func, wait) => {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
const debouncedScrollToBottom = debounce(() => {
if (props.autoScroll && !isUserScrolling.value) {
scrollToBottom();
}
}, 50); // 50ms防抖
// 日志内容变化时,自动滚动到底部
watch(() => props.logContent, (newContent, oldContent) => {
if (!newContent) return;
if (oldContent && newContent.length <= oldContent.length) {
return;
}
nextTick(() => {
debouncedScrollToBottom();
});
}, { flush: 'post' });
// 监听autoScroll属性变化
watch(() => props.autoScroll, (newValue) => {
if (newValue && !isUserScrolling.value) {
nextTick(() => {
scrollToBottom();
});
}
});
onMounted(() => {
// 添加键盘事件监听
document.addEventListener('keydown', handleKeyDown);
// 初始滚动到底部
if (props.autoScroll) {
nextTick(() => {
scrollToBottom();
});
}
});
onUnmounted(() => {
// 移除键盘事件监听
document.removeEventListener('keydown', handleKeyDown);
// 清理定时器
if (scrollTimeout.value) {
clearTimeout(scrollTimeout.value);
}
});
</script>
<style scoped>
.fullscreen-log-viewer {
width: 100%;
height: 100%;
max-height: 100%;
overflow: hidden;
}
.fullscreen-log-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
max-height: 100%;
border: 1px solid #e8e8e8;
border-radius: 4px;
overflow: hidden;
background-color: #1e1e1e;
transition: all 0.3s;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background-color: #2d2d2d;
color: #fff;
border-bottom: 1px solid #000;
}
.log-title {
font-weight: 500;
font-size: 14px;
}
.log-actions {
display: flex;
gap: 8px;
}
:deep(.anticon) {
color: #fff;
}
.log-body {
flex: 1;
overflow-y: auto;
padding: 16px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
background-color: #1e1e1e;
max-height: calc(100% - 40px); /* 减去header的高度 */
scroll-behavior: smooth; /* 启用平滑滚动 */
}
.log-body pre {
margin: 0;
color: #fff;
white-space: pre-wrap;
word-wrap: break-word;
font-size: 13px;
line-height: 1.5;
}
.fullscreen-mode {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
border-radius: 0;
border: none;
width: 100vw;
height: 100vh;
}
.log-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #666;
font-style: italic;
}
/* 自定义滚动条样式 */
.log-body::-webkit-scrollbar {
width: 8px;
}
.log-body::-webkit-scrollbar-track {
background: #2d2d2d;
}
.log-body::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
.log-body::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>

View File

@@ -0,0 +1,226 @@
<template>
<div class="system-variables">
<a-button type="link" @click="visible = true">
<template #icon><InfoCircleOutlined /></template>
查看可用系统变量
</a-button>
<a-modal
v-model:open="visible"
title="系统环境变量列表"
width="800px"
:footer="null"
>
<a-alert
message="这些变量可以在构建脚本中直接使用"
description="在脚本中使用变量的格式:$VARIABLE_NAME 或 ${VARIABLE_NAME}"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-input-search
v-model:value="searchText"
placeholder="搜索变量..."
style="margin-bottom: 16px"
enter-button
@search="onSearch"
/>
<a-table
:columns="columns"
:data-source="filteredVariables"
:pagination="false"
:loading="loading"
>
<template #bodyCell="{ column, text }">
<template v-if="column.key === 'name'">
<code>{{ text }}</code>
</template>
<template v-else-if="column.key === 'description'">
<span>{{ text }}</span>
</template>
</template>
</a-table>
<div class="modal-footer">
<a-button type="primary" @click="visible = false">关闭</a-button>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { InfoCircleOutlined } from '@ant-design/icons-vue';
const visible = ref(false);
const loading = ref(false);
const searchText = ref('');
// 表格列定义
const columns = [
{
title: '变量名',
dataIndex: 'name',
key: 'name',
width: 200,
},
{
title: '说明',
dataIndex: 'description',
key: 'description',
},
];
// 系统变量列表
const systemVariables = [
// 编号相关变量
{
name: 'BUILD_NUMBER',
description: '当前构建的序号',
category: '编号'
},
{
name: 'VERSION',
description: '当前构建的版本号(格式为: 年月日时分秒_CommitID前8位如20250320112507_029e149e',
category: '编号'
},
// Git相关变量
{
name: 'COMMIT_ID',
description: 'Git提交ID',
category: 'Git'
},
{
name: 'BRANCH',
description: '构建分支名称',
category: 'Git'
},
// 项目相关变量
{
name: 'PROJECT_NAME',
description: '项目名称',
category: '项目'
},
{
name: 'PROJECT_ID',
description: '项目ID',
category: '项目'
},
{
name: 'PROJECT_REPO',
description: '项目Git仓库地址',
category: '项目'
},
// 任务相关变量
{
name: 'TASK_NAME',
description: '构建任务名称',
category: '任务'
},
{
name: 'TASK_ID',
description: '构建任务ID',
category: '任务'
},
// 环境相关变量
{
name: 'ENVIRONMENT',
description: '构建环境名称',
category: '环境'
},
{
name: 'ENVIRONMENT_TYPE',
description: '构建环境类型',
category: '环境'
},
{
name: 'ENVIRONMENT_ID',
description: '构建环境ID',
category: '环境'
},
// 构建路径相关变量
{
name: 'BUILD_PATH',
description: '构建目录的绝对路径',
category: '路径'
},
{
name: 'BUILD_WORKSPACE',
description: '构建工作区的绝对路径等同于BUILD_PATH',
category: '路径'
},
{
name: 'service_name',
description: '任务名称TASK_NAME的别名',
category: '别名'
},
{
name: 'build_env',
description: '构建环境名称ENVIRONMENT的别名',
category: '别名'
},
{
name: 'branch',
description: '分支名称BRANCH的别名',
category: '别名'
},
{
name: 'version',
description: '版本号VERSION的别名',
category: '别名'
},
];
// 根据搜索文本过滤变量
const filteredVariables = computed(() => {
if (!searchText.value) {
return systemVariables;
}
const search = searchText.value.toLowerCase();
return systemVariables.filter(variable =>
variable.name.toLowerCase().includes(search) ||
variable.description.toLowerCase().includes(search) ||
variable.category.toLowerCase().includes(search)
);
});
// 搜索处理
const onSearch = () => {
};
onMounted(() => {
});
</script>
<style scoped>
.system-variables {
margin: 12px 0;
}
.modal-footer {
margin-top: 24px;
text-align: right;
}
:deep(.ant-table-thead > tr > th) {
background-color: #fafafa;
font-weight: 500;
}
code {
padding: 2px 6px;
background-color: #f5f5f5;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,904 @@
<template>
<div class="credentials-list">
<div class="page-header">
<a-row justify="space-between" align="middle">
<a-col>
<h2>凭证管理</h2>
</a-col>
<a-col>
<a-button type="primary" @click="showCreateModal">
<template #icon><PlusOutlined /></template>
添加凭证
</a-button>
</a-col>
</a-row>
</div>
<a-card>
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="gitlab_token" tab="GitLab Token凭证">
<a-table
:columns="columns"
:data-source="credentials"
:loading="loading"
:pagination="false"
:locale="{ emptyText: '暂无数据' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm
title="确定要删除这个凭证吗?"
@confirm="handleDelete(record)"
>
<a-button type="link" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-tab-pane>
<!-- SSH密钥凭证 Tab -->
<a-tab-pane key="ssh_key" tab="SSH密钥凭证">
<a-table
:columns="columns"
:data-source="credentials"
:loading="loading"
:pagination="false"
:locale="{ emptyText: '暂无数据' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'deploy_status'">
<a-tag :color="record.deployed ? 'rgba(135,208,104,0.8)' : 'rgba(128, 128, 128, 0.8)'">
{{ record.deploy_status }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" @click="handleEdit(record)">编辑</a-button>
<a-button
type="link"
:loading="deployingCredentials[record.credential_id]"
@click="handleDeploy(record)"
v-if="!record.deployed"
>
部署
</a-button>
<a-button
type="link"
:loading="deployingCredentials[record.credential_id]"
@click="handleUndeploy(record)"
v-else
>
取消部署
</a-button>
<a-popconfirm
title="确定要删除这个凭证吗?"
@confirm="handleDelete(record)"
>
<a-button type="link" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-tab-pane>
<!-- Kubeconfig凭证 Tab -->
<a-tab-pane key="kubeconfig" tab="Kubeconfig凭证">
<a-table
:columns="columns"
:data-source="credentials"
:loading="loading"
:pagination="false"
:locale="{ emptyText: '暂无数据' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'cluster_name'">
<span>{{ record.cluster_name }}</span>
</template>
<template v-if="column.key === 'context_name'">
<code>liteops-{{ record.context_name }}</code>
</template>
<template v-if="column.key === 'deploy_status'">
<a-tag :color="record.deployed ? 'rgba(135,208,104,0.8)' : 'rgba(128, 128, 128, 0.8)'">
{{ record.deploy_status }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" @click="handleEdit(record)">编辑</a-button>
<a-button
type="link"
:loading="deployingCredentials[record.credential_id]"
@click="handleKubeconfigDeploy(record)"
v-if="!record.deployed"
>
部署
</a-button>
<a-button
type="link"
:loading="deployingCredentials[record.credential_id]"
@click="handleKubeconfigUndeploy(record)"
v-else
>
取消部署
</a-button>
<a-popconfirm
title="确定要删除这个凭证吗?删除后将重新部署剩余的配置。"
@confirm="handleDelete(record)"
>
<a-button type="link" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</a-card>
<!-- 凭证表单弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="editingCredential ? '编辑凭证' : '添加凭证'"
@ok="handleModalOk"
:confirmLoading="submitLoading"
width="600px"
>
<a-form
ref="formRef"
:model="formState"
:rules="rules"
layout="vertical"
>
<a-form-item label="凭证名称" name="name">
<a-input v-model:value="formState.name" placeholder="请输入凭证名称" />
</a-form-item>
<a-form-item label="凭证描述" name="description">
<a-textarea
v-model:value="formState.description"
placeholder="请输入凭证描述"
:rows="2"
/>
</a-form-item>
<!-- GitLab Token凭证表单 -->
<template v-if="activeTab === 'gitlab_token'">
<a-form-item label="GitLab Token" name="token">
<a-input-password v-model:value="formState.token" placeholder="请输入GitLab Token" />
</a-form-item>
</template>
<!-- SSH密钥凭证表单 -->
<template v-if="activeTab === 'ssh_key'">
<a-form-item label="SSH私钥内容" name="private_key">
<div style="display: flex; flex-direction: column; gap: 8px;">
<a-textarea
v-model:value="formState.private_key"
placeholder="请输入完整的SSH私钥内容"
:rows="8"
/>
<a-upload
name="file"
:multiple="false"
:showUploadList="false"
:beforeUpload="handleUploadPrivateKey"
>
<a-button>
<template #icon><UploadOutlined /></template>
上传私钥文件
</a-button>
</a-upload>
</div>
</a-form-item>
<a-form-item label="私钥密码 (可选)" name="passphrase">
<a-input-password v-model:value="formState.passphrase" placeholder="如果私钥有密码保护,请输入密码" />
</a-form-item>
</template>
<!-- Kubeconfig凭证表单 -->
<template v-if="activeTab === 'kubeconfig'">
<a-form-item label="Kubeconfig配置内容" name="kubeconfig_content">
<div style="display: flex; flex-direction: column; gap: 8px;">
<a-textarea
v-model:value="formState.kubeconfig_content"
placeholder="请输入完整的Kubeconfig配置内容YAML格式"
:rows="5"
/>
<a-upload
name="file"
:multiple="false"
:showUploadList="false"
:beforeUpload="handleUploadKubeconfig"
>
<a-button>
<template #icon><UploadOutlined /></template>
上传Kubeconfig文件
</a-button>
</a-upload>
</div>
</a-form-item>
<a-alert
message="使用说明"
type="info"
show-icon
>
<template #description>
<div>
<p> 上传或粘贴你的Kubeconfig文件内容</p>
<p> 系统将自动解析并提取集群和上下文信息</p>
<p> 部署后上下文名称将添加 "liteops-" 前缀以避免冲突</p>
</div>
</template>
</a-alert>
</template>
</a-form>
</a-modal>
<!-- SSH密钥部署弹窗 -->
<a-modal
v-model:open="deployModalVisible"
title="部署SSH密钥"
@ok="handleDeployConfirm"
:confirmLoading="deployLoading"
width="500px"
>
<a-alert
message="部署说明"
type="info"
show-icon
>
<template #description>
<div>
<p>部署后该SSH密钥将被配置到CI/CD容器中你可以在构建脚本中直接使用</p>
<p><code>ssh user@your-server-ip</code></p>
<p>来连接到远程服务器进行部署操作</p>
</div>
</template>
</a-alert>
</a-modal>
<!-- 部署结果弹窗 -->
<a-modal
v-model:open="deployResultModalVisible"
title="部署结果"
:footer="null"
width="600px"
>
<a-result
:status="deployResult.success ? 'success' : 'error'"
:title="deployResult.success ? '部署成功' : '部署失败'"
:sub-title="deployResult.message"
>
<template #extra v-if="deployResult.success && deployResult.data">
<div style="text-align: left; background: #f5f5f5; padding: 16px; border-radius: 4px; margin: 16px 0;">
<!-- SSH密钥使用说明 -->
<template v-if="deployResult.data.key_file">
<h4>使用说明</h4>
<p><strong>密钥文件</strong> <code>{{ deployResult.data.key_file }}</code></p>
<p><strong>使用示例</strong> <code>{{ deployResult.data.usage_example }}</code></p>
<p><strong>在构建脚本中使用</strong></p>
<pre style="background: white; padding: 8px; border-radius: 4px;">ssh root@192.168.1.100
ssh user@your-server-ip</pre>
</template>
<!-- Kubeconfig使用说明 -->
<template v-if="deployResult.data.usage_info">
<pre style="background: white; padding: 12px; border-radius: 4px; white-space: pre-wrap; font-family: 'Monaco', 'Consolas', monospace; font-size: 12px; line-height: 1.5;">{{ deployResult.data.usage_info }}</pre>
</template>
</div>
<a-button type="primary" @click="deployResultModalVisible = false">知道了</a-button>
</template>
<template #extra v-else>
<a-button @click="deployResultModalVisible = false">关闭</a-button>
</template>
</a-result>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined, UploadOutlined } from '@ant-design/icons-vue';
import axios from 'axios';
import { checkPermission, hasFunctionPermission } from '../../utils/permission';
const activeTab = ref('gitlab_token');
const loading = ref(false);
const submitLoading = ref(false);
const credentials = ref([]);
const modalVisible = ref(false);
const editingCredential = ref(null);
const formRef = ref();
// 部署相关状态
const deployModalVisible = ref(false);
const deployResultModalVisible = ref(false);
const deployLoading = ref(false);
const currentDeployCredential = ref(null);
const deployingCredentials = ref({});
const deployResult = ref({
success: false,
message: '',
data: null
});
// 表格列定义
const baseColumns = [
{
title: '凭证名称',
dataIndex: 'name',
key: 'name',
},
{
title: '创建者',
key: 'creator',
customRender: ({ record }) => record.creator?.name || '未知',
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '创建时间',
dataIndex: 'create_time',
key: 'create_time',
},
{
title: '操作',
key: 'action',
// width: 200,
},
];
const sshKeyColumns = [
...baseColumns.slice(0, 1),
{
title: '部署状态',
key: 'deploy_status',
},
...baseColumns.slice(1, -1),
{
title: '操作',
key: 'action',
},
];
const kubeconfigColumns = [
...baseColumns.slice(0, 1),
{
title: '集群名称',
key: 'cluster_name',
},
{
title: '上下文名称',
key: 'context_name',
ellipsis: true,
},
{
title: '部署状态',
key: 'deploy_status',
},
...baseColumns.slice(1, -1),
{
title: '操作',
key: 'action',
},
];
// 根据当前选中的标签页获取对应的列定义
const columns = computed(() => {
switch (activeTab.value) {
case 'gitlab_token':
return baseColumns;
case 'ssh_key':
return sshKeyColumns;
case 'kubeconfig':
return kubeconfigColumns;
default:
return baseColumns;
}
});
const formState = reactive({
name: '',
description: '',
token: '',
private_key: '',
passphrase: '',
kubeconfig_content: '',
type: '',
});
const rules = computed(() => {
const baseRules = {
name: [{ required: true, message: '请输入凭证名称' }],
description: [{ required: false, message: '请输入凭证描述' }],
};
// 根据不同的凭证类型返回不同的验证规则
switch (activeTab.value) {
case 'gitlab_token':
return {
...baseRules,
token: [{ required: !editingCredential.value, message: '请输入GitLab Token' }],
};
case 'ssh_key':
return {
...baseRules,
private_key: [{ required: !editingCredential.value, message: '请输入SSH私钥内容' }],
passphrase: [{ required: false }]
};
case 'kubeconfig':
return {
...baseRules,
kubeconfig_content: [{ required: !editingCredential.value, message: '请输入Kubeconfig配置内容' }]
};
default:
return baseRules;
}
});
// 加载凭证列表
const loadCredentials = async () => {
// 检查查看权限
if (!checkPermission('credential', 'view')) {
return;
}
loading.value = true;
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/credentials/', {
headers: {
'Authorization': token
},
params: {
type: activeTab.value
}
});
if (response.data.code === 200) {
credentials.value = response.data.data.map(item => ({
...item,
key: item.credential_id
}));
} else {
message.error(response.data.message || '加载凭证列表失败');
}
} catch (error) {
message.error('加载凭证列表失败');
console.error('Load credentials error:', error);
} finally {
loading.value = false;
}
};
// 重置表单状态
const resetFormState = () => {
Object.keys(formState).forEach(key => {
formState[key] = '';
});
formState.type = activeTab.value;
};
// 显示创建模态框
const showCreateModal = () => {
if (!checkPermission('credential', 'create')) {
return;
}
editingCredential.value = null;
resetFormState();
modalVisible.value = true;
};
const handleEdit = (record) => {
if (!checkPermission('credential', 'edit')) {
return;
}
editingCredential.value = record;
// 根据凭证类型设置表单值
const commonFields = {
name: record.name,
description: record.description,
};
switch (activeTab.value) {
case 'gitlab_token':
Object.assign(formState, {
...commonFields,
// 不回显token
});
break;
case 'ssh_key':
Object.assign(formState, {
...commonFields,
// 不回显私钥和密码
});
break;
case 'kubeconfig':
Object.assign(formState, {
...commonFields,
// 不回显kubeconfig内容
});
break;
}
modalVisible.value = true;
};
// 处理删除
const handleDelete = async (record) => {
if (!checkPermission('credential', 'delete')) {
return;
}
try {
const token = localStorage.getItem('token');
const response = await axios.delete('/api/credentials/', {
headers: {
'Authorization': token
},
data: {
credential_id: record.credential_id,
type: activeTab.value
}
});
if (response.data.code === 200) {
message.success('删除成功');
await loadCredentials();
} else {
message.error(response.data.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
console.error('Delete credential error:', error);
}
};
// 处理部署
const handleDeploy = (record) => {
if (!checkPermission('credential', 'edit')) {
return;
}
currentDeployCredential.value = record;
deployModalVisible.value = true;
};
// 确认部署
const handleDeployConfirm = async () => {
if (!currentDeployCredential.value) return;
deployLoading.value = true;
deployingCredentials.value[currentDeployCredential.value.credential_id] = true;
try {
const token = localStorage.getItem('token');
const response = await axios.post('/api/credentials/', {
action: 'deploy',
credential_id: currentDeployCredential.value.credential_id
}, {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
deployResult.value = {
success: true,
message: response.data.message,
data: response.data.data
};
deployModalVisible.value = false;
deployResultModalVisible.value = true;
message.success('部署成功');
await loadCredentials();
} else {
deployResult.value = {
success: false,
message: response.data.message,
data: null
};
deployResultModalVisible.value = true;
}
} catch (error) {
deployResult.value = {
success: false,
message: error.response?.data?.message || '部署失败',
data: null
};
deployResultModalVisible.value = true;
console.error('Deploy SSH key error:', error);
} finally {
deployLoading.value = false;
deployingCredentials.value[currentDeployCredential.value.credential_id] = false;
deployModalVisible.value = false;
}
};
// 处理取消部署
const handleUndeploy = async (record) => {
if (!checkPermission('credential', 'edit')) {
return;
}
deployingCredentials.value[record.credential_id] = true;
try {
const token = localStorage.getItem('token');
const response = await axios.post('/api/credentials/', {
action: 'undeploy',
credential_id: record.credential_id
}, {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
message.success('取消部署成功');
await loadCredentials();
} else {
message.error(response.data.message || '取消部署失败');
}
} catch (error) {
message.error('取消部署失败');
console.error('Undeploy SSH key error:', error);
} finally {
deployingCredentials.value[record.credential_id] = false;
}
};
// 处理私钥文件上传
const handleUploadPrivateKey = (file) => {
const reader = new FileReader();
reader.onload = (e) => {
formState.private_key = e.target.result;
message.success('私钥文件已上传');
};
reader.onerror = () => {
message.error('读取文件失败');
};
reader.readAsText(file);
return false;
};
// 处理Kubeconfig文件上传
const handleUploadKubeconfig = (file) => {
const reader = new FileReader();
reader.onload = (e) => {
formState.kubeconfig_content = e.target.result;
message.success('Kubeconfig文件已上传');
};
reader.onerror = () => {
message.error('读取文件失败');
};
reader.readAsText(file);
return false;
};
// 处理模态框确认
const handleModalOk = async () => {
try {
await formRef.value.validate();
submitLoading.value = true;
const token = localStorage.getItem('token');
const data = {
name: formState.name,
description: formState.description,
type: activeTab.value,
};
// 根据凭证类型添加不同的字段
switch (activeTab.value) {
case 'gitlab_token':
Object.assign(data, {
token: formState.token,
});
break;
case 'ssh_key':
Object.assign(data, {
private_key: formState.private_key,
passphrase: formState.passphrase,
});
break;
case 'kubeconfig':
Object.assign(data, {
kubeconfig_content: formState.kubeconfig_content,
});
break;
}
if (editingCredential.value) {
data.credential_id = editingCredential.value.credential_id;
}
const response = await axios({
method: editingCredential.value ? 'put' : 'post',
url: '/api/credentials/',
headers: {
'Authorization': token
},
data
});
if (response.data.code === 200) {
message.success(editingCredential.value ? '更新成功' : '创建成功');
modalVisible.value = false;
await loadCredentials();
} else {
throw new Error(response.data.message || (editingCredential.value ? '更新失败' : '创建失败'));
}
} catch (error) {
message.error(error.response?.data?.message || error.message);
console.error('Save credential error:', error);
} finally {
submitLoading.value = false;
}
};
// 监听标签页切换
watch(activeTab, () => {
loadCredentials();
});
// 处理Kubeconfig单独部署
const handleKubeconfigDeploy = async (record) => {
if (!checkPermission('credential', 'edit')) {
return;
}
deployingCredentials.value[record.credential_id] = true;
try {
const token = localStorage.getItem('token');
const response = await axios.post('/api/credentials/', {
action: 'deploy_kubeconfig',
credential_id: record.credential_id
}, {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
// 生成前端的使用说明
const usageInfo = generateKubeconfigUsageInfo(record);
deployResult.value = {
success: true,
message: '部署成功',
data: {
usage_info: usageInfo
}
};
deployResultModalVisible.value = true;
message.success('部署成功');
await loadCredentials();
} else {
deployResult.value = {
success: false,
message: response.data.message,
data: null
};
deployResultModalVisible.value = true;
}
} catch (error) {
deployResult.value = {
success: false,
message: error.response?.data?.message || '部署失败',
data: null
};
deployResultModalVisible.value = true;
console.error('Deploy kubeconfig error:', error);
} finally {
deployingCredentials.value[record.credential_id] = false;
}
};
// 处理Kubeconfig单独取消部署
const handleKubeconfigUndeploy = async (record) => {
if (!checkPermission('credential', 'edit')) {
return;
}
deployingCredentials.value[record.credential_id] = true;
try {
const token = localStorage.getItem('token');
const response = await axios.post('/api/credentials/', {
action: 'undeploy_kubeconfig',
credential_id: record.credential_id
}, {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
message.success('取消部署成功');
await loadCredentials();
} else {
message.error(response.data.message || '取消部署失败');
}
} catch (error) {
message.error('取消部署失败');
console.error('Undeploy kubeconfig error:', error);
} finally {
deployingCredentials.value[record.credential_id] = false;
}
};
// 生成Kubeconfig使用说明
const generateKubeconfigUsageInfo = (record) => {
const contextName = `liteops-${record.context_name}`;
return `Kubeconfig部署成功
集群信息:
集群名称: ${record.cluster_name}
上下文名称: ${contextName}
使用方法:
1. 查看所有上下文:
kubectl config get-contexts
2. 切换到此集群:
kubectl config use-context ${contextName}
3. 验证当前集群连接:
kubectl cluster-info
4. 查看集群节点:
kubectl get nodes
注意: 该配置已合并到 ~/.kube/config 文件中`;
};
onMounted(() => {
if (!hasFunctionPermission('credential', 'view')) {
message.warning('你没有凭证查看权限,部分功能可能受限');
}
loadCredentials();
});
</script>
<style scoped>
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
}
:deep(.ant-card) {
border-radius: 4px;
}
:deep(.ant-tabs-nav) {
margin-bottom: 16px;
}
:deep(.ant-table-thead > tr > th) {
background: #fafafa;
}
:where(.css-dev-only-do-not-override-mdfpa0).ant-result {
padding: 0px;
}
</style>

View File

@@ -0,0 +1,895 @@
<template>
<div class="dashboard">
<a-row :gutter="12" class="stat-cards">
<a-col :span="6">
<a-card :loading="loading" :bordered="false">
<template #title>
<span>
<ProjectOutlined /> 项目总数
</span>
</template>
<div class="card-content">
<h2>{{ stats.project_count || 0 }}</h2>
<p class="card-subtitle">总构建: <span class="build-text">{{ stats.total_builds_count || 0 }}</span> | 任务: <span class="task-text">{{ stats.task_count || 0 }}</span></p>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card :loading="loading" :bordered="false">
<template #title>
<span>
<UserOutlined /> 用户总数
</span>
</template>
<div class="card-content">
<h2>{{ stats.user_count || 0 }}</h2>
<p class="card-subtitle">环境数量: {{ stats.env_count || 0 }}</p>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card :loading="loading" :bordered="false">
<template #title>
<span>
<BuildOutlined /> 构建成功率
</span>
</template>
<div class="card-content">
<h2>{{ stats.success_rate || 0 }}%</h2>
<p class="card-subtitle">最近7天: {{ stats.total_recent_builds || 0 }} 次构建</p>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card :loading="loading" :bordered="false">
<template #title>
<span>
<ClockCircleOutlined /> 今日构建
</span>
</template>
<div class="card-content">
<h2>{{ todayBuilds.length }}</h2>
<p class="card-subtitle">成功: <span class="success-text">{{ successBuilds }}</span> | 失败: <span class="failed-text">{{ failedBuilds }}</span></p>
</div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="12" class="chart-row">
<a-col :span="16">
<a-card title="构建任务趋势" :loading="trendLoading" :bordered="false">
<div class="chart-container" ref="trendChartRef"></div>
</a-card>
</a-col>
<a-col :span="8">
<a-card title="项目类型分布" :loading="distributionLoading" :bordered="false">
<div class="chart-container" ref="pieChartRef"></div>
</a-card>
</a-col>
</a-row>
<a-card title="最近构建任务" class="recent-builds" :loading="recentLoading" :bordered="false">
<a-table
:columns="buildColumns"
:data-source="recentBuilds"
:pagination="{ pageSize: 5, size: 'small' }"
size="small"
:scroll="{ x: 1000 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<span :class="['status-text', `status-${record.status}`]">
{{ getStatusText(record.status) }}
</span>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted, computed, onBeforeUnmount, nextTick } from 'vue';
import {
ProjectOutlined,
UserOutlined,
BuildOutlined,
ClockCircleOutlined
} from '@ant-design/icons-vue';
import axios from 'axios';
import * as echarts from 'echarts/core';
import {
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
ToolboxComponent,
} from 'echarts/components';
import { LineChart, PieChart } from 'echarts/charts';
import { UniversalTransition } from 'echarts/features';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
ToolboxComponent,
LineChart,
PieChart,
CanvasRenderer,
UniversalTransition
]);
// 状态变量
const loading = ref(false);
const trendLoading = ref(false);
const distributionLoading = ref(false);
const recentLoading = ref(false);
// 数据变量
const stats = ref({});
const trendData = ref({ dates: [], success: [], failed: [] });
const distributionData = ref([]);
const recentBuilds = ref([]);
const todayBuilds = ref([]);
// 图表引用
const trendChartRef = ref(null);
const pieChartRef = ref(null);
// 图表实例
let trendChart = null;
let pieChart = null;
// 计算属性
const successBuilds = computed(() => {
return todayBuilds.value.filter(build => build.status === 'success').length;
});
const failedBuilds = computed(() => {
return todayBuilds.value.filter(build => build.status === 'failed').length;
});
// 表格列定义
const buildColumns = [
{
title: '任务名称',
dataIndex: 'task_name',
key: 'task_name',
ellipsis: true,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
},
{
title: '分支',
dataIndex: 'branch',
key: 'branch',
ellipsis: true,
},
{
title: '版本',
dataIndex: 'version',
key: 'version',
ellipsis: true,
},
{
title: '环境',
dataIndex: 'environment',
key: 'environment',
width: 80,
ellipsis: true,
},
{
title: '需求',
dataIndex: 'requirement',
key: 'requirement',
ellipsis: true,
},
{
title: '构建时间',
dataIndex: 'start_time',
key: 'start_time',
},
{
title: '耗时',
dataIndex: 'duration',
key: 'duration',
align: 'center',
},
{
title: '构建人',
dataIndex: 'operator',
key: 'operator',
ellipsis: true,
},
];
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
'success': '成功',
'failed': '失败',
'running': '运行中',
'pending': '等待中',
'terminated': '已终止'
};
return statusMap[status] || '未知';
};
// 获取首页统计数据
const fetchStats = async () => {
loading.value = true;
try {
const response = await axios.get('/api/dashboard/stats/');
if (response.data.code === 200) {
stats.value = response.data.data;
}
} catch (error) {
console.error('获取统计数据失败:', error);
} finally {
loading.value = false;
}
};
// 获取构建趋势数据
const fetchTrendData = async () => {
trendLoading.value = true;
try {
// 接口默认获取最近7天数据
const response = await axios.get('/api/dashboard/build-trend/');
if (response.data.code === 200) {
trendData.value = response.data.data;
// 确保数据结构完整
if (!trendData.value.dates) trendData.value.dates = [];
if (!trendData.value.success) trendData.value.success = [];
if (!trendData.value.failed) trendData.value.failed = [];
}
} catch (error) {
console.error('获取构建趋势数据失败:', error);
} finally {
trendLoading.value = false;
}
};
// 获取项目分布数据
const fetchDistributionData = async () => {
distributionLoading.value = true;
try {
const response = await axios.get('/api/dashboard/project-distribution/');
if (response.data.code === 200) {
distributionData.value = response.data.data || [];
}
} catch (error) {
console.error('获取项目分布数据失败:', error);
} finally {
distributionLoading.value = false;
}
};
// 获取最近构建任务
const fetchRecentBuilds = async () => {
recentLoading.value = true;
try {
const response = await axios.get('/api/dashboard/recent-builds/');
if (response.data.code === 200) {
recentBuilds.value = response.data.data;
}
} catch (error) {
console.error('获取最近构建任务失败:', error);
} finally {
recentLoading.value = false;
}
};
// 获取今日构建数据
const fetchTodayBuilds = async () => {
const today = new Date().toISOString().split('T')[0];
try {
const response = await axios.get(`/api/dashboard/build-detail/?date=${today}`);
if (response.data.code === 200) {
todayBuilds.value = response.data.data;
}
} catch (error) {
console.error('获取今日构建数据失败:', error);
}
};
// 使用 ECharts 渲染构建趋势图表
const renderTrendChart = () => {
if (!trendChartRef.value) {
console.error('Trend chart DOM element not found');
return;
}
// 初始化或获取 ECharts 实例
if (!trendChart) {
trendChart = echarts.init(trendChartRef.value);
} else {
trendChart.clear(); // 清除旧配置
}
const { dates, success, failed } = trendData.value;
// 更柔和的颜色配置
const successColor = 'rgba(115, 209, 61, 0.8)';
const failedColor = 'rgba(247, 103, 107, 0.8)';
// ECharts 配置项
const option = {
animation: true,
animationDuration: 1000,
animationEasing: 'cubicInOut',
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.96)',
borderColor: '#e8e8e8',
borderWidth: 1,
padding: [12, 16],
textStyle: {
fontSize: 12,
color: 'rgba(0, 0, 0, 0.75)'
},
shadowColor: 'rgba(0, 0, 0, 0.08)',
shadowBlur: 16,
shadowOffsetX: 0,
shadowOffsetY: 4,
formatter: function(params) {
let result = `<div style="font-weight: 500; margin-bottom: 8px; color: rgba(0,0,0,0.85);">${params[0].axisValue}</div>`;
params.forEach(item => {
const color = item.seriesName === '成功构建' ? successColor : failedColor;
const style = `display:inline-block; width:8px; height:8px; margin-right:8px; border-radius:50%; background-color:${color};`;
result += `<div style="margin: 4px 0; display: flex; align-items: center;">
<span style="${style}"></span>
<span style="font-size:12px; color:rgba(0,0,0,0.65); margin-right: 8px;">${item.seriesName}</span>
<span style="font-weight:500; color:${color}; font-size: 13px;">${item.value}</span>
</div>`;
});
return result;
},
axisPointer: {
type: 'line',
lineStyle: {
color: 'rgba(0, 0, 0, 0.15)',
type: 'dashed',
width: 1
}
}
},
legend: {
data: ['成功构建', '失败构建'],
top: '2%',
left: 'center',
itemWidth: 12,
itemHeight: 12,
textStyle: {
fontSize: 12,
color: 'rgba(0, 0, 0, 0.75)'
},
icon: 'roundRect',
itemGap: 24
},
grid: {
left: '4%',
right: '4%',
bottom: '12%',
top: '16%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLabel: {
fontSize: 12,
color: 'rgba(0, 0, 0, 0.65)',
margin: 15,
fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
rotate: 0,
interval: 0,
},
axisLine: {
lineStyle: {
color: '#f0f0f0'
}
},
axisTick: {
show: false
}
},
yAxis: {
type: 'value',
axisLabel: {
fontSize: 12,
color: 'rgba(0, 0, 0, 0.65)',
margin: 12,
fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
show: false // 隐藏y轴标签
},
splitLine: {
lineStyle: {
color: '#f8f8f8',
type: 'dashed',
width: 1
}
},
axisLine: {
show: false
},
axisTick: {
show: false
}
},
series: [
{
name: '成功构建',
type: 'line',
smooth: true,
smoothMonotone: 'x',
data: success,
symbol: 'circle',
symbolSize: 4,
showSymbol: false,
emphasis: {
focus: 'series',
scale: false,
itemStyle: {
color: successColor,
borderColor: '#fff',
borderWidth: 2,
shadowColor: 'rgba(115, 209, 61, 0.25)',
shadowBlur: 8
},
lineStyle: {
width: 2.5
}
},
// 鼠标悬停时显示符号
showAllSymbol: 'auto',
lineStyle: {
width: 2,
shadowColor: 'rgba(115, 209, 61, 0.15)',
shadowBlur: 6,
shadowOffsetY: 2,
cap: 'round'
},
itemStyle: {
color: successColor,
borderWidth: 2,
borderColor: '#fff'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: 'rgba(115, 209, 61, 0.15)'
}, {
offset: 1, color: 'rgba(115, 209, 61, 0.01)'
}]
},
opacity: 0.8
}
},
{
name: '失败构建',
type: 'line',
smooth: true,
smoothMonotone: 'x',
data: failed,
symbol: 'circle',
symbolSize: 4,
showSymbol: false,
emphasis: {
focus: 'series',
scale: false,
itemStyle: {
color: failedColor,
borderColor: '#fff',
borderWidth: 2,
shadowColor: 'rgba(247, 103, 107, 0.25)',
shadowBlur: 8
},
lineStyle: {
width: 2.5
}
},
showAllSymbol: 'auto',
lineStyle: {
width: 2,
shadowColor: 'rgba(247, 103, 107, 0.15)',
shadowBlur: 6,
shadowOffsetY: 2,
cap: 'round'
},
itemStyle: {
color: failedColor,
borderWidth: 2,
borderColor: '#fff'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: 'rgba(247, 103, 107, 0.15)'
}, {
offset: 1, color: 'rgba(247, 103, 107, 0.01)'
}]
},
opacity: 0.8
}
}
]
};
// 应用配置项
trendChart.setOption(option);
// 图表交互事件
trendChart.on('mouseover', { seriesIndex: 0 }, function() {
trendChart.setOption({
series: [{
showSymbol: true,
symbolSize: 5
}, {
lineStyle: {
opacity: 0.4
},
areaStyle: {
opacity: 0.2
}
}]
});
});
trendChart.on('mouseover', { seriesIndex: 1 }, function() {
trendChart.setOption({
series: [{
lineStyle: {
opacity: 0.4
},
areaStyle: {
opacity: 0.2
}
}, {
showSymbol: true,
symbolSize: 5
}]
});
});
trendChart.on('mouseout', function() {
trendChart.setOption({
series: [{
showSymbol: false,
lineStyle: {
opacity: 1
},
areaStyle: {
opacity: 0.8
}
}, {
showSymbol: false,
lineStyle: {
opacity: 1
},
areaStyle: {
opacity: 0.8
}
}]
});
});
// 窗口大小调整监听
window.addEventListener('resize', handleResize);
};
// ECharts 渲染项目分布图表
const renderPieChart = () => {
if (!pieChartRef.value) {
console.error('Pie chart DOM element not found');
return;
}
if (!distributionData.value || distributionData.value.length === 0) {
if (pieChart) {
pieChart.clear(); // 清除旧图表
}
return;
}
// 获取 ECharts 实例
if (!pieChart) {
pieChart = echarts.init(pieChartRef.value);
} else {
pieChart.clear(); // 清除旧配置
}
const pieData = distributionData.value.map((item, index) => {
const colors = [
'rgba(22,119,255,0.6)',
'rgba(82, 196, 26, 0.6)',
];
return {
name: item.type,
value: item.value,
itemStyle: {
color: colors[index % colors.length]
}
};
});
// ECharts 配置项
const option = {
animation: true,
animationDuration: 600,
animationEasing: 'cubicOut',
animationDelay: 0,
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#f0f0f0',
borderWidth: 1,
padding: [8, 12],
textStyle: {
fontSize: 12,
color: 'rgba(0, 0, 0, 0.75)'
},
shadowColor: 'rgba(0, 0, 0, 0.05)',
shadowBlur: 8,
shadowOffsetX: 0,
shadowOffsetY: 2
},
legend: {
orient: 'vertical',
left: '5%',
top: 'center',
itemWidth: 10,
itemHeight: 10,
textStyle: {
fontSize: 12,
color: 'rgba(0, 0, 0, 0.65)'
},
icon: 'circle'
},
series: [
{
name: '项目类型分布',
type: 'pie',
radius: ['45%', '75%'],
center: ['65%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: 'rgba(255, 255, 255, 0.8)',
borderWidth: 4
},
label: {
show: false,
position: 'center'
},
emphasis: {
scale: false, // 禁用缩放效果
scaleSize: 0, // 设置缩放大小为0
label: {
show: true,
fontSize: 14,
fontWeight: 'bold',
color: 'rgba(0, 0, 0, 0.85)'
},
itemStyle: {
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.8)',
shadowBlur: 0,
shadowColor: 'transparent', // 设置阴影颜色为透明
shadowOffsetX: 0,
shadowOffsetY: 0
}
},
labelLine: {
show: false
},
data: pieData
}
]
};
// 应用配置项
pieChart.setOption(option);
// 窗口大小调整监听
window.addEventListener('resize', handleResize);
};
// 窗口大小调整
const handleResize = () => {
if (trendChart) {
trendChart.resize();
}
if (pieChart) {
pieChart.resize();
}
};
// 组件卸载前清理资源
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
if (trendChart) {
trendChart.dispose();
trendChart = null;
}
if (pieChart) {
pieChart.dispose();
pieChart = null;
}
});
// 页面加载时获取数据
onMounted(async () => {
fetchStats();
fetchRecentBuilds();
fetchTodayBuilds();
await Promise.all([
fetchTrendData(),
fetchDistributionData()
]);
await nextTick();
renderTrendChart();
renderPieChart();
});
</script>
<style scoped>
.dashboard {
/* padding: 16px; */
color: rgba(0, 0, 0, 0.85);
}
.card-content {
text-align: center;
}
.card-content h2 {
font-size: 18px;
margin-bottom: 4px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
}
.card-content p {
margin: 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.card-subtitle {
margin-top: 4px;
}
.success-text {
color: #73d13d;
font-weight: 500;
}
.failed-text {
color: #ff7875;
font-weight: 500;
}
.task-text {
font-weight: 500;
}
.build-text {
font-weight: 500;
}
.chart-row {
margin-top: 12px;
}
.chart-container {
height: 320px;
width: 100%;
}
.recent-builds {
margin-top: 12px;
}
.stat-cards .ant-card {
height: 100%;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 8px;
box-shadow: none;
transition: none;
}
.stat-cards .ant-card-head-title {
font-size: 14px;
font-weight: 700;
color: rgba(0, 0, 0, 0.95);
}
:deep(.ant-table) {
font-size: 13px;
}
:deep(.ant-table-thead > tr > th) {
font-weight: 500;
color: rgba(0, 0, 0, 0.65);
font-size: 13px;
background-color: rgba(0, 0, 0, 0.02);
}
:deep(.ant-table-tbody > tr > td) {
color: rgba(0, 0, 0, 0.75);
}
:deep(.ant-table-tbody > tr:hover > td) {
background-color: rgba(0, 0, 0, 0.02);
}
:deep(.ant-card-head-title) {
font-size: 14px;
font-weight: 700;
color: rgba(0, 0, 0, 0.95);
}
:deep(.ant-card) {
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 8px;
box-shadow: none;
transition: none;
}
:deep(.ant-pagination-item-link) {
font-size: 12px;
}
:deep(.ant-pagination-item) {
font-size: 12px;
min-width: 28px;
height: 28px;
line-height: 26px;
}
.status-text {
font-weight: 400;
font-size: 13px;
}
.status-success {
color: #73d13d;
}
.status-failed {
color: #ff7875;
}
.status-running {
color: #69c0ff;
}
.status-pending {
color: #ffc53d;
}
.status-terminated {
color: rgba(0, 0, 0, 0.45);
}
</style>

View File

@@ -0,0 +1,290 @@
<template>
<div class="environment-detail">
<div class="page-header">
<a-page-header
:title="environment?.name || '环境详情'"
@back="handleBack"
>
<template #extra>
<a-space>
<a-button @click="handleEditEnvironment">
<template #icon><EditOutlined /></template>
编辑环境
</a-button>
</a-space>
</template>
</a-page-header>
</div>
<!-- 环境基本信息 -->
<a-card title="环境信息" :loading="loading">
<div class="info-list">
<div class="info-item">
<span class="info-label">环境名称</span>
<span class="info-value">{{ environment?.name }}</span>
</div>
<div class="info-item">
<span class="info-label">环境ID</span>
<span class="info-value">{{ environment?.environment_id }}</span>
</div>
<div class="info-item">
<span class="info-label">环境类型</span>
<span class="info-value">{{ getEnvironmentTypeText(environment?.type) }}
</span>
</div>
<div class="info-item">
<span class="info-label">创建者</span>
<span class="info-value">{{ environment?.creator?.name || '未知' }}</span>
</div>
<div class="info-item">
<span class="info-label">创建时间</span>
<span class="info-value">{{ environment?.create_time }}</span>
</div>
<div class="info-item">
<span class="info-label">更新时间</span>
<span class="info-value">{{ environment?.update_time }}</span>
</div>
<div class="info-item">
<span class="info-label">环境描述</span>
<span class="info-value">{{ environment?.description || '暂无描述' }}</span>
</div>
</div>
</a-card>
<!-- 编辑环境抽屉 -->
<a-drawer
v-model:open="drawerVisible"
title="编辑环境"
width="600px"
@close="handleDrawerClose"
>
<a-form
:model="formState"
:rules="rules"
ref="formRef"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="环境名称" name="name">
<a-input v-model:value="formState.name" placeholder="请输入环境名称" />
</a-form-item>
<a-form-item label="环境类型" name="type">
<a-select v-model:value="formState.type" placeholder="请选择环境类型">
<a-select-option value="development">开发环境</a-select-option>
<a-select-option value="testing">测试环境</a-select-option>
<a-select-option value="staging">预发布环境</a-select-option>
<a-select-option value="production">生产环境</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="环境描述" name="description">
<a-textarea
v-model:value="formState.description"
placeholder="请输入环境描述"
:rows="4"
/>
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button @click="handleDrawerClose">取消</a-button>
<a-button
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
保存
</a-button>
</a-space>
</template>
</a-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import { EditOutlined } from '@ant-design/icons-vue';
import axios from 'axios';
import { checkPermission, hasFunctionPermission } from '../../utils/permission';
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const environment = ref(null);
const drawerVisible = ref(false);
const formRef = ref();
const submitLoading = ref(false);
const formState = reactive({
name: '',
type: undefined,
description: '',
});
const rules = {
name: [
{ required: true, message: '请输入环境名称', trigger: 'blur' },
{ min: 2, max: 50, message: '环境名称长度应在 2-50 个字符之间', trigger: 'blur' },
],
type: [
{ required: true, message: '请选择环境类型', trigger: 'change' },
],
};
const getEnvironmentTypeText = (type) => {
const typeMap = {
development: '开发环境',
testing: '测试环境',
staging: '预发布环境',
production: '生产环境',
};
return typeMap[type] || type;
};
const getEnvironmentTypeColor = (type) => {
const colorMap = {
development: 'rgba(24,144,255,0.8)',
testing: 'rgba(8,151,156,0.8)',
staging: 'rgba(212,107,8,0.8)',
production: 'rgba(56,158,13,0.8)',
};
return colorMap[type] || 'default';
};
const fetchEnvironmentDetail = async () => {
const environmentId = route.query.environment_id;
if (!environmentId) {
message.error('环境ID不能为空');
router.push('/environments/list');
return;
}
loading.value = true;
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/environments/', {
headers: {
'Authorization': token
},
params: {
environment_id: environmentId
}
});
if (response.data.code === 200) {
environment.value = response.data.data[0];
} else {
throw new Error(response.data.message || '获取环境详情失败');
}
} catch (error) {
message.error(error.message);
router.push('/environments/list');
} finally {
loading.value = false;
}
};
const handleBack = () => {
router.back();
};
const handleEditEnvironment = () => {
Object.assign(formState, {
name: environment.value.name,
type: environment.value.type,
description: environment.value.description,
});
drawerVisible.value = true;
};
const handleDrawerClose = () => {
drawerVisible.value = false;
formRef.value?.resetFields();
};
const handleSubmit = async () => {
if (!checkPermission('environment', 'edit')) {
return;
}
try {
await formRef.value.validate();
submitLoading.value = true;
const token = localStorage.getItem('token');
const response = await axios.put('/api/environments/', {
environment_id: environment.value.environment_id,
name: formState.name,
type: formState.type,
description: formState.description,
}, {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
message.success('更新环境成功');
handleDrawerClose();
fetchEnvironmentDetail();
} else {
throw new Error(response.data.message || '更新环境失败');
}
} catch (error) {
message.error(error.response?.data?.message || error.message || '更新环境失败');
} finally {
submitLoading.value = false;
}
};
onMounted(() => {
fetchEnvironmentDetail();
});
</script>
<style scoped>
.page-header {
margin-bottom: 24px;
background: #fff;
}
:deep(.ant-page-header) {
padding: 16px 24px;
}
.info-list {
padding: 8px 0;
}
.info-item {
line-height: 32px;
display: flex;
align-items: flex-start;
}
.info-label {
color: rgba(0, 0, 0, 0.45);
min-width: 100px;
}
.info-value {
color: rgba(0, 0, 0, 0.85);
flex: 1;
}
:deep(.ant-drawer-body) {
padding: 24px;
}
:deep(.ant-drawer-footer) {
text-align: right;
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
}
</style>

View File

@@ -0,0 +1,459 @@
<template>
<div class="environment-list">
<div class="page-header">
<a-row justify="space-between" align="middle">
<a-col>
<h2>环境列表</h2>
</a-col>
<a-col>
<a-button type="primary" @click="handleCreateEnvironment">
<template #icon><PlusOutlined /></template>
新建环境
</a-button>
</a-col>
</a-row>
</div>
<a-card>
<!-- 搜索区域 -->
<div class="search-area">
<a-form layout="inline" :style="{ display: 'flex', justifyContent: 'flex-end' }">
<a-form-item label="环境名称">
<a-input
v-model:value="searchForm.name"
placeholder="请输入环境名称"
allow-clear
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="环境类型">
<a-select
v-model:value="searchForm.type"
placeholder="请选择环境类型"
style="width: 160px"
allow-clear
>
<a-select-option value="development">开发环境</a-select-option>
<a-select-option value="testing">测试环境</a-select-option>
<a-select-option value="staging">预发布环境</a-select-option>
<a-select-option value="production">生产环境</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="loading" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
</a-form-item>
</a-form>
</div>
<a-table
:columns="columns"
:data-source="environments"
:loading="loading"
row-key="environment_id"
:locale="{ emptyText: '暂无数据' }"
:pagination="{
total: total,
current: current,
pageSize: pageSize,
pageSizeOptions: ['10', '20', '50', '100'],
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
}"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<span class="environment-name" @click="handleEnvironmentDetail(record)">{{ record.name }}</span>
</template>
<template v-if="column.key === 'type'">
<span>{{ getEnvironmentTypeText(record.type) }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" class="action-button" @click="handleEnvironmentDetail(record)">查看</a-button>
<!-- <a-button type="link" class="action-button" @click="handleEditEnvironment(record)">编辑</a-button> -->
<a-popconfirm
title="确定要删除这个环境吗?"
@confirm="handleDeleteEnvironment(record)"
>
<a-button type="link" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 新建/编辑环境抽屉 -->
<a-drawer
v-model:open="drawerVisible"
:title="isEdit ? '编辑环境' : '新建环境'"
width="600px"
@close="handleDrawerClose"
>
<a-form
:model="formState"
:rules="rules"
ref="formRef"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="环境名称" name="name">
<a-input v-model:value="formState.name" placeholder="请输入环境名称" />
</a-form-item>
<a-form-item label="环境类型" name="type">
<a-select v-model:value="formState.type" placeholder="请选择环境类型">
<a-select-option value="development">开发环境</a-select-option>
<a-select-option value="testing">测试环境</a-select-option>
<a-select-option value="staging">预发布环境</a-select-option>
<a-select-option value="production">生产环境</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="环境描述" name="description">
<a-textarea
v-model:value="formState.description"
placeholder="请输入环境描述"
:rows="4"
/>
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button @click="handleDrawerClose">取消</a-button>
<a-button
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
{{ isEdit ? '保存' : '创建' }}
</a-button>
</a-space>
</template>
</a-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { PlusOutlined, SearchOutlined } from '@ant-design/icons-vue';
import axios from 'axios';
import { checkPermission, hasFunctionPermission } from '../../utils/permission';
const router = useRouter();
const loading = ref(false);
const environments = ref([]);
const drawerVisible = ref(false);
const formRef = ref();
const submitLoading = ref(false);
const isEdit = ref(false);
const pageSize = ref(10);
const current = ref(1);
const total = ref(0);
const formState = reactive({
name: '',
type: undefined,
description: '',
});
const searchForm = reactive({
name: '',
type: undefined,
});
const columns = [
{
title: '环境名称',
dataIndex: 'name',
key: 'name',
},
{
title: '环境类型',
key: 'type',
},
{
title: '环境描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '创建者',
key: 'creator',
customRender: ({ record }) => record.creator?.name || '未知',
},
{
title: '创建时间',
dataIndex: 'create_time',
key: 'create_time',
},
{
title: '操作',
key: 'action',
},
];
const rules = {
name: [
{ required: true, message: '请输入环境名称', trigger: 'blur' },
{ min: 2, max: 50, message: '环境名称长度应在 2-50 个字符之间', trigger: 'blur' },
],
type: [
{ required: true, message: '请选择环境类型', trigger: 'change' },
],
};
const getEnvironmentTypeText = (type) => {
const typeMap = {
development: '开发环境',
testing: '测试环境',
staging: '预发布环境',
production: '生产环境',
};
return typeMap[type] || type;
};
const fetchEnvironments = async () => {
// 检查查看环境列表
if (!checkPermission('environment', 'view')) {
return;
}
loading.value = true;
try {
const token = localStorage.getItem('token');
const params = {
page: current.value,
page_size: pageSize.value,
...searchForm,
};
const response = await axios.get('/api/environments/', {
headers: {
'Authorization': token
},
params: params
});
if (response.data.code === 200) {
environments.value = response.data.data;
total.value = response.data.total;
} else {
message.error(response.data.message || '获取环境列表失败');
}
} catch (error) {
message.error('获取环境列表失败');
console.error('Fetch environments error:', error);
} finally {
loading.value = false;
}
};
const handleCreateEnvironment = () => {
// 检查创建环境权限
if (!checkPermission('environment', 'create')) {
return;
}
isEdit.value = false;
drawerVisible.value = true;
};
const handleEditEnvironment = (record) => {
// 检查编辑环境权限
if (!checkPermission('environment', 'edit')) {
return;
}
isEdit.value = true;
Object.assign(formState, {
environment_id: record.environment_id,
name: record.name,
type: record.type,
description: record.description,
});
drawerVisible.value = true;
};
const handleDrawerClose = () => {
drawerVisible.value = false;
formRef.value?.resetFields();
Object.assign(formState, {
name: '',
type: undefined,
description: '',
});
};
const handleSubmit = async () => {
if (isEdit.value) {
if (!checkPermission('environment', 'edit')) {
return;
}
} else {
if (!checkPermission('environment', 'create')) {
return;
}
}
try {
await formRef.value.validate();
submitLoading.value = true;
const token = localStorage.getItem('token');
const url = isEdit.value ? '/api/environments/' : '/api/environments/';
const method = isEdit.value ? 'put' : 'post';
const data = isEdit.value ? {
environment_id: formState.environment_id,
name: formState.name,
type: formState.type,
description: formState.description,
} : formState;
const response = await axios[method](url, data, {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
message.success(isEdit.value ? '更新环境成功' : '创建环境成功');
handleDrawerClose();
fetchEnvironments();
} else {
throw new Error(response.data.message || (isEdit.value ? '更新环境失败' : '创建环境失败'));
}
} catch (error) {
message.error(error.response?.data?.message || error.message || (isEdit.value ? '更新环境失败' : '创建环境失败'));
} finally {
submitLoading.value = false;
}
};
const handleEnvironmentDetail = (record) => {
if (!checkPermission('environment', 'view', record.environment_id)) {
return;
}
router.push({
path: '/environments/detail',
query: { environment_id: record.environment_id }
});
};
const handleDeleteEnvironment = async (record) => {
// 检查删除权限
if (!checkPermission('environment', 'delete')) {
return;
}
try {
const token = localStorage.getItem('token');
const response = await axios.delete('/api/environments/', {
headers: {
'Authorization': token
},
data: {
environment_id: record.environment_id
}
});
if (response.data.code === 200) {
message.success('删除环境成功');
fetchEnvironments();
} else {
message.error(response.data.message || '删除环境失败');
}
} catch (error) {
message.error('删除环境失败');
console.error('Delete environment error:', error);
}
};
const handleSearch = () => {
current.value = 1;
fetchEnvironments();
};
const handleTableChange = (pagination, filters, sorter) => {
current.value = pagination.current;
pageSize.value = pagination.pageSize;
fetchEnvironments();
};
onMounted(() => {
fetchEnvironments();
});
</script>
<style scoped>
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
}
:deep(.ant-card) {
border-radius: 4px;
}
:deep(.ant-table-thead > tr > th) {
background: #fafafa;
}
:deep(.ant-drawer-body) {
padding: 24px;
}
:deep(.ant-drawer-footer) {
text-align: right;
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
}
.search-area {
margin-bottom: 16px;
}
.environment-name {
color: rgba(0, 0, 0);
cursor: pointer;
}
.environment-name:hover {
color: rgba(0, 0, 0, 0.65);
}
:deep(.action-button) {
color: #1890ff;
padding: 4px 0;
}
:deep(.action-button:hover) {
color: #40a9ff;
}
:deep(.ant-btn-dangerous.ant-btn-link) {
padding: 4px 0;
}
:deep(.ant-form-item) {
margin-bottom: 16px;
margin-right: 16px;
}
:deep(.ant-form-item:last-child) {
margin-right: 0;
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<div class="login-container">
<div class="login-box">
<div class="login-title">
<img src="../../assets/image/liteops.png" alt="LiteOps Logo" class="login-logo" />
</div>
<a-form
:model="formState"
name="loginForm"
@finish="handleSubmit"
:rules="rules"
>
<a-form-item name="username">
<a-input
v-model:value="formState.username"
size="large"
placeholder="请输入用户名"
:class="{ 'input-active': inputFocus.username }"
@focus="inputFocus.username = true"
@blur="inputFocus.username = false"
>
<template #prefix>
<UserOutlined :class="{ 'icon-active': inputFocus.username }" />
</template>
</a-input>
</a-form-item>
<a-form-item name="password">
<a-input-password
v-model:value="formState.password"
size="large"
placeholder="请输入密码"
:class="{ 'input-active': inputFocus.password }"
@focus="inputFocus.password = true"
@blur="inputFocus.password = false"
>
<template #prefix>
<LockOutlined :class="{ 'icon-active': inputFocus.password }" />
</template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-button
type="primary"
html-type="submit"
size="large"
:loading="loading"
block
class="login-button"
:disabled="!formState.username || !formState.password"
>
<span class="button-text"> </span>
</a-button>
</a-form-item>
</a-form>
<div class="footer-text">
<p>© 2023 LiteOps 胡图图不涂涂</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { initUserPermissions } from '../../utils/permission';
const router = useRouter();
const loading = ref(false);
const formState = reactive({
username: '',
password: '',
});
const inputFocus = reactive({
username: false,
password: false
});
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
],
};
const handleSubmit = async (values) => {
try {
loading.value = true;
const response = await axios.post('/api/login/', {
username: values.username,
password: values.password,
});
if (response.data.code === 200) {
const { token, user } = response.data.data;
localStorage.setItem('token', token);
localStorage.setItem('user_info', JSON.stringify(user));
await initUserPermissions();
message.success('登录成功');
router.push('/dashboard');
} else {
message.error(response.data.message || '登录失败');
}
} catch (error) {
message.error('登录失败,请稍后重试');
console.error('Login error:', error);
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(to bottom right, #f8fafc, #e6f7ff);
position: relative;
overflow: hidden;
}
.login-box {
width: 450px;
padding: 60px 50px;
background: #ffffff;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
position: relative;
z-index: 2;
border: 1px solid rgba(230, 230, 230, 0.5);
overflow: hidden;
transition: all 0.3s ease;
}
.login-title {
text-align: center;
margin-bottom: 40px;
}
.login-logo {
height: 100px;
margin-bottom: 10px;
transition: all 0.3s ease;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.15));
}
:deep(.ant-input-affix-wrapper) {
height: 55px;
border-radius: 12px;
border: 2px solid #eaeaea;
transition: all 0.3s ease;
margin-bottom: 20px;
overflow: hidden;
background: #f9fafc;
}
:deep(.ant-input) {
font-size: 16px;
background: #f9fafc;
transition: all 0.3s ease;
}
:deep(.ant-input-affix-wrapper.input-active) {
border-color: #1890ff;
box-shadow: 0 0 0 4px rgba(24, 144, 255, 0.15);
background: white;
}
:deep(.ant-form-item) {
margin-bottom: 25px;
}
.login-button {
height: 55px;
border-radius: 12px;
font-size: 18px;
font-weight: 500;
letter-spacing: 2px;
margin-top: 20px;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
overflow: hidden;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border: none;
box-shadow: 0 8px 15px rgba(24, 144, 255, 0.3);
position: relative;
}
.login-button:active {
transform: translateY(-1px);
box-shadow: 0 5px 10px rgba(24, 144, 255, 0.4);
}
.button-text {
position: relative;
z-index: 2;
}
.login-button::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 5px;
height: 5px;
background: rgba(255, 255, 255, 0.5);
opacity: 0;
border-radius: 100%;
transform: scale(1, 1) translate(-50%, -50%);
transform-origin: 50% 50%;
}
.login-button:focus:not(:active)::after {
animation: ripple 0.8s ease-out;
}
@keyframes ripple {
0% {
transform: scale(0, 0);
opacity: 0.5;
}
100% {
transform: scale(30, 30);
opacity: 0;
}
}
:deep(.ant-input-affix-wrapper:hover),
:deep(.ant-input-affix-wrapper:focus),
:deep(.ant-input-affix-wrapper-focused) {
border-color: #1890ff;
box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.15);
background: white;
}
:deep(.anticon) {
color: #bfbfbf;
transition: all 0.5s cubic-bezier(0.68, -0.55, 0.27, 1.55);
font-size: 20px;
}
:deep(.icon-active) {
color: #1890ff !important;
transform: scale(1.2);
}
:deep(.ant-input-affix-wrapper:hover .anticon),
:deep(.ant-input-affix-wrapper-focused .anticon) {
color: #1890ff;
}
.footer-text {
text-align: center;
margin-top: 30px;
color: #8c8c8c;
font-size: 14px;
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div class="log-detail">
<div class="page-header">
<a-page-header
title="登录日志详情"
@back="handleBack"
/>
</div>
<!-- 日志详情信息 -->
<a-card title="日志信息" :loading="loading">
<div class="info-list" v-if="logDetail">
<div class="info-item">
<span class="info-label">日志ID</span>
<span class="info-value">{{ logDetail.log_id }}</span>
</div>
<div class="info-item">
<span class="info-label">用户名</span>
<span class="info-value">{{ logDetail.username || '未知用户' }}</span>
</div>
<div class="info-item">
<span class="info-label">用户姓名</span>
<span class="info-value">{{ logDetail.user_name || '未知' }}</span>
</div>
<div class="info-item">
<span class="info-label">登录状态</span>
<span class="info-value">
{{ logDetail.status === 'success' ? '成功' : '失败' }}
</span>
</div>
<div class="info-item" v-if="logDetail.fail_reason">
<span class="info-label">失败原因</span>
<span class="info-value">{{ logDetail.fail_reason }}</span>
</div>
<div class="info-item">
<span class="info-label">登录IP</span>
<span class="info-value">{{ logDetail.ip_address }}</span>
</div>
<div class="info-item">
<span class="info-label">登录时间</span>
<span class="info-value">{{ logDetail.login_time }}</span>
</div>
<div class="info-item">
<span class="info-label">用户代理</span>
<span class="info-value user-agent-info">{{ logDetail.user_agent }}</span>
</div>
</div>
<a-empty v-else description="未找到日志信息" />
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import axios from 'axios';
import { checkPermission } from '../../utils/permission';
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const logDetail = ref(null);
const fetchLogDetail = async () => {
const logId = route.query.log_id;
if (!logId) {
message.error('日志ID不能为空');
router.push('/logs/login');
return;
}
// 检查权限
if (!checkPermission('logs_login', 'view')) {
router.push('/dashboard');
return;
}
loading.value = true;
try {
const token = localStorage.getItem('token');
const response = await axios.get(`/api/logs/login/${logId}/`, {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
logDetail.value = response.data.data;
} else {
throw new Error(response.data.message || '获取日志详情失败');
}
} catch (error) {
message.error(error.message || '获取日志详情失败');
router.push('/logs/login');
} finally {
loading.value = false;
}
};
const handleBack = () => {
router.push('/logs/login');
};
onMounted(() => {
fetchLogDetail();
});
</script>
<style scoped>
.page-header {
margin-bottom: 24px;
background: #fff;
}
:deep(.ant-page-header) {
padding: 16px 24px;
}
.info-list {
padding: 8px 0;
}
.info-item {
line-height: 32px;
display: flex;
align-items: flex-start;
margin-bottom: 8px;
}
.info-label {
color: rgba(0, 0, 0, 0.45);
min-width: 100px;
}
.info-value {
color: rgba(0, 0, 0, 0.85);
flex: 1;
}
.user-agent-info {
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,219 @@
<template>
<div class="login-logs-container">
<div class="page-header">
<a-row justify="space-between" align="middle">
<a-col>
<h2>登录日志</h2>
</a-col>
<a-col>
<!-- 搜索表单 -->
<a-form layout="inline">
<a-form-item label="用户名">
<a-input v-model:value="searchForm.username" placeholder="请输入用户名" allowClear />
</a-form-item>
<a-form-item label="IP地址">
<a-input v-model:value="searchForm.ip_address" placeholder="请输入IP地址" allowClear />
</a-form-item>
<a-form-item label="登录状态">
<a-select v-model:value="searchForm.status" style="width: 120px" allowClear>
<a-select-option value="success">成功</a-select-option>
<a-select-option value="failed">失败</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleSearch">查询</a-button>
<a-button style="margin-left: 8px" @click="handleReset">重置</a-button>
</a-form-item>
</a-form>
</a-col>
</a-row>
</div>
<a-card>
<!-- 数据表格 -->
<a-table
:columns="columns"
:data-source="tableData"
:pagination="pagination"
:loading="loading"
@change="handleTableChange"
rowKey="log_id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'status'">
<a-tag :color="record.status === 'success' ? 'rgba(56, 158, 13, 0.8)' : 'rgba(255,77,79,0.8)'">
{{ record.status === 'success' ? '成功' : '失败' }}
</a-tag>
</template>
<template v-if="column.dataIndex === 'login_time'">
{{ record.login_time }}
</template>
<template v-if="column.dataIndex === 'action'">
<a-button type="link" @click="handleViewDetails(record)">详情</a-button>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { checkPermission } from '../../utils/permission';
const router = useRouter();
const columns = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
},
{
title: 'IP地址',
dataIndex: 'ip_address',
key: 'ip_address',
},
{
title: '登录状态',
dataIndex: 'status',
key: 'status',
},
{
title: '登录时间',
dataIndex: 'login_time',
key: 'login_time',
},
{
title: '操作',
dataIndex: 'action',
key: 'action',
}
];
// 表格数据
const tableData = ref([]);
const loading = ref(false);
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showTotal: (total) => `${total} 条记录`,
});
// 搜索表单
const searchForm = reactive({
username: '',
ip_address: '',
status: undefined
});
// 初始化
onMounted(() => {
// 检查权限
if (checkPermission('logs_login', 'view')) {
getLoginLogs();
}
});
// 获取登录日志数据
const getLoginLogs = async () => {
try {
loading.value = true;
// 构建查询参数
const params = {
page: pagination.current,
page_size: pagination.pageSize,
username: searchForm.username,
ip_address: searchForm.ip_address,
status: searchForm.status,
};
const token = localStorage.getItem('token');
const response = await axios.get('/api/logs/login/', {
params,
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
const { logs, total } = response.data.data;
tableData.value = logs;
pagination.total = total;
} else {
message.error(response.data.message || '获取登录日志失败');
}
} catch (error) {
console.error('获取登录日志失败:', error);
message.error('获取登录日志失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.current = 1;
getLoginLogs();
};
const handleReset = () => {
// 重置搜索表单
searchForm.username = '';
searchForm.ip_address = '';
searchForm.status = undefined;
// 重置分页并查询
pagination.current = 1;
getLoginLogs();
};
const handleTableChange = (pag) => {
pagination.current = pag.current;
pagination.pageSize = pag.pageSize;
getLoginLogs();
};
// 查看详情 - 跳转到详情页
const handleViewDetails = (record) => {
router.push({
path: '/logs/login/detail',
query: { log_id: record.log_id }
});
};
</script>
<style scoped>
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
}
:deep(.ant-form-item) {
margin-bottom: 16px;
margin-right: 16px;
}
:deep(.ant-form-item:last-child) {
margin-right: 0;
}
:deep(.ant-input) {
width: 140px;
}
:deep(.ant-select) {
width: 120px;
}
</style>

View File

@@ -0,0 +1,312 @@
<template>
<div class="project-detail">
<div class="page-header">
<a-page-header
:title="project?.name || '项目详情'"
@back="handleBack"
>
<template #extra>
<a-space>
<a-button @click="handleEditProject">
<template #icon><EditOutlined /></template>
编辑项目
</a-button>
</a-space>
</template>
</a-page-header>
</div>
<!-- 项目基本信息 -->
<a-card title="项目信息" :loading="loading">
<div class="info-list">
<div class="info-item">
<span class="info-label">项目名称</span>
<span class="info-value">{{ project?.name }}</span>
</div>
<div class="info-item">
<span class="info-label">项目ID</span>
<span class="info-value">{{ project?.project_id }}</span>
</div>
<div class="info-item">
<span class="info-label">服务类别</span>
<span class="info-value">{{ getCategoryText(project?.category) }}</span>
</div>
<div class="info-item">
<span class="info-label">GitLab仓库</span>
<span class="info-value">{{ project?.repository }}</span>
</div>
<div class="info-item">
<span class="info-label">创建者</span>
<span class="info-value">{{ project?.creator?.name }}</span>
</div>
<div class="info-item">
<span class="info-label">创建时间</span>
<span class="info-value">{{ project?.create_time }}</span>
</div>
<div class="info-item">
<span class="info-label">更新时间</span>
<span class="info-value">{{ project?.update_time }}</span>
</div>
<div class="info-item">
<span class="info-label">项目描述</span>
<span class="info-value">{{ project?.description || '暂无描述' }}</span>
</div>
</div>
</a-card>
<!-- 编辑项目抽屉 -->
<a-drawer
v-model:open="projectDrawerVisible"
title="编辑项目"
width="600px"
@close="handleProjectDrawerClose"
>
<a-form
:model="projectForm"
:rules="projectRules"
ref="projectFormRef"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="项目名称" name="name">
<a-input v-model:value="projectForm.name" placeholder="请输入项目名称" />
</a-form-item>
<a-form-item label="项目描述" name="description">
<a-textarea
v-model:value="projectForm.description"
placeholder="请输入项目描述"
:rows="4"
/>
</a-form-item>
<a-form-item label="服务类别" name="category">
<a-select
v-model:value="projectForm.category"
placeholder="请选择服务类别"
>
<a-select-option value="frontend">前端服务</a-select-option>
<a-select-option value="backend">后端服务</a-select-option>
<a-select-option value="mobile">移动端服务</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="GitLab仓库" name="repository">
<a-input
v-model:value="projectForm.repository"
placeholder="请输入完整的GitLab仓库地址"
/>
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button @click="handleProjectDrawerClose">取消</a-button>
<a-button
type="primary"
:loading="projectSubmitLoading"
@click="handleProjectSubmit"
>
保存
</a-button>
</a-space>
</template>
</a-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import {
EditOutlined,
ReloadOutlined
} from '@ant-design/icons-vue';
import axios from 'axios';
import { checkPermission } from '../../utils/permission';
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const project = ref(null);
// 项目编辑相关
const projectDrawerVisible = ref(false);
const projectFormRef = ref();
const projectSubmitLoading = ref(false);
const projectForm = ref({
name: '',
description: '',
category: '',
repository: '',
});
const projectRules = {
name: [
{ required: true, message: '请输入项目名称', trigger: 'blur' },
{ min: 3, max: 50, message: '项目名称长度应在 3-50 个字符之间', trigger: 'blur' },
],
category: [
{ required: true, message: '请选择服务类别', trigger: 'change' },
],
repository: [
{ required: true, message: '请输入GitLab仓库地址', trigger: 'blur' },
],
};
const getCategoryText = (category) => {
const texts = {
frontend: '前端服务',
backend: '后端服务',
mobile: '移动端服务'
};
return texts[category] || '其他服务';
};
const fetchProjectDetail = async () => {
const projectId = route.query.project_id;
if (!projectId) {
message.error('项目ID不能为空');
router.push('/projects/list');
return;
}
if (!checkPermission('project', 'view')) {
router.push('/dashboard');
return;
}
loading.value = true;
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/projects/', {
headers: {
'Authorization': token
},
params: {
project_id: projectId
}
});
if (response.data.code === 200) {
project.value = response.data.data[0];
// 检查用户是否有权限访问此项目
if (!checkPermission('project', 'view', project.value.project_id)) {
router.push('/projects/list');
return;
}
} else {
throw new Error(response.data.message || '获取项目详情失败');
}
} catch (error) {
message.error(error.message);
router.push('/projects/list');
} finally {
loading.value = false;
}
};
const handleBack = () => {
router.back();
};
const handleEditProject = () => {
if (!checkPermission('project', 'edit', project.value.project_id)) {
return;
}
projectForm.value = {
name: project.value.name,
description: project.value.description,
category: project.value.category,
repository: project.value.repository,
};
projectDrawerVisible.value = true;
};
const handleProjectDrawerClose = () => {
projectDrawerVisible.value = false;
projectFormRef.value?.resetFields();
};
const handleProjectSubmit = async () => {
try {
await projectFormRef.value.validate();
projectSubmitLoading.value = true;
const token = localStorage.getItem('token');
const response = await axios.put('/api/projects/', {
project_id: project.value.project_id,
name: projectForm.value.name,
description: projectForm.value.description,
category: projectForm.value.category,
repository: projectForm.value.repository,
}, {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
message.success('更新项目成功');
handleProjectDrawerClose();
fetchProjectDetail();
} else {
throw new Error(response.data.message || '更新项目失败');
}
} catch (error) {
message.error(error.response?.data?.message || error.message || '更新项目失败');
} finally {
projectSubmitLoading.value = false;
}
};
onMounted(() => {
fetchProjectDetail();
});
</script>
<style scoped>
.page-header {
margin-bottom: 24px;
background: #fff;
}
:deep(.ant-page-header) {
padding: 16px 24px;
}
.info-list {
padding: 8px 0;
}
.info-item {
line-height: 32px;
display: flex;
align-items: flex-start;
}
.info-label {
color: rgba(0, 0, 0, 0.45);
min-width: 100px;
}
.info-value {
color: rgba(0, 0, 0, 0.85);
flex: 1;
}
:deep(.ant-drawer-body) {
padding: 24px;
}
:deep(.ant-drawer-footer) {
text-align: right;
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
}
</style>

View File

@@ -0,0 +1,239 @@
<template>
<div class="project-edit">
<div class="page-header">
<a-page-header
title="编辑项目"
@back="handleBack"
/>
</div>
<a-card>
<a-form
:model="formState"
:rules="rules"
ref="formRef"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="项目名称" name="name">
<a-input v-model:value="formState.name" placeholder="请输入项目名称" />
</a-form-item>
<a-form-item label="项目描述" name="description">
<a-textarea
v-model:value="formState.description"
placeholder="请输入项目描述"
:rows="4"
/>
</a-form-item>
<a-form-item label="GitLab 仓库" name="repository">
<a-input
v-model:value="formState.repository"
placeholder="请输入完整的GitLab仓库地址例如git.example.com/group/project.git"
/>
</a-form-item>
<a-form-item label="GitLab凭证" name="gitlabCredentialId">
<a-select
v-model:value="formState.gitlabCredentialId"
placeholder="请选择GitLab凭证"
:options="gitlabCredentials"
:loading="credentialsLoading"
>
<template #suffixIcon>
<ReloadOutlined
:spin="credentialsLoading"
@click="loadGitlabCredentials"
/>
</template>
</a-select>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 4, span: 16 }">
<a-space>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">保存修改</a-button>
<a-button @click="handleReset">重置</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import { ReloadOutlined } from '@ant-design/icons-vue';
import axios from 'axios';
const router = useRouter();
const route = useRoute();
const formRef = ref();
const credentialsLoading = ref(false);
const submitLoading = ref(false);
const gitlabCredentials = ref([]);
const loading = ref(false);
const formState = reactive({
name: '',
description: '',
repository: '',
gitlabCredentialId: undefined,
project_id: '',
});
const rules = {
name: [
{ required: true, message: '请输入项目名称', trigger: 'blur' },
{ min: 3, max: 50, message: '项目名称长度应在 3-50 个字符之间', trigger: 'blur' },
],
repository: [
{ required: true, message: '请输入 GitLab 仓库地址', trigger: 'blur' },
],
gitlabCredentialId: [
{ required: true, message: '请选择 GitLab 凭证', trigger: 'change' },
],
};
// 加载GitLab凭证列表
const loadGitlabCredentials = async () => {
credentialsLoading.value = true;
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/gitlab-credentials/', {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
gitlabCredentials.value = response.data.data.map(item => ({
label: item.name,
value: item.credential_id
}));
} else {
message.error(response.data.message || '加载GitLab凭证失败');
}
} catch (error) {
message.error('加载GitLab凭证失败');
console.error('Load credentials error:', error);
} finally {
credentialsLoading.value = false;
}
};
// 加载项目详情
const loadProjectDetail = async () => {
const projectId = route.query.project_id;
if (!projectId) {
message.error('项目ID不能为空');
router.push('/projects/list');
return;
}
loading.value = true;
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/projects/', {
headers: {
'Authorization': token
},
params: {
project_id: projectId
}
});
if (response.data.code === 200) {
const projectData = response.data.data.find(item => item.project_id === projectId);
if (projectData) {
formState.project_id = projectData.project_id;
formState.name = projectData.name;
formState.description = projectData.description || '';
formState.repository = projectData.repository;
formState.gitlabCredentialId = projectData.gitlab_credential?.credential_id;
} else {
throw new Error('未找到项目信息');
}
} else {
throw new Error(response.data.message || '加载项目详情失败');
}
} catch (error) {
message.error(error.message || '加载项目详情失败');
console.error('Load project detail error:', error);
router.push('/projects/list');
} finally {
loading.value = false;
}
};
const handleBack = () => {
router.back();
};
const handleSubmit = async () => {
try {
await formRef.value.validate();
submitLoading.value = true;
const token = localStorage.getItem('token');
const response = await axios.put('/api/projects/', {
project_id: formState.project_id,
name: formState.name,
description: formState.description,
repository: formState.repository,
gitlabCredentialId: formState.gitlabCredentialId
}, {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
message.success('更新项目成功');
router.push('/projects/list');
} else {
throw new Error(response.data.message || '更新项目失败');
}
} catch (error) {
message.error(error.response?.data?.message || error.message);
console.error('Update project error:', error);
} finally {
submitLoading.value = false;
}
};
const handleReset = () => {
loadProjectDetail();
};
onMounted(() => {
loadGitlabCredentials();
loadProjectDetail();
});
</script>
<style scoped>
.project-edit {
padding: 24px;
}
.page-header {
margin-bottom: 24px;
background: #fff;
border-radius: 4px;
}
:deep(.ant-page-header) {
padding: 16px 24px;
}
:deep(.ant-card) {
border-radius: 4px;
}
:deep(.ant-form) {
padding: 24px 0;
}
</style>

View File

@@ -0,0 +1,428 @@
<template>
<div class="project-list">
<div class="page-header">
<a-row justify="space-between" align="middle">
<a-col>
<h2>项目列表</h2>
</a-col>
<a-col>
<a-button type="primary" @click="handleCreateProject">
<template #icon><PlusOutlined /></template>
新建项目
</a-button>
</a-col>
</a-row>
</div>
<a-card>
<!-- 搜索区域 -->
<div class="search-area">
<a-form layout="inline" :style="{ display: 'flex', justifyContent: 'flex-end' }">
<a-form-item label="项目名称">
<a-input
v-model:value="searchForm.name"
placeholder="请输入项目名称"
allow-clear
/>
</a-form-item>
<a-form-item label="服务类别">
<a-select
v-model:value="searchForm.category"
placeholder="请选择服务类别"
style="width: 200px"
allow-clear
>
<a-select-option value="frontend">前端服务</a-select-option>
<a-select-option value="backend">后端服务</a-select-option>
<a-select-option value="mobile">移动端服务</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="loading" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
</a-form-item>
</a-form>
</div>
<a-table
:columns="columns"
:data-source="filteredProjects"
:loading="loading"
row-key="project_id"
:locale="{ emptyText: '暂无数据' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<span class="project-name" @click="handleViewProject(record)">{{ record.name }}</span>
</template>
<template v-if="column.key === 'category'">
{{ getCategoryText(record.category) }}
</template>
<template v-if="column.key === 'creator'">
{{ record.creator.name }}
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" class="action-button" @click="handleViewProject(record)">查看</a-button>
<a-popconfirm
title="确定要删除这个项目吗?"
@confirm="handleDeleteProject(record)"
>
<a-button type="link" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 新建项目抽屉 -->
<a-drawer
v-model:open="drawerVisible"
title="新建项目"
width="600px"
@close="handleDrawerClose"
>
<a-form
:model="formState"
:rules="rules"
ref="formRef"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="项目名称" name="name">
<a-input v-model:value="formState.name" placeholder="请输入项目名称" />
</a-form-item>
<a-form-item label="项目描述" name="description">
<a-textarea
v-model:value="formState.description"
placeholder="请输入项目描述"
:rows="4"
/>
</a-form-item>
<a-form-item label="服务类别" name="category">
<a-select
v-model:value="formState.category"
placeholder="请选择服务类别"
>
<a-select-option value="frontend">前端服务</a-select-option>
<a-select-option value="backend">后端服务</a-select-option>
<a-select-option value="mobile">移动端服务</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="GitLab仓库" name="repository">
<a-input
v-model:value="formState.repository"
placeholder="请输入完整的GitLab仓库地址"
/>
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button @click="handleDrawerClose">取消</a-button>
<a-button
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
创建
</a-button>
</a-space>
</template>
</a-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons-vue';
import axios from 'axios';
import {
getPermittedProjectIds,
checkPermission
} from '../../utils/permission';
const router = useRouter();
const loading = ref(false);
const projects = ref([]);
const drawerVisible = ref(false);
const formRef = ref();
const submitLoading = ref(false);
const formState = reactive({
name: '',
description: '',
category: undefined,
repository: '',
});
const searchForm = reactive({
name: '',
category: undefined
});
const columns = [
{
title: '项目名称',
dataIndex: 'name',
key: 'name',
},
{
title: '项目描述',
dataIndex: 'description',
key: 'description',
},
{
title: '服务类别',
dataIndex: 'category',
key: 'category',
},
{
title: '仓库地址',
dataIndex: 'repository',
key: 'repository',
},
{
title: '创建者',
key: 'creator',
},
{
title: '创建时间',
dataIndex: 'create_time',
key: 'create_time',
},
{
title: '操作',
key: 'action',
},
];
const rules = {
name: [
{ required: true, message: '请输入项目名称', trigger: 'blur' },
{ min: 3, max: 50, message: '项目名称长度应在 3-50 个字符之间', trigger: 'blur' },
],
category: [
{ required: true, message: '请选择服务类别', trigger: 'change' },
],
repository: [
{ required: true, message: '请输入GitLab仓库地址', trigger: 'blur' },
],
};
const filteredProjects = computed(() => {
const permittedProjectIds = getPermittedProjectIds();
if (permittedProjectIds === null) {
return projects.value;
}
return projects.value.filter(project =>
permittedProjectIds.includes(project.project_id)
);
});
const getCategoryText = (category) => {
const texts = {
frontend: '前端服务',
backend: '后端服务',
};
return texts[category] || '其他服务';
};
const fetchProjects = async () => {
loading.value = true;
try {
const token = localStorage.getItem('token');
const params = {};
if (searchForm.name) {
params.name = searchForm.name;
}
if (searchForm.category) {
params.category = searchForm.category;
}
const response = await axios.get('/api/projects/', {
headers: {
'Authorization': token
},
params: params
});
if (response.data.code === 200) {
projects.value = response.data.data;
} else {
message.error(response.data.message || '获取项目列表失败');
}
} catch (error) {
message.error('获取项目列表失败');
console.error('Fetch projects error:', error);
} finally {
loading.value = false;
}
};
const handleCreateProject = () => {
if (!checkPermission('project', 'create')) {
return;
}
drawerVisible.value = true;
};
const handleDrawerClose = () => {
drawerVisible.value = false;
formRef.value?.resetFields();
};
const handleSubmit = async () => {
try {
await formRef.value.validate();
submitLoading.value = true;
const token = localStorage.getItem('token');
const response = await axios.post('/api/projects/', {
name: formState.name,
description: formState.description,
category: formState.category,
repository: formState.repository,
}, {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
message.success('项目创建成功');
handleDrawerClose();
fetchProjects();
} else {
throw new Error(response.data.message || '创建项目失败');
}
} catch (error) {
message.error(error.response?.data?.message || error.message || '创建项目失败');
} finally {
submitLoading.value = false;
}
};
const handleViewProject = (record) => {
if (!checkPermission('project', 'view', record.project_id)) {
return;
}
router.push({
path: '/projects/detail',
query: { project_id: record.project_id }
});
};
const handleDeleteProject = async (record) => {
if (!checkPermission('project', 'delete', record.project_id)) {
return;
}
try {
const token = localStorage.getItem('token');
const response = await axios.delete('/api/projects/', {
headers: {
'Authorization': token
},
data: {
project_id: record.project_id
}
});
if (response.data.code === 200) {
message.success('删除项目成功');
fetchProjects();
} else {
message.error(response.data.message || '删除项目失败');
}
} catch (error) {
message.error('删除项目失败');
console.error('Delete project error:', error);
}
};
const handleSearch = () => {
fetchProjects();
};
onMounted(() => {
fetchProjects();
});
</script>
<style scoped>
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
}
:deep(.ant-card) {
border-radius: 4px;
}
:deep(.ant-table-thead > tr > th) {
background: #fafafa;
}
:deep(.ant-drawer-body) {
padding: 24px;
}
:deep(.ant-drawer-footer) {
text-align: right;
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
}
.search-area {
margin-bottom: 16px;
}
.project-name {
color: rgba(0, 0, 0);
cursor: pointer;
}
.project-name:hover {
color: rgba(0, 0, 0, 0.65);
}
.action-button {
color: #1890ff;
}
.action-button:hover {
color: #40a9ff;
}
:deep(.ant-btn-link) {
padding: 4px 8px;
}
:deep(.ant-form-item) {
margin-bottom: 16px;
margin-right: 16px;
}
:deep(.ant-form-item:last-child) {
margin-right: 0;
}
</style>

View File

@@ -0,0 +1,670 @@
<template>
<div class="basic-settings">
<div class="page-header">
<h2>基本设置</h2>
</div>
<a-tabs v-model:activeKey="activeTabKey" @change="handleTabChange">
<!-- 安全设置 -->
<a-tab-pane key="security-config" tab="安全设置">
<a-card>
<a-form
ref="securityFormRef"
:model="securityConfig"
:rules="securityRules"
layout="vertical"
>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="密码最小长度" name="min_password_length">
<a-input-number
v-model:value="securityConfig.min_password_length"
:min="1"
:max="20"
style="width: 100%"
placeholder="请输入密码最小长度"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="密码复杂度要求" name="password_complexity">
<a-checkbox-group v-model:value="securityConfig.password_complexity">
<a-checkbox value="uppercase">包含大写字母</a-checkbox>
<a-checkbox value="lowercase">包含小写字母</a-checkbox>
<a-checkbox value="number">包含数字</a-checkbox>
<a-checkbox value="special">包含特殊字符</a-checkbox>
</a-checkbox-group>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="会话超时时间(分钟)" name="session_timeout">
<a-input-number
v-model:value="securityConfig.session_timeout"
:min="1"
:max="1440"
style="width: 100%"
placeholder="请输入会话超时时间"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="登录失败锁定次数" name="max_login_attempts">
<a-input-number
v-model:value="securityConfig.max_login_attempts"
:min="1"
:max="10"
style="width: 100%"
placeholder="请输入登录失败锁定次数"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="账户锁定时间(分钟)" name="lockout_duration">
<a-input-number
v-model:value="securityConfig.lockout_duration"
:min="1"
:max="60"
style="width: 100%"
placeholder="请输入账户锁定时间"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="启用双因子认证" name="enable_2fa">
<a-switch v-model:checked="securityConfig.enable_2fa" />
</a-form-item>
</a-col>
</a-row>
<a-form-item>
<a-button
type="primary"
@click="saveSecurityConfig"
:loading="securityLoading"
v-if="hasFunctionPermission('system_basic', 'edit')"
>
保存配置
</a-button>
</a-form-item>
</a-form>
</a-card>
</a-tab-pane>
<!-- 通知配置 -->
<a-tab-pane key="notification-config" tab="通知配置">
<a-card>
<div class="notification-header">
<a-button type="primary" @click="showAddRobot" v-if="hasFunctionPermission('system_basic', 'create')">
<template #icon><PlusOutlined /></template>
添加机器人
</a-button>
</div>
<a-table
:columns="robotColumns"
:data-source="robotList"
:loading="notificationLoading"
:pagination="false"
row-key="robot_id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
{{ getRobotTypeText(record.type) }}
</template>
<template v-else-if="column.key === 'security_type'">
{{ getSecurityTypeText(record.security_type) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" @click="handleEdit(record)" v-if="hasFunctionPermission('system_basic', 'edit')">
编辑
</a-button>
<a-button type="link" @click="handleTestRobot(record)" v-if="hasFunctionPermission('system_basic', 'test')">
测试
</a-button>
<a-popconfirm
title="确定要删除这个机器人吗?"
@confirm="handleDeleteRobot(record)"
v-if="hasFunctionPermission('system_basic', 'delete')"
>
<a-button type="link" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</a-tab-pane>
</a-tabs>
<!-- 添加/编辑机器人抽屉 -->
<a-drawer
v-model:open="drawerVisible"
:title="isEdit ? '编辑机器人' : '添加机器人'"
placement="right"
width="500px"
:closable="false"
:footer="null"
@close="handleDrawerClose"
>
<a-form
ref="robotFormRef"
:model="robotForm"
layout="vertical"
>
<a-form-item
label="机器人类型"
name="type"
:rules="[{ required: true, message: '请选择机器人类型' }]"
>
<a-select
v-model:value="robotForm.type"
placeholder="请选择机器人类型"
:disabled="isEdit"
>
<a-select-option value="dingtalk">钉钉机器人</a-select-option>
<a-select-option value="wecom">企业微信机器人</a-select-option>
<a-select-option value="feishu">飞书机器人</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="机器人名称"
name="name"
:rules="[{ required: true, message: '请输入机器人名称' }]"
>
<a-input
v-model:value="robotForm.name"
placeholder="请输入机器人名称"
:maxLength="50"
/>
</a-form-item>
<a-form-item
label="Webhook地址"
name="webhook"
:rules="[{ required: true, message: '请输入Webhook地址' }]"
>
<a-input
v-model:value="robotForm.webhook"
placeholder="请输入Webhook地址"
/>
</a-form-item>
<a-form-item
label="安全设置"
name="security_type"
:rules="[{ required: true, message: '请选择安全设置类型' }]"
>
<a-select
v-model:value="robotForm.security_type"
placeholder="请选择安全设置类型"
>
<a-select-option value="none"></a-select-option>
<a-select-option value="secret">加签密钥</a-select-option>
<a-select-option value="keyword">自定义关键词</a-select-option>
<a-select-option value="ip">IP地址()</a-select-option>
</a-select>
</a-form-item>
<template v-if="robotForm.security_type === 'secret'">
<a-form-item
label="加签密钥"
name="secret"
:rules="[{ required: true, message: '请输入加签密钥' }]"
>
<a-input
v-model:value="robotForm.secret"
placeholder="请输入加签密钥"
/>
</a-form-item>
</template>
<template v-if="robotForm.security_type === 'keyword'">
<a-form-item
label="自定义关键词"
name="keywords"
:rules="[{ required: true, message: '请添加关键词' }]"
>
<a-select
v-model:value="robotForm.keywords"
mode="tags"
placeholder="请输入关键词后按回车添加"
:token-separators="[',']"
/>
</a-form-item>
</template>
<template v-if="robotForm.security_type === 'ip'">
<a-form-item
label="IP白名单"
name="ip_list"
:rules="[{ required: true, message: '请添加IP地址' }]"
>
<a-select
v-model:value="robotForm.ip_list"
mode="tags"
placeholder="请输入IP地址后按回车添加"
:token-separators="[',']"
/>
<div class="form-item-help">
支持IP地址或IP地址段例如: 192.168.1.1 192.168.1.1/24
</div>
</a-form-item>
</template>
<a-form-item
label="备注"
name="remark"
>
<a-textarea
v-model:value="robotForm.remark"
placeholder="请输入备注信息"
:rows="4"
:maxLength="200"
/>
</a-form-item>
</a-form>
<template #footer>
<div style="text-align: left">
<a-space>
<a-button @click="handleDrawerClose">取消</a-button>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">
确定
</a-button>
</a-space>
</div>
</template>
</a-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import axios from 'axios';
import { hasFunctionPermission, checkPermission } from '../../utils/permission';
const activeTabKey = ref('security-config');
// 安全配置
const securityFormRef = ref();
const securityConfig = reactive({
min_password_length: 8,
password_complexity: ['lowercase', 'number'],
session_timeout: 120,
max_login_attempts: 5,
lockout_duration: 30,
enable_2fa: false
});
const securityRules = {
min_password_length: [
{ required: true, message: '请输入密码最小长度', trigger: 'blur' }
],
session_timeout: [
{ required: true, message: '请输入会话超时时间', trigger: 'blur' }
]
};
// 通知机器人相关
const drawerVisible = ref(false);
const submitLoading = ref(false);
const notificationLoading = ref(false);
const isEdit = ref(false);
const robotList = ref([]);
// 表格列定义
const robotColumns = [
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 120,
},
{
title: '机器人名称',
dataIndex: 'name',
key: 'name',
},
{
title: 'Webhook地址',
dataIndex: 'webhook',
key: 'webhook',
ellipsis: true,
},
{
title: '安全设置',
dataIndex: 'security_type',
key: 'security_type',
width: 120,
},
{
title: '备注',
dataIndex: 'remark',
key: 'remark',
ellipsis: true,
},
{
title: '创建时间',
dataIndex: 'create_time',
key: 'create_time',
width: 180,
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
},
];
// 添加/编辑机器人表单
const robotFormRef = ref();
const robotForm = reactive({
robot_id: '',
type: undefined,
name: '',
webhook: '',
security_type: 'none',
secret: '',
keywords: [],
ip_list: [],
remark: '',
});
// 加载状态
const securityLoading = ref(false);
// 标签页切换
const handleTabChange = (key) => {
if (key === 'notification-config') {
loadRobotList();
}
};
// 获取安全配置
const fetchSecurityConfig = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/system/security/', {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
Object.assign(securityConfig, response.data.data);
}
} catch (error) {
console.error('获取安全配置失败:', error);
}
};
// 保存安全配置
const saveSecurityConfig = async () => {
try {
await securityFormRef.value.validate();
securityLoading.value = true;
const token = localStorage.getItem('token');
const response = await axios.put('/api/system/security/', securityConfig, {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
message.success('安全配置保存成功');
} else {
message.error(response.data.message || '保存失败');
}
} catch (error) {
console.error('保存安全配置失败:', error);
message.error('保存失败');
} finally {
securityLoading.value = false;
}
};
// 获取机器人列表
const loadRobotList = async () => {
if (!checkPermission('system_basic', 'view')) {
return;
}
try {
notificationLoading.value = true;
const token = localStorage.getItem('token');
const response = await axios.get('/api/notification/robots/', {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
robotList.value = response.data.data;
} else {
message.error(response.data.message || '获取机器人列表失败');
}
} catch (error) {
console.error('Load robot list error:', error);
message.error('获取机器人列表失败');
} finally {
notificationLoading.value = false;
}
};
// 获取机器人类型文本
const getRobotTypeText = (type) => {
const types = {
dingtalk: '钉钉',
wecom: '企业微信',
feishu: '飞书',
};
return types[type] || type;
};
// 获取安全设置类型文本
const getSecurityTypeText = (type) => {
const types = {
none: '无',
secret: '加签密钥',
keyword: '关键词',
ip: 'IP白名单',
};
return types[type] || type;
};
// 显示添加机器人抽屉
const showAddRobot = () => {
if (!checkPermission('system_basic', 'create')) {
return;
}
isEdit.value = false;
drawerVisible.value = true;
};
// 显示编辑机器人抽屉
const handleEdit = (record) => {
if (!checkPermission('system_basic', 'edit')) {
return;
}
isEdit.value = true;
Object.assign(robotForm, {
robot_id: record.robot_id,
type: record.type,
name: record.name,
webhook: record.webhook,
security_type: record.security_type,
secret: record.secret,
keywords: record.keywords || [],
ip_list: record.ip_list || [],
remark: record.remark,
});
drawerVisible.value = true;
};
const handleDrawerClose = () => {
drawerVisible.value = false;
robotFormRef.value?.resetFields();
Object.assign(robotForm, {
robot_id: '',
type: undefined,
name: '',
webhook: '',
security_type: 'none',
secret: '',
keywords: [],
ip_list: [],
remark: '',
});
};
// 提交表单
const handleSubmit = async () => {
try {
await robotFormRef.value.validate();
submitLoading.value = true;
const token = localStorage.getItem('token');
const method = isEdit.value ? 'put' : 'post';
const response = await axios[method]('/api/notification/robots/', robotForm, {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
message.success(isEdit.value ? '更新机器人成功' : '添加机器人成功');
handleDrawerClose();
loadRobotList();
} else {
message.error(response.data.message || (isEdit.value ? '更新机器人失败' : '添加机器人失败'));
}
} catch (error) {
console.error(isEdit.value ? 'Update robot error:' : 'Add robot error:', error);
message.error(isEdit.value ? '更新机器人失败' : '添加机器人失败');
} finally {
submitLoading.value = false;
}
};
// 测试机器人
const handleTestRobot = async (robot) => {
if (!checkPermission('system_basic', 'test')) {
return;
}
try {
const token = localStorage.getItem('token');
const hide = message.loading('正在发送测试消息...', 0);
const response = await axios.post('/api/notification/robots/test/', {
robot_id: robot.robot_id
}, {
headers: { 'Authorization': token }
});
hide();
if (response.data.code === 200) {
message.success('测试消息发送成功');
} else {
message.error(response.data.message || '发送测试消息失败');
}
} catch (error) {
console.error('Test robot error:', error);
message.error('发送测试消息失败');
}
};
// 删除机器人
const handleDeleteRobot = async (robot) => {
if (!checkPermission('system_basic', 'delete')) {
return;
}
try {
const token = localStorage.getItem('token');
const response = await axios.delete('/api/notification/robots/', {
headers: { 'Authorization': token },
data: { robot_id: robot.robot_id }
});
if (response.data.code === 200) {
message.success('删除成功');
loadRobotList();
} else {
message.error(response.data.message || '删除失败');
}
} catch (error) {
console.error('Delete robot error:', error);
message.error('删除失败');
}
};
onMounted(() => {
fetchSecurityConfig();
});
</script>
<style scoped>
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
}
:deep(.ant-card) {
border-radius: 4px;
}
:deep(.ant-tabs-content-holder) {
padding: 0;
}
:deep(.ant-form-item-explain-error) {
font-size: 12px;
}
:deep(.ant-checkbox-group) {
display: flex;
flex-direction: column;
gap: 8px;
}
.notification-header {
margin-bottom: 16px;
text-align: right;
}
:deep(.ant-drawer-header) {
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
}
:deep(.ant-drawer-body) {
padding: 24px;
}
:deep(.ant-drawer-footer) {
border-top: 1px solid #f0f0f0;
padding: 10px 16px;
}
:deep(.ant-table-thead > tr > th) {
background: #fafafa;
}
.form-item-help {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,476 @@
<template>
<div class="notification">
<div class="page-header">
<a-row justify="space-between" align="middle">
<a-col>
<h2>通知配置</h2>
</a-col>
<a-col>
<a-button type="primary" @click="showAddRobot">
<template #icon><PlusOutlined /></template>
添加机器人
</a-button>
</a-col>
</a-row>
</div>
<a-card>
<a-table
:columns="robotColumns"
:data-source="robotList"
:loading="loading"
:pagination="false"
row-key="robot_id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" @click="handleEdit(record)">
编辑
</a-button>
<a-button type="link" @click="handleTestRobot(record)">
测试
</a-button>
<a-popconfirm
title="确定要删除这个机器人吗?"
@confirm="handleDeleteRobot(record)"
>
<a-button type="link" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 添加/编辑机器人抽屉 -->
<a-drawer
v-model:open="drawerVisible"
:title="isEdit ? '编辑机器人' : '添加机器人'"
placement="right"
width="500px"
:closable="false"
:footer="null"
@close="handleDrawerClose"
>
<a-form
ref="formRef"
:model="robotForm"
layout="vertical"
>
<a-form-item
label="机器人类型"
name="type"
:rules="[{ required: true, message: '请选择机器人类型' }]"
>
<a-select
v-model:value="robotForm.type"
placeholder="请选择机器人类型"
:disabled="isEdit"
>
<a-select-option value="dingtalk">钉钉机器人</a-select-option>
<a-select-option value="wecom">企业微信机器人</a-select-option>
<a-select-option value="feishu">飞书机器人</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="机器人名称"
name="name"
:rules="[{ required: true, message: '请输入机器人名称' }]"
>
<a-input
v-model:value="robotForm.name"
placeholder="请输入机器人名称"
:maxLength="50"
/>
</a-form-item>
<a-form-item
label="Webhook地址"
name="webhook"
:rules="[{ required: true, message: '请输入Webhook地址' }]"
>
<a-input
v-model:value="robotForm.webhook"
placeholder="请输入Webhook地址"
/>
</a-form-item>
<a-form-item
label="安全设置"
name="security_type"
:rules="[{ required: true, message: '请选择安全设置类型' }]"
>
<a-select
v-model:value="robotForm.security_type"
placeholder="请选择安全设置类型"
>
<a-select-option value="none"></a-select-option>
<a-select-option value="secret">加签密钥</a-select-option>
<a-select-option value="keyword">自定义关键词</a-select-option>
<a-select-option value="ip">IP地址()</a-select-option>
</a-select>
</a-form-item>
<template v-if="robotForm.security_type === 'secret'">
<a-form-item
label="加签密钥"
name="secret"
:rules="[{ required: true, message: '请输入加签密钥' }]"
>
<a-input
v-model:value="robotForm.secret"
placeholder="请输入加签密钥"
/>
</a-form-item>
</template>
<template v-if="robotForm.security_type === 'keyword'">
<a-form-item
label="自定义关键词"
name="keywords"
:rules="[{ required: true, message: '请添加关键词' }]"
>
<a-select
v-model:value="robotForm.keywords"
mode="tags"
placeholder="请输入关键词后按回车添加"
:token-separators="[',']"
/>
</a-form-item>
</template>
<template v-if="robotForm.security_type === 'ip'">
<a-form-item
label="IP白名单"
name="ip_list"
:rules="[{ required: true, message: '请添加IP地址' }]"
>
<a-select
v-model:value="robotForm.ip_list"
mode="tags"
placeholder="请输入IP地址后按回车添加"
:token-separators="[',']"
/>
<div class="form-item-help">
支持IP地址或IP地址段例如: 192.168.1.1 192.168.1.1/24
</div>
</a-form-item>
</template>
<a-form-item
label="备注"
name="remark"
>
<a-textarea
v-model:value="robotForm.remark"
placeholder="请输入备注信息"
:rows="4"
:maxLength="200"
/>
</a-form-item>
</a-form>
<template #footer>
<div style="text-align: left">
<a-space>
<a-button @click="handleDrawerClose">取消</a-button>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">
确定
</a-button>
</a-space>
</div>
</template>
</a-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import axios from 'axios';
import { checkPermission, hasFunctionPermission } from '../../utils/permission';
const drawerVisible = ref(false);
const submitLoading = ref(false);
const loading = ref(false);
const isEdit = ref(false);
const robotList = ref([]);
// 表格列定义
const robotColumns = [
{
title: '类型',
dataIndex: 'type',
key: 'type',
customRender: ({ text }) => getRobotTypeText(text)
},
{
title: '机器人名称',
dataIndex: 'name',
key: 'name',
},
{
title: 'Webhook地址',
dataIndex: 'webhook',
key: 'webhook',
ellipsis: true,
},
{
title: '安全设置',
dataIndex: 'security_type',
key: 'security_type',
customRender: ({ text }) => getSecurityTypeText(text)
},
{
title: '备注',
dataIndex: 'remark',
key: 'remark',
ellipsis: true,
},
{
title: '创建时间',
dataIndex: 'create_time',
key: 'create_time',
},
{
title: '操作',
key: 'action',
fixed: 'right',
},
];
// 添加/编辑机器人表单
const formRef = ref();
const robotForm = reactive({
robot_id: '',
type: undefined,
name: '',
webhook: '',
security_type: 'none',
secret: '',
keywords: [],
ip_list: [],
remark: '',
});
// 获取机器人列表
const loadRobotList = async () => {
if (!checkPermission('notification', 'view')) {
return;
}
try {
loading.value = true;
const token = localStorage.getItem('token');
const response = await axios.get('/api/notification/robots/', {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
robotList.value = response.data.data;
} else {
message.error(response.data.message || '获取机器人列表失败');
}
} catch (error) {
console.error('Load robot list error:', error);
message.error('获取机器人列表失败');
} finally {
loading.value = false;
}
};
// 获取机器人类型文本
const getRobotTypeText = (type) => {
const types = {
dingtalk: '钉钉',
wecom: '企业微信',
feishu: '飞书',
};
return types[type] || type;
};
// 获取安全设置类型文本
const getSecurityTypeText = (type) => {
const types = {
none: '无',
secret: '加签密钥',
keyword: '关键词',
ip: 'IP白名单',
};
return types[type] || type;
};
// 显示添加机器人抽屉
const showAddRobot = () => {
if (!checkPermission('notification', 'create')) {
return;
}
isEdit.value = false;
drawerVisible.value = true;
};
// 显示编辑机器人抽屉
const handleEdit = (record) => {
if (!checkPermission('notification', 'edit')) {
return;
}
isEdit.value = true;
Object.assign(robotForm, {
robot_id: record.robot_id,
type: record.type,
name: record.name,
webhook: record.webhook,
security_type: record.security_type,
secret: record.secret,
keywords: record.keywords || [],
ip_list: record.ip_list || [],
remark: record.remark,
});
drawerVisible.value = true;
};
const handleDrawerClose = () => {
drawerVisible.value = false;
formRef.value?.resetFields();
// 重置表单
Object.assign(robotForm, {
robot_id: '',
type: undefined,
name: '',
webhook: '',
security_type: 'none',
secret: '',
keywords: [],
ip_list: [],
remark: '',
});
};
const handleSubmit = async () => {
try {
await formRef.value.validate();
submitLoading.value = true;
const token = localStorage.getItem('token');
const method = isEdit.value ? 'put' : 'post';
const response = await axios[method]('/api/notification/robots/', robotForm, {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
message.success(isEdit.value ? '更新机器人成功' : '添加机器人成功');
handleDrawerClose();
loadRobotList(); // 重新加载列表
} else {
message.error(response.data.message || (isEdit.value ? '更新机器人失败' : '添加机器人失败'));
}
} catch (error) {
console.error(isEdit.value ? 'Update robot error:' : 'Add robot error:', error);
message.error(isEdit.value ? '更新机器人失败' : '添加机器人失败');
} finally {
submitLoading.value = false;
}
};
// 测试机器人
const handleTestRobot = async (robot) => {
// 测试权限
if (!checkPermission('notification', 'test')) {
return;
}
try {
const token = localStorage.getItem('token');
const hide = message.loading('正在发送测试消息...', 0);
const response = await axios.post('/api/notification/robots/test/', {
robot_id: robot.robot_id
}, {
headers: { 'Authorization': token }
});
hide();
if (response.data.code === 200) {
message.success('测试消息发送成功');
} else {
message.error(response.data.message || '发送测试消息失败');
}
} catch (error) {
console.error('Test robot error:', error);
message.error('发送测试消息失败');
}
};
// 删除机器人
const handleDeleteRobot = async (robot) => {
// 删除权限
if (!checkPermission('notification', 'delete')) {
return;
}
try {
const token = localStorage.getItem('token');
const response = await axios.delete('/api/notification/robots/', {
headers: { 'Authorization': token },
data: { robot_id: robot.robot_id }
});
if (response.data.code === 200) {
message.success('删除成功');
loadRobotList(); // 重新加载列表
} else {
message.error(response.data.message || '删除失败');
}
} catch (error) {
console.error('Delete robot error:', error);
message.error('删除失败');
}
};
// 页面加载时获取机器人列表
onMounted(() => {
loadRobotList();
});
</script>
<style scoped>
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
}
:deep(.ant-card) {
border-radius: 4px;
}
:deep(.ant-drawer-header) {
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
}
:deep(.ant-drawer-body) {
padding: 24px;
}
:deep(.ant-drawer-footer) {
border-top: 1px solid #f0f0f0;
padding: 10px 16px;
}
:deep(.ant-table-thead > tr > th) {
background: #fafafa;
}
.form-item-help {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,501 @@
<template>
<div class="user-list">
<div class="page-header">
<a-row justify="space-between" align="middle">
<a-col>
<h2>用户管理</h2>
</a-col>
<a-col>
<a-button type="primary" @click="showCreateModal">
<template #icon><UserAddOutlined /></template>
添加用户
</a-button>
</a-col>
</a-row>
</div>
<a-card>
<a-table
:columns="columns"
:data-source="users"
:loading="loading"
:pagination="{
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: total => `共 ${total} 条记录`
}"
rowKey="user_id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<span :style="{ color: record.status === 0 ? '#ff4d4f' : 'inherit' }">
{{ record.status === 1 ? '正常' : '锁定' }}
</span>
</template>
<template v-else-if="column.key === 'roles'">
<a-space>
<a-tag v-for="role in record.roles" :key="role.role_id" color="">
{{ role.name }}
</a-tag>
</a-space>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="showEditModal(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此用户吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record)"
>
<a-button type="link" danger>删除</a-button>
</a-popconfirm>
<a-divider type="vertical" />
<a-popconfirm
:title="record.status === 1 ? '确定要锁定此用户吗?' : '确定要解锁此用户吗?'"
ok-text="确定"
cancel-text="取消"
@confirm="handleToggleStatus(record)"
>
<a>{{ record.status === 1 ? '锁定' : '解锁' }}</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 创建/编辑用户模态框 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:maskClosable="false"
@ok="handleSubmitForm"
@cancel="resetForm"
:confirm-loading="submitting"
>
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="用户名" name="username">
<a-input v-model:value="formState.username" :disabled="!!formState.user_id" />
</a-form-item>
<a-form-item label="姓名" name="name">
<a-input v-model:value="formState.name" />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formState.email" />
</a-form-item>
<a-form-item label="密码" name="password">
<a-input-password v-model:value="formState.password" />
<div v-if="!!formState.user_id" class="form-help">不修改请留空</div>
</a-form-item>
<a-form-item label="角色" name="role_ids">
<a-select
v-model:value="formState.role_ids"
mode="multiple"
placeholder="请选择角色"
:loading="rolesLoading"
>
<a-select-option v-for="role in roles" :key="role.role_id" :value="role.role_id">
{{ role.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formState.status">
<a-radio :value="1">正常</a-radio>
<a-radio :value="0">锁定</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import { UserAddOutlined } from '@ant-design/icons-vue';
import axios from 'axios';
import { checkPermission } from '../../utils/permission';
// 表格列定义
const columns = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
},
{
title: '角色',
dataIndex: 'roles',
key: 'roles',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
},
{
title: '最后登录时间',
dataIndex: 'login_time',
key: 'login_time',
},
{
title: '操作',
key: 'action',
},
];
// 数据相关的响应式变量
const users = ref([]);
const roles = ref([]);
const loading = ref(false);
const rolesLoading = ref(false);
const formRef = ref(null);
// 安全配置
const securityConfig = ref({
min_password_length: 8,
password_complexity: ['lowercase', 'number']
});
// 获取安全配置
const fetchSecurityConfig = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/system/security/', {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
securityConfig.value = response.data.data;
}
} catch (error) {
console.error('获取安全配置失败:', error);
}
};
// 获取用户列表
const fetchUsers = async () => {
loading.value = true;
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/users/', {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
users.value = response.data.data;
} else {
message.error(response.data.message || '获取用户列表失败');
}
} catch (error) {
console.error('获取用户列表失败:', error);
message.error('获取用户列表失败');
} finally {
loading.value = false;
}
};
// 获取角色列表
const fetchRoles = async () => {
rolesLoading.value = true;
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/roles/', {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
roles.value = response.data.data;
} else {
message.error(response.data.message || '获取角色列表失败');
}
} catch (error) {
console.error('获取角色列表失败:', error);
message.error('获取角色列表失败');
} finally {
rolesLoading.value = false;
}
};
// 初始化
onMounted(() => {
fetchSecurityConfig();
fetchUsers();
fetchRoles();
});
const modalVisible = ref(false);
const submitting = ref(false);
const formState = reactive({
user_id: '',
username: '',
name: '',
email: '',
password: '',
role_ids: [],
status: 1
});
// 动态密码验证规则
const passwordValidator = (rule, value) => {
if (formState.user_id && !value) {
return Promise.resolve();
}
if (!value) {
return Promise.reject('请输入密码');
}
const config = securityConfig.value;
// 检查密码长度
if (value.length < config.min_password_length) {
return Promise.reject(`密码长度不能少于${config.min_password_length}`);
}
// 检查密码复杂度
const complexityChecks = {
'uppercase': { pattern: /[A-Z]/, description: '大写字母' },
'lowercase': { pattern: /[a-z]/, description: '小写字母' },
'number': { pattern: /[0-9]/, description: '数字' },
'special': { pattern: /[!@#$%^&*(),.?":{}|<>]/, description: '特殊字符' }
};
const missingRequirements = [];
for (const requirement of config.password_complexity) {
if (complexityChecks[requirement]) {
const { pattern, description } = complexityChecks[requirement];
if (!pattern.test(value)) {
missingRequirements.push(description);
}
}
}
if (missingRequirements.length > 0) {
return Promise.reject(`密码必须包含: ${missingRequirements.join(', ')}`);
}
return Promise.resolve();
};
const formRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度必须在3-20个字符之间', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }
],
password: [
{ validator: passwordValidator, trigger: 'blur' }
],
role_ids: [
{ required: true, type: 'array', message: '请选择至少一个角色', trigger: 'change' }
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' }
]
};
// 计算属性
const modalTitle = computed(() => {
return formState.user_id ? '编辑用户' : '添加用户';
});
// 显示创建模态框
const showCreateModal = () => {
if (!checkPermission('user', 'create')) {
message.error('你没有权限执行此操作');
return;
}
resetForm();
modalVisible.value = true;
};
// 显示编辑模态框
const showEditModal = (record) => {
if (!checkPermission('user', 'edit')) {
message.error('你没有权限执行此操作');
return;
}
resetForm();
formState.user_id = record.user_id;
formState.username = record.username;
formState.name = record.name;
formState.email = record.email;
formState.status = record.status;
formState.role_ids = record.roles.map(role => role.role_id);
modalVisible.value = true;
};
// 重置表单
const resetForm = () => {
if (formRef.value) {
formRef.value.resetFields();
}
Object.assign(formState, {
user_id: '',
username: '',
name: '',
email: '',
password: '',
role_ids: [],
status: 1
});
};
// 提交表单
const handleSubmitForm = () => {
formRef.value.validate().then(async () => {
submitting.value = true;
try {
// 创建或更新用户
const url = formState.user_id ? '/api/users/' : '/api/users/';
const method = formState.user_id ? 'put' : 'post';
// 如果是编辑模式且密码为空,则不发送密码字段
const data = { ...formState };
if (formState.user_id && !formState.password) {
delete data.password;
}
const token = localStorage.getItem('token');
const response = await axios({
method,
url,
data,
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
message.success(formState.user_id ? '更新用户成功' : '创建用户成功');
modalVisible.value = false;
fetchUsers();
} else {
message.error(response.data.message || '操作失败');
}
} catch (error) {
console.error('操作失败:', error);
message.error('操作失败');
} finally {
submitting.value = false;
}
}).catch(errors => {
console.log('表单验证失败:', errors);
});
};
// 删除用户
const handleDelete = async (record) => {
// 检查用户是否有权限删除用户
if (!checkPermission('user', 'delete')) {
message.error('你没有权限执行此操作');
return;
}
try {
const token = localStorage.getItem('token');
const response = await axios({
method: 'delete',
url: '/api/users/',
data: {
user_id: record.user_id
},
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
message.success('删除用户成功');
fetchUsers();
} else {
message.error(response.data.message || '删除用户失败');
}
} catch (error) {
console.error('删除用户失败:', error);
message.error('删除用户失败');
}
};
// 切换用户状态
const handleToggleStatus = async (record) => {
// 检查用户是否有权限切换用户状态
if (!checkPermission('user', 'toggle_status')) {
message.error('你没有权限执行此操作');
return;
}
try {
const token = localStorage.getItem('token');
const response = await axios({
method: 'put',
url: '/api/users/',
data: {
user_id: record.user_id,
status: record.status === 1 ? 0 : 1
},
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
message.success(record.status === 1 ? '用户已锁定' : '用户已解锁');
fetchUsers();
} else {
message.error(response.data.message || '更新用户状态失败');
}
} catch (error) {
console.error('更新用户状态失败:', error);
message.error('更新用户状态失败');
}
};
</script>
<style scoped>
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
}
.form-help {
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,788 @@
<template>
<div class="user-role">
<div class="page-header">
<a-row justify="space-between" align="middle">
<a-col>
<h2>角色管理</h2>
</a-col>
<a-col>
<a-button type="primary" @click="showCreateModal">
<template #icon><PlusOutlined /></template>
添加角色
</a-button>
</a-col>
</a-row>
</div>
<a-card>
<a-table
:columns="columns"
:data-source="roles"
:loading="loading"
:pagination="{
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: total => `共 ${total} 条记录`
}"
rowKey="role_id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<a @click="showEditModal(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此角色吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record)"
>
<a>删除</a>
</a-popconfirm>
<a-divider type="vertical" />
<a @click="showPermissionModal(record)">权限配置</a>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 创建/编辑角色模态框 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:maskClosable="false"
@ok="handleSubmitForm"
@cancel="resetForm"
:confirm-loading="submitting"
>
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="角色名称" name="name">
<a-input v-model:value="formState.name" placeholder="请输入角色名称" />
</a-form-item>
<a-form-item label="角色描述" name="description">
<a-textarea
v-model:value="formState.description"
placeholder="请输入角色描述"
:rows="4"
show-count
:maxlength="200"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 权限配置模态框 -->
<a-modal
v-model:open="permissionModalVisible"
title="角色权限配置"
width="800px"
:maskClosable="false"
:footer="null"
>
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="menu" tab="菜单权限">
<a-tree
v-model:checkedKeys="menuCheckedKeys"
:treeData="menuTreeData"
checkable
:defaultExpandAll="true"
/>
</a-tab-pane>
<a-tab-pane key="function" tab="功能权限">
<div class="function-permissions">
<a-collapse v-model:activeKey="activeCollapseKeys">
<a-collapse-panel
v-for="module in functionModules"
:key="module.key"
:header="module.title"
>
<a-checkbox-group
v-model:value="functionCheckedKeys[module.key]"
:options="module.permissions"
/>
</a-collapse-panel>
</a-collapse>
</div>
</a-tab-pane>
<a-tab-pane key="data" tab="数据权限">
<div class="data-permissions">
<a-form :model="dataPermissionForm" layout="vertical">
<a-form-item label="项目权限">
<a-radio-group v-model:value="dataPermissionForm.project_scope">
<a-radio value="all">所有项目</a-radio>
<a-radio value="custom">自定义项目</a-radio>
</a-radio-group>
<template v-if="dataPermissionForm.project_scope === 'custom'">
<a-spin :spinning="projectsLoading">
<a-select
v-model:value="dataPermissionForm.project_ids"
mode="multiple"
style="width: 100%; margin-top: 16px"
placeholder="请选择项目"
:options="projects.map(p => ({ value: p.project_id, label: p.name }))"
>
<div v-if="!projectsLoading && projects.length === 0" class="empty-message">
暂无项目可选
</div>
</a-select>
</a-spin>
</template>
</a-form-item>
<a-form-item label="环境权限">
<a-radio-group v-model:value="dataPermissionForm.environment_scope">
<a-radio value="all">所有环境</a-radio>
<a-radio value="custom">自定义环境</a-radio>
</a-radio-group>
<template v-if="dataPermissionForm.environment_scope === 'custom'">
<a-spin :spinning="environmentsLoading">
<a-select
v-model:value="dataPermissionForm.environment_types"
mode="multiple"
style="width: 100%; margin-top: 16px"
placeholder="请选择环境类型"
:options="environments.map(e => ({ value: e.type, label: e.name }))"
>
<div v-if="!environmentsLoading && environments.length === 0" class="empty-message">
暂无环境类型可选
</div>
</a-select>
</a-spin>
</template>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
</a-tabs>
<div class="permission-footer">
<a-space>
<a-button @click="permissionModalVisible = false">取消</a-button>
<a-button type="primary" :loading="permissionSubmitting" @click="savePermissions">保存配置</a-button>
</a-space>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import axios from 'axios';
// 表格列定义
const columns = [
{
title: '角色名称',
dataIndex: 'name',
key: 'name',
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
},
{
title: '创建时间',
dataIndex: 'create_time',
key: 'create_time',
},
{
title: '操作',
key: 'action',
},
];
// 数据相关的响应式变量
const roles = ref([]);
const projects = ref([]);
const environments = ref([]);
const loading = ref(false);
const projectsLoading = ref(false);
const environmentsLoading = ref(false);
const formRef = ref(null);
// 获取角色列表
const fetchRoles = async () => {
loading.value = true;
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/roles/', {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
roles.value = response.data.data;
} else {
message.error(response.data.message || '获取角色列表失败');
}
} catch (error) {
console.error('获取角色列表失败:', error);
message.error('获取角色列表失败');
} finally {
loading.value = false;
}
};
// 获取项目列表
const fetchProjects = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/projects/', {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
projects.value = response.data.data;
}
} catch (error) {
console.error('获取项目列表失败:', error);
}
};
// 获取环境列表
const fetchEnvironments = async () => {
environmentsLoading.value = true;
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/environments/types/', {
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
environments.value = response.data.data || [];
} else {
message.error(response.data.message || '获取环境列表失败');
}
} catch (error) {
console.error('获取环境列表失败:', error);
message.error('获取环境列表失败');
} finally {
environmentsLoading.value = false;
}
};
// 表单相关的响应式变量
const modalVisible = ref(false);
const submitting = ref(false);
const formState = reactive({
role_id: '',
name: '',
description: '',
});
const formRules = {
name: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ min: 2, max: 20, message: '角色名称长度必须在2-20个字符之间', trigger: 'blur' }
],
};
// 计算属性
const modalTitle = computed(() => {
return formState.role_id ? '编辑角色' : '添加角色';
});
// 显示创建模态框
const showCreateModal = () => {
resetForm();
modalVisible.value = true;
};
// 显示编辑模态框
const showEditModal = (record) => {
resetForm();
formState.role_id = record.role_id;
formState.name = record.name;
formState.description = record.description;
modalVisible.value = true;
};
// 重置表单
const resetForm = () => {
if (formRef.value) {
formRef.value.resetFields();
}
Object.assign(formState, {
role_id: '',
name: '',
description: '',
});
};
// 提交表单
const handleSubmitForm = () => {
formRef.value.validate().then(async () => {
submitting.value = true;
try {
const url = '/api/roles/';
const method = formState.role_id ? 'put' : 'post';
const token = localStorage.getItem('token');
const response = await axios({
method,
url,
data: formState,
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
message.success(formState.role_id ? '更新角色成功' : '创建角色成功');
modalVisible.value = false;
fetchRoles();
} else {
message.error(response.data.message || '操作失败');
}
} catch (error) {
console.error('操作失败:', error);
message.error('操作失败');
} finally {
submitting.value = false;
}
}).catch(errors => {
console.log('表单验证失败:', errors);
});
};
const handleDelete = async (record) => {
try {
const token = localStorage.getItem('token');
const response = await axios({
method: 'delete',
url: '/api/roles/',
data: {
role_id: record.role_id
},
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
message.success('删除角色成功');
fetchRoles();
} else {
message.error(response.data.message || '删除角色失败');
}
} catch (error) {
console.error('删除角色失败:', error);
message.error('删除角色失败');
}
};
// 权限配置相关
const permissionModalVisible = ref(false);
const permissionSubmitting = ref(false);
const currentRoleId = ref('');
const activeTab = ref('menu');
const activeCollapseKeys = ref(['project', 'build_task', 'build_history', 'system_basic']);
// 菜单权限树
const menuTreeData = [
{
title: '首页',
key: '/dashboard',
},
{
title: '项目管理',
key: '/projects',
children: [
{
title: '项目列表',
key: '/projects/list',
},
],
},
{
title: '构建与部署',
key: '/build',
children: [
{
title: '构建任务',
key: '/build/tasks',
},
{
title: '构建历史',
key: '/build/history',
},
],
},
{
title: '日志与监控',
key: '/logs',
children: [
{
title: '登陆日志',
key: '/logs/login',
},
],
},
{
title: '用户与权限',
key: '/user',
children: [
{
title: '用户管理',
key: '/user/list',
},
{
title: '角色管理',
key: '/user/role',
},
],
},
{
title: '凭证管理',
key: '/credentials',
},
{
title: '环境配置',
key: '/environments',
children: [
{
title: '环境列表',
key: '/environments/list',
},
],
},
{
title: '系统配置',
key: '/system',
children: [
{
title: '基本设置',
key: '/system/basic',
},
],
},
];
// 功能权限模块
const functionModules = [
{
key: 'project',
title: '项目管理',
permissions: [
{ label: '查看项目', value: 'view' },
{ label: '创建项目', value: 'create' },
{ label: '编辑项目', value: 'edit' },
{ label: '删除项目', value: 'delete' },
],
},
{
key: 'build_task',
title: '构建任务管理',
permissions: [
{ label: '查看任务', value: 'view' },
{ label: '创建任务', value: 'create' },
{ label: '编辑任务', value: 'edit' },
{ label: '删除任务', value: 'delete' },
{ label: '执行构建', value: 'execute' },
{ label: '查看日志', value: 'view_log' },
{ label: '禁用任务', value: 'disable' },
],
},
{
key: 'build_history',
title: '构建历史管理',
permissions: [
{ label: '查看构建历史', value: 'view' },
{ label: '查看构建日志', value: 'view_log' },
{ label: '回滚构建版本', value: 'rollback' },
],
},
{
key: 'logs_login',
title: '登录日志管理',
permissions: [
{ label: '查看登录日志', value: 'view' },
],
},
{
key: 'environment',
title: '环境管理',
permissions: [
{ label: '查看环境', value: 'view' },
{ label: '创建环境', value: 'create' },
{ label: '编辑环境', value: 'edit' },
{ label: '删除环境', value: 'delete' },
],
},
{
key: 'credential',
title: '凭证管理',
permissions: [
{ label: '查看凭证', value: 'view' },
{ label: '创建凭证', value: 'create' },
{ label: '编辑凭证', value: 'edit' },
{ label: '删除凭证', value: 'delete' },
],
},
{
key: 'user',
title: '用户管理',
permissions: [
{ label: '查看用户', value: 'view' },
{ label: '创建用户', value: 'create' },
{ label: '编辑用户', value: 'edit' },
{ label: '删除用户', value: 'delete' },
{ label: '启用/禁用用户', value: 'toggle_status' },
{ label: '重置密码', value: 'reset_password' },
],
},
{
key: 'role',
title: '角色管理',
permissions: [
{ label: '查看角色', value: 'view' },
{ label: '创建角色', value: 'create' },
{ label: '编辑角色', value: 'edit' },
{ label: '删除角色', value: 'delete' },
{ label: '分配权限', value: 'assign_permission' },
],
},
{
key: 'system_basic',
title: '基本设置管理',
permissions: [
{ label: '查看基本设置', value: 'view' },
{ label: '编辑安全配置', value: 'edit' },
{ label: '创建通知机器人', value: 'create' },
{ label: '删除通知机器人', value: 'delete' },
{ label: '测试通知机器人', value: 'test' },
],
},
];
// 选中的权限
const menuCheckedKeys = ref(['dashboard', 'project-list']);
const functionCheckedKeys = ref({});
const dataPermissionForm = reactive({
project_scope: 'all',
project_ids: [],
environment_scope: 'all',
environment_types: [],
operations: ['view'],
});
// 初始化
onMounted(() => {
fetchRoles();
fetchProjects();
fetchEnvironments();
});
// 重置权限选择
const resetPermissions = () => {
menuCheckedKeys.value = [];
functionCheckedKeys.value = {};
dataPermissionForm.project_scope = 'all';
dataPermissionForm.project_ids = [];
dataPermissionForm.environment_scope = 'all';
dataPermissionForm.environment_types = [];
};
// 显示权限配置模态框
const showPermissionModal = (role) => {
currentRoleId.value = role.role_id;
resetPermissions();
if (role.permissions) {
try {
let permissionsObj = role.permissions;
if (typeof permissionsObj === 'string') {
try {
permissionsObj = JSON.parse(permissionsObj);
} catch (parseErr) {
console.error('解析权限字符串失败:', parseErr);
message.error('解析权限数据失败');
return;
}
}
if (permissionsObj.menu && Array.isArray(permissionsObj.menu)) {
menuCheckedKeys.value = permissionsObj.menu;
}
if (permissionsObj.function && typeof permissionsObj.function === 'object') {
functionCheckedKeys.value = permissionsObj.function;
}
if (permissionsObj.data && typeof permissionsObj.data === 'object') {
const dataPerms = permissionsObj.data;
if (dataPerms.project_scope) {
dataPermissionForm.project_scope = dataPerms.project_scope;
}
if (dataPerms.project_ids && Array.isArray(dataPerms.project_ids)) {
dataPermissionForm.project_ids = dataPerms.project_ids;
}
if (dataPerms.environment_scope) {
dataPermissionForm.environment_scope = dataPerms.environment_scope;
}
if (dataPerms.environment_types && Array.isArray(dataPerms.environment_types)) {
dataPermissionForm.environment_types = dataPerms.environment_types;
}
if (dataPerms.operations && Array.isArray(dataPerms.operations)) {
dataPermissionForm.operations = dataPerms.operations;
}
}
} catch (error) {
console.error('解析权限数据出错:', error);
message.error('解析权限数据失败');
}
}
// 加载项目和环境数据
if (projects.value.length === 0) {
fetchProjects();
}
if (environments.value.length === 0) {
fetchEnvironments();
}
permissionModalVisible.value = true;
};
// 保存权限配置
const savePermissions = async () => {
permissionSubmitting.value = true;
try {
// 构建权限对象
const permissions = {
menu: menuCheckedKeys.value,
function: functionCheckedKeys.value,
data: {
project_scope: dataPermissionForm.project_scope,
project_ids: dataPermissionForm.project_ids,
environment_scope: dataPermissionForm.environment_scope,
environment_types: dataPermissionForm.environment_types,
operations: dataPermissionForm.operations
}
};
const token = localStorage.getItem('token');
const response = await axios({
method: 'put',
url: '/api/roles/',
data: {
role_id: currentRoleId.value,
permissions: permissions
},
headers: {
'Authorization': token
}
});
if (response.data.code === 200) {
message.success('权限配置保存成功');
permissionModalVisible.value = false;
fetchRoles();
} else {
message.error(response.data.message || '保存权限配置失败');
}
} catch (error) {
console.error('保存权限配置失败:', error);
message.error('保存权限配置失败');
} finally {
permissionSubmitting.value = false;
}
};
</script>
<style scoped>
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
}
:deep(.ant-card) {
border-radius: 4px;
}
.function-permissions {
max-width: 800px;
}
:deep(.ant-collapse) {
border: none;
background: none;
}
:deep(.ant-collapse-item) {
border-radius: 4px;
margin-bottom: 12px;
border: 1px solid #f0f0f0;
}
:deep(.ant-collapse-header) {
background: #fafafa;
}
:deep(.ant-checkbox-group) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
padding: 8px;
}
.data-permissions {
max-width: 800px;
}
:deep(.ant-radio-group) {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 16px;
}
:deep(.ant-form-item) {
margin-bottom: 24px;
}
.permission-footer {
margin-top: 24px;
text-align: right;
border-top: 1px solid #f0f0f0;
padding-top: 16px;
}
.empty-message {
color: #999;
padding: 8px 0;
text-align: center;
}
.mt-2 {
margin-top: 8px;
}
.mt-3 {
margin-top: 12px;
}
</style>

16
web/vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
port: 8000,
host: true,
strictPort: true,
cors: true,
allowedHosts: [
'0.0.0.0',
]
}
})