mirror of
https://github.com/opsre/LiteOps.git
synced 2026-02-06 23:21:31 +08:00
前端开源初始化提交
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -40,4 +40,4 @@ dist-ssr
|
||||
*.xml
|
||||
|
||||
# web
|
||||
/web/
|
||||
# /web/
|
||||
10
README.md
10
README.md
@@ -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)
|
||||
|
||||
@@ -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', # 初始状态为等待中
|
||||
|
||||
@@ -319,7 +319,7 @@ class NotificationTestView(View):
|
||||
|
||||
# 准备测试消息
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
test_message = "这是一条测试消息,如果您收到了这条消息,说明机器人配置正确。"
|
||||
test_message = "这是一条测试消息,如果你收到了这条消息,说明机器人配置正确。"
|
||||
|
||||
# 根据不同类型的机器人发送测试消息
|
||||
try:
|
||||
|
||||
@@ -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) # 确保目录存在,包括父目录
|
||||
@@ -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
2
web/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
# 在开发环境中使用完整路径
|
||||
VITE_API_URL=http://localhost:8900
|
||||
3
web/.vscode/extensions.json
vendored
Normal file
3
web/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal 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
1
web/public/vite.svg
Normal 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
36
web/src/App.vue
Normal 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>
|
||||
125
web/src/assets/css/global.css
Normal file
125
web/src/assets/css/global.css
Normal 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;
|
||||
}
|
||||
43
web/src/assets/css/reset.css
Normal file
43
web/src/assets/css/reset.css
Normal 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;
|
||||
}
|
||||
BIN
web/src/assets/font/PingFang.ttc
Normal file
BIN
web/src/assets/font/PingFang.ttc
Normal file
Binary file not shown.
BIN
web/src/assets/image/liteops-sidebar.png
Normal file
BIN
web/src/assets/image/liteops-sidebar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/src/assets/image/liteops.png
Normal file
BIN
web/src/assets/image/liteops.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/src/assets/image/liteopsv1.png
Normal file
BIN
web/src/assets/image/liteopsv1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
1
web/src/assets/vue.svg
Normal file
1
web/src/assets/vue.svg
Normal 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 |
30
web/src/components/layout/Content.vue
Normal file
30
web/src/components/layout/Content.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<a-layout-content>
|
||||
<div class="content-container">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
min-height: 360px;
|
||||
border-radius: 4px;
|
||||
overflow-y: auto;
|
||||
/* 设置最大高度,内容超出时可以滚动 */
|
||||
max-height: calc(100vh - 112px);
|
||||
}
|
||||
|
||||
:deep(.ant-layout-content) {
|
||||
margin: 24px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
14
web/src/components/layout/Footer.vue
Normal file
14
web/src/components/layout/Footer.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<a-layout-footer style="text-align: center; background-color: #fff;">
|
||||
LiteOps ©2024 Created by 胡图图
|
||||
</a-layout-footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-layout-footer) {
|
||||
padding: 24px 50px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
font-size: 14px;
|
||||
/* background: #f0f2f5; */
|
||||
}
|
||||
</style>
|
||||
297
web/src/components/layout/Header.vue
Normal file
297
web/src/components/layout/Header.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<a-layout-header style="background: #fff; padding: 0; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<a-breadcrumb>
|
||||
<template v-if="breadcrumbItems.length">
|
||||
<a-breadcrumb-item v-for="item in breadcrumbItems" :key="item.path || item.title">
|
||||
<router-link v-if="item.clickable" :to="item.path">{{ item.title }}</router-link>
|
||||
<span v-else>{{ item.title }}</span>
|
||||
</a-breadcrumb-item>
|
||||
</template>
|
||||
<a-breadcrumb-item v-else>
|
||||
<router-link to="/dashboard">首页</router-link>
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-dropdown>
|
||||
<a class="ant-dropdown-link" @click.prevent>
|
||||
<UserOutlined /> {{ userName }}
|
||||
</a>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="profile" @click="handleProfile">个人信息</a-menu-item>
|
||||
<a-menu-item key="logout" @click="handleLogout">退出登录</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<!-- 个人信息弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="profileModalVisible"
|
||||
title="个人信息"
|
||||
width="500px"
|
||||
:footer="null"
|
||||
:maskClosable="true"
|
||||
>
|
||||
<a-spin :spinning="loading">
|
||||
<div class="user-profile-info">
|
||||
<div class="info-item">
|
||||
<span class="info-label">用户名</span>
|
||||
<span class="info-value">{{ userInfo.username }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">姓名</span>
|
||||
<span class="info-value">{{ userInfo.name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">邮箱</span>
|
||||
<span class="info-value">{{ userInfo.email }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">状态</span>
|
||||
<span class="info-value">
|
||||
{{ userInfo.status === 1 ? '正常' : '禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">最后登录</span>
|
||||
<span class="info-value">{{ userInfo.login_time || '暂无记录' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">创建时间</span>
|
||||
<span class="info-value">{{ userInfo.create_time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider /> <!-- 分割线 -->
|
||||
|
||||
<div class="user-profile-roles" v-if="userInfo.roles && userInfo.roles.length > 0">
|
||||
<div class="info-item">
|
||||
<span class="info-label">角色</span>
|
||||
<span class="info-value">
|
||||
<a-space>
|
||||
<span v-for="role in userInfo.roles" :key="role.role_id">
|
||||
{{ role.name }}
|
||||
</span>
|
||||
</a-space>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-profile-roles" v-else>
|
||||
<div class="info-item">
|
||||
<span class="info-label">角色</span>
|
||||
<span class="info-value">暂无角色信息</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { UserOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// 弹窗相关状态
|
||||
const profileModalVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const userInfo = ref({
|
||||
user_id: '',
|
||||
username: '',
|
||||
name: '',
|
||||
email: '',
|
||||
status: 1,
|
||||
roles: [],
|
||||
login_time: '',
|
||||
create_time: '',
|
||||
update_time: ''
|
||||
});
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
const items = [];
|
||||
const matched = route.matched;
|
||||
|
||||
// 如果当前路由是首页,不显示面包屑项
|
||||
if (route.path === '/dashboard' || route.path === '/') {
|
||||
return items;
|
||||
}
|
||||
|
||||
// 首页可以被点击
|
||||
items.push({
|
||||
title: '首页',
|
||||
path: '/dashboard',
|
||||
clickable: true
|
||||
});
|
||||
|
||||
// 定义一级菜单
|
||||
const parentMenus = ['/projects', '/build', '/logs', '/user', '/environments', '/system'];
|
||||
|
||||
matched.forEach((item) => {
|
||||
if (item.path !== '/' && item.meta && item.meta.title) {
|
||||
// 判断是否为一级菜单
|
||||
const isParentMenu = parentMenus.includes(item.path);
|
||||
|
||||
items.push({
|
||||
title: item.meta.title,
|
||||
path: item.path,
|
||||
clickable: !isParentMenu // 只有一级菜单不可点击
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
// 获取用户名
|
||||
const userName = computed(() => {
|
||||
const userInfo = localStorage.getItem('user_info');
|
||||
if (userInfo) {
|
||||
return JSON.parse(userInfo).name;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// 获取用户信息
|
||||
const fetchUserProfile = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get('/api/user/profile/', {
|
||||
headers: {
|
||||
'Authorization': token
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.code === 200) {
|
||||
userInfo.value = response.data.data;
|
||||
} else {
|
||||
message.error(response.data.message || '获取用户信息失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
message.error('获取用户信息失败,请稍后重试');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 显示个人信息弹窗
|
||||
const handleProfile = () => {
|
||||
profileModalVisible.value = true;
|
||||
fetchUserProfile();
|
||||
};
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.post('/api/logout/', {}, {
|
||||
headers: {
|
||||
'Authorization': token
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.code === 200) {
|
||||
message.success('退出成功');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user_info');
|
||||
router.push('/login');
|
||||
} else {
|
||||
message.error(response.data.message || '退出失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('退出失败,请稍后重试');
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
:deep(.ant-layout-header) {
|
||||
padding: 0;
|
||||
height: 64px;
|
||||
line-height: 64px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
|
||||
:deep(.ant-dropdown-link) {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.ant-breadcrumb a) {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
:deep(.ant-breadcrumb a:hover) {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
:deep(.ant-breadcrumb span) {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* 弹窗相关样式 */
|
||||
:deep(.ant-divider) {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
:deep(.ant-modal-body) {
|
||||
padding: 24px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.user-profile-info,
|
||||
.user-profile-roles {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
width: 80px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
28
web/src/components/layout/MainLayout.vue
Normal file
28
web/src/components/layout/MainLayout.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<a-layout style="min-height: 100vh">
|
||||
<Sidebar />
|
||||
<a-layout>
|
||||
<Header />
|
||||
<Content />
|
||||
<Footer />
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Sidebar from './Sidebar.vue';
|
||||
import Header from './Header.vue';
|
||||
import Content from './Content.vue';
|
||||
import Footer from './Footer.vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-layout) {
|
||||
min-height: 100vh;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
.site-layout .site-layout-background {
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
241
web/src/components/layout/Sidebar.vue
Normal file
241
web/src/components/layout/Sidebar.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<a-layout-sider v-model:collapsed="collapsed" theme="light" collapsible>
|
||||
<div class="logo">
|
||||
<img v-if="collapsed" src="../../assets/image/liteops.png" alt="Logo" />
|
||||
<img v-else src="../../assets/image/liteops-sidebar.png" alt="Logo" />
|
||||
</div>
|
||||
<div class="menu-container">
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:openKeys="openKeys"
|
||||
mode="inline"
|
||||
@click="handleMenuClick"
|
||||
v-if="permissionStore.initialized"
|
||||
>
|
||||
<!-- 首页 -->
|
||||
<a-menu-item key="/dashboard" v-if="hasMenuPermission('/dashboard')">
|
||||
<template #icon>
|
||||
<DashboardOutlined />
|
||||
</template>
|
||||
<span>首页</span>
|
||||
</a-menu-item>
|
||||
|
||||
<!-- 项目管理 -->
|
||||
<a-sub-menu key="/projects" v-if="hasAnySubMenuPermission('/projects')">
|
||||
<template #icon>
|
||||
<ProjectOutlined />
|
||||
</template>
|
||||
<template #title>项目管理</template>
|
||||
<a-menu-item key="/projects/list" v-if="hasMenuPermission('/projects/list')">项目列表</a-menu-item>
|
||||
</a-sub-menu>
|
||||
|
||||
<!-- 构建与部署 -->
|
||||
<a-sub-menu key="/build" v-if="hasAnySubMenuPermission('/build')">
|
||||
<template #icon>
|
||||
<BuildOutlined />
|
||||
</template>
|
||||
<template #title>构建与部署</template>
|
||||
<a-menu-item key="/build/tasks" v-if="hasMenuPermission('/build/tasks')">构建任务</a-menu-item>
|
||||
<a-menu-item key="/build/history" v-if="hasMenuPermission('/build/history')">构建历史</a-menu-item>
|
||||
</a-sub-menu>
|
||||
|
||||
<!-- 日志与监控 -->
|
||||
<a-sub-menu key="/logs" v-if="hasAnySubMenuPermission('/logs')">
|
||||
<template #icon>
|
||||
<FileSearchOutlined />
|
||||
</template>
|
||||
<template #title>日志与监控</template>
|
||||
<a-menu-item key="/logs/login" v-if="hasMenuPermission('/logs/login')">登陆日志</a-menu-item>
|
||||
</a-sub-menu>
|
||||
|
||||
<!-- 用户与权限管理 -->
|
||||
<a-sub-menu key="/user" v-if="hasAnySubMenuPermission('/user')">
|
||||
<template #icon>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
<template #title>用户与权限</template>
|
||||
<a-menu-item key="/user/list" v-if="hasMenuPermission('/user/list')">用户管理</a-menu-item>
|
||||
<a-menu-item key="/user/role" v-if="hasMenuPermission('/user/role')">角色管理</a-menu-item>
|
||||
</a-sub-menu>
|
||||
|
||||
<!-- 凭证管理 -->
|
||||
<a-menu-item key="/credentials" v-if="hasMenuPermission('/credentials')">
|
||||
<template #icon>
|
||||
<KeyOutlined />
|
||||
</template>
|
||||
<span>凭证管理</span>
|
||||
</a-menu-item>
|
||||
|
||||
<!-- 环境配置 -->
|
||||
<a-sub-menu key="/environments" v-if="hasAnySubMenuPermission('/environments')">
|
||||
<template #icon>
|
||||
<CloudServerOutlined />
|
||||
</template>
|
||||
<template #title>环境配置</template>
|
||||
<a-menu-item key="/environments/list" v-if="hasMenuPermission('/environments/list')">环境列表</a-menu-item>
|
||||
</a-sub-menu>
|
||||
|
||||
|
||||
|
||||
<!-- 系统配置 -->
|
||||
<a-sub-menu key="/system" v-if="hasAnySubMenuPermission('/system')">
|
||||
<template #icon>
|
||||
<SettingOutlined />
|
||||
</template>
|
||||
<template #title>系统配置</template>
|
||||
<a-menu-item key="/system/basic" v-if="hasMenuPermission('/system/basic')">基本设置</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</a-menu>
|
||||
<div v-else class="menu-loading">
|
||||
<a-spin tip="加载菜单权限中..." />
|
||||
</div>
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { hasMenuPermission, hasAnySubMenuPermission, permissionStore } from '../../utils/permission';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
ProjectOutlined,
|
||||
BuildOutlined,
|
||||
FileSearchOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
KeyOutlined,
|
||||
CloudServerOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const collapsed = ref(false);
|
||||
const selectedKeys = ref(['/dashboard']);
|
||||
const openKeys = ref([]);
|
||||
|
||||
// 获取当前路由的父级路径
|
||||
const getParentPath = (path) => {
|
||||
const pathParts = path.split('/');
|
||||
return pathParts.length > 2 ? `/${pathParts[1]}` : path;
|
||||
};
|
||||
|
||||
// 初始化菜单状态
|
||||
onMounted(() => {
|
||||
const currentPath = route.path;
|
||||
selectedKeys.value = [currentPath];
|
||||
|
||||
if (currentPath !== '/dashboard') {
|
||||
const parentPath = getParentPath(currentPath);
|
||||
openKeys.value = [parentPath];
|
||||
}
|
||||
});
|
||||
|
||||
// 监听路由变化
|
||||
watch(() => route.path, (newPath) => {
|
||||
selectedKeys.value = [newPath];
|
||||
const parentPath = getParentPath(newPath);
|
||||
|
||||
if (!openKeys.value.includes(parentPath) && newPath !== '/dashboard') {
|
||||
openKeys.value = [parentPath];
|
||||
}
|
||||
});
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
router.push(key);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logo {
|
||||
height: 65px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/* padding: 8px; */
|
||||
/* overflow: hidden; */
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 65px;
|
||||
/* height: auto; */
|
||||
/* max-height: 50px; */
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
height: calc(100vh - 64px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-layout-sider) {
|
||||
box-shadow: 2px 0 8px 0 rgba(29, 35, 41, 0.05);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
:deep(.ant-layout-sider-collapsed .logo) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
:deep(.ant-layout-sider-collapsed .logo img) {
|
||||
max-width: 32px;
|
||||
}
|
||||
|
||||
:deep(.ant-layout-sider-collapsed .ant-menu-item .anticon),
|
||||
:deep(.ant-layout-sider-collapsed .ant-menu-submenu-title .anticon) {
|
||||
margin-right: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item) {
|
||||
height: 40px !important;
|
||||
line-height: 40px !important;
|
||||
margin: 4px 0 !important;
|
||||
padding-left: 24px !important;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-submenu-title) {
|
||||
height: 40px !important;
|
||||
line-height: 40px !important;
|
||||
margin: 4px 0 !important;
|
||||
padding-left: 24px !important;
|
||||
}
|
||||
|
||||
/* 子菜单项的缩进一致 */
|
||||
:deep(.ant-menu-sub .ant-menu-item) {
|
||||
padding-left: 48px !important;
|
||||
}
|
||||
|
||||
/* 图标对齐 */
|
||||
:deep(.ant-menu-item .anticon),
|
||||
:deep(.ant-menu-submenu-title .anticon) {
|
||||
min-width: 14px;
|
||||
margin-right: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.menu-container::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.menu-container::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.menu-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.menu-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
24
web/src/main.js
Normal file
24
web/src/main.js
Normal 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
246
web/src/router/index.js
Normal 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
75
web/src/style.css
Normal 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
222
web/src/utils/permission.js
Normal 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;
|
||||
};
|
||||
682
web/src/views/build/BuildHistory.vue
Normal file
682
web/src/views/build/BuildHistory.vue
Normal 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>
|
||||
300
web/src/views/build/BuildTaskDetail.vue
Normal file
300
web/src/views/build/BuildTaskDetail.vue
Normal 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>
|
||||
879
web/src/views/build/BuildTaskEdit.vue
Normal file
879
web/src/views/build/BuildTaskEdit.vue
Normal 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>
|
||||
1701
web/src/views/build/BuildTasks.vue
Normal file
1701
web/src/views/build/BuildTasks.vue
Normal file
File diff suppressed because it is too large
Load Diff
101
web/src/views/build/components/BuildNotification.vue
Normal file
101
web/src/views/build/components/BuildNotification.vue
Normal 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>
|
||||
123
web/src/views/build/components/CodeEditor.vue
Normal file
123
web/src/views/build/components/CodeEditor.vue
Normal 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>
|
||||
311
web/src/views/build/components/FullscreenLogViewer.vue
Normal file
311
web/src/views/build/components/FullscreenLogViewer.vue
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
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>
|
||||
226
web/src/views/build/components/SystemVariablesList.vue
Normal file
226
web/src/views/build/components/SystemVariablesList.vue
Normal 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>
|
||||
904
web/src/views/credentials/CredentialsList.vue
Normal file
904
web/src/views/credentials/CredentialsList.vue
Normal 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>
|
||||
895
web/src/views/dashboard/Dashboard.vue
Normal file
895
web/src/views/dashboard/Dashboard.vue
Normal 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>
|
||||
290
web/src/views/environments/EnvironmentDetail.vue
Normal file
290
web/src/views/environments/EnvironmentDetail.vue
Normal 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>
|
||||
459
web/src/views/environments/EnvironmentList.vue
Normal file
459
web/src/views/environments/EnvironmentList.vue
Normal 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>
|
||||
269
web/src/views/login/LoginView.vue
Normal file
269
web/src/views/login/LoginView.vue
Normal 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>
|
||||
147
web/src/views/logs/LoginLogDetail.vue
Normal file
147
web/src/views/logs/LoginLogDetail.vue
Normal 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>
|
||||
219
web/src/views/logs/LoginLogs.vue
Normal file
219
web/src/views/logs/LoginLogs.vue
Normal 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>
|
||||
312
web/src/views/projects/ProjectDetail.vue
Normal file
312
web/src/views/projects/ProjectDetail.vue
Normal 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>
|
||||
239
web/src/views/projects/ProjectEdit.vue
Normal file
239
web/src/views/projects/ProjectEdit.vue
Normal 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>
|
||||
428
web/src/views/projects/ProjectList.vue
Normal file
428
web/src/views/projects/ProjectList.vue
Normal 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>
|
||||
670
web/src/views/system/BasicSettings.vue
Normal file
670
web/src/views/system/BasicSettings.vue
Normal 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>
|
||||
476
web/src/views/system/Notification.vue
Normal file
476
web/src/views/system/Notification.vue
Normal 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>
|
||||
501
web/src/views/user/UserList.vue
Normal file
501
web/src/views/user/UserList.vue
Normal 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>
|
||||
788
web/src/views/user/UserRole.vue
Normal file
788
web/src/views/user/UserRole.vue
Normal 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
16
web/vite.config.js
Normal 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',
|
||||
]
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user