push front-end code

This commit is contained in:
2025-10-16 15:59:55 +08:00
parent f49e05d9d8
commit 827c71c81a
230 changed files with 56207 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

3
orion-ops-vue/.env Normal file
View File

@@ -0,0 +1,3 @@
VUE_APP_BASE_API='/orion/api'
VUE_APP_WATERMARK=true
VUE_APP_DEMO_MODE=false

3
orion-ops-vue/.env.dev Normal file
View File

@@ -0,0 +1,3 @@
VUE_APP_BASE_API='/orion/api'
VUE_APP_WATERMARK=true
VUE_APP_DEMO_MODE=false

View File

@@ -0,0 +1,3 @@
VUE_APP_BASE_API='/orion/api'
VUE_APP_WATERMARK=true
VUE_APP_DEMO_MODE=false

23
orion-ops-vue/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

View File

@@ -0,0 +1,60 @@
{
"name": "orion-ops-vue",
"version": "1.3.1",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --mode dev",
"build": "vue-cli-service build --mode production",
"build:demo": "set VUE_APP_DEMO_MODE=true&& npm run build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@antv/g2": "^4.1.48",
"ant-design-vue": "^1.7.8",
"asciinema-player": "^3.0.1",
"axios": "^0.23.0",
"core-js": "^3.8.3",
"js-md5": "^0.7.3",
"lodash": "^4.17.21",
"vue": "^2.6.14",
"vue-router": "^3.5.1",
"vue2-ace-editor": "^0.0.15",
"xterm": "^4.14.1",
"xterm-addon-fit": "^0.5.0",
"xterm-addon-search": "^0.8.1",
"xterm-addon-web-links": "^0.4.0"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"less": "^4.0.0",
"less-loader": "^8.0.0",
"vue-template-compiler": "^2.6.14"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
},
"eslintIgnore": ["*"],
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>orion-ops</title>
</head>
<body>
<div id="app"></div>
</body>
</html>

25
orion-ops-vue/src/App.vue Normal file
View File

@@ -0,0 +1,25 @@
<template>
<a-config-provider :locale="locale">
<div id="app">
<router-view/>
</div>
</a-config-provider>
</template>
<script>
import '../src/css/common.less'
import '../src/css/layout.less'
import '../src/css/table.less'
import '../src/css/component.less'
import zhCN from 'ant-design-vue/lib/locale-provider/zh_CN'
export default {
name: 'App',
data: function() {
return {
locale: zhCN
}
}
}
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 32 32" style="enable-background:new 0 0 32 32;" xml:space="preserve">
<style type="text/css">
.cls-1{fill:url(#SVGID_1_);}
.cls-2{fill:url(#SVGID_2_);}
</style>
<linearGradient id="SVGID_1_" x1="0.32" y1="15.03" x2="20.16" y2="15.03" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#23b6b6"/>
<stop offset="1" stop-color="#189c98"/>
</linearGradient>
<linearGradient id="SVGID_2_" x1="11.84" y1="16.97" x2="31.68" y2="16.97" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#08589b"/>
<stop offset="1" stop-color="#2167b2"/>
</linearGradient>
<path class="cls-1"
d="M20,17.37a1.56,1.56,0,0,0-2.13-.65l-7.56,4.07A4.65,4.65,0,0,1,4,18.91H4a4.65,4.65,0,0,1,1.88-6.3L13.6,8.44a1.56,1.56,0,0,0,.64-2.1h0a1.56,1.56,0,0,0-2.13-.65l-8,4.3a7.24,7.24,0,0,0-2.94,9.82l.51.94a7.24,7.24,0,0,0,9.82,2.94l7.81-4.22a1.56,1.56,0,0,0,.65-2.1Z"/>
<path class="cls-2"
d="M12,14.63a1.56,1.56,0,0,0,2.13.65l7.56-4.07A4.65,4.65,0,0,1,28,13.09h0a4.65,4.65,0,0,1-1.88,6.3L18.4,23.56a1.56,1.56,0,0,0-.64,2.1h0a1.56,1.56,0,0,0,2.13.65l8-4.3a7.24,7.24,0,0,0,2.94-9.82l-.51-.94a7.24,7.24,0,0,0-9.82-2.94l-7.81,4.22a1.56,1.56,0,0,0-.65,2.1Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,154 @@
<template>
<a-modal v-model="visible"
:title="title"
:width="500"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="check"
@cancel="close">
<a-spin :spinning="loading">
<a-form :form="form" v-bind="layout">
<a-form-item label="key">
<a-input v-decorator="decorators.key" :disabled="id != null" allowClear/>
</a-form-item>
<a-form-item label="value" style="margin-bottom: 12px;">
<a-textarea v-decorator="decorators.value" :autoSize="{minRows: 4}" allowClear/>
</a-form-item>
<a-form-item label="描述" style="margin-bottom: 0;">
<a-textarea v-decorator="decorators.description" allowClear/>
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>
<script>
import { pick } from 'lodash'
const layout = {
labelCol: { span: 3 },
wrapperCol: { span: 20 }
}
function getDecorators() {
return {
key: ['key', {
rules: [{
required: true,
message: '请输入key'
}, {
max: 128,
message: 'key长度不能大于128位'
}]
}],
value: ['value', {
rules: [{
required: true,
message: '请输入value'
}, {
max: 2048,
message: 'value长度不能大于2048位'
}]
}],
description: ['description', {
rules: [{
max: 64,
message: '描述长度不能大于64位'
}]
}]
}
}
export default {
name: 'AddAppEnvModal',
data: function() {
return {
id: null,
visible: false,
title: null,
loading: false,
record: null,
appId: null,
profileId: null,
layout,
decorators: getDecorators.call(this),
form: this.$form.createForm(this)
}
},
methods: {
add(appId, profileId) {
this.title = '新增变量'
this.appId = appId
this.profileId = profileId
this.initRecord({})
},
update(id) {
this.title = '修改变量'
this.$api.getAppEnvDetail({ id })
.then(({ data }) => {
this.initRecord(data)
})
},
initRecord(row) {
this.form.resetFields()
this.visible = true
this.id = row.id
this.record = pick(Object.assign({}, row), 'key', 'value', 'description')
this.$nextTick(() => {
this.form.setFieldsValue(this.record)
})
},
check() {
this.loading = true
this.form.validateFields((err, values) => {
if (err) {
this.loading = false
return
}
this.submit(values)
})
},
async submit(values) {
let res
try {
if (!this.id) {
// 添加
res = await this.$api.addAppEnv({
...values,
appId: this.appId,
profileId: this.profileId
})
} else {
// 修改
res = await this.$api.updateAppEnv({
...values,
id: this.id
})
}
if (!this.id) {
this.$message.success('添加成功')
this.$emit('added', res.data)
} else {
this.$message.success('修改成功')
this.$emit('updated', res.data)
}
this.close()
} catch (e) {
// ignore
}
this.loading = false
},
close() {
this.visible = false
this.loading = false
this.appId = null
this.profileId = null
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,176 @@
<template>
<a-spin :spinning="loading">
<a-form :form="form" v-bind="layout">
<a-form-item label="名称" hasFeedback>
<a-input v-decorator="decorators.name" allowClear/>
</a-form-item>
<a-form-item label="唯一标识" hasFeedback>
<a-input v-decorator="decorators.tag" allowClear/>
</a-form-item>
<a-form-item label="版本仓库">
<a-select placeholder="请选择" v-decorator="decorators.repoId" style="width: calc(100% - 45px)" allowClear>
<a-select-option v-for="repo in repoList" :key="repo.id" :value="repo.id">
<span>{{ repo.name }}</span>
</a-select-option>
</a-select>
<a class="reload-repo" title="刷新" @click="getRepositoryList">
<a-icon type="reload"/>
</a>
</a-form-item>
<a-form-item label="描述" style="margin-bottom: 0;">
<a-textarea v-decorator="decorators.description" allowClear/>
</a-form-item>
</a-form>
</a-spin>
</template>
<script>
import { pick } from 'lodash'
import { REPOSITORY_STATUS } from '@/lib/enum'
function getDecorators() {
return {
name: ['name', {
rules: [{
required: true,
message: '请输入名称'
}, {
max: 32,
message: '名称长度不能大于32位'
}]
}],
tag: ['tag', {
rules: [{
required: true,
message: '请输入唯一标识'
}, {
max: 32,
message: '唯一标识长度不能大于32位'
}]
}],
repoId: ['repoId'],
description: ['description', {
rules: [{
max: 64,
message: '描述长度不能大于64位'
}]
}]
}
}
export default {
name: 'AddAppForm',
props: {
layout: {
type: Object,
default: () => {
return {
labelCol: { span: 4 },
wrapperCol: { span: 17 }
}
}
}
},
data: function() {
return {
id: null,
loading: false,
record: null,
repoList: [],
decorators: getDecorators.call(this),
form: this.$form.createForm(this)
}
},
methods: {
add() {
this.initRecord({})
},
update(id) {
this.$api.getAppDetail({ id })
.then(({ data }) => {
this.initRecord(data)
})
},
initRecord(row) {
this.form.resetFields()
this.id = row.id
if (!row.repoId) {
row.repoId = undefined
}
this.record = pick(Object.assign({}, row), 'name', 'tag', 'repoId', 'description')
this.$nextTick(() => {
this.form.setFieldsValue(this.record)
})
},
async getRepositoryList() {
this.$api.getRepositoryList({
limit: 10000,
status: REPOSITORY_STATUS.OK.value
}).then(({ data }) => {
if (data && data.rows && data.rows.length) {
this.repoList = data.rows.map(s => {
return {
id: s.id,
name: s.name
}
})
}
})
},
check() {
this.loading = true
this.$emit('loading', true)
this.form.validateFields((err, values) => {
if (err) {
this.loading = false
this.$emit('loading', false)
return
}
this.submit(values)
})
},
async submit(values) {
let res
try {
if (!this.id) {
// 添加
res = await this.$api.addApp({ ...values })
} else {
// 修改
res = await this.$api.updateApp({
...values,
id: this.id
})
}
if (!this.id) {
this.$message.success('添加成功')
this.$emit('added', res.data)
} else {
this.$message.success('修改成功')
this.$emit('updated', res.data)
}
this.close()
} catch (e) {
// ignore
}
this.loading = false
this.$emit('loading', false)
},
close() {
this.loading = false
this.$emit('loading', false)
this.$emit('close')
}
},
async mounted() {
await this.getRepositoryList()
}
}
</script>
<style scoped>
.reload-repo {
margin-left: 16px;
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<a-modal v-model="visible"
:title="title"
:width="660"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="check"
@cancel="close">
<!-- 表单 -->
<AddAppForm ref="from"
@close="close"
@loading="l => loading = l"
@added="() => $emit('added')"
@updated="() => $emit('updated')"/>
</a-modal>
</template>
<script>
import AddAppForm from './AddAppForm'
export default {
name: 'AddAppModal',
components: {
AddAppForm
},
data: function() {
return {
visible: false,
loading: false,
title: null
}
},
methods: {
add() {
this.title = '添加应用'
this.visible = true
this.$nextTick(() => {
this.$refs.from.add()
})
},
update(id) {
this.title = '修改应用'
this.visible = true
this.$nextTick(() => {
this.$refs.from.update(id)
})
},
check() {
this.$refs.from.check()
},
close() {
this.visible = false
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,162 @@
<template>
<a-modal v-model="visible"
:title="title"
:width="650"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="check"
@cancel="close">
<a-spin :spinning="loading">
<a-form :form="form" v-bind="layout">
<a-form-item label="环境名称" hasFeedback>
<a-input v-decorator="decorators.name" allowClear/>
</a-form-item>
<a-form-item label="唯一标识" hasFeedback>
<a-input v-decorator="decorators.tag" allowClear/>
</a-form-item>
<a-form-item label="是否需要审核">
<a-radio-group v-decorator="decorators.releaseAudit" buttonStyle="solid">
<a-radio-button v-for="type in PROFILE_AUDIT_STATUS" :key="type.value" :value="type.value">
{{ type.label }}
</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item label="描述">
<a-textarea v-decorator="decorators.description" allowClear/>
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>
<script>
import { pick } from 'lodash'
import { PROFILE_AUDIT_STATUS } from '@/lib/enum'
const layout = {
labelCol: { span: 5 },
wrapperCol: { span: 17 }
}
function getDecorators() {
return {
name: ['name', {
rules: [{
required: true,
message: '请输入环境名称'
}, {
max: 32,
message: '环境名称长度不能大于32位'
}]
}],
tag: ['tag', {
rules: [{
required: true,
message: '请输入唯一标识'
}, {
max: 32,
message: '唯一标识长度不能大于32位'
}]
}],
releaseAudit: ['releaseAudit', {
initialValue: 2,
rules: [{
required: true,
message: '请选择是否需要审核'
}]
}],
description: ['description', {
rules: [{
max: 64,
message: '描述长度不能大于64位'
}]
}]
}
}
export default {
name: 'AddAppProfileModal',
data: function() {
return {
PROFILE_AUDIT_STATUS,
id: null,
visible: false,
title: null,
loading: false,
record: null,
layout,
decorators: getDecorators.call(this),
form: this.$form.createForm(this)
}
},
methods: {
add() {
this.title = '新增环境'
this.initRecord({})
},
update(id) {
this.title = '修改环境'
this.$api.getProfileDetail({ id })
.then(({ data }) => {
this.initRecord(data)
})
},
initRecord(row) {
this.form.resetFields()
this.visible = true
this.id = row.id
this.record = pick(Object.assign({}, row), 'name', 'tag', 'releaseAudit', 'description')
this.$nextTick(() => {
this.form.setFieldsValue(this.record)
})
},
check() {
this.loading = true
this.form.validateFields((err, values) => {
if (err) {
this.loading = false
return
}
this.submit(values)
})
},
async submit(values) {
let res
try {
if (!this.id) {
// 添加
res = await this.$api.addProfile({
...values
})
} else {
// 修改
res = await this.$api.updateProfile({
...values,
id: this.id
})
}
if (!this.id) {
this.$message.success('添加成功')
this.$emit('added', res.data)
} else {
this.$message.success('修改成功')
this.$emit('updated', res.data)
}
this.close()
} catch (e) {
// ignore
}
this.loading = false
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,269 @@
<template>
<a-modal v-model="visible"
:title="title"
:width="578"
:maskStyle="{opacity: 0.8, animation: 'none'}"
:dialogStyle="{top: '64px', padding: 0}"
:maskClosable="false"
:destroyOnClose="true"
:footer="false"
@cancel="close">
<a-spin :spinning="loading">
<a-form-model v-bind="layout">
<!-- 流水线名称 -->
<a-form-model-item label="名称" class="name-form-item" required>
<a-input class="name-input" v-model="record.name" :maxLength="32" allowClear/>
</a-form-model-item>
<!-- 流水线描述 -->
<a-form-model-item label="描述" class="description-form-item">
<a-textarea class="description-input" v-model="record.description" :maxLength="64" allowClear/>
</a-form-model-item>
<!-- 流水线操作 -->
<a-form-model-item label="操作" class="detail-form-item" required>
<div class="pipeline-details-wrapper">
<template v-for="(detail, index) of record.details">
<div class="pipeline-detail" :key="detail.id" v-if="detail.visible">
<!-- 操作 -->
<a-input-group compact>
<!-- 操作类型 -->
<a-select style="width: 80px" v-model="detail.stageType" placeholder="请选择">
<a-select-option v-for="stage of STAGE_TYPE"
:key="stage.value"
:value="stage.value">
{{ stage.label }}
</a-select-option>
</a-select>
<!-- 操作应用 -->
<a-select style="width: 271px" v-model="detail.appId" placeholder="请选择应用">
<a-select-option v-for="app of appList"
:key="app.id"
:value="app.id">
{{ app.name }}
</a-select-option>
</a-select>
</a-input-group>
<!-- 操作 -->
<div class="pipeline-detail-handler">
<a-button-group v-if="record.details.length > 1">
<a-button title="移除" @click="removeOption(index)" icon="minus-circle"/>
<a-button title="上移" v-if="index !== 0" @click="swapOption(index, index - 1)" icon="arrow-up"/>
<a-button title="下移" v-if="index !== record.details.length - 1" @click="swapOption(index + 1, index )" icon="arrow-down"/>
</a-button-group>
</div>
</div>
</template>
</div>
<!-- 添加 -->
<a-button type="dashed" class="add-option-button" @click="addOption">
添加应用操作
</a-button>
<!-- 保存 -->
<a-button type="primary" class="save-button" @click="check">
保存
</a-button>
</a-form-model-item>
</a-form-model>
</a-spin>
</a-modal>
</template>
<script>
import { CONFIG_STATUS, STAGE_TYPE } from '@/lib/enum'
const layout = {
labelCol: { span: 3 },
wrapperCol: { span: 21 }
}
export default {
name: 'AddPipelineModal',
data: function() {
return {
STAGE_TYPE,
id: null,
visible: false,
title: null,
loading: false,
profileId: null,
record: {},
appList: [],
layout
}
},
methods: {
add() {
this.title = '新增流水线'
this.initRecord({
details: []
})
},
update(id) {
this.title = '配置流水线'
this.$api.getAppPipelineDetail({
id
}).then(({ data }) => {
this.initRecord(data)
})
},
initRecord(row) {
this.id = row.id
this.record = row
// 设置明细显示
this.record.details.forEach(detail => {
detail.visible = true
})
this.visible = true
// 读取当前环境
const activeProfile = this.$storage.get(this.$storage.keys.ACTIVE_PROFILE)
if (!activeProfile) {
this.$message.warning('请先维护应用环境')
return
}
this.profileId = JSON.parse(activeProfile).id
// 加载应用列表
this.loadAppList()
},
loadAppList() {
this.loading = true
this.app = null
this.appList = []
this.$api.getAppList({
profileId: this.profileId,
limit: 10000
}).then(({ data }) => {
this.loading = false
if (data.rows && data.rows.length) {
this.appList = data.rows.filter(s => s.isConfig === CONFIG_STATUS.CONFIGURED.value)
}
}).catch(() => {
this.loading = false
})
},
addOption() {
this.record.details.push({
visible: true,
id: Date.now(),
appId: undefined,
stageType: STAGE_TYPE.BUILD.value
})
},
removeOption(index) {
this.record.details.visible = false
this.$nextTick(() => {
this.record.details.splice(index, 1)
})
},
swapOption(index, target) {
const temp = this.record.details[target]
this.$set(this.record.details, target, this.record.details[index])
this.$set(this.record.details, index, temp)
},
check() {
if (!this.record.name || !this.record.name.trim().length) {
this.$message.warning('请输入流水线名称')
return
}
if (!this.record.details.length) {
this.$message.warning('请设置流水线操作')
return
}
for (let i = 0; i < this.record.details.length; i++) {
const detail = this.record.details[i]
if (!detail.stageType) {
this.$message.warning(`请选择操作类型 [${i + 1}]`)
return
}
if (!detail.appId) {
this.$message.warning(`请选择操作应用 [${i + 1}]`)
return
}
}
this.loading = true
// 设置数据
const req = {
id: this.id,
profileId: this.profileId,
name: this.record.name,
description: this.record.description,
details: []
}
// 设置详情
this.record.details.forEach(({
appId,
stageType
}) => {
req.details.push({
appId,
stageType
})
})
this.submit(req)
},
async submit(req) {
let res
try {
if (!this.id) {
// 添加
res = await this.$api.addAppPipeline(req)
} else {
// 修改
res = await this.$api.updateAppPipeline(req)
}
if (!this.id) {
this.$message.success('添加成功')
this.$emit('added', res.data)
} else {
this.$message.success('修改成功')
this.$emit('updated', res.data)
}
this.close()
} catch (e) {
// ignore
}
this.loading = false
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.name-form-item {
margin-bottom: 18px;
.name-input {
width: 350px
}
}
.description-form-item {
margin-bottom: 4px;
.description-input {
width: 350px
}
}
.detail-form-item {
margin-bottom: 4px;
.pipeline-detail {
display: flex;
align-items: center;
height: 36px;
margin: 2px 0 6px 0;
}
}
.add-option-button {
width: 350px;
}
.save-button {
width: 350px;
}
</style>

View File

@@ -0,0 +1,261 @@
<template>
<a-modal v-model="visible"
:title="title"
:width="560"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="check"
@cancel="close">
<a-spin :spinning="loading">
<a-form :form="form" v-bind="layout">
<a-form-item label="名称" hasFeedback>
<a-input v-decorator="decorators.name" allowClear/>
</a-form-item>
<a-form-item label="url" hasFeedback>
<a-input v-decorator="decorators.url" allowClear/>
</a-form-item>
<a-form-item label="认证方式">
<a-radio-group v-decorator="decorators.authType">
<a-radio :value="type.value" v-for="type in REPOSITORY_AUTH_TYPE" :key="type.value">
{{ type.label }}
</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="资源用户" v-if="visibleUsername()" hasFeedback>
<a-input v-decorator="decorators.username" allowClear/>
</a-form-item>
<a-form-item label="认证令牌" v-if="!visiblePassword()" style="margin-bottom: 0">
<a-form-item style="display: inline-block; width: 30%">
<a-select v-decorator="decorators.tokenType">
<a-select-option :value="type.value" v-for="type in REPOSITORY_TOKEN_TYPE" :key="type.value">
{{ type.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item style="display: inline-block; width: 70%">
<a-input v-decorator="decorators.privateToken"
:placeholder="getPrivateTokenPlaceholder()"
allowClear/>
</a-form-item>
</a-form-item>
<a-form-item label="资源密码" v-if="visiblePassword()">
<a-input-password v-decorator="decorators.password" allowClear/>
</a-form-item>
<a-form-item label="描述">
<a-textarea v-decorator="decorators.description" allowClear/>
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>
<script>
import { enumValueOf, REPOSITORY_AUTH_TYPE, REPOSITORY_TOKEN_TYPE } from '@/lib/enum'
import { pick } from 'lodash'
const layout = {
labelCol: { span: 5 },
wrapperCol: { span: 16 }
}
function getDecorators() {
return {
name: ['name', {
rules: [{
required: true,
message: '请输入名称'
}, {
max: 32,
message: '名称长度不能大于32位'
}]
}],
url: ['url', {
rules: [{
required: true,
message: '请输入url'
}, {
max: 1024,
message: 'url长度不能大于1024位'
}]
}],
authType: ['authType', {
initialValue: REPOSITORY_AUTH_TYPE.PASSWORD.value
}],
tokenType: ['tokenType', {
initialValue: REPOSITORY_TOKEN_TYPE.GITHUB.value
}],
privateToken: ['privateToken', {
rules: [{
max: 128,
message: '令牌长度不能大于256位'
}, {
validator: this.validatePrivateToken
}]
}],
username: ['username', {
rules: [{
max: 128,
message: '用户名长度不能大于128位'
}, {
validator: this.validateUsername
}]
}],
password: ['password', {
rules: [{
max: 128,
message: '密码长度不能大于128位'
}, {
validator: this.validatePassword
}]
}],
description: ['description', {
rules: [{
max: 64,
message: '描述长度不能大于64位'
}]
}]
}
}
export default {
name: 'AddRepositoryModal',
data: function() {
return {
REPOSITORY_AUTH_TYPE,
REPOSITORY_TOKEN_TYPE,
id: null,
visible: false,
title: null,
loading: false,
record: null,
layout,
decorators: getDecorators.call(this),
form: this.$form.createForm(this)
}
},
methods: {
add() {
this.title = '新增仓库'
this.initRecord({})
},
update(id) {
this.title = '修改仓库'
this.$api.getRepositoryDetail({ id })
.then(({ data }) => {
this.initRecord(data)
})
},
initRecord(row) {
this.form.resetFields()
this.visible = true
this.id = row.id
const username = row.username
const tokenType = row.tokenType
this.record = pick(Object.assign({}, row), 'name', 'url', 'authType', 'description')
// 设置数据
new Promise((resolve) => {
// 加载数据
this.$nextTick(() => {
this.form.setFieldsValue(this.record)
})
resolve()
}).then(() => {
// 加载令牌类型
if (this.record.authType === REPOSITORY_AUTH_TYPE.TOKEN.value) {
this.$nextTick(() => {
this.record.tokenType = tokenType
this.form.setFieldsValue({ tokenType })
})
}
}).then(() => {
// 加载用户名
if (this.visibleUsername(this.record.authType, tokenType)) {
this.$nextTick(() => {
this.record.username = username
this.form.setFieldsValue({ username })
})
}
})
},
validatePrivateToken(rule, value, callback) {
if (!this.id && !value) {
callback(new Error('请输入私人令牌'))
} else {
callback()
}
},
validateUsername(rule, value, callback) {
if (this.form.getFieldValue('password') && !value) {
callback(new Error('用户名和密码须同时存在'))
} else if (this.form.getFieldValue('tokenType') === REPOSITORY_TOKEN_TYPE.GITEE.value && !value) {
callback(new Error('gitee 令牌认证用户名必填'))
} else {
callback()
}
},
validatePassword(rule, value, callback) {
if (this.form.getFieldValue('username') && !value && !this.id) {
callback(new Error('新增时用户名和密码须同时存在'))
} else {
callback()
}
},
visibleUsername(authType = this.form.getFieldValue('authType'), tokenType = this.form.getFieldValue('tokenType')) {
return authType !== REPOSITORY_AUTH_TYPE.TOKEN.value ||
(authType === REPOSITORY_AUTH_TYPE.TOKEN.value && tokenType === REPOSITORY_AUTH_TYPE.TOKEN.value)
},
visiblePassword() {
return this.form.getFieldValue('authType') === REPOSITORY_AUTH_TYPE.PASSWORD.value
},
getPrivateTokenPlaceholder() {
return enumValueOf(REPOSITORY_TOKEN_TYPE, this.form.getFieldValue('tokenType')).description ||
REPOSITORY_TOKEN_TYPE.GITHUB.description
},
check() {
this.loading = true
this.form.validateFields((err, values) => {
if (err) {
this.loading = false
return
}
this.submit(values)
})
},
async submit(values) {
let res
try {
if (!this.id) {
// 添加
res = await this.$api.addRepository({ ...values })
} else {
// 修改
res = await this.$api.updateRepository({
...values,
id: this.id
})
}
if (!this.id) {
this.$message.success('添加成功')
this.$emit('added', res.data)
} else {
this.$message.success('修改成功')
this.$emit('updated', res.data)
}
this.close()
} catch (e) {
// ignore
}
this.loading = false
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,90 @@
<template>
<a-auto-complete v-model="value"
:disabled="disabled"
:placeholder="placeholder"
@change="change"
@search="search"
allowClear>
<template #dataSource>
<a-select-option v-for="app in visibleApp"
:key="app.id"
:value="JSON.stringify(app)"
@click="choose">
{{ app.name }}
</a-select-option>
</template>
</a-auto-complete>
</template>
<script>
export default {
name: 'AppAutoComplete',
props: {
disabled: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: '全部'
}
},
data() {
return {
appList: [],
visibleApp: [],
value: undefined
}
},
methods: {
change(value) {
let id
let val = value
try {
const v = JSON.parse(value)
if (typeof v === 'object') {
id = v.id
val = v.name
}
} catch (e) {
}
this.$emit('change', id, val)
this.value = val
},
choose() {
this.$nextTick(() => {
this.$emit('choose')
})
},
search(value) {
if (!value) {
this.visibleApp = this.appList
return
}
this.visibleApp = this.appList.filter(s => s.name.toLowerCase().includes(value.toLowerCase()))
},
reset() {
this.value = undefined
this.visibleApp = this.appList
}
},
async created() {
const { data } = await this.$api.getAppList({
limit: 10000
})
if (data && data.rows && data.rows.length) {
for (const row of data.rows) {
this.appList.push({
id: row.id,
name: row.name
})
}
this.visibleApp = this.appList
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,328 @@
<template>
<div id="app-build-conf-container">
<a-spin :spinning="loading">
<!-- 产物路径 -->
<div id="app-bundle-container">
<div id="app-bundle-wrapper">
<span class="label normal-label required-label">构建产物路径</span>
<a-textarea class="bundle-input"
v-model="bundlePath"
:maxLength="1024"
:autoSize="{minRows: 1}"
:placeholder="'基于版本仓库的相对路径 或 绝对路径, 路径不能包含 \\\ 应该用 / 替换'"/>
</div>
</div>
<!-- 构建操作 -->
<div id="app-action-container">
<template v-for="(action, index) in actions">
<div class="app-action-block" :key="index" v-if="action.visible">
<!-- 分隔符 -->
<a-divider class="action-divider">构建操作{{ index + 1 }}</a-divider>
<div class="app-action-wrapper">
<!-- 操作 -->
<div class="app-action">
<div class="action-name-wrapper">
<span class="label normal-label required-label action-label">操作名称{{ index + 1 }}</span>
<a-input class="action-name-input" v-model="action.name" :maxLength="32" placeholder="操作名称"/>
</div>
<!-- 代码块 -->
<div class="action-editor-wrapper" v-if="action.type === BUILD_ACTION_TYPE.COMMAND.value">
<span class="label normal-label required-label action-label">主机命令{{ index + 1 }}</span>
<div class="app-action-editor">
<Editor :config="editorConfig" :value="action.command" @change="(v) => action.command = v"/>
</div>
</div>
<div class="action-type-wrapper" v-else>
<span class="label normal-label action-label">操作类型</span>
<a-button class="action-type-name" ghost disabled>
{{ action.type | formatActionType('label') }}
</a-button>
</div>
</div>
<!-- 操作 -->
<div class="app-action-handler">
<a-button-group v-if="actions.length > 1">
<a-button title="移除" @click="removeAction(index)" icon="minus-circle"/>
<a-button title="上移" v-if="index !== 0" @click="swapAction(index, index - 1)" icon="arrow-up"/>
<a-button title="下移" v-if="index !== actions.length - 1" @click="swapAction(index + 1, index )" icon="arrow-down"/>
</a-button-group>
</div>
</div>
</div>
</template>
</div>
<!-- 底部按钮 -->
<div id="app-action-footer">
<a-button class="app-action-footer-button" type="dashed"
@click="addAction(BUILD_ACTION_TYPE.COMMAND.value)">
添加命令操作 (宿主机执行)
</a-button>
<a-button class="app-action-footer-button" type="dashed"
v-if="visibleAddCheckout"
@click="addAction(BUILD_ACTION_TYPE.CHECKOUT.value)">
添加检出操作 (宿主机执行)
</a-button>
<a-button class="app-action-footer-button" type="primary" @click="save">保存</a-button>
</div>
</a-spin>
</div>
</template>
<script>
import { BUILD_ACTION_TYPE, enumValueOf } from '@/lib/enum'
import Editor from '@/components/editor/Editor'
const editorConfig = {
enableLiveAutocompletion: true,
fontSize: 14
}
export default {
name: 'AppBuildConfigForm',
props: {
appId: Number,
dataLoading: Boolean,
detail: Object
},
components: {
Editor
},
computed: {
visibleAddCheckout() {
return this.repoId &&
this.repoId !== null &&
this.actions.map(s => s.type).filter(t => t === BUILD_ACTION_TYPE.CHECKOUT.value).length < 1
}
},
watch: {
detail(e) {
this.initData(e)
},
dataLoading(e) {
this.loading = e
}
},
data() {
return {
BUILD_ACTION_TYPE,
loading: false,
profileId: null,
repoId: null,
bundlePath: undefined,
actions: [],
editorConfig
}
},
methods: {
initData(detail) {
this.profileId = detail.profileId
this.repoId = detail.repoId
this.bundlePath = detail.env && detail.env.bundlePath
if (detail.buildActions && detail.buildActions.length) {
this.actions = detail.buildActions.map(s => {
return {
visible: true,
name: s.name,
type: s.type,
command: s.command
}
})
} else {
this.actions = []
}
},
addAction(type) {
this.actions.push({
type,
command: '',
name: undefined,
visible: true
})
},
removeAction(index) {
this.actions[index].visible = false
this.$nextTick(() => {
this.actions.splice(index, 1)
})
},
swapAction(index, target) {
const temp = this.actions[target]
this.$set(this.actions, target, this.actions[index])
this.$set(this.actions, index, temp)
},
save() {
if (!this.bundlePath || !this.bundlePath.trim().length) {
this.$message.warning('请输入构建产物路径')
return
}
if (this.bundlePath.includes('\\')) {
this.$message.warning('构建产物路径不能包含 \\ 应该用 / 替换')
return
}
if (!this.actions.length) {
this.$message.warning('请设置构建操作')
return
}
for (let i = 0; i < this.actions.length; i++) {
const action = this.actions[i]
if (!action.name) {
this.$message.warning(`请输入操作名称 [构建操作${i + 1}]`)
return
}
if (BUILD_ACTION_TYPE.COMMAND.value === action.type) {
if (!action.command) {
this.$message.warning(`请输入操作命令 [构建操作${i + 1}]`)
return
} else if (action.command.length > 2048) {
this.$message.warning(`操作命令长度不能大于2048位 [构建操作${i + 1}] 当前: ${action.command.length}`)
return
}
}
}
this.loading = true
this.$api.configApp({
appId: this.appId,
profileId: this.profileId,
stageType: 10,
env: {
bundlePath: this.bundlePath
},
buildActions: this.actions
}).then(() => {
this.$message.success('保存成功')
this.$emit('updated')
this.loading = false
}).catch(() => {
this.loading = false
})
}
},
filters: {
formatActionType(type, f) {
return enumValueOf(BUILD_ACTION_TYPE, type)[f]
}
},
mounted() {
this.initData(this.detail)
}
}
</script>
<style lang="less" scoped>
@label-width: 160px;
@action-handler-width: 120px;
@bundle-container-width: 994px;
@bundle-input-width: 700px;
@app-action-container-width: 994px;
@app-action-width: 876px;
@action-name-input-width: 700px;
@app-action-editor-width: 700px;
@app-action-editor-height: 250px;
@action-type-name-width: 700px;
@action-divider-min-width: 830px;
@action-divider-width: 990px;
@app-action-footer-width: 700px;
@footer-margin-left: 168px;
@desc-margin-left: 168px;
#app-build-conf-container {
padding: 18px 8px 0 8px;
overflow: auto;
.label {
width: @label-width;
font-size: 15px;
line-height: 32px;
}
#app-bundle-wrapper {
width: @bundle-container-width;
display: flex;
align-items: flex-start;
justify-content: flex-start;
align-content: center;
.bundle-input {
width: @bundle-input-width;
margin-left: 8px;
}
}
#app-action-container {
width: @app-action-container-width;
margin-top: 16px;
.app-action-wrapper {
width: 100%;
display: flex;
.action-label {
padding: 8px;
}
.app-action {
width: @app-action-width;
padding: 0 8px 8px 8px;
.action-name-wrapper {
display: flex;
align-items: center;
.action-name-input {
width: @action-name-input-width;
}
}
.action-editor-wrapper {
display: flex;
.app-action-editor {
width: @app-action-editor-width;
height: @app-action-editor-height;
margin-top: 8px;
}
}
.action-type-wrapper {
display: flex;
align-items: center;
.action-type-name {
width: @action-type-name-width;
}
}
}
.app-action-handler {
width: @action-handler-width;
margin: 8px 0 0 8px;
}
}
.action-divider {
min-width: @action-divider-min-width;
width: @action-divider-width;
margin: 16px 0;
}
}
#app-action-footer {
margin: 16px 0 8px @footer-margin-left;
width: @app-action-footer-width;
.app-action-footer-button {
width: 100%;
margin-bottom: 8px;
}
}
.config-description {
margin: 4px 0 0 @desc-margin-left;
display: block;
color: rgba(0, 0, 0, .45);
font-size: 13px;
}
}
</style>

View File

@@ -0,0 +1,256 @@
<template>
<a-drawer title="构建详情"
placement="right"
:visible="visible"
:maskStyle="{opacity: 0, animation: 'none'}"
:width="430"
@close="onClose">
<!-- 加载中 -->
<div v-if="loading">
<a-skeleton active :paragraph="{rows: 12}"/>
</div>
<!-- 加载完成 -->
<div v-else>
<!-- 构建信息 -->
<a-descriptions size="middle">
<a-descriptions-item label="应用名称" :span="3">
{{ detail.appName }}
</a-descriptions-item>
<a-descriptions-item label="环境名称" :span="3">
{{ detail.profileName }}
</a-descriptions-item>
<a-descriptions-item label="构建序列" :span="3">
<span class="span-blue">#{{ detail.seq }}</span>
</a-descriptions-item>
<a-descriptions-item label="构建仓库" :span="3" v-if="detail.repoName != null">
{{ detail.repoName }}
</a-descriptions-item>
<a-descriptions-item label="构建分支" :span="3" v-if="detail.branchName != null">
<a-icon type="branches"/>
{{ detail.branchName }}
</a-descriptions-item>
<a-descriptions-item label="commitId" :span="3" v-if="detail.commitId != null">
{{ detail.commitId }}
</a-descriptions-item>
<a-descriptions-item label="构建描述" :span="3" v-if="detail.description != null">
{{ detail.description }}
</a-descriptions-item>
<a-descriptions-item label="构建状态" :span="3">
<a-tag :color="detail.status | formatBuildStatus('color')">
{{ detail.status | formatBuildStatus('label') }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="构建用户" :span="3">
{{ detail.createUserName }}
</a-descriptions-item>
<a-descriptions-item label="修改时间" :span="3">
{{ detail.updateTime | formatDate }}
</a-descriptions-item>
<a-descriptions-item label="开始时间" :span="3" v-if="detail.startTime !== null">
{{ detail.startTime | formatDate }} ({{ detail.startTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="结束时间" :span="3" v-if="detail.endTime !== null">
{{ detail.endTime | formatDate }} ({{ detail.endTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="持续时间" :span="3" v-if="detail.used !== null">
{{ `${detail.keepTime} (${detail.used}ms)` }}
</a-descriptions-item>
<a-descriptions-item label="日志" :span="3" v-if="detail.status !== BUILD_STATUS.WAIT.value">
<a v-if="detail.logUrl" @click="clearDownloadUrl(detail,'logUrl')" target="_blank" :href="detail.logUrl">下载</a>
<a v-else @click="loadDownloadUrl(detail, FILE_DOWNLOAD_TYPE.APP_BUILD_LOG.value,'logUrl')">获取日志文件</a>
</a-descriptions-item>
<a-descriptions-item label="产物" :span="3" v-if="detail.status === BUILD_STATUS.FINISH.value">
<a v-if="detail.downloadUrl" @click="clearDownloadUrl(detail)" target="_blank" :href="detail.downloadUrl">下载</a>
<a v-else @click="loadDownloadUrl(detail, FILE_DOWNLOAD_TYPE.APP_BUILD_BUNDLE.value)">获取产物文件</a>
</a-descriptions-item>
</a-descriptions>
<!-- 构建操作 -->
<a-divider>构建操作</a-divider>
<a-list :dataSource="detail.actions">
<template #renderItem="item">
<a-list-item>
<a-descriptions size="middle">
<a-descriptions-item label="操作名称" :span="3">
{{ item.actionName }}
</a-descriptions-item>
<a-descriptions-item label="操作类型" :span="3">
<a-tag>{{ item.actionType | formatActionType('label') }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="操作状态" :span="3">
<a-tag :color="item.status | formatActionStatus('color')">
{{ item.status | formatActionStatus('label') }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="开始时间" :span="3" v-if="item.startTime !== null">
{{ item.startTime | formatDate }} ({{ item.startTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="结束时间" :span="3" v-if="item.endTime !== null">
{{ item.endTime | formatDate }} ({{ item.endTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="持续时间" :span="3" v-if="item.used !== null">
{{ `${item.keepTime} (${item.used}ms)` }}
</a-descriptions-item>
<a-descriptions-item label="退出码" :span="3" v-if="item.exitCode !== null">
<span :style="{'color': item.exitCode === 0 ? '#4263EB' : '#E03131'}">
{{ item.exitCode }}
</span>
</a-descriptions-item>
<a-descriptions-item label="命令" :span="3" v-if="item.actionType === BUILD_ACTION_TYPE.COMMAND.value">
<a @click="preview(item.actionCommand)">预览</a>
</a-descriptions-item>
<a-descriptions-item label="日志" :span="3" v-if="statusHolder.visibleActionLog(item.status)">
<a v-if="item.downloadUrl" @click="clearDownloadUrl(item)" target="_blank" :href="item.downloadUrl">下载</a>
<a v-else @click="loadDownloadUrl(item, FILE_DOWNLOAD_TYPE.APP_ACTION_LOG.value)">获取日志文件</a>
</a-descriptions-item>
</a-descriptions>
</a-list-item>
</template>
</a-list>
</div>
<!-- 事件 -->
<div class="detail-event">
<EditorPreview ref="preview"/>
</div>
</a-drawer>
</template>
<script>
import { defineArrayKey } from '@/lib/utils'
import { formatDate } from '@/lib/filters'
import { ACTION_STATUS, BUILD_ACTION_TYPE, BUILD_STATUS, enumValueOf, FILE_DOWNLOAD_TYPE } from '@/lib/enum'
import EditorPreview from '@/components/preview/EditorPreview'
const statusHolder = {
visibleActionLog: (status) => {
return status === ACTION_STATUS.RUNNABLE.value ||
status === ACTION_STATUS.FINISH.value ||
status === ACTION_STATUS.FAILURE.value ||
status === ACTION_STATUS.TERMINATED.value
}
}
export default {
name: 'AppBuildDetailDrawer',
components: {
EditorPreview
},
data() {
return {
FILE_DOWNLOAD_TYPE,
ACTION_STATUS,
BUILD_STATUS,
BUILD_ACTION_TYPE,
visible: false,
loading: true,
pollId: null,
detail: {},
statusHolder
}
},
methods: {
open(id) {
// 关闭轮询状态
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
this.detail = {}
this.visible = true
this.loading = true
this.$api.getAppBuildDetail({
id
}).then(({ data }) => {
this.loading = false
data.logUrl = null
data.downloadUrl = null
defineArrayKey(data.actions, 'downloadUrl')
this.detail = data
// 轮询状态
if (data.status === BUILD_STATUS.WAIT.value || data.status === BUILD_STATUS.RUNNABLE.value) {
this.pollId = setInterval(this.pollStatus, 5000)
}
}).catch(() => {
this.loading = false
})
},
pollStatus() {
if (!this.detail || !this.detail.status) {
return
}
if (this.detail.status !== BUILD_STATUS.WAIT.value && this.detail.status !== BUILD_STATUS.RUNNABLE.value) {
clearInterval(this.pollId)
this.pollId = null
return
}
this.$api.getAppBuildStatus({
id: this.detail.id
}).then(({ data }) => {
this.detail.status = data.status
this.detail.used = data.used
this.detail.keepTime = data.keepTime
this.detail.startTime = data.startTime
this.detail.startTimeAgo = data.startTimeAgo
this.detail.endTime = data.endTime
this.detail.endTimeAgo = data.endTimeAgo
if (data.actions && data.actions.length) {
for (const action of data.actions) {
this.detail.actions.filter(s => s.id === action.id).forEach(s => {
s.status = action.status
s.keepTime = action.keepTime
s.used = action.used
s.startTime = action.startTime
s.startTimeAgo = action.startTimeAgo
s.endTime = action.endTime
s.endTimeAgo = action.endTimeAgo
s.exitCode = action.exitCode
})
}
}
})
},
async loadDownloadUrl(record, type, field = 'downloadUrl') {
try {
const downloadUrl = await this.$api.getFileDownloadToken({
type,
id: record.id
})
record[field] = this.$api.fileDownloadExec({ token: downloadUrl.data })
} catch (e) {
// ignore
}
},
clearDownloadUrl(record, field = 'downloadUrl') {
setTimeout(() => {
record[field] = null
})
},
preview(command) {
this.$refs.preview.preview(command)
},
onClose() {
this.visible = false
// 关闭轮询状态
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
}
},
filters: {
formatDate,
formatActionStatus(status, f) {
return enumValueOf(ACTION_STATUS, status)[f]
},
formatBuildStatus(status, f) {
return enumValueOf(BUILD_STATUS, status)[f]
},
formatActionType(type, f) {
return enumValueOf(BUILD_ACTION_TYPE, type)[f]
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,393 @@
<template>
<a-modal v-model="visible"
:width="550"
:maskStyle="{opacity: 0.8, animation: 'none'}"
:dialogStyle="{top: '64px', padding: 0}"
:bodyStyle="{padding: '8px'}"
:maskClosable="false"
:destroyOnClose="true">
<!-- 页头 -->
<template #title>
<span v-if="selectAppPage">选择应用</span>
<span v-if="!selectAppPage">
<a-icon class="mx4 pointer span-blue"
title="重新选择"
v-if="visibleReselect && appId"
@click="reselectAppList"
type="arrow-left"/>
应用构建
</span>
</template>
<!-- 初始化骨架 -->
<a-skeleton v-if="initiating" active :paragraph="{rows: 4}"/>
<!-- 主体 -->
<a-spin v-else :spinning="loading || appLoading">
<!-- 应用选择 -->
<div class="app-list-container" v-if="selectAppPage">
<!-- 无应用数据 -->
<a-empty v-if="!appList.length" description="请先配置应用"/>
<!-- 应用列表 -->
<div v-else class="app-list">
<div class="app-item" v-for="app of appList" :key="app.id" @click="chooseApp(app.id)">
<div class="app-name">
<a-icon class="mx8" type="code-sandbox"/>
{{ app.name }}
</div>
<a-tag color="#5C7CFA">
{{ app.tag }}
</a-tag>
</div>
</div>
</div>
<!-- 构建配置 -->
<div class="build-container" v-else>
<div class="build-form">
<!-- 分支 -->
<div class="build-form-item" v-if="app && app.repoId">
<span class="build-form-item-label normal-label required-label">分支</span>
<a-select class="build-form-item-input"
v-model="submit.branchName"
placeholder="分支"
@change="reloadCommit"
allowClear>
<a-select-option v-for="branch of branchList" :key="branch.name" :value="branch.name">
{{ branch.name }}
</a-select-option>
</a-select>
<a-icon type="reload" class="reload" title="刷新" @click="reloadBranch"/>
</div>
<!-- commit -->
<div class="build-form-item" v-if="app && app.repoId">
<span class="build-form-item-label normal-label required-label">commit</span>
<a-select class="build-form-item-input commit-selector"
v-model="submit.commitId"
placeholder="提交记录"
allowClear>
<a-select-option v-for="commit of commitList" :key="commit.id" :value="commit.id">
<div class="commit-item">
<div class="commit-item-left">
<span class="commit-item-id">{{ commit.id.substring(0, 7) }}</span>
<span class="commit-item-message">{{ commit.message }}</span>
</div>
<span class="commit-item-date">
{{ commit.time | formatDate('MM-dd HH:mm' ) }}
</span>
</div>
</a-select-option>
</a-select>
<a-icon type="reload" class="reload" title="刷新" @click="reloadCommit"/>
</div>
<!-- 描述 -->
<div class="build-form-item" style="margin: 8px 0;">
<span class="build-form-item-label normal-label">构建描述</span>
<a-textarea class="build-form-item-input"
v-model="submit.description"
style="height: 50px; width: 430px"
:maxLength="64"
allowClear/>
</div>
</div>
</div>
</a-spin>
<!-- 页脚 -->
<template #footer>
<!-- 关闭 -->
<a-button @click="close">关闭</a-button>
<!-- 构建 -->
<a-button type="primary"
:loading="loading"
:disabled="selectAppPage || loading || appLoading || initiating"
@click="build">
构建
</a-button>
</template>
</a-modal>
</template>
<script>
import { formatDate } from '@/lib/filters'
import { CONFIG_STATUS } from '@/lib/enum'
export default {
name: 'AppBuildModal',
props: {
visibleReselect: Boolean
},
data: function() {
return {
selectAppPage: true,
appId: null,
profileId: null,
app: null,
appList: [],
branchList: [],
commitList: [],
submit: {
branchName: null,
commitId: null,
description: null
},
visible: false,
loading: false,
appLoading: false,
initiating: false
}
},
methods: {
async openBuild(profileId, id) {
if (!profileId) {
this.$message.warning('请先维护应用环境')
return
}
this.profileId = profileId
this.appList = []
this.cleanData()
this.selectAppPage = !id
this.loading = false
this.appLoading = false
this.initiating = true
this.visible = true
await this.loadAppList()
if (id) {
this.chooseApp(id)
}
this.initiating = false
},
async chooseApp(id) {
this.cleanData()
this.appId = id
this.selectAppPage = false
const filter = this.appList.filter(s => s.id === id)
if (!filter.length) {
this.$message.warning('未找到该应用')
}
this.app = filter[0]
if (this.app.repoId) {
this.appLoading = true
await this.loadRepository()
this.appLoading = false
}
},
async loadAppList() {
const { data: { rows } } = await this.$api.getAppList({
profileId: this.profileId,
limit: 10000
})
this.appList = rows.filter(s => s.isConfig === CONFIG_STATUS.CONFIGURED.value)
},
async reselectAppList() {
this.selectAppPage = true
if (this.appList.length) {
return
}
this.initiating = true
await this.loadAppList()
this.initiating = false
},
cleanData() {
this.app = {}
this.appId = null
this.branchList = []
this.commitList = []
this.submit.branchName = null
this.submit.commitId = null
this.submit.description = null
},
async loadRepository() {
await this.$api.getRepositoryInfo({
id: this.app.repoId,
appId: this.appId,
profileId: this.profileId
}).then(({ data }) => {
this.branchList = data.branches
// 分支列表
const defaultBranch = this.branchList.filter(s => s.isDefault === 1)
if (defaultBranch && defaultBranch.length) {
this.submit.branchName = defaultBranch[0].name
} else {
this.submit.branchName = null
}
// 提交列表
this.commitList = data.commits
if (data.commits && data.commits.length) {
this.submit.commitId = this.commitList[0].id
} else {
this.submit.commitId = null
}
})
},
reloadBranch() {
this.appLoading = true
this.$api.getRepositoryBranchList({
id: this.app.repoId
}).then(({ data }) => {
this.appLoading = false
this.branchList = data
}).catch(() => {
this.appLoading = false
})
},
reloadCommit() {
if (!this.submit.branchName) {
this.commitList = []
this.submit.commitId = undefined
return
}
if (!this.submit.branchName) {
this.$message.warning('请先选择分支')
return
}
this.appLoading = true
this.$api.getRepositoryCommitList({
id: this.app.repoId,
branchName: this.submit.branchName
}).then(({ data }) => {
this.appLoading = false
this.commitList = data
if (data && data.length) {
this.submit.commitId = this.commitList[0].id
} else {
this.submit.commitId = null
}
}).catch(() => {
this.appLoading = false
})
},
async build() {
if (!this.app) {
this.$message.warning('请选择构建应用')
return
}
if (this.app.repoId) {
if (!this.submit.branchName) {
this.$message.warning('请选择分支')
return
}
if (!this.submit.commitId) {
this.$message.warning('请选择commit')
return
}
}
this.loading = true
this.$api.submitAppBuild({
appId: this.appId,
profileId: this.profileId,
...this.submit
}).then(() => {
this.$message.success('已开始构建')
this.$emit('submit')
this.visible = false
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
}
},
filters: {
formatDate
}
}
</script>
<style lang="less" scoped>
.app-list {
margin: 0 4px 0 8px;
height: 355px;
overflow-y: auto;
.app-item {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px;
padding: 4px 4px 4px 8px;
background: #F8F9FA;
border-radius: 4px;
height: 40px;
cursor: pointer;
.app-name {
width: 300px;
text-overflow: ellipsis;
display: block;
overflow-x: hidden;
white-space: nowrap;
}
}
.app-item:hover {
background: #E7F5FF;
}
}
.build-form {
display: flex;
flex-wrap: wrap;
.build-form-item {
display: flex;
align-items: center;
width: 100%;
.build-form-item-label {
width: 70px;
margin: 16px 8px;
font-size: 15px;
}
.build-form-item-input {
width: 390px;
}
.reload {
font-size: 19px;
margin-left: 16px;
cursor: pointer;
color: #339AF0;
}
.reload:hover {
color: #228BE6;
}
}
}
.commit-item {
display: flex;
justify-content: space-between;
.commit-item-left {
display: flex;
width: 285px;
}
.commit-item-id {
width: 50px;
margin-right: 8px;
color: #000;
}
.commit-item-message {
width: 227px;
display: block;
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
}
.commit-item-date {
font-size: 12px;
margin-right: 4px;
}
}
::v-deep .commit-selector .ant-select-selection-selected-value {
width: 100%;
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<!-- 统计折线图 -->
<div class="statistic-chart-container">
<p class="statistics-description">近七天构建统计</p>
<a-spin :spinning="loading">
<!-- 折线图 -->
<div class="statistic-chart-wrapper">
<div id="statistic-chart"/>
</div>
</a-spin>
</div>
</template>
<script>
import { Chart } from '@antv/g2'
export default {
name: 'AppBuildStatisticsCharts',
data() {
return {
loading: false,
chart: null
}
},
methods: {
async init(appId, profileId) {
this.loading = true
const { data } = await this.$api.getAppBuildStatisticsChart({
appId,
profileId
})
this.loading = false
this.renderChart(data)
},
clean() {
this.loading = false
this.chart && this.chart.destroy()
this.chart = null
},
renderChart(data) {
// 处理数据
const chartsData = []
for (const d of data) {
chartsData.push({
date: d.date,
type: '构建次数',
value: d.buildCount
})
chartsData.push({
date: d.date,
type: '成功次数',
value: d.successCount
})
chartsData.push({
date: d.date,
type: '失败次数',
value: d.failureCount
})
}
this.clean()
// 渲染图表
this.chart = new Chart({
container: 'statistic-chart',
autoFit: true
})
this.chart.data(chartsData)
this.chart.tooltip({
showCrosshairs: true,
shared: true
})
this.chart.line()
.position('date*value')
.color('type')
.shape('circle')
this.chart.render()
}
},
beforeDestroy() {
this.clean()
}
}
</script>
<style lang="less" scoped>
.statistic-chart-wrapper {
margin: 0 24px 16px 24px;
#statistic-chart {
height: 500px;
}
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<a-spin :spinning="loading">
<!-- 全部构建指标 -->
<p class="statistics-description">全部构建指标</p>
<div class="app-build-statistic-metrics">
<div class="clean"/>
<!-- 统计指标 -->
<div class="app-build-statistic-header">
<a-statistic class="statistic-metrics-item" title="构建次数" :value="allMetrics.buildCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item green" title="成功次数" :value="allMetrics.successCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item red" title="失败次数" :value="allMetrics.failureCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item" title="平均耗时" :value="allMetrics.avgUsedInterval"/>
</div>
<div class="clean"/>
</div>
<!-- 近七天构建指标 -->
<p class="statistics-description" style="margin-top: 28px">近七天构建指标</p>
<div class="app-build-statistic-metrics">
<div class="clean"/>
<!-- 统计指标 -->
<div class="app-build-statistic-header">
<a-statistic class="statistic-metrics-item" title="构建次数" :value="latelyMetrics.buildCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item green" title="成功次数" :value="latelyMetrics.successCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item red" title="失败次数" :value="latelyMetrics.failureCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item" title="平均耗时" :value="latelyMetrics.avgUsedInterval"/>
</div>
<div class="clean"/>
</div>
</a-spin>
</template>
<script>
export default {
name: 'AppBuildStatisticsMetrics',
data() {
return {
loading: false,
allMetrics: {
buildCount: 0,
successCount: 0,
failureCount: 0,
avgUsedInterval: 0
},
latelyMetrics: {
buildCount: 0,
successCount: 0,
failureCount: 0,
avgUsedInterval: 0
}
}
},
methods: {
async init(appId, profileId) {
this.loading = true
const { data } = await this.$api.getAppBuildStatisticsMetrics({
appId,
profileId
})
this.loading = false
this.allMetrics = data.all
this.latelyMetrics = data.lately
},
clean() {
this.loading = false
this.allMetrics = {
buildCount: 0,
successCount: 0,
failureCount: 0,
avgUsedInterval: 0
}
this.latelyMetrics = {
buildCount: 0,
successCount: 0,
failureCount: 0,
avgUsedInterval: 0
}
}
},
beforeDestroy() {
this.clean()
}
}
</script>
<style lang="less" scoped>
.app-build-statistic-metrics {
display: flex;
justify-content: center;
margin: 24px 0 12px 16px;
.app-build-statistic-header {
display: flex;
.statistic-metrics-item {
margin: 0 16px;
}
::v-deep .statistic-metrics-item.green .ant-statistic-content {
color: #58C612;
}
::v-deep .statistic-metrics-item.red .ant-statistic-content {
color: #DD2C00;
}
.statistic-metrics-divider {
height: auto;
}
::v-deep .ant-statistic-content {
text-align: center;
}
}
}
</style>

View File

@@ -0,0 +1,338 @@
<template>
<div class="app-build-statistic-record-view-container">
<a-spin :spinning="loading">
<!-- 构建视图 -->
<div class="app-build-statistic-record-view-wrapper" v-if="initialized && view">
<div class="app-build-statistic-record-view">
<p class="statistics-description">近十次构建视图</p>
<!-- 构建视图主体 -->
<div class="app-build-statistic-main">
<!-- 构建操作 -->
<div class="app-build-actions-wrapper">
<!-- 平均时间 -->
<div class="app-build-actions-legend">
<span class="avg-used-legend-wrapper">
平均构建时间: <span class="avg-used-legend">{{ view.avgUsedInterval }}</span>
</span>
</div>
<!-- 构建操作 -->
<div class="app-build-actions" v-for="(action, index) of view.actions" :key="action.id">
<div :class="['app-build-actions-name', index % 2 === 0 ? 'app-build-actions-name-theme1' : 'app-build-actions-name-theme2']">
{{ action.name }}
</div>
<div :class="['app-build-actions-avg', index % 2 === 0 ? 'app-build-actions-avg-theme1' : 'app-build-actions-avg-theme2']">
<div class="app-build-actions-avg">
{{ action.avgUsedInterval }}
</div>
</div>
</div>
</div>
<!-- 构建日志 -->
<div class="app-build-action-logs-container">
<!-- 日志 -->
<div class="app-build-action-logs-wrapper"
v-for="buildRecord of view.buildRecordList"
:key="buildRecord.buildId">
<!-- 构建信息头 -->
<a target="_blank"
title="点击查看构建日志"
:href="`#/app/build/log/view/${buildRecord.buildId}`"
@click="openLogView($event,'build', buildRecord.buildId)">
<div class="app-build-record-legend">
<!-- 构建状态 -->
<div class="app-build-record-status">
<!-- 构建序列 -->
<div class="build-seq">
<span class="span-blue">#{{ buildRecord.seq }}</span>
</div>
<!-- 构建状态 -->
<a-tag class="m0" :color="buildRecord.status | formatBuildStatus('color')">
{{ buildRecord.status | formatBuildStatus('label') }}
</a-tag>
</div>
<!-- 构建时间 -->
<div class="app-build-record-info">
<span>{{ buildRecord.buildDate | formatDate('MM-dd HH:mm') }}</span>
<span v-if="buildRecord.usedInterval" class="ml4"> (used: {{ buildRecord.usedInterval }})</span>
</div>
</div>
</a>
<!-- 构建操作 -->
<div class="app-build-action-log-actions-wrapper">
<div class="app-build-action-log-action-wrapper"
v-for="(actionLog, index) of buildRecord.actionLogs" :key="index">
<!-- 构建操作值 -->
<div class="app-build-action-log-action"
v-if="!getCanOpenLog(actionLog)"
:style="getActionLogStyle(actionLog)"
v-text="getActionLogValue(actionLog)">
</div>
<!-- 可打开日志 -->
<a v-else target="_blank"
title="点击查看操作日志"
:href="`#/app/action/log/view/${actionLog.id}`"
@click="openLogView($event,'action', actionLog.id)">
<div class="app-build-action-log-action"
:style="getActionLogStyle(actionLog)"
v-text="getActionLogValue(actionLog)">
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 无数据 -->
<div style="padding: 0 16px" v-else-if="initialized && !view">
无构建记录
</div>
</a-spin>
<!-- 事件 -->
<div class="app-build-statistic-event-container">
<!-- 构建日志模态框 -->
<AppBuildLogAppenderModal ref="buildLogView"/>
<!-- 操作日志模态框 -->
<AppActionLogAppenderModal ref="actionLogView"/>
</div>
</div>
</template>
<script>
import { formatDate } from '@/lib/filters'
import { enumValueOf, ACTION_STATUS, BUILD_STATUS } from '@/lib/enum'
import AppBuildLogAppenderModal from '@/components/log/AppBuildLogAppenderModal'
import AppActionLogAppenderModal from '@/components/log/AppActionLogAppenderModal'
export default {
name: 'AppBuildStatisticsViews',
components: {
AppBuildLogAppenderModal,
AppActionLogAppenderModal
},
data() {
return {
loading: false,
initialized: false,
view: {}
}
},
methods: {
async init(appId, profileId) {
this.loading = true
this.initialized = false
const { data } = await this.$api.getAppBuildStatisticsView({
appId,
profileId
})
this.view = data
this.initialized = true
this.loading = false
},
clean() {
this.initialized = false
this.loading = false
this.view = {}
},
async refresh(appId, profileId) {
const { data } = await this.$api.getAppBuildStatisticsView({
appId,
profileId
})
this.view = data
},
openLogView(e, type, id) {
if (!e.ctrlKey) {
e.preventDefault()
// 打开模态框
this.$refs[`${type}LogView`].open(id)
return false
} else {
// 跳转页面
return true
}
},
getCanOpenLog(actionLog) {
if (actionLog) {
return enumValueOf(ACTION_STATUS, actionLog.status).log
} else {
return false
}
},
getActionLogStyle(actionLog) {
if (actionLog) {
return enumValueOf(ACTION_STATUS, actionLog.status).actionStyle
} else {
return {
background: '#FFD43B'
}
}
},
getActionLogValue(actionLog) {
if (actionLog) {
return enumValueOf(ACTION_STATUS, actionLog.status).actionValue(actionLog)
} else {
return '未执行'
}
}
},
beforeDestroy() {
this.clean()
},
filters: {
formatDate,
formatBuildStatus(status, f) {
return enumValueOf(BUILD_STATUS, status)[f]
}
}
}
</script>
<style lang="less" scoped>
.app-build-statistic-record-view-wrapper {
margin: 0 24px 24px 24px;
overflow: auto;
.app-build-statistic-record-view {
margin: 0 16px 16px 16px;
border-radius: 4px;
display: inline-block;
}
.app-build-statistic-main {
display: inline-block;
box-shadow: 0 0 4px 1px #DEE2E6;
}
.app-build-actions-wrapper {
display: flex;
min-height: 82px;
.app-build-actions-legend {
width: 180px;
display: flex;
align-items: flex-end;
justify-content: flex-end;
padding: 4px 8px 4px 0;
font-weight: 600;
.avg-used-legend-wrapper {
display: flex;
align-items: center;
}
.avg-used-legend {
margin-left: 4px;
color: #000;
font-size: 16px;
}
}
.app-build-actions {
display: flex;
flex-direction: column;
width: 148px;
.app-build-actions-name {
height: 100%;
font-size: 15px;
color: #181E33;
font-weight: 600;
line-height: 18px;
display: flex;
align-items: center;
justify-content: center;
padding: 4px 16px;
border-bottom: 1px solid #DEE2E6;
}
.app-build-actions-name-theme1 {
background: #F1F3F5;
}
.app-build-actions-name-theme2 {
background: #F8F9FA;
}
.app-build-actions-avg {
text-align: center;
height: 28px;
padding: 2px;
}
.app-build-actions-avg-theme1 {
background: #E9ECEF;
}
.app-build-actions-avg-theme2 {
background: #F1F4F7;
}
}
}
.app-build-action-logs-wrapper {
display: flex;
height: 74px;
.app-build-record-legend {
width: 180px;
height: 100%;
padding: 8px;
border-radius: 4px;
display: flex;
align-items: flex-start;
flex-direction: column;
justify-content: space-between;
border-top: 2px solid #F8F9FA;
.app-build-record-status {
margin-bottom: 8px;
display: flex;
justify-content: space-between;
width: 100%;
}
.app-build-record-info {
color: #000000;
font-size: 12px;
}
}
.app-build-record-legend:hover {
transition: .3s;
background: #D0EBFF;
}
.app-build-action-log-actions-wrapper {
display: flex;
.app-build-action-log-action-wrapper {
width: 148px;
border-radius: 4px;
.app-build-action-log-action {
margin: 2px 1px;
height: calc(100% - 2px);
border-radius: 4px;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-around;
opacity: 0.8;
color: rgba(0, 0, 0, .8);
}
.app-build-action-log-action:hover {
opacity: 1;
transition: .3s;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<a-drawer
title="流水线详情"
placement="right"
:maskStyle="{opacity: 0, animation: 'none'}"
:closable="false"
:visible="visible"
:width="350"
@close="onClose">
<!-- 加载中 -->
<a-skeleton v-if="loading" active :paragraph="{rows: 6}"/>
<!-- 描述 -->
<a-descriptions v-else size="middle">
<a-descriptions-item label="流水线名称" :span="3">
{{ record.name }}
</a-descriptions-item>
<a-descriptions-item label="流水线描述" :span="3">
{{ record.description }}
</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="3">
{{ record.createTime | formatDate }}
</a-descriptions-item>
<a-descriptions-item label="修改时间" :span="3">
{{ record.updateTime | formatDate }}
</a-descriptions-item>
<!-- 操作流水线 -->
<a-descriptions-item :span="3">
<a-timeline style="margin-top: 16px">
<a-timeline-item v-for="detail of record.details" :key="detail.id">
<!-- 操作 -->
<span class="mr4 span-blue">{{ detail.stageType | formatStageType('label') }}</span>
<!-- 应用名称 -->
<span>{{ detail.appName }}</span>
</a-timeline-item>
</a-timeline>
</a-descriptions-item>
</a-descriptions>
</a-drawer>
</template>
<script>
import { formatDate } from '@/lib/filters'
import { enumValueOf, STAGE_TYPE } from '@/lib/enum'
export default {
name: 'AppPipelineDetailViewDrawer',
data() {
return {
visible: false,
loading: false,
record: {}
}
},
methods: {
open(id) {
this.record = {}
this.visible = true
this.loading = true
this.$api.getAppPipelineDetail({
id
}).then(({ data }) => {
this.record = data
this.loading = false
}).catch(() => {
this.loading = false
})
},
onClose() {
this.visible = false
}
},
filters: {
formatDate,
formatStageType(status, f) {
return enumValueOf(STAGE_TYPE, status)[f]
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,83 @@
<template>
<a-modal v-model="visible"
title="执行审核"
:maskClosable="false"
:destroyOnClose="true"
:width="550">
<a-spin :spinning="loading">
<div class="audit-description">
<span class="description-label normal-label mr8">审核描述</span>
<a-textarea class="description-area" v-model="description" :maxLength="64"/>
</div>
</a-spin>
<!-- 操作 -->
<template #footer>
<a-button @click="audit(false)" :disabled="loading">驳回</a-button>
<a-button type="primary" @click="audit(true)" :disabled="loading">通过</a-button>
</template>
</a-modal>
</template>
<script>
import { AUDIT_STATUS } from '@/lib/enum'
export default {
name: 'AppPipelineExecAuditModal',
data() {
return {
id: null,
visible: false,
loading: false,
description: null
}
},
methods: {
open(id) {
this.visible = true
this.loading = false
this.id = id
},
audit(res) {
if (!res && !this.description) {
this.$message.warning('请输入驳回描述')
return
}
this.loading = true
this.$api.auditAppPipelineTask({
id: this.id,
auditStatus: res ? AUDIT_STATUS.RESOLVE.value : AUDIT_STATUS.REJECT.value,
auditReason: this.description
}).then(() => {
this.$message.success('审核完成')
this.$emit('audit', this.id, res)
this.close()
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
this.description = null
this.id = null
}
}
}
</script>
<style lang="less" scoped>
.audit-description {
display: flex;
align-items: baseline;
.description-label {
width: 80px;
}
.description-area {
height: 60px;
}
}
</style>

View File

@@ -0,0 +1,239 @@
<template>
<a-modal v-model="visible"
:title="title"
:width="560"
:maskStyle="{opacity: 0.4, animation: 'none'}"
:bodyStyle="{padding: '24px 24px 0 24px'}"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="check"
@cancel="close">
<a-spin :spinning="loading">
<a-form-model v-bind="layout">
<!-- 分支 -->
<a-form-model-item label="分支" v-if="detail.repoId" required>
<a-select class="build-form-item-input"
v-model="submit.branchName"
placeholder="分支"
@change="reloadCommit"
allowClear>
<a-select-option v-for="branch of branchList" :key="branch.name" :value="branch.name">
{{ branch.name }}
</a-select-option>
</a-select>
<a-icon type="reload" class="reload" title="刷新" @click="reloadBranch"/>
</a-form-model-item>
<!-- commit -->
<a-form-model-item label="commit" v-if="detail.repoId" required>
<a-select class="build-form-item-input commit-selector"
v-model="submit.commitId"
placeholder="提交记录"
allowClear>
<a-select-option v-for="commit of commitList" :key="commit.id" :value="commit.id">
<div class="commit-item">
<div class="commit-item-left">
<span class="commit-item-id">{{ commit.id.substring(0, 7) }}</span>
<span class="commit-item-message">{{ commit.message }}</span>
</div>
<span class="commit-item-date">
{{ commit.time | formatDate('MM-dd HH:mm' ) }}
</span>
</div>
</a-select-option>
</a-select>
<a-icon type="reload" class="reload" title="刷新" @click="reloadCommit"/>
</a-form-model-item>
<!-- 构建描述 -->
<a-form-model-item label="构建描述">
<a-textarea v-model="submit.description" :maxLength="64" allowClear/>
</a-form-model-item>
</a-form-model>
</a-spin>
</a-modal>
</template>
<script>
import { formatDate } from '@/lib/filters'
const layout = {
labelCol: { span: 4 },
wrapperCol: { span: 18 }
}
export default {
name: 'AppPipelineExecBuildModal',
data: function() {
return {
visible: false,
loading: false,
title: null,
detail: {},
branchList: [],
commitList: [],
submit: {
branchName: null,
commitId: null,
description: null
},
layout
}
},
methods: {
open(detail) {
this.detail = { ...detail }
this.title = `${detail.appName} 构建配置`
this.branchList = []
this.commitList = []
this.submit.branchName = detail.branchName
this.submit.commitId = detail.commitId
this.submit.description = detail.description
this.visible = true
this.loadRepository()
},
loadRepository() {
if (!this.detail.repoId) {
return
}
this.loading = true
this.$api.getRepositoryInfo({
id: this.detail.repoId,
appId: this.detail.appId,
profileId: this.detail.profileId
}).then(({ data }) => {
this.loading = false
this.branchList = data.branches
// 分支列表
const defaultBranch = this.branchList.filter(s => s.isDefault === 1)
if (this.submit.branchName) {
// nothing
} else if (defaultBranch && defaultBranch.length) {
this.submit.branchName = defaultBranch[0].name
} else {
this.submit.branchName = null
}
// 提交列表
this.commitList = data.commits
if (this.submit.commitId) {
// nothing
} else if (data.commits && data.commits.length) {
this.submit.commitId = this.commitList[0].id
} else {
this.submit.commitId = null
}
}).catch(() => {
this.loading = false
})
},
reloadBranch() {
this.loading = true
this.$api.getRepositoryBranchList({
id: this.detail.repoId
}).then(({ data }) => {
this.loading = false
this.branchList = data
}).catch(() => {
this.loading = false
})
},
reloadCommit() {
if (!this.submit.branchName) {
this.commitList = []
this.submit.commitId = undefined
return
}
if (!this.submit.branchName) {
this.$message.warning('请先选择分支')
return
}
this.loading = true
this.$api.getRepositoryCommitList({
id: this.detail.repoId,
branchName: this.submit.branchName
}).then(({ data }) => {
this.loading = false
this.commitList = data
if (data && data.length) {
this.submit.commitId = this.commitList[0].id
} else {
this.submit.commitId = null
}
}).catch(() => {
this.loading = false
})
},
check() {
if (this.detail.repoId) {
if (!this.submit.branchName) {
this.$message.warning('请选择分支')
return
}
if (!this.submit.commitId) {
this.$message.warning('请选择commit')
return
}
}
this.$emit('ok', this.detail.id, this.submit)
this.close()
},
close() {
this.visible = false
this.loading = false
}
},
filters: {
formatDate
}
}
</script>
<style lang="less" scoped>
.build-form-item-input {
width: 346px;
}
.reload {
font-size: 19px;
margin-left: 16px;
cursor: pointer;
color: #339AF0;
}
.reload:hover {
color: #228BE6;
}
.commit-item {
display: flex;
justify-content: space-between;
.commit-item-left {
display: flex;
width: 285px;
}
.commit-item-id {
width: 50px;
margin-right: 8px;
color: #000;
}
.commit-item-message {
width: 174px;
display: block;
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
}
.commit-item-date {
font-size: 12px;
margin-right: 4px;
}
}
::v-deep .commit-selector .ant-select-selection-selected-value {
width: 100%;
}
</style>

View File

@@ -0,0 +1,415 @@
<template>
<a-modal v-model="visible"
:width="550"
:maskStyle="{opacity: 0.8, animation: 'none'}"
:dialogStyle="{top: '64px', padding: 0}"
:bodyStyle="{padding: '8px'}"
:maskClosable="false"
:destroyOnClose="true">
<!-- 页头 -->
<template #title>
<span v-if="selectPipelinePage">选择流水线</span>
<span v-if="!selectPipelinePage">
<a-icon class="mx4 pointer span-blue"
title="重新选择"
v-if="visibleReselect && id"
@click="reselectPipelineList"
type="arrow-left"/>
执行流水线
</span>
</template>
<!-- 初始化骨架 -->
<a-skeleton v-if="initiating" active :paragraph="{rows: 8}"/>
<!-- 主体 -->
<a-spin v-else :spinning="loading">
<!-- 流水线选择 -->
<div class="pipeline-list-container" v-if="selectPipelinePage">
<!-- 无流水线数据 -->
<a-empty v-if="!pipelineList.length" description="请先配置应用流水线"/>
<!-- 流水线列表 -->
<div class="pipeline-list">
<div class="pipeline-item" v-for="pipeline of pipelineList"
:key="pipeline.id"
@click="choosePipeline(pipeline.id)">
<div class="pipeline-name">
<a-icon class="mx8" type="code-sandbox"/>
{{ pipeline.name }}
</div>
</div>
</div>
</div>
<!-- 执行配置 -->
<div class="pipeline-container" v-else>
<!-- 执行参数 -->
<a-form-model v-bind="layout">
<!-- 执行标题 -->
<a-form-model-item class="exec-form-item" label="执行标题" required>
<a-input class="name-input" v-model="submit.title" :maxLength="32" allowClear/>
</a-form-model-item>
<!-- 执行类型 -->
<a-form-model-item class="exec-form-item" label="执行类型" required>
<a-radio-group v-model="submit.timedExec" buttonStyle="solid">
<a-radio-button :value="type.value" v-for="type in TIMED_TYPE" :key="type.value">
{{ type.execLabel }}
</a-radio-button>
</a-radio-group>
</a-form-model-item>
<!-- 调度时间 -->
<a-form-model-item class="exec-form-item" label="调度时间"
v-if="submit.timedExec === TIMED_TYPE.TIMED.value" required>
<a-date-picker v-model="submit.timedExecTime" :showTime="true" format="YYYY-MM-DD HH:mm:ss"/>
</a-form-model-item>
<!-- 执行描述 -->
<a-form-model-item class="exec-form-item" label="执行描述">
<a-textarea class="description-input" v-model="submit.description" :maxLength="64" allowClear/>
</a-form-model-item>
</a-form-model>
<a-divider class="detail-divider">流水线操作</a-divider>
<!-- 执行操作 -->
<div class="pipeline-wrapper">
<a-timeline>
<a-timeline-item v-for="detail of details" :key="detail.id">
<div class="pipeline-detail-wrapper">
<!-- 操作名称 -->
<div class="pipeline-stage-type span-blue">
{{ detail.stageType | formatStageType('label') }}
</div>
<!-- 应用名称 -->
<div class="pipeline-app-name">
{{ detail.appName }}
</div>
<!-- 应用操作 -->
<div class="pipeline-handler">
<span class="pipeline-config-message auto-ellipsis-item"
:title="getConfigMessage(detail)"
v-text="getConfigMessage(detail)"/>
<!-- 设置 -->
<a-badge class="pipeline-set-badge" :dot="visibleConfigDot(detail)">
<span class="span-blue pointer" title="设置" @click="openSetting(detail)">设置</span>
</a-badge>
</div>
</div>
</a-timeline-item>
</a-timeline>
</div>
</div>
</a-spin>
<!-- 页脚 -->
<template #footer>
<!-- 关闭 -->
<a-button @click="close">关闭</a-button>
<!-- 执行 -->
<a-button type="primary"
:loading="loading"
:disabled="selectPipelinePage || loading || initiating"
@click="execPipeline">
执行
</a-button>
</template>
<!-- 事件 -->
<div class="pipeline-event-container">
<!-- 构建配置 -->
<AppPipelineExecBuildModal ref="buildSetting" @ok="pipelineConfigured"/>
<!-- 发布配置 -->
<AppPipelineExecReleaseModal ref="releaseSetting" @ok="pipelineConfigured"/>
</div>
</a-modal>
</template>
<script>
import { enumValueOf, STAGE_TYPE, TIMED_TYPE } from '@/lib/enum'
import AppPipelineExecBuildModal from '@/components/app/AppPipelineExecBuildModal'
import AppPipelineExecReleaseModal from '@/components/app/AppPipelineExecReleaseModal'
const layout = {
labelCol: { span: 4 },
wrapperCol: { span: 18 }
}
export default {
name: 'AppPipelineExecModal',
components: {
AppPipelineExecReleaseModal,
AppPipelineExecBuildModal
},
props: {
visibleReselect: Boolean
},
data: function() {
return {
TIMED_TYPE,
selectPipelinePage: false,
id: null,
visible: false,
initiating: false,
loading: false,
profileId: null,
pipelineList: [],
record: {},
submit: {
title: null,
description: null,
timedExec: null,
timedExecTime: null
},
details: [],
layout
}
},
methods: {
async openPipelineList(profileId) {
this.profileId = profileId
this.pipelineList = []
this.selectPipelinePage = true
this.loading = false
this.initiating = true
this.visible = true
await this.loadPipelineList()
this.initiating = false
},
async openPipeline(profileId, id) {
this.profileId = profileId
this.pipelineList = []
this.cleanPipelineData()
this.selectPipelinePage = false
this.loading = false
this.initiating = true
this.visible = true
await this.selectPipeline(id)
this.initiating = false
},
async choosePipeline(id) {
this.cleanPipelineData()
this.selectPipelinePage = false
this.loading = true
await this.selectPipeline(id)
this.loading = false
},
async loadPipelineList() {
const { data: { rows } } = await this.$api.getAppPipelineList({
profileId: this.profileId,
limit: 10000
})
this.pipelineList = rows
},
async selectPipeline(id) {
this.id = id
await this.$api.getAppPipelineDetail({
id
}).then(({ data }) => {
this.record = data
this.details = data.details
this.submit.title = `执行${data.name}`
})
},
async reselectPipelineList() {
this.selectPipelinePage = true
if (this.pipelineList.length) {
return
}
this.initiating = true
await this.loadPipelineList()
this.initiating = false
},
cleanPipelineData() {
this.record = {}
this.details = []
this.submit.title = null
this.submit.description = null
this.submit.timedExec = TIMED_TYPE.NORMAL.value
this.submit.timedExecTime = null
},
visibleConfigDot(detail) {
if (detail.stageType === STAGE_TYPE.BUILD.value) {
if (detail.repoId) {
return !detail.branchName
} else {
return false
}
} else {
return false
}
},
getConfigMessage(detail) {
if (detail.stageType === STAGE_TYPE.BUILD.value) {
// 构建
if (detail.repoId) {
if (detail.branchName) {
return `${detail.branchName} ${detail.commitId.substring(0, 7)}`
} else {
return '请选择构建版本'
}
} else {
return '无需配置'
}
} else {
// 发布
if (detail.buildSeq) {
return `发布版本: #${detail.buildSeq}`
} else {
return '发布版本: 最新版本'
}
}
},
openSetting(detail) {
if (detail.stageType === STAGE_TYPE.BUILD.value) {
this.$refs.buildSetting.open(detail)
} else {
this.$refs.releaseSetting.open(detail)
}
},
pipelineConfigured(detailId, config) {
this.details.filter(s => s.id === detailId).forEach((detail) => {
for (const configKey in config) {
detail[configKey] = config[configKey]
}
})
this.$set(this.details, 0, this.details[0])
},
execPipeline() {
// 检查参数
if (!this.submit.title) {
this.$message.warning('请输入执行标题')
return
}
if (this.submit.timedExec === TIMED_TYPE.TIMED.value) {
if (!this.submit.timedExecTime) {
this.$message.warning('请选择调度时间')
return
}
if (this.submit.timedExecTime.unix() * 1000 < Date.now()) {
this.$message.warning('调度时间需要大于当前时间')
return
}
} else {
this.submit.timedExecTime = undefined
}
for (const detail of this.details) {
if (detail.stageType === STAGE_TYPE.BUILD.value && detail.repoId && !detail.branchName) {
this.$message.warning(`请选择 ${detail.appName} 构建版本`)
return
}
}
// 封装数据
const request = {
pipelineId: this.id,
...this.submit,
details: []
}
for (const detail of this.details) {
request.details.push({
id: detail.id,
branchName: detail.branchName,
commitId: detail.commitId,
buildId: detail.buildId,
title: detail.title,
description: detail.description,
machineIdList: detail.machineIdList
})
}
this.loading = true
this.$api.submitAppPipelineTask(request).then(() => {
this.$message.success('已创建流水线任务')
this.$emit('submit')
this.visible = false
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
}
},
filters: {
formatStageType(type, f) {
return enumValueOf(STAGE_TYPE, type)[f]
}
}
}
</script>
<style lang="less" scoped>
.pipeline-container {
padding: 16px 16px 0 16px;
}
.pipeline-list {
margin: 0 4px 0 8px;
height: 355px;
overflow-y: auto;
.pipeline-item {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px;
padding: 4px 4px 4px 8px;
background: #F8F9FA;
border-radius: 4px;
height: 40px;
cursor: pointer;
.pipeline-name {
width: 300px;
text-overflow: ellipsis;
display: block;
overflow-x: hidden;
white-space: nowrap;
}
}
.pipeline-item:hover {
background: #E7F5FF;
}
}
.exec-form-item {
margin-bottom: 12px;
}
.detail-divider {
margin: 0 0 24px 0;
}
.pipeline-detail-wrapper {
display: flex;
align-items: center;
.pipeline-stage-type {
margin-right: 4px;
}
.pipeline-app-name {
width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pipeline-handler {
width: 180px;
margin-left: 8px;
display: flex;
align-items: center;
justify-content: flex-end;
.pipeline-config-message {
margin-right: 8px;
color: #000000;
font-size: 12px;
width: 144px;
text-align: end;
}
}
::v-deep .pipeline-set-badge .ant-badge-dot {
margin: -2px;
}
}
::v-deep .ant-timeline-item-last {
padding-bottom: 0;
}
</style>

View File

@@ -0,0 +1,226 @@
<template>
<a-modal v-model="visible"
:title="title"
:width="560"
:maskStyle="{opacity: 0.4, animation: 'none'}"
:bodyStyle="{padding: '24px 24px 0 24px'}"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="check"
@cancel="close">
<!-- 发布配置 -->
<a-spin :spinning="loading">
<a-form-model v-bind="layout">
<!-- 发布标题 -->
<a-form-model-item label="发布标题" required>
<a-input class="release-form-item-input"
v-model="submit.title"
placeholder="标题"
:maxLength="32"
allowClear/>
</a-form-model-item>
<!-- 发布版本 -->
<a-form-model-item label="发布版本">
<a-select class="release-form-item-input build-selector"
v-model="submit.buildId"
placeholder="最新版本"
allowClear>
<a-select-option v-for="build of buildList" :key="build.id" :value="build.id">
<div class="build-item">
<div class="build-item-left">
<span class="span-blue">#{{ build.seq }}</span>
<span class="build-item-message">{{ build.description }}</span>
</div>
<span class="build-item-date">
{{ build.createTime | formatDate('MM-dd HH:mm') }}
</span>
</div>
</a-select-option>
</a-select>
<a-icon type="reload" class="reload" title="刷新" @click="loadBuildList"/>
</a-form-model-item>
<!-- 发布机器 -->
<a-form-model-item label="发布机器">
<MachineChecker ref="machineChecker"
placement="bottomLeft"
:defaultValue="submit.machineIdList"
:query="{idList: appMachineIdList}">
<template #trigger>
<span class="span-blue pointer">已选择 {{ submit.machineIdList.length }} 台机器</span>
</template>
<template #footer>
<a-button type="primary" size="small" @click="chooseMachines">确定</a-button>
</template>
</MachineChecker>
</a-form-model-item>
<!-- 发布描述 -->
<a-form-model-item label="发布描述" required>
<a-textarea class="release-form-item-input"
v-model="submit.description"
style="height: 50px; width: 430px"
:maxLength="64"
allowClear/>
</a-form-model-item>
</a-form-model>
</a-spin>
</a-modal>
</template>
<script>
import { formatDate } from '@/lib/filters'
import MachineChecker from '@/components/machine/MachineChecker'
const layout = {
labelCol: { span: 4 },
wrapperCol: { span: 18 }
}
export default {
name: 'AppPipelineExecReleaseModal',
components: {
MachineChecker
},
data: function() {
return {
title: null,
buildList: [],
appMachineIdList: [],
submit: {
title: null,
buildId: null,
buildSeq: null,
description: null,
machineIdList: []
},
detail: {},
visible: false,
loading: false,
layout
}
},
methods: {
async open(detail) {
this.detail = { ...detail }
this.title = `${detail.appName} 发布配置`
this.buildList = []
this.appMachineIdList = []
this.submit.title = detail.title || `发布${detail.appName}`
this.submit.buildId = detail.buildId
this.submit.buildSeq = detail.buildSeq
this.submit.description = detail.description
this.submit.machineIdList = detail.machineIdList || []
this.visible = true
this.loading = true
// 加载构建列表
await this.loadBuildList()
// 加载发布机器
await this.loadReleaseMachine()
this.loading = false
},
async loadBuildList() {
await this.$api.getBuildReleaseList({
appId: this.detail.appId,
profileId: this.detail.profileId
}).then(({ data }) => {
this.buildList = data
})
},
async loadReleaseMachine() {
await this.$api.getAppMachineId({
id: this.detail.appId,
profileId: this.detail.profileId
}).then(({ data }) => {
if (data && data.length) {
this.appMachineIdList = data
if (!this.submit.machineIdList.length) {
this.submit.machineIdList = data
}
} else {
this.$message.warning('请先配置应用发布机器')
}
})
},
chooseMachines() {
const ref = this.$refs.machineChecker
if (!ref.checkedList.length) {
this.$message.warning('请选择发布机器机器')
return
}
this.submit.machineIdList = ref.checkedList
ref.hide()
},
async check() {
if (!this.submit.title) {
this.$message.warning('请输入发布标题')
return
}
if (!this.submit.machineIdList.length) {
this.$message.warning('请选择发布机器')
return
}
this.submit.buildSeq = this.buildList.filter(s => s.id === this.submit.buildId).map(s => s.seq)[0]
this.$emit('ok', this.detail.id, this.submit)
this.close()
},
close() {
this.visible = false
this.loading = false
}
},
filters: {
formatDate
}
}
</script>
<style lang="less" scoped>
.release-form-item-input {
width: 348px;
}
.reload {
font-size: 19px;
margin-left: 16px;
cursor: pointer;
color: #339AF0;
}
.reload:hover {
color: #228BE6;
}
.build-item {
display: flex;
justify-content: space-between;
.build-item-left {
display: flex;
align-items: center;
width: 276px;
}
.build-item-seq {
margin-right: 8px;
}
.build-item-message {
width: 200px;
display: block;
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
}
.build-item-date {
font-size: 12px;
margin-right: 4px;
}
}
::v-deep .build-selector .ant-select-selection-selected-value {
width: 100%;
}
</style>

View File

@@ -0,0 +1,74 @@
<template>
<a-modal v-model="visible"
title="设置定时执行"
:width="330"
:okButtonProps="{props: {disabled: !valid}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="submit"
@cancel="close">
<a-spin :spinning="loading">
<div class="">
<span class="normal-label mr8">调度时间 </span>
<a-date-picker v-model="timedExecTime" :showTime="true" format="YYYY-MM-DD HH:mm:ss"/>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { PIPELINE_STATUS, TIMED_TYPE } from '@/lib/enum'
import moment from 'moment'
export default {
name: 'AppPipelineExecTimedModal',
data() {
return {
record: null,
visible: false,
loading: false,
timedExecTime: undefined
}
},
computed: {
valid() {
if (!this.timedExecTime) {
return false
}
return Date.now() < this.timedExecTime
}
},
methods: {
open(record) {
this.record = record
this.timedExecTime = (record.timedExecTime && moment(record.timedExecTime)) || undefined
this.visible = true
},
submit() {
const time = this.timedExecTime.unix() * 1000
this.loading = true
this.$api.setAppPipelineTaskTimedExec({
id: this.record.id,
timedExecTime: time
}).then(() => {
this.loading = false
this.visible = false
this.record.timedExecTime = time
this.record.status = PIPELINE_STATUS.WAIT_SCHEDULE.value
this.record.timedExec = TIMED_TYPE.TIMED.value
this.$emit('updated')
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,73 @@
<template>
<a-select v-model="id"
:disabled="disabled"
:placeholder="placeholder"
@change="$emit('change', id)"
allowClear>
<a-select-option v-for="pipeline in pipelineList"
:value="pipeline.id"
:key="pipeline.id">
{{ pipeline.name }}
</a-select-option>
</a-select>
</template>
<script>
export default {
name: 'AppPipelineSelector',
props: {
disabled: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: '全部'
},
value: {
type: Number,
default: undefined
}
},
data() {
return {
id: undefined,
pipelineList: []
}
},
watch: {
value(e) {
this.id = e
}
},
methods: {
reset() {
this.id = undefined
}
},
async created() {
// 读取当前环境
const activeProfile = this.$storage.get(this.$storage.keys.ACTIVE_PROFILE)
if (!activeProfile) {
this.$message.warning('请先维护应用环境')
return
}
const pipelineListRes = await this.$api.getAppPipelineList({
profileId: JSON.parse(activeProfile).id,
limit: 10000
})
if (pipelineListRes.data && pipelineListRes.data.rows && pipelineListRes.data.rows.length) {
for (const row of pipelineListRes.data.rows) {
this.pipelineList.push({
id: row.id,
name: row.name
})
}
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,95 @@
<template>
<!-- 统计折线图 -->
<div class="statistic-chart-container">
<p class="statistics-description">近七天执行统计</p>
<a-spin :spinning="loading">
<!-- 折线图 -->
<div class="statistic-chart-wrapper">
<div id="statistic-chart"/>
</div>
</a-spin>
</div>
</template>
<script>
import { Chart } from '@antv/g2'
export default {
name: 'AppPipelineStatisticsCharts',
data() {
return {
loading: false,
chart: null
}
},
methods: {
async init(pipelineId) {
this.loading = true
const { data } = await this.$api.getAppPipelineTaskStatisticsChart({
pipelineId
})
this.loading = false
this.renderChart(data)
},
clean() {
this.loading = false
this.chart && this.chart.destroy()
this.chart = null
},
renderChart(data) {
// 处理数据
const chartsData = []
for (const d of data) {
chartsData.push({
date: d.date,
type: '执行次数',
value: d.execCount
})
chartsData.push({
date: d.date,
type: '成功次数',
value: d.successCount
})
chartsData.push({
date: d.date,
type: '失败次数',
value: d.failureCount
})
}
this.clean()
// 渲染图表
this.chart = new Chart({
container: 'statistic-chart',
autoFit: true
})
this.chart.data(chartsData)
this.chart.tooltip({
showCrosshairs: true,
shared: true
})
this.chart.line()
.position('date*value')
.color('type')
.shape('circle')
this.chart.render()
}
},
beforeDestroy() {
this.clean()
}
}
</script>
<style lang="less" scoped>
.statistic-chart-wrapper {
margin: 0 24px 16px 24px;
#statistic-chart {
height: 500px;
}
}
</style>

View File

@@ -0,0 +1,123 @@
<template>
<a-spin :spinning="loading">
<!-- 全部执行指标 -->
<p class="statistics-description">全部执行指标</p>
<div class="app-pipeline-statistic-metrics">
<div class="clean"/>
<!-- 统计指标 -->
<div class="app-pipeline-statistic-header">
<a-statistic class="statistic-metrics-item" title="执行次数" :value="allMetrics.execCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item green" title="成功次数" :value="allMetrics.successCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item red" title="失败次数" :value="allMetrics.failureCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item" title="平均耗时" :value="allMetrics.avgUsedInterval"/>
</div>
<div class="clean"/>
</div>
<!-- 近七天执行指标 -->
<p class="statistics-description" style="margin-top: 28px">近七天执行指标</p>
<div class="app-pipeline-statistic-metrics">
<div class="clean"/>
<!-- 统计指标 -->
<div class="app-pipeline-statistic-header">
<a-statistic class="statistic-metrics-item" title="执行次数" :value="latelyMetrics.execCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item green" title="成功次数" :value="latelyMetrics.successCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item red" title="失败次数" :value="latelyMetrics.failureCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item" title="平均耗时" :value="latelyMetrics.avgUsedInterval"/>
</div>
<div class="clean"/>
</div>
</a-spin>
</template>
<script>
export default {
name: 'AppPipelineStatisticsMetrics',
data() {
return {
loading: false,
allMetrics: {
execCount: 0,
successCount: 0,
failureCount: 0,
avgUsedInterval: 0
},
latelyMetrics: {
execCount: 0,
successCount: 0,
failureCount: 0,
avgUsedInterval: 0
}
}
},
methods: {
async init(pipelineId) {
this.loading = true
const { data } = await this.$api.getAppPipelineTaskStatisticsMetrics({
pipelineId
})
this.loading = false
this.allMetrics = data.all
this.latelyMetrics = data.lately
},
clean() {
this.loading = false
this.allMetrics = {
execCount: 0,
successCount: 0,
failureCount: 0,
avgUsedInterval: 0
}
this.latelyMetrics = {
execCount: 0,
successCount: 0,
failureCount: 0,
avgUsedInterval: 0
}
}
},
beforeDestroy() {
this.clean()
}
}
</script>
<style lang="less" scoped>
.app-pipeline-statistic-metrics {
display: flex;
justify-content: center;
margin: 24px 0 12px 16px;
.app-pipeline-statistic-header {
display: flex;
.statistic-metrics-item {
margin: 0 16px;
}
::v-deep .statistic-metrics-item.green .ant-statistic-content {
color: #58C612;
}
::v-deep .statistic-metrics-item.red .ant-statistic-content {
color: #DD2C00;
}
.statistic-metrics-divider {
height: auto;
}
::v-deep .ant-statistic-content {
text-align: center;
}
}
}
</style>

View File

@@ -0,0 +1,347 @@
<template>
<div class="app-pipeline-statistic-record-view-container">
<a-spin :spinning="loading">
<!-- 流水线视图 -->
<div class="app-pipeline-statistic-record-view-wrapper" v-if="initialized && view">
<div class="app-pipeline-statistic-record-view">
<p class="statistics-description">近十次执行视图</p>
<!-- 流水线视图主体 -->
<div class="app-pipeline-statistic-main">
<!-- 流水线操作 -->
<div class="app-pipeline-details-wrapper">
<!-- 平均时间 -->
<div class="app-pipeline-details-legend">
<span class="avg-used-legend-wrapper">
<span>平均执行时间: </span>
<span class="avg-used-legend">{{ view.avgUsedInterval }}</span>
</span>
</div>
<!-- 流水线操作 -->
<div class="app-pipeline-details" v-for="(detail, index) of view.details" :key="detail.id">
<div :class="['app-pipeline-details-name', index % 2 === 0 ? 'app-pipeline-details-name-theme1' : 'app-pipeline-details-name-theme2']">
<span class="span-blue mr4">{{ detail.stageType | formatStageType('label') }}</span>
<span>{{ detail.appName }}</span>
</div>
<div :class="['app-pipeline-details-avg', index % 2 === 0 ? 'app-pipeline-details-avg-theme1' : 'app-pipeline-details-avg-theme2']">
<div class="app-pipeline-details-avg">
{{ detail.avgUsedInterval }}
</div>
</div>
</div>
</div>
<!-- 流水线日志 -->
<div class="app-pipeline-detail-logs-container">
<!-- 日志 -->
<div class="app-pipeline-detail-logs-wrapper"
v-for="pipelineTask of view.pipelineTaskList"
:key="pipelineTask.id">
<!-- 流水线信息头 -->
<div class="app-pipeline-record-legend">
<!-- 流水线状态 -->
<div class="app-pipeline-record-status">
<!-- 流水线序列 -->
<div class="pipeline-title">
<span class="span-blue">{{ pipelineTask.title }}</span>
</div>
<!-- 流水线状态 -->
<a-tag class="m0" :color="pipelineTask.status | formatPipelineStatus('color')">
{{ pipelineTask.status | formatPipelineStatus('label') }}
</a-tag>
</div>
<!-- 流水线时间 -->
<div class="app-pipeline-record-info">
<span>{{ pipelineTask.execDate | formatDate('MM-dd HH:mm') }}</span>
<span v-if="pipelineTask.usedInterval" class="ml4"> (used: {{ pipelineTask.usedInterval }})</span>
</div>
</div>
<!-- 流水线操作 -->
<div class="app-pipeline-detail-log-details-wrapper">
<div class="app-pipeline-detail-log-detail-wrapper"
v-for="(detailLog, index) of pipelineTask.details" :key="index">
<!-- 流水线操作值 -->
<div class="app-pipeline-detail-log-detail"
v-if="!getCanOpenLog(detailLog)"
:style="getDetailLogStyle(detailLog)"
v-text="getDetailLogValue(detailLog)">
</div>
<!-- 可打开日志 -->
<a v-else target="_blank"
:title="detailLog.stageType | formatStageLogTitle"
:href="detailLog.stageType | formatStageLogSrc(detailLog.relId)"
@click="openLogView($event, detailLog.stageType, detailLog.relId)">
<div class="app-pipeline-detail-log-detail"
:style="getDetailLogStyle(detailLog)"
v-text="getDetailLogValue(detailLog)">
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 无数据 -->
<div style="padding: 0 16px" v-else-if="initialized && !view">
无执行记录
</div>
</a-spin>
<!-- 事件 -->
<div class="app-pipeline-statistic-event-container">
<!-- 构建日志模态框 -->
<AppBuildLogAppenderModal ref="buildLogView"/>
<!-- 发布日志模态框 -->
<AppReleaseLogAppenderModal ref="releaseLogView"/>
</div>
</div>
</template>
<script>
import { formatDate } from '@/lib/filters'
import { enumValueOf, PIPELINE_DETAIL_STATUS, PIPELINE_STATUS, STAGE_TYPE } from '@/lib/enum'
import AppBuildLogAppenderModal from '@/components/log/AppBuildLogAppenderModal'
import AppReleaseLogAppenderModal from '@/components/log/AppReleaseLogAppenderModal'
export default {
name: 'AppPipelineStatisticsViews',
components: {
AppBuildLogAppenderModal,
AppReleaseLogAppenderModal
},
data() {
return {
STAGE_TYPE,
loading: false,
initialized: false,
view: {}
}
},
methods: {
async init(pipelineId) {
this.loading = true
this.initialized = false
const { data } = await this.$api.getAppPipelineTaskStatisticsView({
pipelineId
})
this.view = data
this.initialized = true
this.loading = false
},
clean() {
this.initialized = false
this.loading = false
this.view = {}
},
async refresh(pipelineId) {
const { data } = await this.$api.getAppPipelineTaskStatisticsView({
pipelineId
})
this.view = data
},
openLogView(e, type, id) {
const stageType = enumValueOf(STAGE_TYPE, type).symbol
if (!e.ctrlKey) {
e.preventDefault()
// 打开模态框
this.$refs[`${stageType}LogView`].open(id)
return false
} else {
// 跳转页面
return true
}
},
getCanOpenLog(detailLog) {
if (detailLog) {
return enumValueOf(PIPELINE_DETAIL_STATUS, detailLog.status).log
} else {
return false
}
},
getDetailLogStyle(detailLog) {
if (detailLog) {
return enumValueOf(PIPELINE_DETAIL_STATUS, detailLog.status).actionStyle
} else {
return {
background: '#FFD43B'
}
}
},
getDetailLogValue(detailLog) {
if (detailLog) {
return enumValueOf(PIPELINE_DETAIL_STATUS, detailLog.status).actionValue(detailLog)
} else {
return '未执行'
}
}
},
filters: {
formatDate,
formatStageType(type, f) {
return enumValueOf(STAGE_TYPE, type)[f]
},
formatStageLogTitle(type) {
return `点击查看${enumValueOf(STAGE_TYPE, type).label}日志`
},
formatStageLogSrc(type, relId) {
return `#/app/${enumValueOf(STAGE_TYPE, type).symbol}/log/view/${relId}`
},
formatPipelineStatus(status, f) {
return enumValueOf(PIPELINE_STATUS, status)[f]
}
},
beforeDestroy() {
this.clean()
}
}
</script>
<style lang="less" scoped>
.app-pipeline-statistic-record-view-wrapper {
margin: 0 24px 24px 24px;
overflow: auto;
.app-pipeline-statistic-record-view {
margin: 0 16px 16px 16px;
border-radius: 4px;
display: inline-block;
}
.app-pipeline-statistic-main {
display: inline-block;
box-shadow: 0 0 4px 1px #DEE2E6;
}
.app-pipeline-details-wrapper {
display: flex;
min-height: 82px;
.app-pipeline-details-legend {
width: 180px;
display: flex;
align-items: flex-end;
justify-content: flex-end;
padding: 4px 8px 4px 0;
font-weight: 600;
.avg-used-legend-wrapper {
display: flex;
align-items: center;
}
.avg-used-legend {
margin-left: 4px;
color: #000;
font-size: 16px;
}
}
.app-pipeline-details {
display: flex;
flex-direction: column;
width: 148px;
.app-pipeline-details-name {
height: 100%;
font-size: 15px;
color: #181E33;
font-weight: 600;
line-height: 18px;
display: flex;
align-items: center;
justify-content: center;
padding: 4px 16px;
border-bottom: 1px solid #DEE2E6;
}
.app-pipeline-details-name-theme1 {
background: #F1F3F5;
}
.app-pipeline-details-name-theme2 {
background: #F8F9FA;
}
.app-pipeline-details-avg {
text-align: center;
height: 28px;
padding: 2px;
}
.app-pipeline-details-avg-theme1 {
background: #E9ECEF;
}
.app-pipeline-details-avg-theme2 {
background: #F1F4F7;
}
}
}
.app-pipeline-detail-logs-wrapper {
display: flex;
height: 74px;
.app-pipeline-record-legend {
width: 180px;
height: 100%;
padding: 8px;
border-radius: 4px;
display: flex;
align-items: flex-start;
flex-direction: column;
justify-content: space-between;
border-top: 2px solid #F8F9FA;
.app-pipeline-record-status {
margin-bottom: 8px;
display: flex;
justify-content: space-between;
width: 100%;
}
.pipeline-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 112px;
display: inline-block;
}
.app-pipeline-record-info {
color: #000000;
font-size: 12px;
}
}
.app-pipeline-detail-log-details-wrapper {
display: flex;
.app-pipeline-detail-log-detail-wrapper {
width: 148px;
border-radius: 4px;
.app-pipeline-detail-log-detail {
margin: 2px 1px;
height: calc(100% - 2px);
border-radius: 4px;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-around;
opacity: 0.8;
color: rgba(0, 0, 0, .8);
}
.app-pipeline-detail-log-detail:hover {
opacity: 1;
transition: .3s;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,226 @@
<template>
<a-drawer title="执行详情"
placement="right"
:visible="visible"
:maskStyle="{opacity: 0, animation: 'none'}"
:width="430"
@close="onClose">
<!-- 加载中 -->
<div v-if="loading">
<a-skeleton active :paragraph="{rows: 12}"/>
</div>
<!-- 加载完成 -->
<div v-else>
<!-- 流水线信息 -->
<a-descriptions size="middle">
<a-descriptions-item label="流水线名称" :span="3">
{{ detail.pipelineName }}
</a-descriptions-item>
<a-descriptions-item label="执行标题" :span="3">
{{ detail.title }}
</a-descriptions-item>
<a-descriptions-item label="环境名称" :span="3">
{{ detail.profileName }}
</a-descriptions-item>
<a-descriptions-item label="执行描述" :span="3" v-if="detail.description != null">
{{ detail.description }}
</a-descriptions-item>
<a-descriptions-item label="执行状态" :span="3">
<a-tag :color="detail.status | formatPipelineStatus('color')">
{{ detail.status | formatPipelineStatus('label') }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="调度时间" :span="3" v-if="detail.timedExecTime !== null">
{{ detail.timedExecTime | formatDate }}
</a-descriptions-item>
<a-descriptions-item label="创建用户" :span="3">
{{ detail.createUserName }}
</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="3" v-if="detail.createTime !== null">
{{ detail.createTime | formatDate }}
</a-descriptions-item>
<a-descriptions-item label="审核用户" :span="3" v-if="detail.auditUserName !== null">
{{ detail.auditUserName }}
</a-descriptions-item>
<a-descriptions-item label="审核时间" :span="3" v-if="detail.auditTime !== null">
{{ detail.auditTime | formatDate }}
</a-descriptions-item>
<a-descriptions-item label="审核批注" :span="3" v-if="detail.auditReason !== null">
{{ detail.auditReason }}
</a-descriptions-item>
<a-descriptions-item label="执行用户" :span="3" v-if=" detail.execUserName !== null">
{{ detail.execUserName }}
</a-descriptions-item>
<a-descriptions-item label="开始时间" :span="3" v-if="detail.execStartTime !== null">
{{ detail.execStartTime | formatDate }} ({{ detail.execStartTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="结束时间" :span="3" v-if="detail.execEndTime !== null">
{{ detail.execEndTime | formatDate }} ({{ detail.execEndTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="持续时间" :span="3" v-if="detail.used !== null">
{{ `${detail.keepTime} (${detail.used}ms)` }}
</a-descriptions-item>
</a-descriptions>
<!-- 流水线操作 -->
<a-divider>流水线操作</a-divider>
<a-list size="small" :dataSource="detail.details">
<template #renderItem="item">
<a-list-item>
<a-descriptions size="middle">
<a-descriptions-item label="执行操作" :span="3">
<span class="span-blue">
{{ item.stageType | formatStageType('label') }}
</span>
{{ item.appName }}
</a-descriptions-item>
<a-descriptions-item label="执行状态" :span="3">
<a-tag :color="item.status | formatPipelineDetailStatus('color')">
{{ item.status | formatPipelineDetailStatus('label') }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="构建分支" :span="3" v-if="item.stageType === STAGE_TYPE.BUILD.value && item.config.branchName">
<a-icon type="branches"/>
{{ item.config.branchName }}
<a-tooltip v-if="item.config.commitId">
<template #title>
{{ item.config.commitId }}
</template>
<span class="span-blue">
#{{ item.config.commitId.substring(0, 7) }}
</span>
</a-tooltip>
</a-descriptions-item>
<a-descriptions-item label="发布版本" :span="3" v-if="item.stageType === STAGE_TYPE.RELEASE.value">
<span class="span-blue">
{{ item.config.buildSeq ? `#${item.config.buildSeq}` : '最新版本' }}
</span>
</a-descriptions-item>
<a-descriptions-item label="发布机器" :span="3" v-if="item.stageType === STAGE_TYPE.RELEASE.value">
<span v-if="item.config.machineIdList && item.config.machineIdList.length" class="span-blue">
{{ item.config.machineList.map(s => s.name).join(', ') }}
</span>
<span v-else class="span-blue">全部机器</span>
</a-descriptions-item>
<a-descriptions-item label="开始时间" :span="3" v-if="item.startTime !== null">
{{ item.startTime | formatDate }} ({{ item.startTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="结束时间" :span="3" v-if="item.endTime !== null">
{{ item.endTime | formatDate }} ({{ item.endTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="持续时间" :span="3" v-if="item.keepTime !== null">
{{ `${item.keepTime} (${item.used}ms)` }}
</a-descriptions-item>
</a-descriptions>
</a-list-item>
</template>
</a-list>
</div>
</a-drawer>
</template>
<script>
import { formatDate } from '@/lib/filters'
import { enumValueOf, PIPELINE_DETAIL_STATUS, PIPELINE_STATUS, STAGE_TYPE } from '@/lib/enum'
export default {
name: 'AppPipelineTaskDetailDrawer',
data() {
return {
STAGE_TYPE,
visible: false,
loading: true,
pollId: null,
detail: {}
}
},
methods: {
open(id) {
// 关闭轮询状态
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
this.detail = {}
this.visible = true
this.loading = true
this.$api.getAppPipelineTaskDetail({
id
}).then(({ data }) => {
this.loading = false
this.detail = data
// 轮询状态
if (data.status === PIPELINE_STATUS.WAIT_AUDIT.value ||
data.status === PIPELINE_STATUS.AUDIT_REJECT.value ||
data.status === PIPELINE_STATUS.WAIT_RUNNABLE.value ||
data.status === PIPELINE_STATUS.WAIT_SCHEDULE.value ||
data.status === PIPELINE_STATUS.RUNNABLE.value) {
this.pollId = setInterval(this.pollStatus, 5000)
}
}).catch(() => {
this.loading = false
})
},
onClose() {
this.visible = false
// 关闭轮询状态
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
},
pollStatus() {
if (!this.detail || !this.detail.status) {
return
}
if (this.detail.status !== PIPELINE_STATUS.WAIT_AUDIT.value &&
this.detail.status !== PIPELINE_STATUS.AUDIT_REJECT.value &&
this.detail.status !== PIPELINE_STATUS.WAIT_RUNNABLE.value &&
this.detail.status !== PIPELINE_STATUS.WAIT_SCHEDULE.value &&
this.detail.status !== PIPELINE_STATUS.RUNNABLE.value) {
clearInterval(this.pollId)
this.pollId = null
return
}
this.$api.geAppPipelineTaskStatus({
id: this.detail.id
}).then(({ data }) => {
this.detail.status = data.status
this.detail.used = data.used
this.detail.keepTime = data.keepTime
this.detail.execStartTime = data.startTime
this.detail.execStartTimeAgo = data.startTimeAgo
this.detail.execEndTime = data.endTime
this.detail.execEndTimeAgo = data.endTimeAgo
if (data.details && data.details.length) {
for (const detail of data.details) {
this.detail.details.filter(s => s.id === detail.id).forEach(s => {
s.status = detail.status
s.keepTime = detail.keepTime
s.used = detail.used
s.startTime = detail.startTime
s.startTimeAgo = detail.startTimeAgo
s.endTime = detail.endTime
s.endTimeAgo = detail.endTimeAgo
})
}
}
})
}
},
filters: {
formatDate,
formatPipelineStatus(status, f) {
return enumValueOf(PIPELINE_STATUS, status)[f]
},
formatPipelineDetailStatus(status, f) {
return enumValueOf(PIPELINE_DETAIL_STATUS, status)[f]
},
formatStageType(type, f) {
return enumValueOf(STAGE_TYPE, type)[f]
}
}
}
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,162 @@
<template>
<a-popover v-model="visible"
:destroyTooltipOnHide="true"
trigger="click"
overlayClassName="profile-content-list-popover"
placement="bottom">
<!-- 标题 -->
<template #title>
<div class="profile-title">
<a-checkbox v-if="profiles.length"
:indeterminate="indeterminate"
:checked="checkAll"
@change="chooseAll">
全选
</a-checkbox>
<div v-else/>
<a @click="hide">关闭</a>
</div>
</template>
<!-- 内容 -->
<template #content>
<div class="profile-content">
<a-spin :spinning="loading">
<!-- 复选框 -->
<div class="profile-list-wrapper" v-if="profiles.length">
<div class="profile-list">
<a-checkbox-group v-model="checkedList" @change="onChange">
<a-row v-for="(option, index) of profiles" :key="index" style="margin: 4px 0">
<a-checkbox :value="option.id" :disabled="checkDisabled(option.id)">
{{ option.name }}
</a-checkbox>
</a-row>
</a-checkbox-group>
</div>
</div>
<div class="profile-list-empty" v-if="empty">
<a-empty/>
</div>
<!-- 分割线 -->
<a-divider class="content-divider"/>
<!-- 底部栏 -->
<div class="profile-button-tools">
<slot name="footer"/>
</div>
</a-spin>
</div>
</template>
<!-- 触发器 -->
<slot name="trigger"/>
</a-popover>
</template>
<script>
export default {
name: 'AppProfileChecker',
data() {
return {
visible: false,
init: false,
indeterminate: false,
checkAll: false,
loading: false,
checkedList: [],
profiles: [],
empty: false
}
},
watch: {
visible(e) {
if (e && !this.init) {
this.initData()
}
}
},
methods: {
initData() {
this.loading = true
this.init = true
this.$api.fastGetProfileList()
.then(({ data }) => {
this.loading = false
if (data && data.length) {
this.profiles = data.map(s => {
return {
id: s.id,
name: s.name
}
})
} else {
this.empty = true
}
})
.catch(() => {
this.loading = false
this.init = false
})
},
hide() {
this.visible = false
},
onChange(checkedList) {
this.indeterminate = !!checkedList.length && checkedList.length < this.profiles.length - 1
this.checkAll = checkedList.length === this.profiles.length - 1
},
chooseAll(e) {
Object.assign(this, {
checkedList: e.target.checked
? this.profiles.map(d => d.id).filter(id => !this.checkDisabled(id))
: [],
indeterminate: false,
checkAll: e.target.checked
})
},
checkDisabled(id) {
const activeProfile = this.$storage.get(this.$storage.keys.ACTIVE_PROFILE)
if (activeProfile) {
return JSON.parse(activeProfile).id === id
}
return false
},
clear() {
this.checkedList = []
this.checkAll = false
this.indeterminate = false
}
}
}
</script>
<style lang="less" scoped>
.profile-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.profile-list-wrapper {
padding: 4px 2px 4px 16px;
.profile-list {
max-height: 130px;
width: 100%;
overflow-y: auto;
}
}
.profile-list-empty {
padding: 4px 0;
}
.content-divider {
margin: 0;
}
.profile-button-tools {
padding: 4px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<a-modal v-model="visible"
title="发布审核"
:maskClosable="false"
:destroyOnClose="true"
:width="550">
<a-spin :spinning="loading">
<div class="audit-description">
<span class="description-label normal-label mr8">审核描述</span>
<a-textarea class="description-area" v-model="description" :maxLength="64"/>
</div>
</a-spin>
<!-- 操作 -->
<template #footer>
<a-button @click="audit(false)" :disabled="loading">驳回</a-button>
<a-button type="primary" @click="audit(true)" :disabled="loading">通过</a-button>
</template>
</a-modal>
</template>
<script>
import { AUDIT_STATUS } from '@/lib/enum'
export default {
name: 'AppReleaseAuditModal',
data() {
return {
id: null,
visible: false,
loading: false,
description: null
}
},
methods: {
open(id) {
this.visible = true
this.loading = false
this.id = id
},
audit(res) {
if (!res && !this.description) {
this.$message.warning('请输入驳回描述')
return
}
this.loading = true
this.$api.auditAppRelease({
id: this.id,
status: res ? AUDIT_STATUS.RESOLVE.value : AUDIT_STATUS.REJECT.value,
reason: this.description
}).then(() => {
this.$message.success('审核完成')
this.$emit('audit', this.id, res)
this.close()
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
this.description = null
this.id = null
}
}
}
</script>
<style lang="less" scoped>
.audit-description {
display: flex;
align-items: baseline;
.description-label {
width: 80px;
}
.description-area {
height: 60px;
}
}
</style>

View File

@@ -0,0 +1,457 @@
<template>
<div id="app-release-conf-container">
<a-spin :spinning="loading">
<!-- 发布机器 -->
<div id="app-machine-wrapper">
<span class="label normal-label required-label">发布机器</span>
<MachineChecker style="margin-left: 8px"
ref="machineChecker"
:defaultValue="machines"
:query="machineQuery">
<template #trigger>
<span class="span-blue pointer">已选择 {{ machines.length }} 台机器</span>
</template>
<template #footer>
<a-button type="primary" size="small" @click="chooseMachines">确定</a-button>
</template>
</MachineChecker>
</div>
<!-- 发布序列 -->
<div id="app-release-serial-wrapper">
<span class="label normal-label required-label">发布序列</span>
<a-radio-group class="ml8" v-model="releaseSerial">
<a-radio v-for="type of SERIAL_TYPE" :key="type.value" :value="type.value">
{{ type.label }}
</a-radio>
</a-radio-group>
</div>
<!-- 异常处理 -->
<div id="app-release-exception-wrapper" v-show="releaseSerial === SERIAL_TYPE.SERIAL.value">
<span class="label normal-label required-label">异常处理</span>
<a-radio-group class="ml8" v-model="exceptionHandler">
<a-tooltip v-for="type of EXCEPTION_HANDLER_TYPE" :key="type.value" :title="type.title">
<a-radio :value="type.value">
{{ type.label }}
</a-radio>
</a-tooltip>
</a-radio-group>
</div>
<!-- 发布操作 -->
<div id="app-action-container">
<template v-for="(action, index) in actions">
<div class="app-action-block" :key="index" v-if="action.visible">
<!-- 分隔符 -->
<a-divider class="action-divider">发布操作{{ index + 1 }}</a-divider>
<div class="app-action-wrapper">
<!-- 操作 -->
<div class="app-action">
<!-- 名称 -->
<div class="action-name-wrapper">
<span class="label action-label normal-label required-label">操作名称</span>
<a-input class="action-name-input" v-model="action.name" :maxLength="32" placeholder="操作名称"/>
</div>
<!-- 代码块 -->
<div class="action-editor-wrapper" v-if="action.type === RELEASE_ACTION_TYPE.COMMAND.value">
<span class="label action-label normal-label required-label">目标主机命令</span>
<div class="app-action-editor">
<Editor :config="editorConfig" :value="action.command" @change="(v) => action.command = v"/>
</div>
</div>
<!-- 文件传输方式 -->
<div class="action-transfer-wrapper" v-if="action.type === RELEASE_ACTION_TYPE.TRANSFER.value">
<span class="label action-label normal-label required-label">文件传输方式</span>
<!-- 类型选择 -->
<a-select class="transfer-input" v-model="transferMode">
<a-select-option v-for="type of RELEASE_TRANSFER_MODE" :key="type.value" :value="type.value">
{{ type.label }}
</a-select-option>
</a-select>
</div>
<!-- 文件传输路径 -->
<div class="action-transfer-wrapper" v-if="action.type === RELEASE_ACTION_TYPE.TRANSFER.value">
<span class="label action-label normal-label required-label">文件传输路径</span>
<a-textarea class="transfer-input"
v-model="transferPath"
:autoSize="{minRows: 1}"
:maxLength="1024"
placeholder="目标机器产物传输绝对路径, 路径不能包含 \ 应该用 / 替换"/>
</div>
<!-- 文件传输类型 -->
<div class="action-transfer-wrapper" v-if="action.type === RELEASE_ACTION_TYPE.TRANSFER.value">
<span class="label action-label normal-label required-label">文件传输类型</span>
<!-- 文件传输类型 -->
<a-select class="transfer-input help-input" v-model="transferFileType">
<a-select-option v-for="type of RELEASE_TRANSFER_FILE_TYPE" :key="type.value" :value="type.value">
{{ type.label }}
</a-select-option>
</a-select>
<!-- 描述 -->
<a-popover placement="top">
<template slot="content">
如构建产物为普通文件选择 (文件 / 文件夹)<br/>
如构建产物为文件夹且传输整个文件夹选择 (文件 / 文件夹)<br/>
如构建产物为文件夹且传输文件夹zip选择 (文件夹zip)
</template>
<a-icon class="help-trigger" type="question-circle"/>
</a-popover>
</div>
<!-- scp 传输命令 -->
<div class="action-transfer-wrapper"
v-if="action.type === RELEASE_ACTION_TYPE.TRANSFER.value && transferMode === RELEASE_TRANSFER_MODE.SCP.value">
<span class="label action-label normal-label required-label"> scp 传输命令</span>
<!-- scp 传输命令 -->
<a-textarea class="transfer-input help-input"
v-model="action.command"
:autoSize="{minRows: 1}"
:maxLength="1024"
placeholder="目标机器和宿主机需要建立 ssh 免密登录"/>
<!-- scp 传输命令描述 -->
<a-popover placement="top">
<template slot="content">
bundle_path&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;构建产物路径<br/>
target_username&nbsp;&nbsp;目标机器用户<br/>
target_host&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;目标机器主机<br/>
transfer_path&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;传输路径
</template>
<a-icon class="help-trigger" type="question-circle"/>
</a-popover>
</div>
</div>
<!-- 操作 -->
<div class="app-action-handler">
<a-button-group v-if="actions.length > 1">
<a-button title="移除" @click="removeAction(index)" icon="minus-circle"/>
<a-button title="上移" v-if="index !== 0" @click="swapAction(index, index - 1)" icon="arrow-up"/>
<a-button title="下移" v-if="index !== actions.length - 1" @click="swapAction(index + 1, index )" icon="arrow-down"/>
</a-button-group>
</div>
</div>
</div>
</template>
</div>
<!-- 底部按钮 -->
<div id="app-action-footer">
<a-button class="app-action-footer-button" type="dashed"
@click="addAction(RELEASE_ACTION_TYPE.COMMAND.value)">
添加命令操作 (发布机器执行)
</a-button>
<a-button class="app-action-footer-button" type="dashed"
v-if="visibleAddTransfer"
@click="addAction(RELEASE_ACTION_TYPE.TRANSFER.value)">
添加传输操作 (构建产物传输至发布机器)
</a-button>
<a-button class="app-action-footer-button" type="primary" @click="save">保存</a-button>
</div>
</a-spin>
</div>
</template>
<script>
import { ENABLE_STATUS, EXCEPTION_HANDLER_TYPE, RELEASE_ACTION_TYPE, RELEASE_TRANSFER_FILE_TYPE, RELEASE_TRANSFER_MODE, SERIAL_TYPE } from '@/lib/enum'
import Editor from '@/components/editor/Editor'
import MachineChecker from '@/components/machine/MachineChecker'
const editorConfig = {
enableLiveAutocompletion: true,
fontSize: 14
}
export default {
name: 'AppReleaseConfigForm',
props: {
appId: Number,
dataLoading: Boolean,
detail: Object
},
components: {
Editor,
MachineChecker
},
computed: {
visibleAddTransfer() {
return this.actions.map(s => s.type).filter(t => t === RELEASE_ACTION_TYPE.TRANSFER.value).length < 1
}
},
watch: {
detail(e) {
this.initData(e)
},
dataLoading(e) {
this.loading = e
}
},
data() {
return {
RELEASE_ACTION_TYPE,
RELEASE_TRANSFER_MODE,
RELEASE_TRANSFER_FILE_TYPE,
SERIAL_TYPE,
EXCEPTION_HANDLER_TYPE,
loading: false,
profileId: null,
transferPath: undefined,
transferMode: RELEASE_TRANSFER_MODE.SCP.value,
transferFileType: RELEASE_TRANSFER_FILE_TYPE.NORMAL.value,
releaseSerial: SERIAL_TYPE.PARALLEL.value,
exceptionHandler: EXCEPTION_HANDLER_TYPE.SKIP_ALL.value,
machines: [],
actions: [],
editorConfig,
machineQuery: { status: ENABLE_STATUS.ENABLE.value }
}
},
methods: {
initData(detail) {
this.profileId = detail.profileId
this.transferPath = detail.env && detail.env.transferPath
this.transferMode = detail.env && detail.env.transferMode
this.transferFileType = detail.env && detail.env.transferFileType
this.releaseSerial = detail.env && detail.env.releaseSerial
this.exceptionHandler = detail.env && detail.env.exceptionHandler
if (detail.releaseMachines && detail.releaseMachines.length) {
this.machines = detail.releaseMachines.map(s => s.machineId)
} else {
this.machines = []
}
if (detail.releaseActions && detail.releaseActions.length) {
this.actions = detail.releaseActions.map(s => {
return {
visible: true,
name: s.name,
type: s.type,
command: s.command
}
})
} else {
this.actions = []
}
},
chooseMachines() {
const ref = this.$refs.machineChecker
this.machines = ref.checkedList
ref.hide()
},
addAction(type) {
const action = {
type,
name: undefined,
visible: true
}
if (RELEASE_ACTION_TYPE.TRANSFER.value === type) {
action.command = 'scp "@{bundle_path}" @{target_username}@@{target_host}:"@{transfer_path}"'
} else {
action.command = ''
}
this.actions.push(action)
},
removeAction(index) {
this.actions[index].visible = false
this.$nextTick(() => {
this.actions.splice(index, 1)
})
},
swapAction(index, target) {
const temp = this.actions[target]
this.$set(this.actions, target, this.actions[index])
this.$set(this.actions, index, temp)
},
save() {
if (!this.machines.length) {
this.$message.warning('请选择发布机器')
return
}
if (!this.actions.length) {
this.$message.warning('请设置发布操作')
return
}
for (let i = 0; i < this.actions.length; i++) {
const action = this.actions[i]
if (!action.name) {
this.$message.warning(`请输入操作名称 [发布操作${i + 1}]`)
return
}
if (RELEASE_ACTION_TYPE.COMMAND.value === action.type) {
if (!action.command || !action.command.trim().length) {
this.$message.warning(`请输入操作命令 [发布操作${i + 1}]`)
return
} else if (action.command.length > 2048) {
this.$message.warning(`操作命令长度不能大于2048位 [发布操作${i + 1}] 当前: ${action.command.length}`)
return
}
} else if (RELEASE_ACTION_TYPE.TRANSFER.value === action.type) {
if (!this.transferPath || !this.transferPath.trim().length) {
this.$message.warning('传输操作 传输路径不能为空')
return
}
if (this.transferPath.includes('\\')) {
this.$message.warning('产物传输路径不能包含 \\ 应该用 / 替换')
return
}
if (RELEASE_TRANSFER_MODE.SCP.value === this.transferMode) {
if (!action.command || !action.command.trim().length) {
this.$message.warning('请输入 scp 传输命令')
return
}
}
}
}
this.loading = true
this.$api.configApp({
appId: this.appId,
profileId: this.profileId,
stageType: 20,
env: {
transferPath: this.transferPath,
transferMode: this.transferMode,
transferFileType: this.transferFileType,
releaseSerial: this.releaseSerial,
exceptionHandler: this.exceptionHandler
},
machineIdList: this.machines,
releaseActions: this.actions
}).then(() => {
this.$message.success('保存成功')
this.$emit('updated')
this.loading = false
}).catch(() => {
this.loading = false
})
}
},
mounted() {
this.initData(this.detail)
}
}
</script>
<style lang="less" scoped>
@label-width: 160px;
@action-handler-width: 120px;
@app-top-container-width: 994px;
@app-action-container-width: 994px;
@app-action-width: 876px;
@action-name-input-width: 700px;
@app-action-editor-width: 700px;
@app-action-editor-height: 250px;
@transfer-input-width: 700px;
@help-input-width: 670px;
@action-divider-min-width: 830px;
@action-divider-width: 990px;
@app-action-footer-width: 700px;
@footer-margin-left: 168px;
@desc-margin-left: 160px;
#app-release-conf-container {
padding: 18px 8px 0 8px;
overflow: auto;
.label {
width: @label-width;
font-size: 15px;
line-height: 32px;
}
#app-machine-wrapper {
width: @app-top-container-width;
display: flex;
align-items: center;
justify-content: flex-start;
align-content: center;
}
#app-release-serial-wrapper, #app-release-exception-wrapper {
width: @app-top-container-width;
display: flex;
align-items: center;
margin-top: 8px;
}
#app-action-container {
width: @app-action-container-width;
margin-top: 16px;
.app-action-wrapper {
width: 100%;
display: flex;
.action-label {
padding: 8px;
}
.app-action {
width: @app-action-width;
padding: 0 8px 8px 8px;
.action-name-wrapper {
display: flex;
align-items: center;
.action-name-input {
width: @action-name-input-width;
}
}
.action-editor-wrapper {
display: flex;
.app-action-editor {
width: @app-action-editor-width;
height: @app-action-editor-height;
margin-top: 8px;
}
}
.action-transfer-wrapper {
display: flex;
align-items: center;
.transfer-input {
width: @transfer-input-width;
}
}
}
.app-action-handler {
width: @action-handler-width;
margin: 8px 0 0 8px;
}
}
.action-divider {
min-width: @action-divider-min-width;
width: @action-divider-width;
margin: 16px 0;
}
}
#app-action-footer {
margin: 16px 0 8px @footer-margin-left;
width: @app-action-footer-width;
.app-action-footer-button {
width: 100%;
margin-bottom: 8px;
}
}
.config-description {
margin: 4px 0 0 @desc-margin-left;
display: block;
color: rgba(0, 0, 0, .45);
font-size: 13px;
}
.help-input {
width: @help-input-width !important;
}
.help-trigger {
cursor: pointer;
color: #1890FF;
font-size: 20px;
padding-left: 8px;
}
}
</style>

View File

@@ -0,0 +1,253 @@
<template>
<a-drawer title="发布详情"
placement="right"
:visible="visible"
:maskStyle="{opacity: 0, animation: 'none'}"
:width="430"
@close="onClose">
<!-- 加载中 -->
<div v-if="loading">
<a-skeleton active :paragraph="{rows: 12}"/>
</div>
<!-- 加载完成 -->
<div v-else>
<!-- 发布信息 -->
<a-descriptions size="middle">
<a-descriptions-item label="发布标题" :span="3">
{{ detail.title }}
</a-descriptions-item>
<a-descriptions-item label="应用名称" :span="3">
{{ detail.appName }}
</a-descriptions-item>
<a-descriptions-item label="环境名称" :span="3">
{{ detail.profileName }}
</a-descriptions-item>
<a-descriptions-item label="构建序列" :span="3">
<span class="span-blue">#{{ detail.buildSeq }}</span>
</a-descriptions-item>
<a-descriptions-item label="发布描述" :span="3" v-if="detail.description != null">
{{ detail.description }}
</a-descriptions-item>
<a-descriptions-item label="发布状态" :span="3">
<a-tag :color="detail.status | formatReleaseStatus('color')">
{{ detail.status | formatReleaseStatus('label') }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="发布类型" :span="3">
{{ detail.type | formatReleaseType('label') }}
-
{{ detail.serializer | formatSerialType('label') }}
<span v-if="detail.serializer === SERIAL_TYPE.SERIAL.value">
({{ detail.exceptionHandler | formatExceptionHandler('label') }})
</span>
</a-descriptions-item>
<a-descriptions-item label="调度时间" :span="3" v-if="detail.timedReleaseTime !== null">
{{ detail.timedReleaseTime | formatDate }}
</a-descriptions-item>
<a-descriptions-item label="创建用户" :span="3">
{{ detail.createUserName }}
</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="3" v-if="detail.createTime !== null">
{{ detail.createTime | formatDate }}
</a-descriptions-item>
<a-descriptions-item label="审核用户" :span="3" v-if="detail.auditUserName !== null">
{{ detail.auditUserName }}
</a-descriptions-item>
<a-descriptions-item label="审核时间" :span="3" v-if="detail.auditTime !== null">
{{ detail.auditTime | formatDate }}
</a-descriptions-item>
<a-descriptions-item label="审核批注" :span="3" v-if="detail.auditReason !== null">
{{ detail.auditReason }}
</a-descriptions-item>
<a-descriptions-item label="发布用户" :span="3" v-if=" detail.releaseUserName !== null">
{{ detail.releaseUserName }}
</a-descriptions-item>
<a-descriptions-item label="开始时间" :span="3" v-if="detail.startTime !== null">
{{ detail.startTime | formatDate }} ({{ detail.startTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="结束时间" :span="3" v-if="detail.endTime !== null">
{{ detail.endTime | formatDate }} ({{ detail.endTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="持续时间" :span="3" v-if="detail.used !== null">
{{ `${detail.keepTime} (${detail.used}ms)` }}
</a-descriptions-item>
</a-descriptions>
<!-- 发布机器 -->
<a-divider>发布机器</a-divider>
<a-list class="machine-list-container" size="small" bordered :dataSource="detail.machines">
<template #header>
<span class="span-blue"> {{ detail.machines.length }} 台机器</span>
</template>
<template #renderItem="item">
<a-list-item>
<span>{{ item.machineName }}</span>
<div>
<a @click="$copy(item.machineHost)">{{ item.machineHost }}</a>
<a-tag :color="item.status | formatActionStatus('color')" style="margin: 0 0 0 8px">
{{ item.status | formatActionStatus('label') }}
</a-tag>
</div>
</a-list-item>
</template>
</a-list>
<!-- 发布操作 -->
<a-divider>发布操作</a-divider>
<a-list size="small" :dataSource="detail.actions">
<template #renderItem="item">
<a-list-item>
<a-descriptions size="middle">
<a-descriptions-item label="操作名称" :span="3">
{{ item.name }}
</a-descriptions-item>
<a-descriptions-item label="操作类型" :span="3">
<a-tag>{{ item.type | formatReleaseActionType('label') }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="命令" :span="3" v-if="item.type === RELEASE_ACTION_TYPE.COMMAND.value">
<a @click="preview(item.command)">预览</a>
</a-descriptions-item>
</a-descriptions>
</a-list-item>
</template>
</a-list>
</div>
<!-- 事件 -->
<div class="detail-event">
<EditorPreview ref="preview"/>
</div>
</a-drawer>
</template>
<script>
import { formatDate } from '@/lib/filters'
import { enumValueOf, ACTION_STATUS, EXCEPTION_HANDLER_TYPE, RELEASE_ACTION_TYPE, RELEASE_STATUS, RELEASE_TYPE, SERIAL_TYPE } from '@/lib/enum'
import EditorPreview from '@/components/preview/EditorPreview'
export default {
name: 'AppReleaseDetailDrawer',
components: {
EditorPreview
},
data() {
return {
RELEASE_ACTION_TYPE,
SERIAL_TYPE,
visible: false,
loading: true,
pollId: null,
detail: {}
}
},
methods: {
open(id) {
// 关闭轮询状态
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
this.detail = {}
this.visible = true
this.loading = true
this.$api.getAppReleaseDetail({
id
}).then(({ data }) => {
this.loading = false
this.detail = data
// 轮询状态
if (data.status === RELEASE_STATUS.WAIT_AUDIT.value ||
data.status === RELEASE_STATUS.AUDIT_REJECT.value ||
data.status === RELEASE_STATUS.WAIT_RUNNABLE.value ||
data.status === RELEASE_STATUS.WAIT_SCHEDULE.value ||
data.status === RELEASE_STATUS.RUNNABLE.value) {
this.pollId = setInterval(this.pollStatus, 5000)
}
}).catch(() => {
this.loading = false
})
},
pollStatus() {
if (!this.detail || !this.detail.status) {
return
}
if (this.detail.status !== RELEASE_STATUS.WAIT_AUDIT.value &&
this.detail.status !== RELEASE_STATUS.AUDIT_REJECT.value &&
this.detail.status !== RELEASE_STATUS.WAIT_RUNNABLE.value &&
this.detail.status !== RELEASE_STATUS.WAIT_SCHEDULE.value &&
this.detail.status !== RELEASE_STATUS.RUNNABLE.value) {
clearInterval(this.pollId)
this.pollId = null
return
}
this.$api.getAppReleaseStatus({
id: this.detail.id
}).then(({ data }) => {
this.detail.status = data.status
this.detail.used = data.used
this.detail.keepTime = data.keepTime
this.detail.startTime = data.startTime
this.detail.startTimeAgo = data.startTimeAgo
this.detail.endTime = data.endTime
this.detail.endTimeAgo = data.endTimeAgo
if (data.machines && data.machines.length) {
for (const machine of data.machines) {
this.detail.machines.filter(s => s.id === machine.id).forEach(s => {
s.status = machine.status
s.keepTime = machine.keepTime
s.used = machine.used
s.startTime = machine.startTime
s.startTimeAgo = machine.startTimeAgo
s.endTime = machine.endTime
s.endTimeAgo = machine.endTimeAgo
})
}
}
})
},
preview(command) {
this.$refs.preview.preview(command)
},
onClose() {
this.visible = false
// 关闭轮询状态
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
}
},
filters: {
formatDate,
formatReleaseStatus(status, f) {
return enumValueOf(RELEASE_STATUS, status)[f]
},
formatReleaseType(type, f) {
return enumValueOf(RELEASE_TYPE, type)[f]
},
formatSerialType(type, f) {
return enumValueOf(SERIAL_TYPE, type)[f]
},
formatExceptionHandler(type, f) {
return enumValueOf(EXCEPTION_HANDLER_TYPE, type)[f]
},
formatReleaseActionType(status, f) {
return enumValueOf(RELEASE_ACTION_TYPE, status)[f]
},
formatActionStatus(status, f) {
return enumValueOf(ACTION_STATUS, status)[f]
}
}
}
</script>
<style lang="less" scoped>
.machine-list-container {
::v-deep .ant-list-header {
padding: 8px;
}
::v-deep .ant-list-item {
padding-right: 8px;
padding-left: 8px;
}
}
</style>

View File

@@ -0,0 +1,226 @@
<template>
<a-drawer title="机器详情"
placement="right"
:visible="visible"
:maskStyle="{opacity: 0, animation: 'none'}"
:width="430"
@close="onClose">
<!-- 加载中 -->
<div v-if="loading">
<a-skeleton active :paragraph="{rows: 12}"/>
</div>
<!-- 加载完成 -->
<div v-else>
<!-- 发布信息 -->
<a-descriptions size="middle">
<a-descriptions-item label="机器名称" :span="3">
{{ detail.machineName }}
</a-descriptions-item>
<a-descriptions-item label="机器主机" :span="3">
{{ detail.machineHost }}
</a-descriptions-item>
<a-descriptions-item label="发布状态" :span="3">
<a-tag :color="detail.status | formatActionStatus('color')">
{{ detail.status | formatActionStatus('label') }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="开始时间" :span="3" v-if="detail.startTime !== null">
{{ detail.startTime | formatDate }} ({{ detail.startTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="结束时间" :span="3" v-if="detail.endTime !== null">
{{ detail.endTime | formatDate }} ({{ detail.endTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="持续时间" :span="3" v-if="detail.used !== null">
{{ `${detail.keepTime} (${detail.used}ms)` }}
</a-descriptions-item>
<a-descriptions-item label="日志" :span="3" v-if="statusHolder.visibleActionLog(detail.status)">
<a v-if="detail.downloadUrl" @click="clearDownloadUrl(detail)" target="_blank" :href="detail.downloadUrl">下载</a>
<a v-else @click="loadDownloadUrl(detail, FILE_DOWNLOAD_TYPE.APP_RELEASE_MACHINE_LOG.value)">获取操作日志</a>
</a-descriptions-item>
</a-descriptions>
<!-- 发布操作 -->
<a-divider>发布操作</a-divider>
<a-list :dataSource="detail.actions">
<template #renderItem="item">
<a-list-item>
<a-descriptions size="middle">
<a-descriptions-item label="操作名称" :span="3">
{{ item.actionName }}
</a-descriptions-item>
<a-descriptions-item label="操作类型" :span="3">
<a-tag>{{ item.actionType | formatActionType('label') }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="操作状态" :span="3">
<a-tag :color="item.status | formatActionStatus('color')">
{{ item.status | formatActionStatus('label') }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="开始时间" :span="3" v-if="item.startTime !== null">
{{ item.startTime | formatDate }} ({{ item.startTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="结束时间" :span="3" v-if="item.endTime !== null">
{{ item.endTime | formatDate }} ({{ item.endTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="持续时间" :span="3" v-if="item.used !== null">
{{ `${item.keepTime} (${item.used}ms)` }}
</a-descriptions-item>
<a-descriptions-item label="退出码" :span="3" v-if="item.exitCode !== null">
<span :style="{'color': item.exitCode === 0 ? '#4263EB' : '#E03131'}">
{{ item.exitCode }}
</span>
</a-descriptions-item>
<a-descriptions-item label="命令" :span="3" v-if="item.actionType === RELEASE_ACTION_TYPE.COMMAND.value">
<a @click="preview(item.actionCommand)">预览</a>
</a-descriptions-item>
<a-descriptions-item label="日志" :span="3" v-if="statusHolder.visibleActionLog(item.status)">
<a v-if="item.downloadUrl" @click="clearDownloadUrl(item)" target="_blank" :href="item.downloadUrl">下载</a>
<a v-else @click="loadDownloadUrl(item, FILE_DOWNLOAD_TYPE.APP_ACTION_LOG.value)">获取操作日志</a>
</a-descriptions-item>
</a-descriptions>
</a-list-item>
</template>
</a-list>
</div>
<!-- 事件 -->
<div class="detail-event">
<EditorPreview ref="preview"/>
</div>
</a-drawer>
</template>
<script>
import { defineArrayKey } from '@/lib/utils'
import { formatDate } from '@/lib/filters'
import { ACTION_STATUS, enumValueOf, FILE_DOWNLOAD_TYPE, RELEASE_ACTION_TYPE } from '@/lib/enum'
import EditorPreview from '@/components/preview/EditorPreview'
const statusHolder = {
visibleActionLog: (status) => {
return status === ACTION_STATUS.RUNNABLE.value ||
status === ACTION_STATUS.FINISH.value ||
status === ACTION_STATUS.FAILURE.value ||
status === ACTION_STATUS.TERMINATED.value
}
}
export default {
name: 'AppReleaseMachineDetailDrawer',
components: {
EditorPreview
},
data() {
return {
FILE_DOWNLOAD_TYPE,
RELEASE_ACTION_TYPE,
visible: false,
loading: true,
pollId: null,
detail: {},
statusHolder
}
},
methods: {
open(id) {
// 关闭轮询状态
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
this.detail = {}
this.visible = true
this.loading = true
this.$api.getAppReleaseMachineDetail({
releaseMachineId: id
}).then(({ data }) => {
this.loading = false
data.downloadUrl = null
defineArrayKey(data.actions, 'downloadUrl')
this.detail = data
// 轮询状态
if (data.status === ACTION_STATUS.WAIT.value ||
data.status === ACTION_STATUS.RUNNABLE.value) {
this.pollId = setInterval(this.pollStatus, 5000)
}
}).catch(() => {
this.loading = false
})
},
pollStatus() {
if (!this.detail || !this.detail.status) {
return
}
if (this.detail.status !== ACTION_STATUS.WAIT.value &&
this.detail.status !== ACTION_STATUS.RUNNABLE.value) {
clearInterval(this.pollId)
this.pollId = null
return
}
this.$api.getAppReleaseMachineStatus({
releaseMachineId: this.detail.id
}).then(({ data }) => {
this.detail.status = data.status
this.detail.used = data.used
this.detail.keepTime = data.keepTime
this.detail.startTime = data.startTime
this.detail.startTimeAgo = data.startTimeAgo
this.detail.endTime = data.endTime
this.detail.endTimeAgo = data.endTimeAgo
if (data.actions && data.actions.length) {
for (const action of data.actions) {
this.detail.actions.filter(s => s.id === action.id).forEach(s => {
s.status = action.status
s.keepTime = action.keepTime
s.used = action.used
s.startTime = action.startTime
s.startTimeAgo = action.startTimeAgo
s.endTime = action.endTime
s.endTimeAgo = action.endTimeAgo
s.exitCode = action.exitCode
})
}
}
})
},
async loadDownloadUrl(record, type) {
try {
const downloadUrl = await this.$api.getFileDownloadToken({
type,
id: record.id
})
record.downloadUrl = this.$api.fileDownloadExec({ token: downloadUrl.data })
} catch (e) {
// ignore
}
},
clearDownloadUrl(record) {
setTimeout(() => {
record.downloadUrl = null
})
},
preview(command) {
this.$refs.preview.preview(command)
},
onClose() {
this.visible = false
// 关闭轮询状态
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
}
},
filters: {
formatDate,
formatActionStatus(status, f) {
return enumValueOf(ACTION_STATUS, status)[f]
},
formatActionType(status, f) {
return enumValueOf(RELEASE_ACTION_TYPE, status)[f]
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,414 @@
<template>
<a-modal v-model="visible"
:width="550"
:maskStyle="{opacity: 0.8, animation: 'none'}"
:dialogStyle="{top: '64px', padding: 0}"
:bodyStyle="{padding: '8px'}"
:maskClosable="false"
:destroyOnClose="true">
<!-- 标题 -->
<template #title>
<span v-if="selectAppPage">选择应用</span>
<span v-if="!selectAppPage">
<a-icon class="mx4 pointer span-blue"
title="重新选择"
v-if="visibleReselect && appId"
@click="reselectAppList"
type="arrow-left"/>
应用发布
</span>
</template>
<!-- 初始化骨架 -->
<a-skeleton v-if="initiating" active :paragraph="{rows: 5}"/>
<!-- 主体 -->
<a-spin v-else :spinning="loading || appLoading">
<!-- 应用选择 -->
<div class="app-list-container" v-if="selectAppPage">
<!-- 无应用数据 -->
<a-empty v-if="!appList.length" description="请先配置应用"/>
<!-- 应用列表 -->
<div v-else class="app-list">
<div class="app-item" v-for="app of appList" :key="app.id" @click="chooseApp(app.id)">
<div class="app-name">
<a-icon class="mx8" type="code-sandbox"/>
{{ app.name }}
</div>
<a-tag color="#5C7CFA">
{{ app.tag }}
</a-tag>
</div>
</div>
</div>
<!-- 发布配置 -->
<div class="release-container" v-else>
<div class="release-form">
<!-- 发布标题 -->
<div class="release-form-item">
<span class="release-form-item-label normal-label required-label">发布标题</span>
<a-input class="release-form-item-input"
v-model="submit.title"
placeholder="标题"
:maxLength="32"
allowClear/>
</div>
<!-- 发布版本 -->
<div class="release-form-item">
<span class="release-form-item-label normal-label required-label">发布版本</span>
<a-select class="release-form-item-input build-selector"
v-model="submit.buildId"
placeholder="版本"
allowClear>
<a-select-option v-for="build of buildList" :key="build.id" :value="build.id">
<div class="build-item">
<div class="build-item-left">
<span class="span-blue build-item-seq">#{{ build.seq }}</span>
<span class="build-item-message">{{ build.description }}</span>
</div>
<span class="build-item-date">
{{ build.createTime | formatDate('MM-dd HH:mm') }}
</span>
</div>
</a-select-option>
</a-select>
<a-icon type="reload" class="reload" title="刷新" @click="loadBuildList"/>
</div>
<!-- 发布类型 -->
<div class="release-form-item">
<span class="release-form-item-label normal-label required-label">发布类型</span>
<a-radio-group v-model="submit.timedRelease" buttonStyle="solid">
<a-radio-button :value="type.value" v-for="type in TIMED_TYPE" :key="type.value">
{{ type.releaseLabel }}
</a-radio-button>
</a-radio-group>
</div>
<!-- 调度时间 -->
<div class="release-form-item" v-if="submit.timedRelease === TIMED_TYPE.TIMED.value">
<span class="release-form-item-label normal-label required-label">调度时间</span>
<a-date-picker v-model="submit.timedReleaseTime" :showTime="true" format="YYYY-MM-DD HH:mm:ss"/>
</div>
<!-- 发布机器 -->
<div class="release-form-item">
<span class="release-form-item-label normal-label required-label">发布机器</span>
<MachineChecker ref="machineChecker"
class="release-form-item-input"
placement="bottomLeft"
:defaultValue="appMachineIdList"
:query="{idList: appMachineIdList}">
<template #trigger>
<span class="span-blue pointer">已选择 {{ submit.machineIdList.length }} 台机器</span>
</template>
<template #footer>
<a-button type="primary" size="small" @click="chooseMachines">确定</a-button>
</template>
</MachineChecker>
</div>
<!-- 描述 -->
<div class="release-form-item" style="margin: 8px 0;">
<span class="release-form-item-label normal-label">发布描述</span>
<a-textarea class="release-form-item-input"
v-model="submit.description"
style="height: 50px; width: 430px"
:maxLength="64"
allowClear/>
</div>
</div>
</div>
</a-spin>
<!-- 页脚 -->
<template #footer>
<!-- 关闭 -->
<a-button @click="close">关闭</a-button>
<!-- 发布 -->
<a-button type="primary"
:loading="loading"
:disabled="selectAppPage || loading || appLoading || initiating"
@click="release">
发布
</a-button>
</template>
</a-modal>
</template>
<script>
import { formatDate } from '@/lib/filters'
import { CONFIG_STATUS, TIMED_TYPE } from '@/lib/enum'
import MachineChecker from '@/components/machine/MachineChecker'
export default {
name: 'AppReleaseModal',
components: {
MachineChecker
},
props: {
visibleReselect: Boolean
},
data: function() {
return {
TIMED_TYPE,
selectAppPage: true,
appId: null,
profileId: null,
app: null,
appList: [],
buildList: [],
appMachineIdList: [-1],
submit: {
title: undefined,
buildId: undefined,
description: undefined,
timedRelease: undefined,
timedReleaseTime: undefined,
machineIdList: []
},
visible: false,
loading: false,
appLoading: false,
initiating: false
}
},
methods: {
async openRelease(profileId, id) {
if (!profileId) {
this.$message.warning('请先维护应用环境')
return
}
this.cleanData()
this.selectAppPage = !id
this.profileId = profileId
this.appId = null
this.app = null
this.appList = []
this.loading = false
this.appLoading = false
this.initiating = true
this.visible = true
await this.loadAppList()
if (id) {
this.chooseApp(id)
}
this.initiating = false
},
async chooseApp(id) {
this.cleanData()
this.appId = id
this.selectAppPage = false
const filter = this.appList.filter(s => s.id === id)
if (!filter.length) {
this.$message.warning('未找到该应用')
}
this.app = filter[0]
this.submit.title = `发布${this.app.name}`
this.appLoading = true
await this.loadReleaseMachine()
await this.loadBuildList()
this.appLoading = false
},
cleanData() {
this.app = {}
this.appId = null
this.buildList = []
this.appMachineIdList = [-1]
this.submit.title = undefined
this.submit.buildId = undefined
this.submit.description = undefined
this.submit.timedRelease = TIMED_TYPE.NORMAL.value
this.submit.timedReleaseTime = undefined
this.submit.machineIdList = []
},
async loadReleaseMachine() {
const { data } = await this.$api.getAppMachineId({
id: this.appId,
profileId: this.profileId
})
if (data && data.length) {
this.appMachineIdList = data
this.submit.machineIdList = data
} else {
this.$message.warning('请先配置应用发布机器')
}
},
async loadBuildList() {
const { data } = await this.$api.getBuildReleaseList({
appId: this.appId,
profileId: this.profileId
})
this.buildList = data
if (!this.submit.buildId && this.buildList && this.buildList.length) {
this.submit.buildId = this.buildList[0].id
}
},
async loadAppList() {
const { data: { rows } } = await this.$api.getAppList({
profileId: this.profileId,
limit: 10000
})
this.appList = rows.filter(s => s.isConfig === CONFIG_STATUS.CONFIGURED.value)
},
async reselectAppList() {
this.selectAppPage = true
if (this.appList.length) {
return
}
this.initiating = true
await this.loadAppList()
this.initiating = false
},
chooseMachines() {
const ref = this.$refs.machineChecker
if (!ref.checkedList.length) {
this.$message.warning('请选择发布机器机器')
return
}
this.submit.machineIdList = ref.checkedList
ref.hide()
},
async release() {
if (!this.app) {
this.$message.warning('请选择发布应用')
return
}
if (!this.submit.title) {
this.$message.warning('请输入发布标题')
return
}
if (!this.submit.buildId) {
this.$message.warning('请选择发布版本')
return
}
if (!this.submit.machineIdList.length) {
this.$message.warning('请选择发布机器')
return
}
if (this.submit.timedRelease === TIMED_TYPE.TIMED.value) {
if (!this.submit.timedReleaseTime) {
this.$message.warning('请选择调度时间')
return
}
if (this.submit.timedReleaseTime.unix() * 1000 < Date.now()) {
this.$message.warning('调度时间需要大于当前时间')
return
}
} else {
this.submit.timedReleaseTime = undefined
}
this.loading = true
this.$api.submitAppRelease({
appId: this.appId,
profileId: this.profileId,
...this.submit
}).then(() => {
this.$message.success('已创建发布任务')
this.$emit('submit')
this.visible = false
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
}
},
filters: {
formatDate
}
}
</script>
<style lang="less" scoped>
.app-list {
margin: 0 4px 0 8px;
height: 355px;
overflow-y: auto;
.app-item {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px;
padding: 4px 4px 4px 8px;
background: #F8F9FA;
border-radius: 4px;
height: 40px;
cursor: pointer;
.app-name {
width: 300px;
text-overflow: ellipsis;
display: block;
overflow-x: hidden;
white-space: nowrap;
}
}
.app-item:hover {
background: #E7F5FF;
}
}
.release-form {
display: flex;
flex-wrap: wrap;
.release-form-item {
display: flex;
align-items: center;
width: 100%;
.release-form-item-label {
width: 80px;
margin: 16px 8px;
font-size: 15px;
}
.release-form-item-input {
width: 380px;
}
.reload {
font-size: 19px;
margin-left: 16px;
cursor: pointer;
color: #339AF0;
}
.reload:hover {
color: #228BE6;
}
}
}
.build-item {
display: flex;
justify-content: space-between;
.build-item-left {
display: flex;
align-items: center;
width: 276px;
}
.build-item-seq {
margin-right: 8px;
}
.build-item-message {
width: 225px;
display: block;
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
}
.build-item-date {
font-size: 12px;
margin-right: 4px;
}
}
::v-deep .build-selector .ant-select-selection-selected-value {
width: 100%;
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<!-- 统计折线图 -->
<div class="statistic-chart-container">
<p class="statistics-description">近七天发布统计</p>
<a-spin :spinning="loading">
<!-- 折线图 -->
<div class="statistic-chart-wrapper">
<div id="statistic-chart"/>
</div>
</a-spin>
</div>
</template>
<script>
import { Chart } from '@antv/g2'
export default {
name: 'AppReleaseStatisticsCharts',
data() {
return {
loading: false,
chart: null
}
},
methods: {
async init(appId, profileId) {
this.loading = true
const { data } = await this.$api.getAppReleaseStatisticsChart({
appId,
profileId
})
this.loading = false
this.renderChart(data)
},
clean() {
this.loading = false
this.chart && this.chart.destroy()
this.chart = null
},
renderChart(data) {
// 处理数据
const chartsData = []
for (const d of data) {
chartsData.push({
date: d.date,
type: '发布次数',
value: d.releaseCount
})
chartsData.push({
date: d.date,
type: '成功次数',
value: d.successCount
})
chartsData.push({
date: d.date,
type: '失败次数',
value: d.failureCount
})
}
this.clean()
// 渲染图表
this.chart = new Chart({
container: 'statistic-chart',
autoFit: true
})
this.chart.data(chartsData)
this.chart.tooltip({
showCrosshairs: true,
shared: true
})
this.chart.line()
.position('date*value')
.color('type')
.shape('circle')
this.chart.render()
}
},
beforeDestroy() {
this.clean()
}
}
</script>
<style lang="less" scoped>
.statistic-chart-wrapper {
margin: 0 24px 16px 24px;
#statistic-chart {
height: 500px;
}
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<a-spin :spinning="loading">
<!-- 全部发布指标 -->
<p class="statistics-description">全部发布指标</p>
<div class="app-release-statistic-metrics">
<div class="clean"/>
<!-- 统计指标 -->
<div class="app-release-statistic-header">
<a-statistic class="statistic-metrics-item" title="发布次数" :value="allMetrics.releaseCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item green" title="成功次数" :value="allMetrics.successCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item red" title="失败次数" :value="allMetrics.failureCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item" title="平均耗时" :value="allMetrics.avgUsedInterval"/>
</div>
<div class="clean"/>
</div>
<!-- 近七天发布指标 -->
<p class="statistics-description" style="margin-top: 28px">近七天发布指标</p>
<div class="app-release-statistic-metrics">
<div class="clean"/>
<!-- 统计指标 -->
<div class="app-release-statistic-header">
<a-statistic class="statistic-metrics-item" title="发布次数" :value="latelyMetrics.releaseCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item green" title="成功次数" :value="latelyMetrics.successCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item red" title="失败次数" :value="latelyMetrics.failureCount"/>
<a-divider class="statistic-metrics-divider" type="vertical"/>
<a-statistic class="statistic-metrics-item" title="平均耗时" :value="latelyMetrics.avgUsedInterval"/>
</div>
<div class="clean"/>
</div>
</a-spin>
</template>
<script>
export default {
name: 'AppReleaseStatisticsMetrics',
data() {
return {
loading: false,
allMetrics: {
releaseCount: 0,
successCount: 0,
failureCount: 0,
avgUsedInterval: '0s'
},
latelyMetrics: {
releaseCount: 0,
successCount: 0,
failureCount: 0,
avgUsedInterval: '0s'
}
}
},
methods: {
async init(appId, profileId) {
this.loading = true
const { data } = await this.$api.getAppReleaseStatisticsMetrics({
appId,
profileId
})
this.loading = false
this.allMetrics = data.all
this.latelyMetrics = data.lately
},
clean() {
this.loading = false
this.allMetrics = {
releaseCount: 0,
successCount: 0,
failureCount: 0,
avgUsedInterval: '0s'
}
this.latelyMetrics = {
releaseCount: 0,
successCount: 0,
failureCount: 0,
avgUsedInterval: '0s'
}
}
},
beforeDestroy() {
this.clean()
}
}
</script>
<style lang="less" scoped>
.app-release-statistic-metrics {
display: flex;
justify-content: center;
margin: 24px 0 12px 16px;
.app-release-statistic-header {
display: flex;
.statistic-metrics-item {
margin: 0 16px;
}
::v-deep .statistic-metrics-item.green .ant-statistic-content {
color: #58C612;
}
::v-deep .statistic-metrics-item.red .ant-statistic-content {
color: #DD2C00;
}
.statistic-metrics-divider {
height: auto;
}
::v-deep .ant-statistic-content {
text-align: center;
}
}
}
</style>

View File

@@ -0,0 +1,466 @@
<template>
<div class="app-release-statistic-view-container">
<a-spin :spinning="loading">
<!-- 发布视图 -->
<div class="app-release-statistic-record-view-wrapper" v-if="initialized && view">
<div class="app-release-statistic-record-view">
<p class="statistics-description">近十次发布视图</p>
<div class="app-release-statistic-main">
<!-- 发布操作 -->
<div class="app-release-actions-wrapper">
<!-- 平均时间 -->
<div class="app-release-actions-legend-wrapper">
<div class="app-release-actions-legend">
<span class="avg-used-legend-wrapper">
平均发布时间: <span class="avg-used-legend">{{ view.avgUsedInterval }}</span>
</span>
</div>
<div class="app-release-actions-machine-legend">
发布机器
</div>
</div>
<!-- 发布操作 -->
<div class="app-release-actions" v-for="(action, index) of view.actions" :key="action.id">
<div :class="['app-release-actions-name', index % 2 === 0 ? 'app-release-actions-name-theme1' : 'app-release-actions-name-theme2']">
{{ action.name }}
</div>
<div :class="['app-release-actions-avg', index % 2 === 0 ? 'app-release-actions-avg-theme1' : 'app-release-actions-avg-theme2']">
<div class="app-release-actions-avg">
{{ action.avgUsedInterval }}
</div>
</div>
</div>
</div>
<!-- 发布日志 -->
<div class="app-release-machines-container">
<!-- 发布机器 -->
<div class="app-release-machines-wrapper" v-for="releaseRecord of view.releaseRecordList" :key="releaseRecord.releaseId">
<!-- 发布信息头 -->
<div class="app-release-machine-legend-wrapper">
<!-- 发布信息 -->
<a target="_blank"
style="height: 100%"
title="点击查看发布日志"
:href="`#/app/release/log/view/${releaseRecord.releaseId}`"
@click="openLogView($event,'release', releaseRecord.releaseId)">
<div class="app-release-record-legend">
<div class="app-release-record-status">
<span class="app-release-title" :title="releaseRecord.releaseTitle">
{{ releaseRecord.releaseTitle }}
</span>
<!-- 发布状态 -->
<a-tag class="mx4" :color="releaseRecord.status | formatReleaseStatus('color')">
{{ releaseRecord.status | formatReleaseStatus('label') }}
</a-tag>
</div>
<div class="app-release-record-info">
<span>{{ releaseRecord.releaseDate | formatDate('MM-dd HH:mm') }}</span>
<span v-if="releaseRecord.usedInterval"> (used: {{ releaseRecord.usedInterval }})</span>
</div>
</div>
</a>
<!-- 发布机器信息 -->
<div class="app-release-machine-legend">
<a target="_blank"
style="height: 100%"
title="点击查看机器日志"
v-for="(machine, index) of releaseRecord.machines"
:key="machine.id"
:href="`#/app/release/machine/log/view/${machine.id}`"
@click="openLogView($event,'machine', machine.id)">
<!-- 发布机器 -->
<div :class="['app-release-machine-info', index !== releaseRecord.machines.length - 1 ? 'app-release-machine-info-next' : '']">
<span class="app-release-machine-info-name" :title=" machine.machineName">{{ machine.machineName }}</span>
<div class="app-release-machine-info-status">
<a-tag :color="machine.status | formatActionStatus('color')">
{{ machine.status | formatActionStatus('label') }}
</a-tag>
<span class="app-release-machine-info-used" v-if="machine.usedInterval">{{ machine.usedInterval }}</span>
<span v-else/>
</div>
</div>
</a>
</div>
</div>
<!-- 发布机器操作 -->
<div class="app-release-machine-actions-wrapper">
<div :class="['app-release-machine-actions', index !== releaseRecord.machines.length - 1 ? 'app-release-machine-actions-next' : '']"
v-for="(machine, index) of releaseRecord.machines"
:key="machine.id">
<!-- 发布操作 -->
<div class="app-release-action-log-actions-wrapper">
<div class="app-release-action-log-action-wrapper"
v-for="(actionLog, index) of machine.actionLogs" :key="index">
<!-- 构建操作值 -->
<div class="app-release-action-log-action"
v-if="!getCanOpenLog(actionLog)"
:style="getActionLogStyle(actionLog)"
v-text="getActionLogValue(actionLog)">
</div>
<!-- 可打开日志 -->
<a v-else target="_blank"
title="点击查看操作日志"
:href="`#/app/action/log/view/${actionLog.id}`"
@click="openLogView($event,'action', actionLog.id)">
<div class="app-release-action-log-action"
:style="getActionLogStyle(actionLog)"
v-text="getActionLogValue(actionLog)">
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 无数据 -->
<div style="padding: 0 16px" v-else-if="initialized && !view">
无发布记录
</div>
</a-spin>
<!-- 事件 -->
<div class="app-release-statistic-event-container">
<!-- 发布日志模态框 -->
<AppReleaseLogAppenderModal ref="releaseLogView"/>
<!-- 发布日志模态框 -->
<AppReleaseMachineLogAppenderModal ref="machineLogView"/>
<!-- 操作日志模态框 -->
<AppActionLogAppenderModal ref="actionLogView"/>
</div>
</div>
</template>
<script>
import { formatDate } from '@/lib/filters'
import { ACTION_STATUS, enumValueOf, RELEASE_STATUS } from '@/lib/enum'
import AppActionLogAppenderModal from '@/components/log/AppActionLogAppenderModal'
import AppReleaseMachineLogAppenderModal from '@/components/log/AppReleaseMachineLogAppenderModal'
import AppReleaseLogAppenderModal from '@/components/log/AppReleaseLogAppenderModal'
export default {
name: 'AppReleaseStatisticsViews',
components: {
AppActionLogAppenderModal,
AppReleaseLogAppenderModal,
AppReleaseMachineLogAppenderModal
},
data() {
return {
loading: false,
initialized: false,
view: {}
}
},
methods: {
async init(appId, profileId) {
this.loading = true
this.initialized = false
const { data } = await this.$api.getAppReleaseStatisticsView({
appId,
profileId
})
this.view = data
this.initialized = true
this.loading = false
},
clean() {
this.initialized = false
this.loading = false
this.view = {}
},
async refresh(appId, profileId) {
const { data } = await this.$api.getAppReleaseStatisticsView({
appId,
profileId
})
this.view = data
},
openLogView(e, type, id) {
if (!e.ctrlKey) {
e.preventDefault()
// 打开模态框
this.$refs[`${type}LogView`].open(id)
return false
} else {
// 跳转页面
return true
}
},
getCanOpenLog(actionLog) {
if (actionLog) {
return enumValueOf(ACTION_STATUS, actionLog.status).log
} else {
return false
}
},
getActionLogStyle(actionLog) {
if (actionLog) {
return enumValueOf(ACTION_STATUS, actionLog.status).actionStyle
} else {
return {
background: '#FFD43B'
}
}
},
getActionLogValue(actionLog) {
if (actionLog) {
return enumValueOf(ACTION_STATUS, actionLog.status).actionValue(actionLog)
} else {
return '未执行'
}
}
},
beforeDestroy() {
this.clean()
},
filters: {
formatDate,
formatReleaseStatus(status, f) {
return enumValueOf(RELEASE_STATUS, status)[f]
},
formatActionStatus(status, f) {
return enumValueOf(ACTION_STATUS, status)[f]
}
}
}
</script>
<style lang="less" scoped>
.app-release-statistic-record-view-wrapper {
margin: 0 24px 24px 24px;
overflow: auto;
.app-release-statistic-record-view {
margin: 0 16px 16px 16px;
border-radius: 4px;
display: inline-block;
}
.app-release-statistic-main {
display: inline-block;
box-shadow: 0 0 4px 1px #DEE2E6;
}
.app-release-actions-wrapper {
display: flex;
min-height: 82px;
.app-release-actions-legend-wrapper {
width: 320px;
display: flex;
font-weight: 600;
.avg-used-legend-wrapper {
display: flex;
align-items: center;
}
.avg-used-legend {
margin-left: 4px;
color: #000;
font-size: 16px;
}
.app-release-actions-legend {
width: 180px;
height: 100%;
padding: 4px 8px 4px 0;
display: flex;
align-items: flex-end;
justify-content: flex-end;
border-right: 2px solid #F8F9FA;
}
.app-release-actions-machine-legend {
width: 140px;
height: 100%;
padding: 4px 0 4px 8px;
display: flex;
align-items: flex-end;
justify-content: flex-start;
}
}
.app-release-actions {
display: flex;
flex-direction: column;
width: 148px;
.app-release-actions-name {
height: 100%;
font-size: 15px;
color: #181E33;
font-weight: 600;
line-height: 18px;
display: flex;
align-items: center;
justify-content: center;
padding: 4px 16px;
border-bottom: 1px solid #DEE2E6;
}
.app-release-actions-name-theme1 {
background: #F1F3F5;
}
.app-release-actions-name-theme2 {
background: #F8F9FA;
}
.app-release-actions-avg {
text-align: center;
height: 28px;
padding: 2px;
}
.app-release-actions-avg-theme1 {
background: #E9ECEF;
}
.app-release-actions-avg-theme2 {
background: #F1F4F7;
}
}
}
.app-release-machines-wrapper {
display: flex;
.app-release-machine-legend-wrapper {
width: 320px;
border-top: 2px solid #F8F9FA;
border-radius: 4px;
display: flex;
align-items: flex-start;
flex-direction: row;
justify-content: center;
.app-release-record-legend {
width: 180px;
height: 100%;
padding: 8px 0 8px 8px;
border-radius: 4px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
border-right: 2px solid #F8F9FA;
.app-release-record-status {
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: stretch;
width: 100%;
.app-release-title {
width: 108px;
text-overflow: ellipsis;
white-space: nowrap;
overflow-x: hidden;
}
}
.app-release-record-info {
color: #000000;
font-size: 12px;
}
}
.app-release-record-legend:hover {
transition: .3s;
background: #D0EBFF;
}
.app-release-machine-legend {
display: inline-block;
width: 140px;
border-right: 2px solid #F8F9FA;
.app-release-machine-info {
height: 64px;
border-radius: 4px;
display: flex;
flex-direction: column;
justify-content: space-around;
.app-release-machine-info-name {
color: #181E33;
padding: 0 4px;
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
}
.app-release-machine-info-status {
display: flex;
justify-content: space-between;
padding: 0 4px 4px 4px;
align-items: flex-end;
}
.app-release-machine-info-used {
color: #000000;
font-size: 14px;
}
}
.app-release-machine-info-next {
border-bottom: 2px solid #F8F9FA;
}
.app-release-machine-info:hover {
transition: .3s;
background: #dbe4ff;
}
}
}
.app-release-machine-actions-wrapper {
display: flex;
flex-direction: column;
.app-release-machine-actions {
height: 64px;
.app-release-action-log-actions-wrapper {
display: flex;
height: 100%;
.app-release-action-log-action-wrapper {
width: 148px;
border-radius: 4px;
.app-release-action-log-action {
margin: 2px 1px;
height: 100%;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-around;
opacity: 0.8;
color: rgba(0, 0, 0, .8);
}
.app-release-action-log-action:hover {
opacity: 1;
transition: .3s;
}
}
}
}
.app-release-machine-actions-next {
border-bottom: 2px solid #F8F9FA;
}
}
}
}
</style>

View File

@@ -0,0 +1,74 @@
<template>
<a-modal v-model="visible"
title="设置发布定时"
:width="330"
:okButtonProps="{props: {disabled: !valid}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="submit"
@cancel="close">
<a-spin :spinning="loading">
<div class="timed-release-picker-wrapper">
<span class="normal-label mr8">调度时间 </span>
<a-date-picker v-model="timedReleaseTime" :showTime="true" format="YYYY-MM-DD HH:mm:ss"/>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { RELEASE_STATUS, TIMED_TYPE } from '@/lib/enum'
import moment from 'moment'
export default {
name: 'AppReleaseTimedModal',
data() {
return {
record: null,
visible: false,
loading: false,
timedReleaseTime: undefined
}
},
computed: {
valid() {
if (!this.timedReleaseTime) {
return false
}
return Date.now() < this.timedReleaseTime
}
},
methods: {
open(record) {
this.record = record
this.timedReleaseTime = (record.timedReleaseTime && moment(record.timedReleaseTime)) || undefined
this.visible = true
},
submit() {
const time = this.timedReleaseTime.unix() * 1000
this.loading = true
this.$api.setAppTimedRelease({
id: this.record.id,
timedReleaseTime: time
}).then(() => {
this.loading = false
this.visible = false
this.record.timedReleaseTime = time
this.record.status = RELEASE_STATUS.WAIT_SCHEDULE.value
this.record.timedRelease = TIMED_TYPE.TIMED.value
this.$emit('updated')
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,64 @@
<template>
<a-select v-model="id"
:disabled="disabled"
:placeholder="placeholder"
@change="$emit('change', id)"
allowClear>
<a-select-option v-for="app in appList"
:value="app.id"
:key="app.id">
{{ app.name }}
</a-select-option>
</a-select>
</template>
<script>
export default {
name: 'AppSelector',
props: {
disabled: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: '全部'
},
value: {
type: Number,
default: undefined
}
},
data() {
return {
id: undefined,
appList: []
}
},
watch: {
value(e) {
this.id = e
}
},
methods: {
reset() {
this.id = undefined
}
},
async created() {
const appListRes = await this.$api.getAppList({ limit: 10000 })
if (appListRes.data && appListRes.data.rows && appListRes.data.rows.length) {
for (const row of appListRes.data.rows) {
this.appList.push({
id: row.id,
name: row.name
})
}
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,93 @@
<template>
<a-auto-complete v-model="value"
:disabled="disabled"
:placeholder="placeholder"
@change="change"
@search="search"
allowClear>
<template #dataSource>
<a-select-option v-for="pipeline in visiblePipeline"
:key="pipeline.id"
:value="JSON.stringify(pipeline)"
@click="choose">
{{ pipeline.name }}
</a-select-option>
</template>
</a-auto-complete>
</template>
<script>
export default {
name: 'PipelineAutoComplete',
props: {
disabled: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: '全部'
}
},
data() {
return {
pipelineList: [],
visiblePipeline: [],
value: undefined
}
},
methods: {
async loadData(profileId) {
this.pipelineList = []
this.visiblePipeline = []
const { data } = await this.$api.getAppPipelineList({
profileId,
limit: 10000
})
if (data && data.rows && data.rows.length) {
for (const row of data.rows) {
this.pipelineList.push({
id: row.id,
name: row.name
})
}
this.visiblePipeline = this.pipelineList
}
},
change(value) {
let id
let val = value
try {
const v = JSON.parse(value)
if (typeof v === 'object') {
id = v.id
val = v.name
}
} catch (e) {
}
this.$emit('change', id, val)
this.value = val
},
choose() {
this.$nextTick(() => {
this.$emit('choose')
})
},
search(value) {
if (!value) {
this.visiblePipeline = this.pipelineList
return
}
this.visiblePipeline = this.pipelineList.filter(s => s.name.toLowerCase().includes(value.toLowerCase()))
},
reset() {
this.value = undefined
this.visiblePipeline = this.pipelineList
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,90 @@
<template>
<a-auto-complete v-model="value"
:disabled="disabled"
:placeholder="placeholder"
@change="change"
@search="search"
allowClear>
<template #dataSource>
<a-select-option v-for="profile in visibleProfile"
:key="profile.id"
:value="JSON.stringify(profile)"
@click="choose">
{{ profile.name }}
</a-select-option>
</template>
</a-auto-complete>
</template>
<script>
export default {
name: 'ProfileAutoComplete',
props: {
disabled: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: '全部'
}
},
data() {
return {
profileList: [],
visibleProfile: [],
value: undefined
}
},
methods: {
change(value) {
let id
let val = value
try {
const v = JSON.parse(value)
if (typeof v === 'object') {
id = v.id
val = v.name
}
} catch (e) {
}
this.$emit('change', id, val)
this.value = val
},
choose() {
this.$nextTick(() => {
this.$emit('choose')
})
},
search(value) {
if (!value) {
this.visibleProfile = this.profileList
return
}
this.visibleProfile = this.profileList.filter(s => s.name.toLowerCase().includes(value.toLowerCase()))
},
reset() {
this.value = undefined
this.visibleProfile = this.profileList
}
},
async created() {
const { data } = await this.$api.getProfileList({
limit: 10000
})
if (data && data.rows && data.rows.length) {
for (const row of data.rows) {
this.profileList.push({
id: row.id,
name: row.name
})
}
this.visibleProfile = this.profileList
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,181 @@
<template>
<a-modal v-model="visible"
title="应用构建 清理"
okText="清理"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="clear"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-clear-container">
<!-- 清理区间 -->
<div class="data-clear-range">
<a-radio-group class="nowrap" v-model="submit.range">
<a-radio-button :value="DATA_CLEAR_RANGE.DAY.value">保留天数</a-radio-button>
<a-radio-button :value="DATA_CLEAR_RANGE.TOTAL.value">保留条数</a-radio-button>
<a-radio-button :value="DATA_CLEAR_RANGE.REL_ID.value">清理应用</a-radio-button>
</a-radio-group>
</div>
<!-- 清理参数 -->
<div class="data-clear-params">
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.DAY.value === submit.range">
<span class="normal-label clear-label">保留天数</span>
<a-input-number class="param-input"
v-model="submit.reserveDay"
:min="0"
:max="9999"
placeholder="清理后数据所保留的天数"/>
</div>
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.TOTAL.value === submit.range">
<span class="normal-label clear-label">保留条数</span>
<a-input-number class="param-input"
v-model="submit.reserveTotal"
:min="0"
:max="9999"
placeholder="清理后数据所保留的条数"/>
</div>
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.REL_ID.value === submit.range">
<span class="normal-label clear-label">清理应用</span>
<AppSelector class="param-input"
placeholder="请选择清理的应用"
@change="(e) => submit.relIdList[0] = e"/>
</div>
</div>
<!-- 执行用户 -->
<div class="choose-param-wrapper">
<span class="normal-label clear-label">执行用户</span>
<a-checkbox class="param-input" v-model="iCreated">只清理我执行的</a-checkbox>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { DATA_CLEAR_RANGE, DATA_CLEAR_TYPE } from '@/lib/enum'
import AppSelector from '@/components/app/AppSelector'
export default {
name: 'AppBuildClearModal',
components: {
AppSelector
},
data: function() {
return {
DATA_CLEAR_RANGE,
visible: false,
loading: false,
iCreated: true,
submit: {
range: null,
profileId: null,
reserveDay: null,
reserveTotal: null,
relIdList: []
}
}
},
methods: {
open(profileId) {
this.submit.profileId = profileId
this.iCreated = true
this.submit.reserveDay = null
this.submit.reserveTotal = null
this.submit.relIdList = []
this.submit.range = DATA_CLEAR_RANGE.DAY.value
this.loading = false
this.visible = true
},
clear() {
if (!this.submit.profileId) {
this.$message.warning('请选择需要清理的环境')
return
}
if (this.submit.range === DATA_CLEAR_RANGE.DAY.value) {
if (this.submit.reserveDay === null) {
this.$message.warning('请输入需要保留的天数')
return
}
} else if (this.submit.range === DATA_CLEAR_RANGE.TOTAL.value) {
if (this.submit.reserveTotal === null) {
this.$message.warning('请输入需要保留的条数')
return
}
} else if (this.submit.range === DATA_CLEAR_RANGE.REL_ID.value) {
if (!this.submit.relIdList.length) {
this.$message.warning('请选择需要清理的应用')
return
}
} else {
return
}
this.$confirm({
title: '确认清理',
content: '清理后数据将无法恢复, 确定要清理吗?',
mask: false,
okText: '确认',
okType: 'danger',
cancelText: '取消',
onOk: () => {
this.doClear()
}
})
},
doClear() {
this.loading = true
this.$api.clearData({
...this.submit,
iCreated: this.iCreated ? 1 : 2,
clearType: DATA_CLEAR_TYPE.APP_BUILD.value
}).then(({ data }) => {
this.loading = false
this.visible = false
this.$emit('clear')
this.$message.info(`共清理 ${data}条数据`)
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-clear-container {
width: 100%;
}
.data-clear-range {
margin-bottom: 24px;
display: flex;
justify-content: center;
}
.clear-label {
width: 64px;
text-align: end;
}
.data-clear-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
.choose-param-wrapper {
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<a-modal v-model="visible"
title="应用流水线 清理"
okText="清理"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="clear"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-clear-container">
<!-- 清理区间 -->
<div class="data-clear-range">
<a-radio-group class="nowrap" v-model="submit.range">
<a-radio-button :value="DATA_CLEAR_RANGE.DAY.value">保留天数</a-radio-button>
<a-radio-button :value="DATA_CLEAR_RANGE.TOTAL.value">保留条数</a-radio-button>
<a-radio-button :value="DATA_CLEAR_RANGE.REL_ID.value">清理流水线</a-radio-button>
</a-radio-group>
</div>
<!-- 清理参数 -->
<div class="data-clear-params">
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.DAY.value === submit.range">
<span class="normal-label clear-label">保留天数</span>
<a-input-number class="param-input"
v-model="submit.reserveDay"
:min="0"
:max="9999"
placeholder="清理后数据所保留的天数"/>
</div>
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.TOTAL.value === submit.range">
<span class="normal-label clear-label">保留条数</span>
<a-input-number class="param-input"
v-model="submit.reserveTotal"
:min="0"
:max="9999"
placeholder="清理后数据所保留的条数"/>
</div>
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.REL_ID.value === submit.range">
<span class="normal-label clear-label">流水线</span>
<AppPipelineSelector class="param-input"
placeholder="请选择清理的流水线"
@change="(e) => submit.relIdList[0] = e"/>
</div>
</div>
<!-- 创建用户 -->
<div class="choose-param-wrapper">
<span class="normal-label clear-label">创建用户</span>
<a-checkbox class="param-input" v-model="iCreated">只清理我创建的</a-checkbox>
</div>
<!-- 执行用户 -->
<div class="choose-param-wrapper">
<span class="normal-label clear-label">审核用户</span>
<a-checkbox class="param-input" v-model="iAudited">只清理我审核的</a-checkbox>
</div>
<!-- 执行用户 -->
<div class="choose-param-wrapper">
<span class="normal-label clear-label">执行用户</span>
<a-checkbox class="param-input" v-model="iExecute">只清理我执行的</a-checkbox>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { DATA_CLEAR_RANGE, DATA_CLEAR_TYPE } from '@/lib/enum'
import AppPipelineSelector from '@/components/app/AppPipelineSelector'
export default {
name: 'AppPipelineClearModal',
components: {
AppPipelineSelector
},
data: function() {
return {
DATA_CLEAR_RANGE,
visible: false,
loading: false,
iCreated: true,
iAudited: false,
iExecute: false,
submit: {
range: null,
profileId: null,
reserveDay: null,
reserveTotal: null,
relIdList: []
}
}
},
methods: {
open(profileId) {
this.submit.profileId = profileId
this.iCreated = true
this.iAudited = false
this.iExecute = false
this.submit.reserveDay = null
this.submit.reserveTotal = null
this.submit.relIdList = []
this.submit.range = DATA_CLEAR_RANGE.DAY.value
this.loading = false
this.visible = true
},
clear() {
if (!this.submit.profileId) {
this.$message.warning('请选择需要清理的环境')
return
}
if (this.submit.range === DATA_CLEAR_RANGE.DAY.value) {
if (this.submit.reserveDay === null) {
this.$message.warning('请输入需要保留的天数')
return
}
} else if (this.submit.range === DATA_CLEAR_RANGE.TOTAL.value) {
if (this.submit.reserveTotal === null) {
this.$message.warning('请输入需要保留的条数')
return
}
} else if (this.submit.range === DATA_CLEAR_RANGE.REL_ID.value) {
if (!this.submit.relIdList.length) {
this.$message.warning('请选择需要清理的应用')
return
}
} else {
return
}
this.$confirm({
title: '确认清理',
content: '清理后数据将无法恢复, 确定要清理吗?',
mask: false,
okText: '确认',
okType: 'danger',
cancelText: '取消',
onOk: () => {
this.doClear()
}
})
},
doClear() {
this.loading = true
this.$api.clearData({
...this.submit,
iCreated: this.iCreated ? 1 : 2,
iAudited: this.iAudited ? 1 : 2,
iExecute: this.iExecute ? 1 : 2,
clearType: DATA_CLEAR_TYPE.APP_PIPELINE_EXEC.value
}).then(({ data }) => {
this.loading = false
this.visible = false
this.$emit('clear')
this.$message.info(`共清理 ${data}条数据`)
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-clear-container {
width: 100%;
}
.data-clear-range {
margin-bottom: 24px;
display: flex;
justify-content: center;
}
.clear-label {
width: 64px;
text-align: end;
}
.data-clear-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 242px;
}
.choose-param-wrapper {
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<a-modal v-model="visible"
title="应用发布 清理"
okText="清理"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="clear"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-clear-container">
<!-- 清理区间 -->
<div class="data-clear-range">
<a-radio-group class="nowrap" v-model="submit.range">
<a-radio-button :value="DATA_CLEAR_RANGE.DAY.value">保留天数</a-radio-button>
<a-radio-button :value="DATA_CLEAR_RANGE.TOTAL.value">保留条数</a-radio-button>
<a-radio-button :value="DATA_CLEAR_RANGE.REL_ID.value">清理应用</a-radio-button>
</a-radio-group>
</div>
<!-- 清理参数 -->
<div class="data-clear-params">
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.DAY.value === submit.range">
<span class="normal-label clear-label">保留天数</span>
<a-input-number class="param-input"
v-model="submit.reserveDay"
:min="0"
:max="9999"
placeholder="清理后数据所保留的天数"/>
</div>
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.TOTAL.value === submit.range">
<span class="normal-label clear-label">保留条数</span>
<a-input-number class="param-input"
v-model="submit.reserveTotal"
:min="0"
:max="9999"
placeholder="清理后数据所保留的条数"/>
</div>
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.REL_ID.value === submit.range">
<span class="normal-label clear-label">清理应用</span>
<AppSelector class="param-input"
placeholder="请选择清理的应用"
@change="(e) => submit.relIdList[0] = e"/>
</div>
</div>
<!-- 创建用户 -->
<div class="choose-param-wrapper">
<span class="normal-label clear-label">创建用户</span>
<a-checkbox class="param-input" v-model="iCreated">只清理我创建的</a-checkbox>
</div>
<!-- 执行用户 -->
<div class="choose-param-wrapper">
<span class="normal-label clear-label">审核用户</span>
<a-checkbox class="param-input" v-model="iAudited">只清理我审核的</a-checkbox>
</div>
<!-- 执行用户 -->
<div class="choose-param-wrapper">
<span class="normal-label clear-label">执行用户</span>
<a-checkbox class="param-input" v-model="iExecute">只清理我执行的</a-checkbox>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { DATA_CLEAR_RANGE, DATA_CLEAR_TYPE } from '@/lib/enum'
import AppSelector from '@/components/app/AppSelector'
export default {
name: 'AppReleaseClearModal',
components: {
AppSelector
},
data: function() {
return {
DATA_CLEAR_RANGE,
visible: false,
loading: false,
iCreated: true,
iAudited: false,
iExecute: false,
submit: {
range: null,
profileId: null,
reserveDay: null,
reserveTotal: null,
relIdList: []
}
}
},
methods: {
open(profileId) {
this.submit.profileId = profileId
this.iCreated = true
this.iAudited = false
this.iExecute = false
this.submit.reserveDay = null
this.submit.reserveTotal = null
this.submit.relIdList = []
this.submit.range = DATA_CLEAR_RANGE.DAY.value
this.loading = false
this.visible = true
},
clear() {
if (!this.submit.profileId) {
this.$message.warning('请选择需要清理的环境')
return
}
if (this.submit.range === DATA_CLEAR_RANGE.DAY.value) {
if (this.submit.reserveDay === null) {
this.$message.warning('请输入需要保留的天数')
return
}
} else if (this.submit.range === DATA_CLEAR_RANGE.TOTAL.value) {
if (this.submit.reserveTotal === null) {
this.$message.warning('请输入需要保留的条数')
return
}
} else if (this.submit.range === DATA_CLEAR_RANGE.REL_ID.value) {
if (!this.submit.relIdList.length) {
this.$message.warning('请选择需要清理的应用')
return
}
} else {
return
}
this.$confirm({
title: '确认清理',
content: '清理后数据将无法恢复, 确定要清理吗?',
mask: false,
okText: '确认',
okType: 'danger',
cancelText: '取消',
onOk: () => {
this.doClear()
}
})
},
doClear() {
this.loading = true
this.$api.clearData({
...this.submit,
iCreated: this.iCreated ? 1 : 2,
iAudited: this.iAudited ? 1 : 2,
iExecute: this.iExecute ? 1 : 2,
clearType: DATA_CLEAR_TYPE.APP_RELEASE.value
}).then(({ data }) => {
this.loading = false
this.visible = false
this.$emit('clear')
this.$message.info(`共清理 ${data}条数据`)
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-clear-container {
width: 100%;
}
.data-clear-range {
margin-bottom: 24px;
display: flex;
justify-content: center;
}
.clear-label {
width: 64px;
text-align: end;
}
.data-clear-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
.choose-param-wrapper {
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<a-modal v-model="visible"
title="批量执行 清理"
okText="清理"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="clear"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-clear-container">
<!-- 清理区间 -->
<div class="data-clear-range">
<a-radio-group class="nowrap" v-model="submit.range">
<a-radio-button :value="DATA_CLEAR_RANGE.DAY.value">保留天数</a-radio-button>
<a-radio-button :value="DATA_CLEAR_RANGE.TOTAL.value">保留条数</a-radio-button>
<a-radio-button :value="DATA_CLEAR_RANGE.REL_ID.value">清理机器</a-radio-button>
</a-radio-group>
</div>
<!-- 清理参数 -->
<div class="data-clear-params">
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.DAY.value === submit.range">
<span class="normal-label clear-label">保留天数</span>
<a-input-number class="param-input"
v-model="submit.reserveDay"
:min="0"
:max="9999"
placeholder="清理后数据所保留的天数"/>
</div>
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.TOTAL.value === submit.range">
<span class="normal-label clear-label">保留条数</span>
<a-input-number class="param-input"
v-model="submit.reserveTotal"
:min="0"
:max="9999"
placeholder="清理后数据所保留的条数"/>
</div>
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.REL_ID.value === submit.range">
<span class="normal-label clear-label">清理机器</span>
<MachineSelector class="param-input"
placeholder="请选择清理的机器"
@change="(e) => submit.relIdList[0] = e"/>
</div>
</div>
<!-- 管理员 -->
<div class="all-user-wrapper" v-if="$isAdmin()">
<span class="normal-label clear-label">执行用户</span>
<a-checkbox class="param-input" v-model="iCreated">只清理我执行的</a-checkbox>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { DATA_CLEAR_RANGE, DATA_CLEAR_TYPE } from '@/lib/enum'
import MachineSelector from '@/components/machine/MachineSelector'
export default {
name: 'BatchExecClearModal',
components: { MachineSelector },
data: function() {
return {
DATA_CLEAR_RANGE,
visible: false,
loading: false,
iCreated: true,
submit: {
range: null,
reserveDay: null,
reserveTotal: null,
relIdList: []
}
}
},
methods: {
open() {
this.iCreated = true
this.submit.reserveDay = null
this.submit.reserveTotal = null
this.submit.relIdList = []
this.submit.range = DATA_CLEAR_RANGE.DAY.value
this.loading = false
this.visible = true
},
clear() {
if (this.submit.range === DATA_CLEAR_RANGE.DAY.value) {
if (this.submit.reserveDay === null) {
this.$message.warning('请输入需要保留的天数')
return
}
} else if (this.submit.range === DATA_CLEAR_RANGE.TOTAL.value) {
if (this.submit.reserveTotal === null) {
this.$message.warning('请输入需要保留的条数')
return
}
} else if (this.submit.range === DATA_CLEAR_RANGE.REL_ID.value) {
if (!this.submit.relIdList.length) {
this.$message.warning('请选择需要清理的机器')
return
}
} else {
return
}
this.$confirm({
title: '确认清理',
content: '清理后数据将无法恢复, 确定要清理吗?',
mask: false,
okText: '确认',
okType: 'danger',
cancelText: '取消',
onOk: () => {
this.doClear()
}
})
},
doClear() {
this.loading = true
this.$api.clearData({
...this.submit,
iCreated: this.iCreated ? 1 : 2,
clearType: DATA_CLEAR_TYPE.BATCH_EXEC.value
}).then(({ data }) => {
this.loading = false
this.visible = false
this.$emit('clear')
this.$message.info(`共清理 ${data}条数据`)
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-clear-container {
width: 100%;
}
.data-clear-range {
margin-bottom: 24px;
display: flex;
justify-content: center;
}
.clear-label {
width: 64px;
text-align: end;
}
.data-clear-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
.all-user-wrapper {
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,164 @@
<template>
<a-modal v-model="visible"
title="操作日志 清理"
okText="清理"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="clear"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-clear-container">
<!-- 清理区间 -->
<div class="data-clear-range">
<a-radio-group class="nowrap" v-model="submit.range">
<a-radio-button :value="DATA_CLEAR_RANGE.DAY.value">保留天数</a-radio-button>
<a-radio-button :value="DATA_CLEAR_RANGE.TOTAL.value">保留条数</a-radio-button>
<a-radio-button :value="DATA_CLEAR_RANGE.REL_ID.value">操作分类</a-radio-button>
</a-radio-group>
</div>
<!-- 清理参数 -->
<div class="data-clear-params">
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.DAY.value === submit.range">
<span class="normal-label clear-label">保留天数</span>
<a-input-number class="param-input"
v-model="submit.reserveDay"
:min="0"
:max="9999"
placeholder="清理后数据所保留的天数"/>
</div>
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.TOTAL.value === submit.range">
<span class="normal-label clear-label">保留条数</span>
<a-input-number class="param-input"
v-model="submit.reserveTotal"
:min="0"
:max="9999"
placeholder="清理后数据所保留的条数"/>
</div>
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.REL_ID.value === submit.range">
<span class="normal-label clear-label">操作分类</span>
<a-select class="param-input"
placeholder="请选择需要清理的操作分类"
@change="(e) => submit.relIdList[0] = e">
<a-select-option v-for="classify in EVENT_CLASSIFY" :key="classify.value" :value="classify.value">
{{ classify.label }}
</a-select-option>
</a-select>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { DATA_CLEAR_RANGE, EVENT_CLASSIFY, DATA_CLEAR_TYPE } from '@/lib/enum'
export default {
name: 'EventLogClearModal',
data: function() {
return {
DATA_CLEAR_RANGE,
EVENT_CLASSIFY,
visible: false,
loading: false,
submit: {
range: null,
reserveDay: null,
reserveTotal: null,
relIdList: []
}
}
},
methods: {
open() {
this.submit.reserveDay = null
this.submit.reserveTotal = null
this.submit.relIdList = []
this.submit.range = DATA_CLEAR_RANGE.DAY.value
this.loading = false
this.visible = true
},
clear() {
if (this.submit.range === DATA_CLEAR_RANGE.DAY.value) {
if (this.submit.reserveDay === null) {
this.$message.warning('请输入需要保留的天数')
return
}
} else if (this.submit.range === DATA_CLEAR_RANGE.TOTAL.value) {
if (this.submit.reserveTotal === null) {
this.$message.warning('请输入需要保留的条数')
return
}
} else if (this.submit.range === DATA_CLEAR_RANGE.REL_ID.value) {
if (!this.submit.relIdList.length) {
this.$message.warning('请选择需要清理的操作分类')
return
}
} else {
return
}
this.$confirm({
title: '确认清理',
content: '清理后数据将无法恢复, 确定要清理吗?',
mask: false,
okText: '确认',
okType: 'danger',
cancelText: '取消',
onOk: () => {
this.doClear()
}
})
},
doClear() {
this.loading = true
this.$api.clearData({
...this.submit,
clearType: DATA_CLEAR_TYPE.USER_EVENT_LOG.value
}).then(({ data }) => {
this.loading = false
this.visible = false
this.$emit('clear')
this.$message.info(`共清理 ${data}条数据`)
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-clear-container {
width: 100%;
}
.data-clear-range {
margin-bottom: 24px;
display: flex;
justify-content: center;
}
.clear-label {
width: 64px;
text-align: end;
}
.data-clear-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<a-modal v-model="visible"
title="报警记录 清理"
okText="清理"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="clear"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-clear-container">
<!-- 清理区间 -->
<div class="data-clear-range">
<a-radio-group class="nowrap" v-model="submit.range">
<a-radio-button :value="DATA_CLEAR_RANGE.DAY.value">保留天数</a-radio-button>
<a-radio-button :value="DATA_CLEAR_RANGE.TOTAL.value">保留条数</a-radio-button>
</a-radio-group>
</div>
<!-- 清理参数 -->
<div class="data-clear-params">
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.DAY.value === submit.range">
<span class="normal-label clear-label">保留天数</span>
<a-input-number class="param-input"
v-model="submit.reserveDay"
:min="0"
:max="9999"
placeholder="清理后数据所保留的天数"/>
</div>
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.TOTAL.value === submit.range">
<span class="normal-label clear-label">保留条数</span>
<a-input-number class="param-input"
v-model="submit.reserveTotal"
:min="0"
:max="9999"
placeholder="清理后数据所保留的条数"/>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { DATA_CLEAR_RANGE, DATA_CLEAR_TYPE } from '@/lib/enum'
export default {
name: 'MachineAlarmHistoryClearModal',
data: function() {
return {
DATA_CLEAR_RANGE,
visible: false,
loading: false,
submit: {
range: null,
machineId: null,
reserveDay: null,
reserveTotal: null
}
}
},
methods: {
open(machineId) {
this.submit.machineId = machineId
this.submit.reserveDay = null
this.submit.reserveTotal = null
this.submit.range = DATA_CLEAR_RANGE.DAY.value
this.loading = false
this.visible = true
},
clear() {
if (!this.submit.machineId) {
this.$message.warning('无机器id')
return
}
if (this.submit.range === DATA_CLEAR_RANGE.DAY.value) {
if (this.submit.reserveDay === null) {
this.$message.warning('请输入需要保留的天数')
return
}
} else if (this.submit.range === DATA_CLEAR_RANGE.TOTAL.value) {
if (this.submit.reserveTotal === null) {
this.$message.warning('请输入需要保留的条数')
return
}
} else {
return
}
this.$confirm({
title: '确认清理',
content: '清理后数据将无法恢复, 确定要清理吗?',
mask: false,
okText: '确认',
okType: 'danger',
cancelText: '取消',
onOk: () => {
this.doClear()
}
})
},
doClear() {
this.loading = true
this.$api.clearData({
...this.submit,
clearType: DATA_CLEAR_TYPE.MACHINE_ALARM_HISTORY.value
}).then(({ data }) => {
this.loading = false
this.visible = false
this.$emit('clear')
this.$message.info(`共清理 ${data}条数据`)
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-clear-container {
width: 100%;
}
.data-clear-range {
margin-bottom: 24px;
display: flex;
justify-content: center;
}
.clear-label {
width: 64px;
text-align: end;
}
.data-clear-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<a-modal v-model="visible"
title="调度任务 清理"
okText="清理"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="clear"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-clear-container">
<!-- 清理区间 -->
<div class="data-clear-range">
<a-radio-group class="nowrap" v-model="submit.range">
<a-radio-button :value="DATA_CLEAR_RANGE.DAY.value">保留天数</a-radio-button>
<a-radio-button :value="DATA_CLEAR_RANGE.TOTAL.value">保留条数</a-radio-button>
</a-radio-group>
</div>
<!-- 清理参数 -->
<div class="data-clear-params">
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.DAY.value === submit.range">
<span class="normal-label clear-label">保留天数</span>
<a-input-number class="param-input"
v-model="submit.reserveDay"
:min="0"
:max="9999"
placeholder="清理后数据所保留的天数"/>
</div>
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.TOTAL.value === submit.range">
<span class="normal-label clear-label">保留条数</span>
<a-input-number class="param-input"
v-model="submit.reserveTotal"
:min="0"
:max="9999"
placeholder="清理后数据所保留的条数"/>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { DATA_CLEAR_RANGE, DATA_CLEAR_TYPE } from '@/lib/enum'
export default {
name: 'SchedulerRecordClearModal',
data: function() {
return {
DATA_CLEAR_RANGE,
visible: false,
loading: false,
submit: {
range: null,
relId: null,
reserveDay: null,
reserveTotal: null
}
}
},
methods: {
open(relId) {
this.submit.relId = relId
this.submit.reserveDay = null
this.submit.reserveTotal = null
this.submit.range = DATA_CLEAR_RANGE.DAY.value
this.loading = false
this.visible = true
},
clear() {
if (!this.submit.relId) {
this.$message.warning('无任务id')
return
}
if (this.submit.range === DATA_CLEAR_RANGE.DAY.value) {
if (this.submit.reserveDay === null) {
this.$message.warning('请输入需要保留的天数')
return
}
} else if (this.submit.range === DATA_CLEAR_RANGE.TOTAL.value) {
if (this.submit.reserveTotal === null) {
this.$message.warning('请输入需要保留的条数')
return
}
} else {
return
}
this.$confirm({
title: '确认清理',
content: '清理后数据将无法恢复, 确定要清理吗?',
mask: false,
okText: '确认',
okType: 'danger',
cancelText: '取消',
onOk: () => {
this.doClear()
}
})
},
doClear() {
this.loading = true
this.$api.clearData({
...this.submit,
clearType: DATA_CLEAR_TYPE.SCHEDULER_RECORD.value
}).then(({ data }) => {
this.loading = false
this.visible = false
this.$emit('clear')
this.$message.info(`共清理 ${data}条数据`)
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-clear-container {
width: 100%;
}
.data-clear-range {
margin-bottom: 24px;
display: flex;
justify-content: center;
}
.clear-label {
width: 64px;
text-align: end;
}
.data-clear-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<a-modal v-model="visible"
title="终端日志 清理"
okText="清理"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="clear"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-clear-container">
<!-- 清理区间 -->
<div class="data-clear-range">
<a-radio-group class="nowrap" v-model="submit.range">
<a-radio-button :value="DATA_CLEAR_RANGE.DAY.value">保留天数</a-radio-button>
<a-radio-button :value="DATA_CLEAR_RANGE.TOTAL.value">保留条数</a-radio-button>
<a-radio-button :value="DATA_CLEAR_RANGE.REL_ID.value">清理机器</a-radio-button>
</a-radio-group>
</div>
<!-- 清理参数 -->
<div class="data-clear-params">
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.DAY.value === submit.range">
<span class="normal-label clear-label">保留天数</span>
<a-input-number class="param-input"
v-model="submit.reserveDay"
:min="0"
:max="9999"
placeholder="清理后数据所保留的天数"/>
</div>
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.TOTAL.value === submit.range">
<span class="normal-label clear-label">保留条数</span>
<a-input-number class="param-input"
v-model="submit.reserveTotal"
:min="0"
:max="9999"
placeholder="清理后数据所保留的条数"/>
</div>
<div class="data-clear-param" v-if="DATA_CLEAR_RANGE.REL_ID.value === submit.range">
<span class="normal-label clear-label">清理机器</span>
<MachineSelector class="param-input"
placeholder="请选择清理的机器"
@change="(e) => submit.relIdList[0] = e"/>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { DATA_CLEAR_RANGE, DATA_CLEAR_TYPE } from '@/lib/enum'
import MachineSelector from '@/components/machine/MachineSelector'
export default {
name: 'TerminalLogClearModal',
components: { MachineSelector },
data: function() {
return {
DATA_CLEAR_RANGE,
visible: false,
loading: false,
submit: {
range: null,
reserveDay: null,
reserveTotal: null,
relIdList: []
}
}
},
methods: {
open() {
this.submit.reserveDay = null
this.submit.reserveTotal = null
this.submit.relIdList = []
this.submit.range = DATA_CLEAR_RANGE.DAY.value
this.loading = false
this.visible = true
},
clear() {
if (this.submit.range === DATA_CLEAR_RANGE.DAY.value) {
if (this.submit.reserveDay === null) {
this.$message.warning('请输入需要保留的天数')
return
}
} else if (this.submit.range === DATA_CLEAR_RANGE.TOTAL.value) {
if (this.submit.reserveTotal === null) {
this.$message.warning('请输入需要保留的条数')
return
}
} else if (this.submit.range === DATA_CLEAR_RANGE.REL_ID.value) {
if (!this.submit.relIdList.length) {
this.$message.warning('请选择需要清理的机器')
return
}
} else {
return
}
this.$confirm({
title: '确认清理',
content: '清理后数据将无法恢复, 确定要清理吗?',
mask: false,
okText: '确认',
okType: 'danger',
cancelText: '取消',
onOk: () => {
this.doClear()
}
})
},
doClear() {
this.loading = true
this.$api.clearData({
...this.submit,
clearType: DATA_CLEAR_TYPE.TERMINAL_LOG.value
}).then(({ data }) => {
this.loading = false
this.visible = false
this.$emit('clear')
this.$message.info(`共清理 ${data}条数据`)
}).catch(() => {
this.loading = false
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-clear-container {
width: 100%;
}
.data-clear-range {
margin-bottom: 24px;
display: flex;
justify-content: center;
}
.clear-label {
width: 64px;
text-align: end;
}
.data-clear-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<!-- 右键菜单 -->
<div class="right-menu" ref="rightMenu" @contextmenu.prevent>
<a-dropdown :trigger="['click']">
<span ref="rightMenuTrigger" class="right-menu-trigger" @contextmenu.prevent></span>
<template #overlay>
<a-menu @click="clickRightMenuItem" @contextmenu.prevent>
<slot name="items"/>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
<script>
export default {
name: 'RightClickMenu',
props: {
x: {
type: Function,
default: e => {
return e.offsetX + 10
}
},
y: {
type: Function,
default: e => {
return e.clientY
}
}
},
methods: {
openRightMenu(e) {
if (e.button === 2) {
this.$refs.rightMenu.style.left = this.x(e) + 'px'
this.$refs.rightMenu.style.top = this.y(e) + 'px'
this.$refs.rightMenu.style.display = 'block'
this.$refs.rightMenuTrigger.click()
} else {
this.$refs.rightMenu.style.display = 'none'
}
},
clickRightMenuItem({ key }) {
this.$emit('clickRight', key)
}
}
}
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,153 @@
<template>
<a-modal v-model="visible"
:title="title"
:width="650"
:dialogStyle="{top: '64px'}"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="check"
@cancel="close">
<a-spin :spinning="loading">
<a-form :form="form" v-bind="layout">
<a-form-item label="模板名称" hasFeedback>
<a-input v-decorator="decorators.name" allowClear/>
</a-form-item>
<a-form-item label="模板内容">
<Editor :height="350" v-decorator="decorators.value"/>
</a-form-item>
<a-form-item label="模板描述">
<a-textarea v-decorator="decorators.description" allowClear/>
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>
<script>
import { pick } from 'lodash'
import Editor from '@/components/editor/Editor'
const layout = {
labelCol: { span: 5 },
wrapperCol: { span: 17 }
}
function getDecorators() {
return {
name: ['name', {
rules: [{
required: true,
message: '请输入模板名称'
}, {
max: 32,
message: '模板名称长度不能大于32位'
}]
}],
value: ['value', {
initialValue: undefined,
rules: [{
required: true,
message: '请输入模板内容'
}, {
max: 2048,
message: '模板内容长度不能大于2048位'
}]
}],
description: ['description', {
rules: [{
max: 64,
message: '模板描述长度不能大于64位'
}]
}]
}
}
export default {
name: 'AddTemplateModal',
components: {
Editor
},
data: function() {
return {
id: null,
visible: false,
title: null,
loading: false,
record: null,
layout,
decorators: getDecorators.call(this),
form: this.$form.createForm(this)
}
},
methods: {
add() {
this.title = '新增模板'
this.initRecord({})
},
update(id) {
this.title = '修改模板'
this.$api.getTemplateDetail({ id })
.then(({ data }) => {
this.initRecord(data)
})
},
initRecord(row) {
this.form.resetFields()
this.visible = true
this.id = row.id
this.record = pick(Object.assign({}, row), 'name', 'value', 'description')
this.$nextTick(() => {
this.form.setFieldsValue(this.record)
})
},
check() {
this.loading = true
this.form.validateFields((err, values) => {
if (err) {
this.loading = false
return
}
this.submit(values)
})
},
async submit(values) {
let res
try {
if (!this.id) {
// 添加
res = await this.$api.addTemplate({
...values
})
} else {
// 修改
res = await this.$api.updateTemplate({
...values,
id: this.id
})
}
if (!this.id) {
this.$message.success('添加成功')
this.$emit('added', res.data)
} else {
this.$message.success('修改成功')
this.$emit('updated', res.data)
}
this.close()
} catch (e) {
// ignore
}
this.loading = false
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,159 @@
<template>
<a-modal v-model="visible"
:title="title"
:width="650"
:dialogStyle="{top: '128px'}"
:bodyStyle="{padding: '24px 8px 0 8px'}"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
:mask="mask"
@ok="check"
@cancel="close">
<a-spin :spinning="loading">
<a-form :form="form" v-bind="layout">
<a-form-item label="webhook 名称" hasFeedback>
<a-input v-decorator="decorators.name" allowClear/>
</a-form-item>
<a-form-item label="webhook url" hasFeedback>
<a-input v-decorator="decorators.url" allowClear/>
</a-form-item>
<a-form-item label="webhook 类型">
<a-select v-decorator="decorators.type" placeholder="请选择">
<a-select-option v-for="type of WEBHOOK_TYPE" :key="type.value" :value="type.value">
{{ type.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>
<script>
import { pick } from 'lodash'
import { WEBHOOK_TYPE } from '@/lib/enum'
const layout = {
labelCol: { span: 5 },
wrapperCol: { span: 17 }
}
function getDecorators() {
return {
name: ['name', {
rules: [{
required: true,
message: '请输入 webhook 名称'
}, {
max: 64,
message: 'webhook 名称长度不能大于64位'
}]
}],
url: ['url', {
rules: [{
required: true,
message: '请输入webhook url'
}, {
max: 2048,
message: 'webhook url 长度不能大于2048位'
}]
}],
type: ['type', {
rules: [{
required: true,
message: '请选择 webhook 类型'
}]
}]
}
}
export default {
name: 'AddWebhookModal',
props: {
mask: Boolean
},
data: function() {
return {
WEBHOOK_TYPE,
id: null,
visible: false,
title: null,
loading: false,
record: null,
layout,
decorators: getDecorators.call(this),
form: this.$form.createForm(this)
}
},
methods: {
add() {
this.title = '新增 webhook'
this.initRecord({})
},
update(id) {
this.title = '修改 webhook'
this.$api.getWebhookConfigDetail({ id })
.then(({ data }) => {
this.initRecord(data)
})
},
initRecord(row) {
this.form.resetFields()
this.visible = true
this.id = row.id
this.record = pick(Object.assign({}, row), 'name', 'url', 'type')
this.$nextTick(() => {
this.form.setFieldsValue(this.record)
})
},
check() {
this.loading = true
this.form.validateFields((err, values) => {
if (err) {
this.loading = false
return
}
this.submit(values)
})
},
async submit(values) {
let res
try {
if (!this.id) {
// 添加
res = await this.$api.addWebhookConfig({
...values
})
} else {
// 修改
res = await this.$api.updateWebhookConfig({
...values,
id: this.id
})
}
if (!this.id) {
this.$message.success('添加成功')
this.$emit('added', res.data)
} else {
this.$message.success('修改成功')
this.$emit('updated', res.data)
}
this.close()
} catch (e) {
// ignore
}
this.loading = false
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,233 @@
<template>
<a-modal v-model="visible"
:width="1024"
:dialogStyle="{top: '64px'}"
:bodyStyle="{padding: '8px'}"
:maskClosable="false"
:destroyOnClose="true"
@cancel="close">
<!-- 历史值表格 -->
<div class="table-main-container table-scroll-x-auto">
<a-table :columns="columns"
:dataSource="rows"
:pagination="pagination"
rowKey="id"
@change="getList"
:scroll="{x: '100%'}"
:loading="loading"
size="middle">
<!-- beforeValue -->
<template #beforeValue="record">
<div class="auto-ellipsis">
<a class="copy-icon-left" v-if="record.beforeValue" @click="$copy(record.beforeValue)">
<a-icon type="copy"/>
</a>
<span class="pointer auto-ellipsis-item" title="预览" @click="preview(record.beforeValue)">
{{ record.beforeValue }}
</span>
</div>
</template>
<!-- afterValue -->
<template #afterValue="record">
<div class="auto-ellipsis">
<a class="copy-icon-left" @click="$copy(record.afterValue)">
<a-icon type="copy"/>
</a>
<span class="pointer auto-ellipsis-item" title="预览" @click="preview(record.afterValue)">
{{ record.afterValue }}
</span>
</div>
</template>
<!-- 类型 -->
<template #type="record">
<a-tag class="m0" :color="record.type | formatType('color')">
{{ record.type | formatType('label') }}
</a-tag>
</template>
<!-- 修改时间 -->
<template #createTime="record">
{{ record.createTime | formatDate }} ({{ record.createTimeAgo }})
</template>
<!-- 操作 -->
<template #action="record">
<a @click="rollback(record)">回滚</a>
</template>
</a-table>
</div>
<!-- 历史值表格 -->
<div class="history-event">
<!-- 预览框 -->
<TextPreview ref="preview"/>
</div>
<!-- 头部 -->
<template #title>
<span>历史记录</span>
<span class="span-blue" style="margin-left: 8px">{{ env.key }}</span>
<a @click="$copy(env.key)">
<a-icon class="copy-icon-right" type="copy"/>
</a>
</template>
<!-- 底部 -->
<template #footer>
<a-button @click="close">关闭</a-button>
</template>
</a-modal>
</template>
<script>
import { formatDate } from '@/lib/filters'
import { enumValueOf, HISTORY_VALUE_OPTION_TYPE } from '@/lib/enum'
import TextPreview from '@/components/preview/TextPreview'
const columns = [
{
title: '序号',
key: 'seq',
width: 60,
align: 'center',
customRender: (text, record, index) => `${index + 1}`
},
{
title: 'beforeValue',
key: 'beforeValue',
width: 200,
ellipsis: true,
sorter: (a, b) => (a.beforeValue || '').localeCompare(b.beforeValue || ''),
scopedSlots: { customRender: 'beforeValue' }
},
{
title: 'afterValue',
key: 'afterValue',
width: 200,
ellipsis: true,
sorter: (a, b) => (a.afterValue || '').localeCompare(b.afterValue || ''),
scopedSlots: { customRender: 'afterValue' }
},
{
title: '类型',
key: 'type',
width: 80,
align: 'center',
scopedSlots: { customRender: 'type' }
},
{
title: '修改人',
key: 'updateUserName',
dataIndex: 'updateUserName',
width: 100,
align: 'center'
},
{
title: '修改时间',
key: 'createTime',
width: 220,
align: 'center',
sorter: (a, b) => a.createTime - b.createTime,
scopedSlots: { customRender: 'createTime' }
},
{
title: '操作',
key: 'action',
width: 90,
align: 'center',
scopedSlots: { customRender: 'action' }
}
]
export default {
name: 'EnvHistoryModal',
components: {
TextPreview
},
data: function() {
return {
loading: false,
visible: false,
env: {},
rows: [],
pagination: {
current: 1,
pageSize: 10,
total: 0,
showTotal: function(total) {
return `${total}`
}
},
columns
}
},
methods: {
open(env) {
this.env = env
this.visible = true
this.getList({})
},
getList(page = this.pagination) {
this.loading = true
this.$api.getHistoryValueList({
valueId: this.env.valueId,
valueType: this.env.valueType,
page: page.current,
limit: page.pageSize
}).then(({ data }) => {
const pagination = { ...this.pagination }
pagination.total = data.total
pagination.current = data.page
this.rows = data.rows || []
this.pagination = pagination
this.loading = false
}).catch(() => {
this.loading = false
})
},
rollback(record) {
var updateValue
switch (record.type) {
case HISTORY_VALUE_OPTION_TYPE.INSERT.value:
updateValue = record.afterValue
break
default:
updateValue = record.beforeValue
}
this.$confirm({
title: '确认回滚',
content: `是否回滚值为 ${updateValue}`,
okText: '确认',
okType: 'danger',
cancelText: '取消',
onOk: () => {
this.loading = true
this.$api.rollbackHistoryValue({
id: record.id
}).then(() => {
this.loading = false
this.$message.success('已回滚')
this.getList({})
this.$emit('rollback', record.id, updateValue)
}).catch(() => {
this.loading = false
})
}
})
},
preview(value) {
this.$refs.preview.preview(value)
},
close() {
this.loading = false
this.visible = false
this.env = {}
}
},
filters: {
formatDate,
formatType(status, f) {
return enumValueOf(HISTORY_VALUE_OPTION_TYPE, status)[f]
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,167 @@
<template>
<a-modal v-model="visible"
:width="1000"
:dialogStyle="{top: '16px'}"
:bodyStyle="{padding: '8px'}"
:destroyOnClose="true"
title="模板列表">
<!-- 搜索列 -->
<div class="table-tools-bar">
<!-- 左侧 -->
<div class="tools-fixed-left">
<a-form-model layout="inline" class="command-template-search-form" ref="query" :model="query">
<a-form-model-item label="模板名称" prop="name">
<a-input v-model="query.name" allowClear/>
</a-form-model-item>
<a-form-model-item label="模板内容" prop="value">
<a-input v-model="query.value" allowClear/>
</a-form-model-item>
</a-form-model>
</div>
<!-- 右侧 -->
<div class="tools-fixed-right">
<a-icon type="search" class="tools-icon" title="查询" @click="getList({})"/>
<a-icon type="reload" class="tools-icon" title="重置" @click="resetForm"/>
</div>
</div>
<!-- 表格 -->
<div class="table-main-container table-scroll-x-auto">
<a-table :columns="columns"
:dataSource="rows"
:pagination="pagination"
rowKey="id"
@change="getList"
:scroll="{x: '100%'}"
:loading="loading"
size="middle">
<!-- 模板内容 -->
<template #value="record">
<span :title="record.value">
<a-icon class="span-blue pointer" type="copy" title="复制" @click="$copy(record.value)"/>
{{ record.value }}
</span>
</template>
<!-- 操作 -->
<template #action="record">
<!-- 选择 -->
<a @click="selected(record.value)">选择</a>
</template>
</a-table>
</div>
<!-- 页脚 -->
<template #footer>
<a-button @click="() => visible = false">关闭</a-button>
</template>
</a-modal>
</template>
<script>
/**
* 列
*/
const columns = [
{
title: '序号',
key: 'seq',
width: 65,
align: 'center',
customRender: (text, record, index) => `${index + 1}`
},
{
title: '模板名称',
dataIndex: 'name',
key: 'name',
width: 120,
ellipsis: true,
sorter: (a, b) => a.name.localeCompare(b.name)
},
{
title: '模板内容',
key: 'value',
width: 240,
ellipsis: true,
sorter: (a, b) => a.value.localeCompare(b.value),
scopedSlots: { customRender: 'value' }
},
{
title: '模板描述',
key: 'description',
width: 140,
ellipsis: true,
dataIndex: 'description'
},
{
title: '操作',
key: 'action',
width: 60,
fixed: 'right',
align: 'center',
scopedSlots: { customRender: 'action' }
}
]
export default {
name: 'TemplateSelector',
data() {
return {
visible: false,
query: {
name: null,
value: null,
description: null
},
rows: [],
pagination: {
current: 1,
pageSize: 10,
total: 0,
showTotal: function(total) {
return `${total}`
}
},
loading: false,
columns
}
},
methods: {
open() {
this.visible = true
if (!this.rows.length) {
this.getList({})
}
},
close() {
this.visible = false
},
getList(page = this.pagination) {
this.loading = true
this.$api.getTemplateList({
...this.query,
page: page.current,
limit: page.pageSize
}).then(({ data }) => {
const pagination = { ...this.pagination }
pagination.total = data.total
pagination.current = data.page
this.rows = data.rows || []
this.pagination = pagination
this.loading = false
}).catch(() => {
this.loading = false
})
},
resetForm() {
this.$refs.query.resetFields()
this.getList({})
},
selected(value) {
this.$emit('selected', value)
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,108 @@
<template>
<ace ref="editor"
:value="value"
:lang="lang"
:width="width === 0 ? '100%' : width"
:height="height === 0 ? '100%' : height"
:theme="theme"
:options="options"
@init="initEditor"
v-bind="config">
</ace>
</template>
<script>
import ace from 'vue2-ace-editor'
export default {
name: 'Editor',
components: {
ace
},
props: {
value: {
type: String,
default: ''
},
width: {
type: Number,
default: 0
},
height: {
type: Number,
default: 0
},
readOnly: {
type: Boolean,
default: false
},
theme: {
type: String,
default: 'iplastic'
},
lang: {
type: String,
default: 'sh'
},
config: {
type: Object,
default: () => {
return {
enableLiveAutocompletion: true,
fontSize: 16
}
}
}
},
computed: {
options() {
return {
enableBasicAutocompletion: true,
enableSnippets: true,
showPrintMargin: false,
fontSize: this.config.fontSize,
enableLiveAutocompletion: this.config.enableLiveAutocompletion,
readOnly: this.readOnly
}
}
},
methods: {
initEditor(editor) {
require('brace/ext/language_tools')
// 设置语言
require('brace/mode/sh')
require('brace/mode/json')
require('brace/mode/xml')
require('brace/mode/yaml')
require('brace/mode/properties')
require('brace/snippets/sh')
require('brace/snippets/json')
require('brace/snippets/xml')
require('brace/snippets/yaml')
require('brace/snippets/properties')
// 设置主题
// 浅色 iplastic sqlserver tomorrow xcode
// 深色 dracula gruvbox idle_fingers merbivore terminal tomorrow_night_bright
require('brace/theme/iplastic')
// 监听值的变化
editor.getSession().on('change', () => {
this.$emit('change', editor.getValue())
})
},
getValue() {
return this.$refs.editor.editor.getValue()
},
setValue(value) {
this.$refs.editor.editor.session.setValue(value)
},
clear() {
this.$refs.editor.editor.session.setValue('')
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,110 @@
<template>
<a-modal v-model="visible"
title="详情"
width="700px"
:dialogStyle="{top: '16px'}"
:maskClosable="false"
:destroyOnClose="true">
<a-spin :spinning="loading">
<div id="exec-task-descriptions">
<a-descriptions bordered size="middle">
<a-descriptions-item label="执行主机" :span="2">
{{ `${detail.machineName} (${detail.machineHost})` }}
</a-descriptions-item>
<a-descriptions-item label="执行用户" :span="1">
{{ detail.username }}
</a-descriptions-item>
<a-descriptions-item label="执行命令" :span="3">
<Editor :height="300" :readOnly="true" :value="detail.command"/>
</a-descriptions-item>
<a-descriptions-item v-if="detail.description" label="执行描述" :span="3">
{{ detail.description }}
</a-descriptions-item>
<a-descriptions-item v-if="detail.createTime" label="创建时间" :span="2">
{{ detail.createTime | formatDate }} ({{ detail.createTimeAgo }})
</a-descriptions-item>
<a-descriptions-item label="状态" :span="1">
<a-tag v-if="detail.status" :color="detail.status | formatExecStatus('color')">
{{ detail.status | formatExecStatus('label') }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="detail.startDate" label="开始时间" :span="detail.exitCode === null ? 3 : 2">
{{ detail.startDate | formatDate }} ({{ detail.startDateAgo }})
</a-descriptions-item>
<a-descriptions-item v-if="detail.exitCode !== null" label="退出码" :span="1">
<span :style="{'color': detail.exitCode === 0 ? '#4263EB' : '#E03131'}">
{{ detail.exitCode }}
</span>
</a-descriptions-item>
<a-descriptions-item v-if="detail.endDate" label="结束时间" :span="2">
{{ detail.endDate | formatDate }} ({{ detail.endDateAgo }})
</a-descriptions-item>
<a-descriptions-item v-if="detail.used" label="用时" :span="1">
{{ `${detail.keepTime} (${detail.used}ms)` }}
</a-descriptions-item>
</a-descriptions>
</div>
</a-spin>
<template #footer>
<a-button class="mr8" type="primary" @click="() => $copy(detail.command)">复制命令</a-button>
<a-button @click="close">关闭</a-button>
</template>
</a-modal>
</template>
<script>
import { formatDate } from '@/lib/filters'
import { enumValueOf, BATCH_EXEC_STATUS } from '@/lib/enum'
import Editor from '@/components/editor/Editor'
export default {
name: 'ExecTaskDetailModal',
components: {
Editor
},
data() {
return {
loading: false,
visible: false,
detail: {}
}
},
methods: {
open(id) {
this.loading = true
this.visible = true
this.$api.getExecDetail({ id })
.then(({ data }) => {
this.loading = false
this.detail = data
})
.catch(() => {
this.loading = false
})
},
close() {
this.loading = false
this.visible = false
this.detail = {}
}
},
filters: {
formatDate,
formatExecStatus(status, f) {
return enumValueOf(BATCH_EXEC_STATUS, status)[f]
}
}
}
</script>
<style scoped>
::v-deep #exec-task-descriptions table th {
padding: 14px;
width: 95px;
}
::v-deep #exec-task-descriptions table td {
padding: 14px 8px;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<a-modal v-model="visible"
title="应用环境 导出"
okText="导出"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="exportData"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-export-container">
<!-- 导出参数 -->
<div class="data-export-params">
<!-- 文档密码 -->
<div class="data-export-param">
<span class="normal-label export-label">文档密码</span>
<a-input class="param-input"
v-model="protectPassword"
:maxLength="10"
placeholder="导出文档的密码(数字及字母)"/>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { downloadFile } from '@/lib/utils'
import { EXPORT_TYPE } from '@/lib/enum'
export default {
name: 'AppProfileExportModal',
data: function() {
return {
visible: false,
loading: false,
protectPassword: undefined
}
},
methods: {
open() {
this.protectPassword = undefined
this.loading = false
this.visible = true
},
exportData() {
this.loading = true
this.$api.exportData({
exportType: EXPORT_TYPE.APP_PROFILE.value,
protectPassword: this.protectPassword
}).then((e) => {
this.loading = false
this.visible = false
this.$message.success('导出成功, 片刻后自动下载')
downloadFile(e)
}).catch(() => {
this.loading = false
this.$message.error('导出失败')
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-export-container {
width: 100%;
}
.export-label {
width: 64px;
text-align: end;
}
.data-export-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<a-modal v-model="visible"
title="应用版本仓库 导出"
okText="导出"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="exportData"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-export-container">
<!-- 导出参数 -->
<div class="data-export-params">
<!-- 密码导出 -->
<div class="data-export-param mb16">
<span class="normal-label export-label">导出密码</span>
<a-checkbox class="param-input" v-model="exportPassword">是否导出密码 (密文, 仅用于导入)</a-checkbox>
</div>
<!-- 文档密码 -->
<div class="data-export-param">
<span class="normal-label export-label">文档密码</span>
<a-input class="param-input"
v-model="protectPassword"
:maxLength="10"
placeholder="导出文档的密码(数字及字母)"/>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { downloadFile } from '@/lib/utils'
import { EXPORT_TYPE } from '@/lib/enum'
export default {
name: 'AppRepositoryExportModal',
data: function() {
return {
visible: false,
loading: false,
exportPassword: false,
protectPassword: undefined
}
},
methods: {
open() {
this.exportPassword = false
this.protectPassword = undefined
this.loading = false
this.visible = true
},
exportData() {
this.loading = true
this.$api.exportData({
exportType: EXPORT_TYPE.APP_REPOSITORY.value,
exportPassword: this.exportPassword ? 1 : 2,
protectPassword: this.protectPassword
}).then((e) => {
this.loading = false
this.visible = false
this.$message.success('导出成功, 片刻后自动下载')
downloadFile(e)
}).catch(() => {
this.loading = false
this.$message.error('导出失败')
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-export-container {
width: 100%;
}
.export-label {
width: 64px;
text-align: end;
}
.data-export-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<a-modal v-model="visible"
title="应用信息 导出"
okText="导出"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="exportData"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-export-container">
<!-- 导出参数 -->
<div class="data-export-params">
<!-- 文档密码 -->
<div class="data-export-param">
<span class="normal-label export-label">文档密码</span>
<a-input class="param-input"
v-model="protectPassword"
:maxLength="10"
placeholder="导出文档的密码(数字及字母)"/>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { downloadFile } from '@/lib/utils'
import { EXPORT_TYPE } from '@/lib/enum'
export default {
name: 'ApplicationExportModal',
data: function() {
return {
visible: false,
loading: false,
protectPassword: undefined
}
},
methods: {
open() {
this.protectPassword = undefined
this.loading = false
this.visible = true
},
exportData() {
this.loading = true
this.$api.exportData({
exportType: EXPORT_TYPE.APPLICATION.value,
protectPassword: this.protectPassword
}).then((e) => {
this.loading = false
this.visible = false
this.$message.success('导出成功, 片刻后自动下载')
downloadFile(e)
}).catch(() => {
this.loading = false
this.$message.error('导出失败')
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-export-container {
width: 100%;
}
.export-label {
width: 64px;
text-align: end;
}
.data-export-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<a-modal v-model="visible"
title="命令模板 导出"
okText="导出"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="exportData"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-export-container">
<!-- 导出参数 -->
<div class="data-export-params">
<!-- 文档密码 -->
<div class="data-export-param">
<span class="normal-label export-label">文档密码</span>
<a-input class="param-input"
v-model="protectPassword"
:maxLength="10"
placeholder="导出文档的密码(数字及字母)"/>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { downloadFile } from '@/lib/utils'
import { EXPORT_TYPE } from '@/lib/enum'
export default {
name: 'CommandTemplateExportModal',
data: function() {
return {
visible: false,
loading: false,
protectPassword: undefined
}
},
methods: {
open() {
this.protectPassword = undefined
this.loading = false
this.visible = true
},
exportData() {
this.loading = true
this.$api.exportData({
exportType: EXPORT_TYPE.COMMAND_TEMPLATE.value,
protectPassword: this.protectPassword
}).then((e) => {
this.loading = false
this.visible = false
this.$message.success('导出成功, 片刻后自动下载')
downloadFile(e)
}).catch(() => {
this.loading = false
this.$message.error('导出失败')
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-export-container {
width: 100%;
}
.export-label {
width: 64px;
text-align: end;
}
.data-export-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<a-modal v-model="visible"
title="操作日志 导出"
okText="导出"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="exportData"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-export-container">
<!-- 导出参数 -->
<div class="data-export-params">
<!-- 导出分类 -->
<div class="data-export-param mb16">
<span class="normal-label export-label">导出分类</span>
<a-select class="param-input"
placeholder="全部"
@change="(e) => classify = e">
<a-select-option v-for="classify in EVENT_CLASSIFY" :key="classify.value" :value="classify.value">
{{ classify.label }}
</a-select-option>
</a-select>
</div>
<!-- 选择用户 -->
<div class="data-export-param mb16" v-if="$isAdmin() && manager">
<span class="normal-label export-label">选择用户</span>
<UserSelector class="param-input"
placeholder="全部"
:disabled="onlyMyself"
@change="(e) => userId = e"/>
</div>
<!-- 只看自己 -->
<div class="data-export-param mb16" v-if="$isAdmin() && manager">
<span class="normal-label export-label">只看自己</span>
<a-checkbox class="param-input" v-model="onlyMyself"/>
</div>
<!-- 文档密码 -->
<div class="data-export-param">
<span class="normal-label export-label">文档密码</span>
<a-input class="param-input"
v-model="protectPassword"
:maxLength="10"
placeholder="导出文档的密码(数字及字母)"/>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { downloadFile } from '@/lib/utils'
import { EVENT_CLASSIFY, EXPORT_TYPE } from '@/lib/enum'
import UserSelector from '@/components/user/UserSelector'
export default {
name: 'EventLogExportExportModal',
components: { UserSelector },
props: {
manager: Boolean
},
data: function() {
return {
EVENT_CLASSIFY,
visible: false,
loading: false,
protectPassword: undefined,
classify: undefined,
onlyMyself: undefined,
userId: undefined
}
},
methods: {
open() {
this.protectPassword = undefined
this.classify = undefined
this.onlyMyself = undefined
this.userId = undefined
this.loading = false
this.visible = true
},
exportData() {
this.loading = true
this.$api.exportData({
exportType: EXPORT_TYPE.USER_EVENT_LOG.value,
protectPassword: this.protectPassword,
classify: this.classify,
userId: this.userId,
onlyMyself: this.manager ? (this.onlyMyself ? 1 : 2) : 1
}).then((e) => {
this.loading = false
this.visible = false
this.$message.success('导出成功, 片刻后自动下载')
downloadFile(e)
}).catch(() => {
this.loading = false
this.$message.error('导出失败')
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-export-container {
width: 100%;
}
.export-label {
width: 64px;
text-align: end;
}
.data-export-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<a-modal v-model="visible"
title="机器报警记录 导出"
okText="导出"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="exportData"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-export-container">
<!-- 导出参数 -->
<div class="data-export-params">
<!-- 导出机器 -->
<div class="data-export-param mb16">
<span class="normal-label export-label">导出机器</span>
<MachineSelector class="param-input"
placeholder="全部"
@change="(m) => machineId = m"/>
</div>
<!-- 文档密码 -->
<div class="data-export-param">
<span class="normal-label export-label">文档密码</span>
<a-input class="param-input"
v-model="protectPassword"
:maxLength="10"
placeholder="导出文档的密码(数字及字母)"/>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { downloadFile } from '@/lib/utils'
import { EXPORT_TYPE } from '@/lib/enum'
import MachineSelector from '@/components/machine/MachineSelector'
export default {
name: 'MachineAlarmHistoryExportModal',
components: { MachineSelector },
data: function() {
return {
visible: false,
loading: false,
protectPassword: undefined,
machineId: undefined
}
},
methods: {
open() {
this.machineId = undefined
this.protectPassword = undefined
this.loading = false
this.visible = true
},
exportData() {
this.loading = true
this.$api.exportData({
exportType: EXPORT_TYPE.MACHINE_ALARM_HISTORY.value,
protectPassword: this.protectPassword,
machineId: this.machineId
}).then((e) => {
this.loading = false
this.visible = false
this.$message.success('导出成功, 片刻后自动下载')
downloadFile(e)
}).catch(() => {
this.loading = false
this.$message.error('导出失败')
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-export-container {
width: 100%;
}
.export-label {
width: 64px;
text-align: end;
}
.data-export-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<a-modal v-model="visible"
title="机器信息 导出"
okText="导出"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="exportData"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-export-container">
<!-- 导出参数 -->
<div class="data-export-params">
<!-- 密码导出 -->
<div class="data-export-param mb16">
<span class="normal-label export-label">导出密码</span>
<a-checkbox class="param-input" v-model="exportPassword">是否导出密码 (密文, 仅用于导入)</a-checkbox>
</div>
<!-- 文档密码 -->
<div class="data-export-param">
<span class="normal-label export-label">文档密码</span>
<a-input class="param-input"
v-model="protectPassword"
:maxLength="10"
placeholder="导出文档的密码(数字及字母)"/>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { downloadFile } from '@/lib/utils'
import { EXPORT_TYPE } from '@/lib/enum'
export default {
name: 'MachineExportModal',
data: function() {
return {
visible: false,
loading: false,
exportPassword: false,
protectPassword: undefined
}
},
methods: {
open() {
this.exportPassword = false
this.protectPassword = undefined
this.loading = false
this.visible = true
},
exportData() {
this.loading = true
this.$api.exportData({
exportType: EXPORT_TYPE.MACHINE_INFO.value,
exportPassword: this.exportPassword ? 1 : 2,
protectPassword: this.protectPassword
}).then((e) => {
this.loading = false
this.visible = false
this.$message.success('导出成功, 片刻后自动下载')
downloadFile(e)
}).catch(() => {
this.loading = false
this.$message.error('导出失败')
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-export-container {
width: 100%;
}
.export-label {
width: 64px;
text-align: end;
}
.data-export-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<a-modal v-model="visible"
title="机器代理 导出"
okText="导出"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="exportData"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-export-container">
<!-- 导出参数 -->
<div class="data-export-params">
<!-- 密码导出 -->
<div class="data-export-param mb16">
<span class="normal-label export-label">导出密码</span>
<a-checkbox class="param-input" v-model="exportPassword">是否导出密码 (密文, 仅用于导入)</a-checkbox>
</div>
<!-- 文档密码 -->
<div class="data-export-param">
<span class="normal-label export-label">文档密码</span>
<a-input class="param-input"
v-model="protectPassword"
:maxLength="10"
placeholder="导出文档的密码(数字及字母)"/>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { downloadFile } from '@/lib/utils'
import { EXPORT_TYPE } from '@/lib/enum'
export default {
name: 'MachineProxyExportModal',
data: function() {
return {
visible: false,
loading: false,
exportPassword: false,
protectPassword: undefined
}
},
methods: {
open() {
this.exportPassword = false
this.protectPassword = undefined
this.loading = false
this.visible = true
},
exportData() {
this.loading = true
this.$api.exportData({
exportType: EXPORT_TYPE.MACHINE_PROXY.value,
exportPassword: this.exportPassword ? 1 : 2,
protectPassword: this.protectPassword
}).then((e) => {
this.loading = false
this.visible = false
this.$message.success('导出成功, 片刻后自动下载')
downloadFile(e)
}).catch(() => {
this.loading = false
this.$message.error('导出失败')
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-export-container {
width: 100%;
}
.export-label {
width: 64px;
text-align: end;
}
.data-export-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<a-modal v-model="visible"
title="日志文件 导出"
okText="导出"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="exportData"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-export-container">
<!-- 导出参数 -->
<div class="data-export-params">
<!-- 导出机器 -->
<div class="data-export-param mb16">
<span class="normal-label export-label">导出机器</span>
<MachineSelector class="param-input"
placeholder="全部"
@change="(m) => machineId = m"/>
</div>
<!-- 文档密码 -->
<div class="data-export-param">
<span class="normal-label export-label">文档密码</span>
<a-input class="param-input"
v-model="protectPassword"
:maxLength="10"
placeholder="导出文档的密码(数字及字母)"/>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { downloadFile } from '@/lib/utils'
import { EXPORT_TYPE } from '@/lib/enum'
import MachineSelector from '@/components/machine/MachineSelector'
export default {
name: 'TailFileExportModal',
components: { MachineSelector },
data: function() {
return {
visible: false,
loading: false,
protectPassword: undefined,
machineId: undefined
}
},
methods: {
open() {
this.machineId = undefined
this.protectPassword = undefined
this.loading = false
this.visible = true
},
exportData() {
this.loading = true
this.$api.exportData({
exportType: EXPORT_TYPE.TAIL_FILE.value,
protectPassword: this.protectPassword,
machineId: this.machineId
}).then((e) => {
this.loading = false
this.visible = false
this.$message.success('导出成功, 片刻后自动下载')
downloadFile(e)
}).catch(() => {
this.loading = false
this.$message.error('导出失败')
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-export-container {
width: 100%;
}
.export-label {
width: 64px;
text-align: end;
}
.data-export-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<a-modal v-model="visible"
title="终端日志 导出"
okText="导出"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="exportData"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-export-container">
<!-- 导出参数 -->
<div class="data-export-params">
<!-- 导出机器 -->
<div class="data-export-param mb16">
<span class="normal-label export-label">导出机器</span>
<MachineSelector class="param-input"
placeholder="全部"
@change="(m) => machineId = m"/>
</div>
<!-- 文档密码 -->
<div class="data-export-param">
<span class="normal-label export-label">文档密码</span>
<a-input class="param-input"
v-model="protectPassword"
:maxLength="10"
placeholder="导出文档的密码(数字及字母)"/>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { downloadFile } from '@/lib/utils'
import { EXPORT_TYPE } from '@/lib/enum'
import MachineSelector from '@/components/machine/MachineSelector'
export default {
name: 'TerminalLogExportModal',
components: { MachineSelector },
data: function() {
return {
visible: false,
loading: false,
protectPassword: undefined,
machineId: undefined
}
},
methods: {
open() {
this.machineId = undefined
this.protectPassword = undefined
this.loading = false
this.visible = true
},
exportData() {
this.loading = true
this.$api.exportData({
exportType: EXPORT_TYPE.TERMINAL_LOG.value,
protectPassword: this.protectPassword,
machineId: this.machineId
}).then((e) => {
this.loading = false
this.visible = false
this.$message.success('导出成功, 片刻后自动下载')
downloadFile(e)
}).catch(() => {
this.loading = false
this.$message.error('导出失败')
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-export-container {
width: 100%;
}
.export-label {
width: 64px;
text-align: end;
}
.data-export-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<a-modal v-model="visible"
title="webhook 导出"
okText="导出"
:width="400"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="exportData"
@cancel="close">
<a-spin :spinning="loading">
<div class="data-export-container">
<!-- 导出参数 -->
<div class="data-export-params">
<!-- 导出分类 -->
<div class="data-export-param mb16">
<span class="normal-label export-label">类型</span>
<a-select class="param-input"
placeholder="全部"
@change="(e) => type = e">
<a-select-option v-for="type in WEBHOOK_TYPE" :key="type.value" :value="type.value">
{{ type.label }}
</a-select-option>
</a-select>
</div>
<!-- 文档密码 -->
<div class="data-export-param">
<span class="normal-label export-label">文档密码</span>
<a-input class="param-input"
v-model="protectPassword"
:maxLength="10"
placeholder="导出文档的密码(数字及字母)"/>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { downloadFile } from '@/lib/utils'
import { EXPORT_TYPE, WEBHOOK_TYPE } from '@/lib/enum'
export default {
name: 'WebhookExportModal',
data: function() {
return {
WEBHOOK_TYPE,
visible: false,
loading: false,
protectPassword: undefined,
type: undefined
}
},
methods: {
open() {
this.type = undefined
this.protectPassword = undefined
this.loading = false
this.visible = true
},
exportData() {
this.loading = true
this.$api.exportData({
type: this.type,
exportType: EXPORT_TYPE.WEBHOOK.value,
protectPassword: this.protectPassword
}).then((e) => {
this.loading = false
this.visible = false
this.$message.success('导出成功, 片刻后自动下载')
downloadFile(e)
}).catch(() => {
this.loading = false
this.$message.error('导出失败')
})
},
close() {
this.visible = false
this.loading = false
}
}
}
</script>
<style lang="less" scoped>
.data-export-container {
width: 100%;
}
.export-label {
width: 64px;
text-align: end;
}
.data-export-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
</style>

View File

@@ -0,0 +1,241 @@
<template>
<a-modal v-model="visible"
:title="importType.title"
:okText="dataCheckPage ? '开始导入' : '导入'"
:width="500"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="clickImport"
@cancel="close">
<a-spin :spinning="loading">
<!-- 导入页面 -->
<div class="data-import-container" v-if="!dataCheckPage">
<!-- 导入提示 -->
<a-alert class="import-alert-message" v-if="importType.tips" :message="importType.tips" type="info"/>
<!-- 上传框 -->
<div class="file-select-container">
<!-- 文件选择 -->
<div class="file-select-wrapper">
<a-upload accept=".xlsx"
:beforeUpload="selectFile"
:customRequest="() => {}"
:showUploadList="false">
<a-button type="link">选择文件</a-button>
</a-upload>
<!-- 已选择的文件 -->
<span class="selected-file-name" v-if="uploadFile" :title="uploadFile.name">
{{ uploadFile.name }}
</span>
</div>
<!-- 下载模板 -->
<a-button type="link" @click="downloadTemplate">下载模板</a-button>
</div>
<!-- 参数 -->
<div class="data-import-params">
<!-- 文档密码 -->
<div class="data-import-param">
<span class="normal-label import-label">文档密码</span>
<a-input class="param-input"
v-model="protectPassword"
:maxLength="10"
placeholder="导入文档的密码"/>
</div>
</div>
</div>
<!-- 检查页面 -->
<div class="data-check-container" v-else>
<!-- 非法数据 -->
<div class="check-data-container" v-if="checkData.illegalRows.length">
<span class="normal-label check-label span-red">非法数据 (不会导入)</span>
<div class="check-data-wrapper" v-for="row of checkData.illegalRows" :key="row.index">
<span class="span-red">{{ row.row }}</span> , <span v-if="row.symbol" class="ml4 mr8">{{ row.symbol }}</span>
<span class="span-red">{{ row.illegalMessage }}</span>
</div>
</div>
<!-- 新增数据 -->
<div class="check-data-container" v-if="checkData.insertRows.length">
<span class="normal-label check-label span-blue">新增数据</span>
<div class="check-data-wrapper" v-for="row of checkData.insertRows" :key="row.index">
<span class="span-blue">{{ row.row }}</span> , <span class="ml4">{{ row.symbol }}</span>
</div>
</div>
<!-- 修改数据 -->
<div class="check-data-container" v-if="checkData.updateRows.length">
<span class="normal-label check-label span-blue">修改数据</span>
<div class="check-data-wrapper" v-for="row of checkData.updateRows" :key="row.index">
<span class="span-blue">{{ row.row }}</span> , <span class="ml4">{{ row.symbol }}</span>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { downloadFile } from '@/lib/utils'
export default {
name: 'DataImportModal',
props: {
importType: Object
},
data: function() {
return {
visible: false,
loading: false,
dataCheckPage: false,
protectPassword: undefined,
uploadFile: null,
checkData: null
}
},
methods: {
open() {
this.dataCheckPage = false
this.protectPassword = undefined
this.uploadFile = null
this.checkData = null
this.loading = false
this.visible = true
},
selectFile(e) {
const suffix = e.name.substring(e.name.lastIndexOf('.') + 1)
if (suffix !== 'xlsx') {
this.$message.error('请选择 xlsx 表格进行导入')
return false
}
this.uploadFile = e
},
clickImport() {
if (this.dataCheckPage) {
this.asyncImportData()
} else {
this.checkImportData()
}
},
checkImportData() {
if (!this.uploadFile) {
this.$message.warning('请选择导入文件')
return
}
this.loading = true
const formData = new FormData()
formData.append('file', this.uploadFile)
formData.append('type', this.importType.value)
formData.append('protectPassword', this.protectPassword)
this.$api.checkImportData(formData).then(({ data }) => {
this.loading = false
this.dataCheckPage = true
this.checkData = data
}).catch(() => {
this.loading = false
})
},
asyncImportData() {
if (this.checkData.insertRows.length + this.checkData.updateRows.length === 0) {
this.$message.warning('无可用导入数据, 无法导入')
return
}
this.loading = true
this.$api.importData({
importToken: this.checkData.importToken
}).then(() => {
this.loading = false
this.visible = false
this.$message.success('已开始导入')
}).catch(() => {
this.loading = false
})
},
downloadTemplate() {
this.$api.getImportTemplate({
type: this.importType.value
}).then((e) => {
downloadFile(e)
}).catch(() => {
this.$message.error('下载失败')
})
},
close() {
if (this.dataCheckPage) {
this.$api.cancelImportData({
importToken: this.checkData.importToken
})
}
this.visible = false
this.loading = false
this.uploadFile = null
}
}
}
</script>
<style lang="less" scoped>
.data-import-container {
width: 100%;
}
.import-alert-message {
margin-bottom: 8px;
}
.file-select-container {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
button {
padding: 0;
}
.selected-file-name {
white-space: nowrap;
overflow: hidden;
width: 294px;
display: inline-flex;
margin-left: 8px;
}
}
.import-label {
width: 64px;
text-align: end;
}
.data-import-param {
width: 100%;
display: flex;
align-items: center;
}
.param-input {
margin-left: 8px;
width: 236px;
}
.data-check-container {
margin: -12px 0;
.check-data-container {
margin: 8px 0;
}
.check-label {
margin-bottom: 4px;
font-weight: 600;
font-size: 18px;
}
.check-data-wrapper {
line-height: 1.6;
font-size: 15px;
padding-left: 16px;
color: rgba(0, 0, 0, .85);
}
}
</style>

View File

@@ -0,0 +1,158 @@
<template>
<a-layout-header class="header-main">
<!-- 头部左侧 -->
<div class="header-fixed-left">
<!-- 折叠 -->
<a-icon class="trigger-icon header-block-container header-block-fold"
:type="fold ? 'menu-unfold' : 'menu-fold'"
:title="fold ? '展开' : '折叠'"
@click="changeFold"/>
<!-- 左侧配置 -->
<a-icon class="trigger-icon header-block-container"
v-for="(prop, index) of leftProps"
:key="index"
:title="prop.title"
:type="prop.icon"
@click="handlerCall(prop)"/>
</div>
<!-- 头部右侧 -->
<div class="header-fixed-right">
<!-- 环境选择 -->
<HeaderProfileSelect id="header-profile-selector"
class="header-block-container"
ref="profileSelect"
v-show="profileSelectorVisible"
@chooseProfile="chooseProfile"/>
<!-- 站内信 -->
<WebSideMessageDrawer id="web-side-message-drawer" class="header-block-container"/>
<!-- 用户下拉 -->
<HeaderUser id="header-user" class="header-block-container"/>
</div>
</a-layout-header>
</template>
<script>
import HeaderProfileSelect from './HeaderProfileSelect'
import HeaderUser from './HeaderUser'
import WebSideMessageDrawer from '@/components/layout/WebSideMessageDrawer'
export default {
name: 'Header',
components: {
WebSideMessageDrawer,
HeaderProfileSelect,
HeaderUser
},
data: function() {
return {
fold: false,
profileSelectorVisible: false,
leftProps: []
}
},
methods: {
changeFold() {
this.fold = !this.fold
this.$emit('changeFoldStatus')
},
handlerCall(prop) {
prop.call && this[prop.call] && this[prop.call]()
prop.event && this.$emit('onHeaderEvent', prop.event)
},
back() {
this.$router.back(-1)
},
checkVisible(e = this.$route) {
this.profileSelectorVisible = e.meta.visibleProfile === true
this.leftProps = e.meta.leftProps || []
},
chooseProfile(profile) {
this.$emit('chooseProfile', profile)
},
reloadProfile() {
this.$refs.profileSelect.loadProfile()
}
},
created() {
this.checkVisible()
}
}
</script>
<style lang="less" scoped>
.header-main {
background: #FFF;
padding-left: 0;
display: flex;
justify-content: space-between;
.header-fixed-left {
display: flex;
.header-block-fold {
margin-left: 2px;
}
.trigger-icon {
font-size: 18px;
line-height: 48px;
padding: 2px 16px;
cursor: pointer;
transition: color 0.3s;
}
.trigger-icon:hover {
color: #1890FF;
}
}
.header-fixed-right {
display: flex;
align-items: center;
#header-profile-selector {
padding: 0 8px;
height: 48px;
display: flex;
align-items: center;
font-size: 16px;
line-height: 18px;
::v-deep i {
padding-left: 4px;
margin-top: 4px;
}
}
#web-side-message-drawer {
padding: 0 16px;
height: 48px;
display: flex;
align-items: center;
margin: 0 4px 0 0;
}
#header-user {
padding: 0 8px;
margin-right: 2px;
height: 48px;
display: flex;
align-items: center;
}
}
}
.header-block-container {
transition: color 0.3s ease-in-out, background-color 0.3s ease-in-out;
border-radius: 4px;
}
.header-block-container:hover {
color: hsla(0, 0%, 100%, .2);
background-color: #E7F5FF;
color: #148EFF;
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<a-dropdown v-if="profileList.length">
<a class="ant-dropdown-link" id="current-profile" @click="e => e.preventDefault()">
{{ currentProfile.name }}
<a-icon type="down"/>
</a>
<template #overlay>
<a-menu @click="chooseProfile">
<a-menu-item v-for="profile in profileList" :key="JSON.stringify(profile)">
{{ profile.name }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<script>
import { isEmptyStr } from '@/lib/utils'
export default {
name: 'HeaderProfileSelect',
data() {
return {
currentProfile: '',
profileList: []
}
},
methods: {
chooseProfile({ key }) {
this.currentProfile = JSON.parse(key)
this.$storage.set(this.$storage.keys.ACTIVE_PROFILE, key)
this.$emit('chooseProfile', this.currentProfile)
},
loadProfile() {
this.$api.fastGetProfileList().then(({ data }) => {
if (!data || !data.length) {
return
}
this.profileList = data
}).then(() => {
if (!this.profileList.length) {
this.$storage.remove(this.$storage.keys.ACTIVE_PROFILE)
return
}
let storageProfile = this.$storage.get(this.$storage.keys.ACTIVE_PROFILE)
if (isEmptyStr(storageProfile)) {
// 如果没有则拿到第一个
storageProfile = this.profileList[0]
} else {
let matches = false
storageProfile = JSON.parse(storageProfile)
for (var profileValue of this.profileList) {
if (profileValue.id === storageProfile.id) {
matches = true
}
}
if (!matches) {
storageProfile = this.profileList[0]
}
}
this.$storage.set(this.$storage.keys.ACTIVE_PROFILE, JSON.stringify(storageProfile))
this.currentProfile = storageProfile
})
}
},
mounted() {
this.loadProfile()
}
}
</script>
<style scoped>
#current-profile {
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<a-dropdown>
<a class="ant-dropdown-link" @click="e => e.preventDefault()">
<template v-if="user.avatar">
<a-avatar :src="user.avatar" :size="36"/>
</template>
<template v-else-if="user.nickname">
<a-avatar :size="36" :style="{backgroundColor: '#7265E6', verticalAlign: 'middle'}">
{{ user.nickname.substring(user.nickname.length - 1) }}
</a-avatar>
</template>
<template v-else>
<div style="width: 36px; height: 36px"/>
</template>
</a>
<template #overlay>
<a-menu @click="chooseMenu">
<a-menu-item key="nickname" id="user-nickname">
<a-icon type="smile"/>
{{ user.nickname }}
</a-menu-item>
<a-menu-item key="userInfo">
<a-icon type="user"/>
个人中心
</a-menu-item>
<a-menu-item key="resetPassword">
<a-icon type="safety-certificate"/>
修改密码
<ResetPassword ref="resetModel" @resetSuccess="resetSuccess"/>
</a-menu-item>
<a-menu-item key="logout">
<a-icon type="export"/>
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<script>
import ResetPassword from '../user/ResetPassword'
const menuItemHandler = {
nickname() {
},
userInfo() {
this.$router.push({ path: '/user/detail' })
},
resetPassword() {
this.openResetModel()
},
async logout() {
await this.$api.logout()
this.$storage.clear()
this.$storage.clearSession()
this.$router.push({ path: '/login' })
}
}
export default {
name: 'HeaderUser',
components: { ResetPassword },
data: function() {
return {
user: {
nickname: '',
avatar: ''
}
}
},
methods: {
chooseMenu({ key }) {
menuItemHandler[key].call(this)
},
openResetModel() {
this.$refs.resetModel.open()
},
resetSuccess() {
this.$storage.clear()
this.$storage.clearSession()
this.$router.push('/login')
}
},
mounted() {
this.$api.getUserDetail()
.then(({ data }) => {
this.user.nickname = data.nickname
this.user.avatar = data.avatar
})
}
}
</script>
<style scoped>
#user-nickname {
color: #495057;
padding-left: 12px;
cursor: default;
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<a-layout id="common-layout" v-if="validToken">
<!-- 左侧 -->
<a-layout-sider id="common-sider" v-model="collapsed" :trigger="null">
<!-- <div class="logo"/> -->
<!-- 左侧菜单 -->
<Menu ref="menu"/>
</a-layout-sider>
<a-layout id="common-right">
<!-- 头部菜单 -->
<Header id="common-header"
ref="header"
@changeFoldStatus="collapsed = !collapsed"
@chooseProfile="chooseProfile"
@onHeaderEvent="onHeaderEvent"/>
<!-- 主体部分 -->
<a-layout-content id="common-content">
<a-spin :spinning="globalLoading" :tip="globalLoadingTip">
<router-view ref="route"
:key="$route.fullPath"
@reloadProfile="reloadProfile"
@openLoading="openLoading"
@closeLoading="closeLoading"/>
</a-spin>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<script>
import Menu from './Menu'
import Header from './Header'
export default {
components: {
Menu,
Header
},
watch: {
$route(e) {
// 设置菜单选中
this.$refs.menu.chooseMenu(e)
// 设置头部按钮
this.$refs.header.checkVisible(e)
}
},
data() {
return {
collapsed: false,
validToken: false,
globalLoading: false,
globalLoadingTip: null
}
},
methods: {
chooseProfile(e) {
this.$refs.route && this.$refs.route.chooseProfile && this.$refs.route.chooseProfile(e)
},
onHeaderEvent(e) {
this.$refs.route && this.$refs.route.onHeaderEvent && this.$refs.route.onHeaderEvent(e)
},
reloadProfile() {
this.$refs.header.reloadProfile()
},
openLoading(tip = null) {
this.globalLoading = true
this.globalLoadingTip = tip
},
closeLoading() {
this.globalLoading = false
this.globalLoadingTip = null
}
},
async beforeCreate() {
if (this.$getUserId()) {
await this.$api.validToken().then(() => {
this.validToken = true
}).catch(() => {
this.validToken = false
})
} else {
this.validToken = false
}
if (!this.validToken) {
this.$storage.clear()
this.$storage.clearSession()
this.$router.push('/login')
}
}
}
</script>
<style lang="less" scoped>
#common-layout {
height: 100vh;
.logo {
height: 32px;
background: rgba(255, 255, 255, 0.2);
margin: 16px;
}
#common-sider {
overflow: auto;
}
}
#common-right {
overflow: hidden;
#common-content {
padding: 18px;
overflow: auto;
flex: auto;
}
#common-header {
z-index: 10;
padding-right: 0;
box-shadow: 0 1px 4px rgba(0, 21, 41, .08);
height: 48px;
}
}
::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<a-menu theme="dark"
mode="inline"
:selectedKeys="selectedKeys"
:defaultOpenKeys="defaultOpenKeys">
<template v-for="menuItem in menuList">
<!-- 一级菜单 -->
<a-menu-item v-if="!menuItem.children" :key="menuItem.id">
<router-link :to="menuItem.path">
<a-icon :type="menuItem.icon"/>
<span>{{ menuItem.name }}</span>
</router-link>
</a-menu-item>
<!-- 二级菜单 -->
<a-sub-menu v-else :key="menuItem.id">
<template #title>
<a-icon :type="menuItem.icon"/>
<span class="usn">{{ menuItem.name }}</span>
</template>
<a-menu-item v-for="subMenuItem in menuItem.children" :key="subMenuItem.id">
<router-link :to="subMenuItem.path">
<a-icon :type="subMenuItem.icon"/>
<span>{{ subMenuItem.name }}</span>
</router-link>
</a-menu-item>
</a-sub-menu>
</template>
</a-menu>
</template>
<script>
export default {
name: 'Menu',
data() {
return {
menuList: [],
selectedKeys: [],
defaultOpenKeys: []
}
},
methods: {
chooseMenu(route = this.$route) {
const routerPath = route.path
for (const menu of this.menuList) {
if (menu.path && routerPath.startsWith(menu.path)) {
// 一级菜单选中
this.selectedKeys[0] = menu.id
this.$forceUpdate()
return
}
if (menu.children) {
for (const child of menu.children) {
if (child.path && routerPath.startsWith(child.path)) {
// 二级菜单选中
let present = false
for (const defaultOpenKey of this.defaultOpenKeys) {
if (defaultOpenKey === menu.id) {
present = true
break
}
}
this.selectedKeys[0] = child.id
if (!present) {
this.defaultOpenKeys.push(menu.id)
}
this.$forceUpdate()
return
}
}
}
}
}
},
async mounted() {
// 加载菜单
this.$api.getMenu().then(({ data }) => {
let id = 0
for (const menu of data) {
menu.id = ++id
const children = menu.children
if (children) {
for (let i = 0; i < children.length; i++) {
children[i].id = ++id
}
}
this.menuList.push(menu)
}
// 选中
this.chooseMenu()
})
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,530 @@
<template>
<div class="web-side-message-container pointer" @click="onOpen" title="消息">
<!-- 触发器 -->
<a class="icon-wrapper">
<!-- 红点 -->
<a-badge class="unread-message-dot" :dot="unreadCount > 0">
<a-icon class="web-side-message-trigger" type="notification"/>
</a-badge>
</a>
<!-- 侧边抽屉 -->
<a-drawer :title="null"
placement="right"
:closable="false"
:width="400"
:maskStyle="{opacity: 0, animation: 'none'}"
:bodyStyle="{padding: 0}"
:maskClosable="true"
:visible="visible"
:afterVisibleChange="visibleChange"
@close="onClose">
<!-- 消息头 -->
<div class="message-header-container">
<!-- 头部左侧 -->
<div class="message-header-left">
<!-- 消息状态 -->
<a-dropdown :trigger="['click']">
<!-- 消息状态触发器 -->
<a class="message-block-item message-status-trigger" @click="e => e.preventDefault()">
<a-icon :type="messageStatus.icon"/>
<span>{{ messageStatus.label }}</span>
<a-icon type="down"/>
</a>
<!-- 消息类型下拉框 -->
<a-menu slot="overlay">
<a-menu-item v-for="item of messageStatusList" :key="item.label">
<span class="header-dropdown-item message-type-dropdown-item" @click="changeStatus(item)">
<a-icon :type="item.icon"/>
<span>{{ item.label }}</span>
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
</div>
<!-- 头部右侧 -->
<div class="message-header-right">
<!-- 消息处理 -->
<a-dropdown :trigger="['click']">
<!-- 消息处理触发器 -->
<a class="message-block-item" @click="e => e.preventDefault()">
<a-icon class="message-action-trigger" type="ellipsis"/>
</a>
<!-- 消息处理下拉框 -->
<a-menu slot="overlay">
<a-menu-item key="1">
<span class="header-dropdown-item message-action-dropdown-item" @click="readAllMessage()">
<a-icon type="check"/>
<span>标记所有消息为已读</span>
</span>
</a-menu-item>
<a-menu-item key="2">
<span class="header-dropdown-item message-action-dropdown-item" @click="deleteAllMessage()">
<a-icon type="delete"/>
<span>删除所有已读消息</span>
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
<!-- 关闭消息 -->
<a class="message-block-item" @click="onClose" title="关闭">
<a-icon class="message-action-trigger" type="close"/>
</a>
</div>
</div>
<!-- 消息列表 -->
<div class="message-list-container">
<a-spin :spinning="loading">
<!-- 消息 -->
<template v-if="rows.length">
<div class="message-wrapper" v-for="row of rows"
:key="row.id"
@click="clickMessage(row)"
@mouseenter="addMessageActive(row)"
@mouseleave="removeMessageActive(row)">
<!-- 消息头 -->
<div class="message-top-wrapper">
<!-- 消息左侧 消息分类 -->
<div class="message-top-left">
<a-icon class="message-classify-icon" :type="row.classify | formatMessageClassify('icon')"/>
<div class="message-classify-text-wrapper">
<!-- 分类 -->
<span class="message-classify-label">{{ row.classify | formatMessageClassify('label') }}</span>
<span class="message-classify-divider">/</span>
<!-- 类型 -->
<span class="message-type-label">{{ row.type | formatMessageType('label') }}</span>
<!-- 未读 -->
<a-badge v-if="row.status === READ_STATUS.UNREAD.value" class="message-unread-dot" status="error"/>
</div>
</div>
<!-- 消息右侧 按钮-->
<div class="message-top-right" v-show="row.visibleTools">
<a-icon v-if="row.status === READ_STATUS.UNREAD.value"
type="check"
title="已读"
@click.stop="readMessage(row)"/>
<a-icon type="close" title="删除" @click.stop="deleteMessage(row.id)"/>
</div>
</div>
<!-- 消息 -->
<div class="message-text-wrapper">
<a-icon class="message-text-icon" type="bell" theme="twoTone"/>
<span class="message-text" v-html="row.message"/>
</div>
<!-- 消息时间 -->
<div class="message-date-wrapper">
{{ row.createTime }}
</div>
</div>
</template>
<!-- 无数据 -->
<template v-else>
<div class="message-empty-wrapper">
<a-empty description="暂无消息"/>
</div>
</template>
<!-- 加载更多 -->
<div class="load-more-wrapper">
<a-button v-if="hasMore" type="link" @click="loadMoreMessage">加载更多</a-button>
</div>
</a-spin>
</div>
</a-drawer>
</div>
</template>
<script>
import { getUUID, clearStainKeywords, dateFormat, replaceStainKeywords } from '@/lib/utils'
import { enumValueOf, MESSAGE_CLASSIFY, MESSAGE_TYPE, READ_STATUS } from '@/lib/enum'
const messageStatusList = [{
status: READ_STATUS.UNREAD.value,
label: '未读消息',
icon: 'tag'
}, {
status: undefined,
label: '全部消息',
icon: 'tags'
}]
export default {
name: 'WebSideMessageDrawer',
data() {
return {
visible: false,
loading: false,
unreadCount: 0,
limit: 30,
pollMaxId: null,
minId: null,
rows: [],
pollId: null,
messageStatus: messageStatusList[0],
messageStatusList,
READ_STATUS,
hasMore: false
}
},
methods: {
onOpen() {
this.visible = true
},
onClose() {
this.visible = false
},
changeStatus(status) {
this.messageStatus = status
this.getMessages()
},
readAllMessage() {
this.$api.setWebSideMessageAllRead().then(() => {
this.rows.forEach(row => {
row.status = READ_STATUS.READ.value
})
this.$message.success('操作成功')
})
},
deleteAllMessage() {
this.$api.deleteAllReadMessage().then(() => {
this.rows = this.rows.filter(row => {
return row.status === READ_STATUS.UNREAD.value
})
this.$message.success('操作成功')
})
},
readMessage(row) {
this.$api.setMessageRead({
idList: [row.id]
}).then(() => {
row.status = READ_STATUS.READ.value
})
},
deleteMessage(id) {
this.$api.deleteWebSideMessage({
idList: [id]
}).then(() => {
for (let i = 0; i < this.rows.length; i++) {
if (this.rows[i].id === id) {
this.rows.splice(i, 1)
return
}
}
})
},
clickMessage(row) {
// 重定向
this.$router.push(enumValueOf(MESSAGE_TYPE, row.type).redirect(row))
this.onClose()
if (row.status === READ_STATUS.READ.value) {
return
}
// 修改状态
row.status = READ_STATUS.READ.value
this.$api.setMessageRead({
idList: [row.id]
})
},
addMessageActive(row) {
row.visibleTools = true
this.$forceUpdate()
},
removeMessageActive(row) {
row.visibleTools = false
this.$forceUpdate()
},
visibleChange(e) {
if (!e) {
return
}
this.loadNewMessage()
},
getMessages() {
// 获取站内信
this.$api.getWebSideMessageList({
status: this.messageStatus.status,
limit: this.limit
}).then(({ data }) => {
this.rows = data.rows || []
this.rows.forEach(this.processMessage)
this.hasMore = this.rows.length === this.limit
this.loading = false
}).then(() => {
this.loading = false
})
},
loadNewMessage() {
this.$api.getNewMessage({
maxId: this.rows.length ? this.rows[0].id : null,
status: this.messageStatus.status
}).then(({ data }) => {
this.unreadCount = data.unreadCount
const newMessages = data.newMessages
if (newMessages && newMessages.length) {
newMessages.forEach(this.processMessage)
newMessages.forEach(row => this.rows.unshift(row))
}
})
},
loadMoreMessage() {
this.loading = true
this.$api.getMoreMessage({
maxId: this.rows[this.rows.length - 1].id,
status: this.messageStatus.status,
limit: this.limit
}).then(({ data }) => {
this.loading = false
const length = data.length
this.hasMore = length === this.limit
if (!length) {
return
}
data.forEach(this.processMessage)
data.forEach(row => this.rows.push(row))
}).then(() => {
this.loading = false
})
},
processMessage(row) {
// 格式化时间
row.createTime = dateFormat(new Date(row.createTime), 'MM月dd日 HH:mm:ss')
// 处理数据
row.message = replaceStainKeywords(row.message)
// 显示按钮
row.visibleTools = false
},
pollWebSideMessage() {
this.$api.pollWebSideMessage({
maxId: this.pollMaxId
}).then(({ data }) => {
this.unreadCount = data.unreadCount
this.pollMaxId = data.maxId
const newMessages = data.newMessages
if (newMessages && newMessages.length) {
// 通知新消息
for (const newMessage of newMessages) {
setTimeout(() => {
const messageType = enumValueOf(MESSAGE_TYPE, newMessage.type)
const key = getUUID()
this.$notification[messageType.notify]({
key,
message: messageType.label,
description: clearStainKeywords(newMessage.message),
duration: messageType.duration,
onClick: () => {
this.$notification.close(key)
this.$router.push(messageType.redirect(newMessage))
this.$api.setMessageRead({
idList: [newMessage.id]
})
}
})
})
}
}
})
}
},
filters: {
formatMessageClassify(classify, f) {
return enumValueOf(MESSAGE_CLASSIFY, classify)[f]
},
formatMessageType(type, f) {
return enumValueOf(MESSAGE_TYPE, type)[f]
}
},
mounted() {
this.pollId !== null && clearInterval(this.pollId)
this.pollWebSideMessage()
// 轮询
this.pollId = setInterval(this.pollWebSideMessage, 15000)
// 加载消息
this.getMessages()
},
beforeDestroy() {
this.pollId !== null && clearInterval(this.pollId)
this.pollId = null
}
}
</script>
<style lang="less" scoped>
.web-side-message-trigger {
font-size: 19px;
color: #181E33;
}
.icon-wrapper {
display: inline-block;
margin-top: 3px;
}
::v-deep .unread-message-dot .ant-badge-dot {
margin: -2px;
}
.message-header-container {
height: 48px;
padding: 0 12px;
border-bottom: 1px solid #CED4DA;
display: flex;
align-items: center;
justify-content: space-between;
.message-header-right {
display: flex;
}
}
.message-block-item {
padding-left: 4px;
display: flex;
align-items: center;
color: grey !important;
}
.message-block-item:hover {
color: #1890FF !important;
transition: .2s;
}
.message-status-trigger {
user-select: none;
span {
font-size: 15px;
display: inline-block;
padding: 0 5px 0 7px;
}
i {
font-size: 17px !important;
}
}
.header-dropdown-item {
display: flex;
align-items: center;
user-select: none;
i {
font-size: 17px !important;
margin-right: 10px !important;
}
span {
font-size: 14px !important;
}
}
.header-dropdown-item:hover {
color: #1890FF !important;
transition: .2s;
}
.message-type-dropdown-item {
width: 124px;
}
.message-action-dropdown-item {
width: 186px;
}
.message-action-trigger {
margin: 0 6px;
font-size: 20px;
}
.message-list-container {
display: flex;
height: calc(100vh - 48px);
overflow-y: auto;
}
.message-wrapper {
padding: 16px;
border-bottom: 1px solid #CED4DA;
cursor: pointer;
}
.message-wrapper:hover {
background: #F5F5F5;
}
.message-empty-wrapper {
margin: 32px 0 0 96px;
}
.load-more-wrapper {
display: flex;
justify-content: center;
margin-top: 4px;
}
.message-top-wrapper, .message-text-wrapper {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 4px;
}
.message-date-wrapper {
font-size: 12px;
margin-left: 32px;
color: grey;
}
.message-classify-icon, .message-text-icon {
margin-right: 8px;
font-size: 15px;
color: #1890FF;
width: 26px;
height: 26px;
background: #E6F7FF;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.message-top-wrapper {
.message-top-left, .message-top-right {
display: flex;
}
.message-top-right > i {
width: 23px;
font-size: 16px;
}
.message-top-right > i:hover {
color: #1890FF !important;
transition: .2s;
}
.message-classify-text-wrapper {
margin-top: 2px;
display: flex;
}
.message-classify-label, .message-type-label {
color: #262626;
}
.message-classify-divider {
color: #1890FF;
font-weight: 500;
margin: 0 4px;
}
.message-unread-dot {
display: flex;
margin-left: 3px;
}
}
.message-text {
width: 332px;
margin-top: 2px;
}
</style>

View File

@@ -0,0 +1,263 @@
<template>
<a-modal v-model="visible"
:title="title"
:width="650"
:dialogStyle="{top: '64px', padding: 0}"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="check"
@cancel="close">
<a-spin :spinning="loading">
<a-form :form="form" v-bind="layout">
<a-form-item label="机器">
<MachineSelector ref="machineSelector"
placeholder="请选择"
@change="setDefaultConfig"
:query="machineQuery"
v-decorator="decorators.machineId"/>
</a-form-item>
<!-- 追踪模式 -->
<a-form-item v-if="form.getFieldValue('machineId') === 1" label="追踪模式">
<a-radio-group v-decorator="decorators.tailMode">
<a-tooltip v-for="type of FILE_TAIL_MODE" :key="type.value" :title="type.tips">
<a-radio :value="type.value">
{{ type.label }}
</a-radio>
</a-tooltip>
</a-radio-group>
</a-form-item>
<a-form-item label="名称" hasFeedback>
<a-input v-decorator="decorators.name" allowClear/>
</a-form-item>
<a-form-item label="文件路径" hasFeedback>
<a-input v-decorator="decorators.path" allowClear/>
</a-form-item>
<a-form-item label="命令" style="margin-bottom: 12px">
<a-textarea v-decorator="decorators.command"
:disabled="form.getFieldValue('machineId') === 1 &&
form.getFieldValue('tailMode') === FILE_TAIL_MODE.TRACKER.value"
allowClear/>
</a-form-item>
<a-form-item label="文件偏移量(行)" hasFeedback>
<a-input v-decorator="decorators.offset" allowClear/>
</a-form-item>
<a-form-item label="文件编码">
<a-input v-decorator="decorators.charset" allowClear/>
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>
<script>
import { pick } from 'lodash'
import { ENABLE_STATUS, FILE_TAIL_MODE } from '@/lib/enum'
import MachineSelector from '@/components/machine/MachineSelector'
const layout = {
labelCol: { span: 5 },
wrapperCol: { span: 17 }
}
function getDecorators() {
return {
name: ['name', {
rules: [{
required: true,
message: '请输入名称'
}, {
max: 64,
message: '名称长度不能大于64位'
}]
}],
machineId: ['machineId', {
rules: [{
required: true,
message: '请选择机器'
}]
}],
tailMode: ['tailMode', {
rules: [{
required: true,
message: '请选择追踪模式'
}],
initialValue: FILE_TAIL_MODE.TRACKER.value
}],
path: ['path', {
rules: [{
required: true,
message: '请输入文件路径'
}, {
max: 1024,
message: '文件路径长度不能大于1024位'
}]
}],
command: ['command', {
rules: [{
required: true,
message: '请输入命令'
}, {
max: 1024,
message: '命令长度不能大于1024位'
}]
}],
offset: ['offset', {
rules: [{
required: true,
message: '请输入文件偏移量'
}, {
validator: this.validateOffset
}]
}],
charset: ['charset', {
rules: [{
required: true,
message: '请输入文件编码'
}, {
max: 16,
message: '文件编码长度不能大于16位'
}]
}]
}
}
export default {
name: 'AddLogFileModal',
components: {
MachineSelector
},
data: function() {
return {
FILE_TAIL_MODE,
id: null,
visible: false,
title: null,
loading: false,
record: null,
updateConfig: true,
layout,
decorators: getDecorators.call(this),
form: this.$form.createForm(this),
machineQuery: { status: ENABLE_STATUS.ENABLE.value }
}
},
methods: {
validateOffset(rule, value, callback) {
if (value === '') {
callback(new Error('请输入文件偏移量'))
} else if (parseFloat(value) !== parseInt(value)) {
callback(new Error('请输入文件偏移量必须为整数'))
} else {
if (value < 0 || value > 10000) {
callback(new Error('文件偏移量必须在0-10000之间'))
} else {
callback()
}
}
},
setDefaultConfig(machineId) {
if (!this.updateConfig) {
return
}
if (!machineId) {
return
}
this.$api.getTailConfig({ machineId })
.then(({ data }) => {
const config = pick(Object.assign({}, data), 'command', 'offset', 'charset')
this.$nextTick(() => {
this.form.setFieldsValue(config)
})
})
},
add() {
this.title = '新增日志文件'
this.updateConfig = true
this.initRecord({})
},
update(id) {
this.title = '修改日志文件'
this.updateConfig = false
this.$api.getTailDetail({
id
}).then(({ data }) => {
this.initRecord(data)
})
},
initRecord(row) {
this.form.resetFields()
this.visible = true
this.id = row.id
this.record = pick(Object.assign({}, row), 'machineId', 'name', 'path', 'command', 'offset', 'charset')
// 设置数据
new Promise((resolve) => {
// 加载数据
this.$nextTick(() => {
this.form.setFieldsValue(this.record)
})
resolve()
}).then(() => {
// 设置追踪类型
if (this.record.machineId === 1) {
this.$nextTick(() => {
const tailMode = row.tailMode
this.record.tailMode = tailMode
this.form.setFieldsValue({ tailMode })
})
}
})
setTimeout(() => {
this.updateConfig = true
})
},
check() {
this.loading = true
this.form.validateFields((err, values) => {
if (err) {
this.loading = false
return
}
this.submit(values)
})
},
async submit(values) {
let res
try {
if (!this.id) {
// 添加
res = await this.$api.addTailFile({
...values
})
} else {
// 修改
res = await this.$api.updateTailFile({
...values,
id: this.id
})
}
if (!this.id) {
this.$message.success('添加成功')
this.$emit('added', res.data)
} else {
this.$message.success('修改成功')
this.$emit('updated', res.data)
}
this.close()
} catch (e) {
// ignore
}
this.loading = false
},
close() {
this.visible = false
this.loading = false
this.$refs.machineSelector.reset()
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,99 @@
<template>
<div class="app-action-container">
<!-- 日志 -->
<div class="app-action-log">
<logAppender ref="appender"
size="default"
:height="appenderHeight"
:relId="id"
:tailType="FILE_TAIL_TYPE.APP_ACTION_LOG.value"
:downloadType="FILE_DOWNLOAD_TYPE.APP_ACTION_LOG.value">
<!-- 左侧工具 -->
<template #left-tools>
<div class="action-log-tools">
<a-tag color="#5C7CFA" v-if="detail.actionName">
{{ detail.actionName }}
</a-tag>
</div>
</template>
</logAppender>
</div>
</div>
</template>
<script>
import { ACTION_STATUS, FILE_DOWNLOAD_TYPE, FILE_TAIL_TYPE } from '@/lib/enum'
import LogAppender from '@/components/log/LogAppender'
export default {
name: 'AppActionLogAppender',
components: { LogAppender },
props: {
appenderHeight: String
},
data() {
return {
FILE_TAIL_TYPE,
FILE_DOWNLOAD_TYPE,
id: null,
pollId: null,
detail: {}
}
},
methods: {
open(id) {
this.id = id
this.$api.getAppActionDetail({
id: this.id
}).then(({ data }) => {
this.detail = data
// 设置轮询状态
if (this.detail.status === ACTION_STATUS.WAIT.value ||
this.detail.status === ACTION_STATUS.RUNNABLE.value) {
this.pollId = setInterval(this.pollStatus, 2000)
}
}).then(() => {
this.$nextTick(() => this.$refs.appender.openTail())
})
},
close() {
// 关闭轮询
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
// 关闭tail
this.$nextTick(() => {
this.$refs.appender.clear()
this.$refs.appender.dispose()
})
this.id = null
this.detail = {}
},
pollStatus() {
this.$api.getAppActionStatus({
id: this.id
}).then(({ data }) => {
this.detail.status = data.status
// 清除状态轮询
if (this.detail.status !== ACTION_STATUS.WAIT.value &&
this.detail.status !== ACTION_STATUS.RUNNABLE.value) {
clearInterval(this.pollId)
this.pollId = null
}
})
}
},
beforeDestroy() {
this.pollId !== null && clearInterval(this.pollId)
this.pollId = null
}
}
</script>
<style lang="less" scoped>
.action-log-tools {
display: flex;
align-items: baseline;
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<a-modal v-model="visible"
:closable="false"
:title="null"
:footer="null"
:dialogStyle="{top: '32px', padding: 0}"
:bodyStyle="{padding: 0}"
:destroyOnClose="true"
:forceRender="true"
@cancel="close"
width="96%">
<!-- 日志面板 -->
<AppActionLogAppender ref="logger" appenderHeight="calc(100vh - 106px)"/>
</a-modal>
</template>
<script>
import AppActionLogAppender from '@/components/log/AppActionLogAppender'
export default {
name: 'AppActionLogAppenderModal',
components: { AppActionLogAppender },
data() {
return {
visible: false
}
},
methods: {
open(id) {
this.visible = true
setTimeout(() => {
this.$refs.logger.open(id)
}, 300)
},
close() {
this.visible = false
this.$nextTick(() => {
this.$refs.logger.close()
})
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,238 @@
<template>
<div class="app-build-container">
<!-- 步骤 -->
<div class="app-build-steps">
<a-steps :current="current" :status="stepStatus">
<template v-for="action in detail.actions">
<a-step :key="action.id"
:title="action.actionName"
:subTitle="action.used ? `${action.used}ms` : ''">
<template v-if="action.status === ACTION_STATUS.RUNNABLE.value" #icon>
<a-icon type="loading"/>
</template>
</a-step>
</template>
</a-steps>
</div>
<!-- 日志 -->
<div class="app-build-log">
<logAppender ref="appender"
size="default"
:height="appenderHeight"
:relId="id"
:tailType="FILE_TAIL_TYPE.APP_BUILD_LOG.value"
:downloadType="FILE_DOWNLOAD_TYPE.APP_BUILD_LOG.value">
<!-- 左侧工具 -->
<template #left-tools>
<div class="build-log-tools">
<a-tag color="#5C7CFA" v-if="detail.seq">
#{{ detail.seq }}
</a-tag>
<a-tag color="#40C057" v-if="detail.appName">
{{ detail.appName }}
</a-tag>
<a-tag color="#845EF7" v-if="detail.profileName">
{{ detail.profileName }}
</a-tag>
<!-- 命令输入 -->
<a-input-search class="command-write-input"
size="default"
v-if="BUILD_STATUS.RUNNABLE.value === detail.status"
v-model="command"
placeholder="输入"
@search="sendCommand">
<template #enterButton>
<a-icon type="forward"/>
</template>
<!-- 发送 lf -->
<template #suffix>
<a-icon :class="{'send-lf-trigger': true, 'send-lf-trigger-enable': sendLf}"
title="是否发送 \n"
type="pull-request"
@click="() => sendLf = !sendLf"/>
</template>
</a-input-search>
<!-- 停止 -->
<a-popconfirm v-if="BUILD_STATUS.RUNNABLE.value === detail.status"
title="是否要停止执行?"
placement="bottomLeft"
ok-text="确定"
cancel-text="取消"
@confirm="terminate">
<a-button icon="close">停止</a-button>
</a-popconfirm>
<!-- 下载 -->
<div class="download-bundle-wrapper" v-if="detail.status === BUILD_STATUS.FINISH.value">
<a-button v-if="!downloadUrl" icon="link" size="small" @click="loadDownloadUrl">获取产物链接</a-button>
<a target="_blank" :href="downloadUrl" @click="clearDownloadUrl" v-else>
<a-button icon="download">下载产物</a-button>
</a>
</div>
</div>
</template>
</logAppender>
</div>
</div>
</template>
<script>
import { enumValueOf, ACTION_STATUS, BUILD_STATUS, FILE_DOWNLOAD_TYPE, FILE_TAIL_TYPE } from '@/lib/enum'
import LogAppender from '@/components/log/LogAppender'
export default {
name: 'AppBuildLogAppender',
components: { LogAppender },
props: {
appenderHeight: String
},
computed: {
stepStatus() {
if (this.detail.status) {
return enumValueOf(BUILD_STATUS, this.detail.status).stepStatus
}
return null
}
},
data() {
return {
FILE_TAIL_TYPE,
FILE_DOWNLOAD_TYPE,
BUILD_STATUS,
ACTION_STATUS,
id: null,
current: 0,
detail: {},
command: null,
sendLf: true,
pollId: null,
downloadUrl: null
}
},
methods: {
open(id) {
this.id = id
this.$api.getAppBuildDetail({
id: this.id
}).then(({ data }) => {
this.detail = data
this.setStepsCurrent()
// 设置轮询状态
if (this.detail.status === BUILD_STATUS.WAIT.value ||
this.detail.status === BUILD_STATUS.RUNNABLE.value) {
this.pollId = setInterval(this.pollStatus, 2000)
}
}).then(() => {
this.$nextTick(() => this.$refs.appender.openTail())
})
},
close() {
// 关闭轮询
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
// 关闭tail
this.$nextTick(() => {
this.$refs.appender.clear()
this.$refs.appender.dispose()
})
this.id = null
this.current = 0
this.detail = {}
this.downloadUrl = null
},
pollStatus() {
this.$api.getAppBuildStatus({
id: this.id
}).then(({ data }) => {
this.detail.status = data.status
// 清除状态轮询
if (this.detail.status !== BUILD_STATUS.WAIT.value &&
this.detail.status !== BUILD_STATUS.RUNNABLE.value) {
clearInterval(this.pollId)
this.pollId = null
}
// 设置action
if (!data.actions || !data.actions.length || !this.detail.actions || !this.detail.actions.length) {
return
}
for (const actionStatus of data.actions) {
this.detail.actions.filter(s => s.id === actionStatus.id).forEach(e => {
e.status = actionStatus.status
e.used = actionStatus.used
})
}
// 设置当前操作
this.setStepsCurrent()
})
},
terminate() {
this.$api.terminateAppBuild({
id: this.detail.id
}).then(() => {
this.$message.success('已停止')
})
},
sendCommand() {
let command = this.command || ''
if (this.sendLf) {
command += '\n'
}
if (!command) {
return
}
this.command = ''
this.$api.writeAppBuild({
id: this.detail.id,
command
})
},
setStepsCurrent() {
const len = this.detail.actions.length
let curr = len - 1
for (let i = 0; i < len; i++) {
const status = this.detail.actions[i].status
if (status !== ACTION_STATUS.FINISH.value) {
curr = i
break
}
}
this.current = curr
},
async loadDownloadUrl() {
try {
const downloadUrl = await this.$api.getFileDownloadToken({
type: FILE_DOWNLOAD_TYPE.APP_BUILD_BUNDLE.value,
id: this.id
})
this.downloadUrl = this.$api.fileDownloadExec({ token: downloadUrl.data })
} catch (e) {
// ignore
}
},
clearDownloadUrl() {
setTimeout(() => {
this.downloadUrl = null
})
}
},
beforeDestroy() {
this.pollId !== null && clearInterval(this.pollId)
this.pollId = null
}
}
</script>
<style lang="less" scoped>
.app-build-steps {
height: 46px;
padding: 8px 12px 0 12px;
}
.build-log-tools {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<a-modal v-model="visible"
:closable="false"
:title="null"
:footer="null"
:dialogStyle="{top: '16px', padding: 0}"
:bodyStyle="{padding: 0}"
:destroyOnClose="true"
:forceRender="true"
@cancel="close"
width="96%">
<!-- 日志面板 -->
<AppBuildLogAppender ref="logger" appenderHeight="calc(100vh - 122px)"/>
</a-modal>
</template>
<script>
import AppBuildLogAppender from '@/components/log/AppBuildLogAppender'
export default {
name: 'AppBuildLogAppenderModal',
components: { AppBuildLogAppender },
data() {
return {
visible: false
}
},
methods: {
open(id) {
this.visible = true
setTimeout(() => {
this.$refs.logger.open(id)
}, 300)
},
close() {
this.visible = false
this.$nextTick(() => {
this.$refs.logger.close()
})
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,335 @@
<template>
<a-spin :spinning="loading">
<div class="app-release-container" :style="{height}">
<!-- 菜单 -->
<div class="release-machines-menu gray-box-shadow">
<a-menu mode="inline" v-model="selectedKeys">
<a-menu-item v-for="machine in record.machines"
:key="machine.id"
:title="machine.machineName"
@click="chooseMachineLog(machine.id)">
<div class="menu-item-machine-wrapper">
<!-- 机器名称 -->
<span class="menu-item-machine-name auto-ellipsis-item">{{ machine.machineName }}</span>
<!-- 状态 -->
<span class="menu-item-machine-status">
<a-tag :color="machine.status | formatActionStatus('color')">
{{ machine.status | formatActionStatus('label') }}
</a-tag>
</span>
</div>
</a-menu-item>
</a-menu>
</div>
<!-- 机器 -->
<div class="release-machine-container gray-box-shadow">
<div class="release-machine"
v-for="machine in record.machines"
v-show="machine.id === selectedKeys[0]"
:key="machine.id">
<!-- 进度 -->
<div class="machine-steps">
<a-steps :current="current[machine.id]"
:status="machine.status | formatActionStatus('stepStatus')">
<template v-for="action in machine.actions">
<a-step :key="action.id"
:title="action.actionName"
:subTitle="action.used ? `${action.used}ms` : ''">
<template v-if="action.status === ACTION_STATUS.RUNNABLE.value" #icon>
<a-icon type="loading"/>
</template>
</a-step>
</template>
</a-steps>
</div>
<!-- 日志 -->
<div class="machine-logger-appender">
<LogAppender :ref="`appender-${machine.id}`"
size="default"
:height="appenderHeight"
:relId="machine.id"
:tailType="FILE_TAIL_TYPE.APP_RELEASE_LOG.value"
:downloadType="FILE_DOWNLOAD_TYPE.APP_RELEASE_MACHINE_LOG.value">
<template #left-tools>
<!-- 命令输入 -->
<a-input-search class="command-write-input"
size="default"
v-if="ACTION_STATUS.RUNNABLE.value === machine.status"
v-model="command"
placeholder="输入"
@search="sendCommand(machine.id)">
<template #enterButton>
<a-icon type="forward"/>
</template>
<!-- 发送 lf -->
<template #suffix>
<a-icon :class="{'send-lf-trigger': true, 'send-lf-trigger-enable': sendLf}"
title="是否发送 \n"
type="pull-request"
@click="() => sendLf = !sendLf"/>
</template>
</a-input-search>
<!-- 停止 -->
<a-popconfirm v-if="ACTION_STATUS.RUNNABLE.value === machine.status"
title="是否要停止执行?"
placement="bottomLeft"
ok-text="确定"
cancel-text="取消"
@confirm="terminateMachine(machine.id)">
<a-button icon="close">停止</a-button>
</a-popconfirm>
<!-- 跳过 -->
<a-popconfirm v-if="ACTION_STATUS.WAIT.value === machine.status"
title="是否要跳过执行?"
placement="bottomLeft"
ok-text="确定"
cancel-text="取消"
@confirm="skipMachine(machine.id)">
<a-button icon="stop">跳过</a-button>
</a-popconfirm>
</template>
</LogAppender>
</div>
</div>
</div>
</div>
</a-spin>
</template>
<script>
import { enumValueOf, ACTION_STATUS, FILE_DOWNLOAD_TYPE, FILE_TAIL_TYPE, RELEASE_STATUS } from '@/lib/enum'
import LogAppender from '@/components/log/LogAppender'
export default {
name: 'AppReleaseLogAppender',
components: { LogAppender },
props: {
appenderHeight: String,
height: String
},
computed: {},
data() {
return {
FILE_TAIL_TYPE,
FILE_DOWNLOAD_TYPE,
ACTION_STATUS,
id: null,
loading: false,
record: {},
command: null,
sendLf: true,
pollId: null,
selectedKeys: [],
openedFiles: [],
current: {}
}
},
methods: {
async open(id) {
this.id = id
// 关闭轮询
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
// 加载明细
this.loading = true
await this.$api.getAppReleaseDetail({
id: id,
queryAction: 1
}).then(({ data }) => {
// 设置数据
this.record = data
this.selectedKeys[0] = data.machines[0].id
this.loading = false
this.setStepsCurrent()
}).then(() => {
// 打开日志
for (const machine of this.record.machines) {
if (machine.status !== ACTION_STATUS.WAIT.value &&
machine.status !== ACTION_STATUS.SKIPPED.value) {
this.$refs[`appender-${machine.id}`][0].openTail()
this.openedFiles.push(machine.id)
}
}
}).then(() => {
// 设置轮询
if (this.record.status === RELEASE_STATUS.RUNNABLE.value) {
// 轮询状态
this.pollId = setInterval(this.pollStatus, 2000)
}
}).catch(() => {
this.loading = false
})
},
close() {
if (!this.record.machines || !this.record.machines.length) {
return
}
// 关闭tail
for (const machine of this.record.machines) {
const appender = this.$refs[`appender-${machine.id}`][0]
appender.clear()
appender.dispose()
}
// 关闭轮询
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
this.id = null
this.record = {}
this.selectedKeys = []
this.openedFiles = []
this.current = {}
},
chooseMachineLog(id) {
setTimeout(() => {
this.$nextTick(() => this.$refs[`appender-${id}`][0].fitTerminal())
}, 100)
},
terminateMachine(releaseMachineId) {
this.$api.terminateAppReleaseMachine({
releaseMachineId: releaseMachineId
}).then(() => {
this.$message.success('已停止')
})
},
skipMachine(releaseMachineId) {
this.$api.skipAppReleaseMachine({
releaseMachineId: releaseMachineId
}).then(() => {
this.$message.success('已跳过')
})
},
sendCommand(releaseMachineId) {
let command = this.command || ''
if (this.sendLf) {
command += '\n'
}
if (!command) {
return
}
this.command = ''
this.$api.writeAppReleaseMachine({
releaseMachineId: releaseMachineId,
command
})
},
async pollStatus() {
if (!this.record.machines || !this.record.machines.length) {
return
}
const pollId = this.record.machines.map(s => s.id)
this.$api.getAppReleaseMachineListStatus({
releaseMachineIdList: pollId
}).then(({ data }) => {
if (!data || !data.length) {
return
}
const notFinish = data.map(s => s.status).filter(s => s === ACTION_STATUS.WAIT.value ||
s === ACTION_STATUS.RUNNABLE.value)
// 清除状态轮询
if (notFinish.length === 0) {
clearInterval(this.pollId)
this.pollId = null
}
// 设置机器状态
for (const machineStatus of data) {
this.record.machines.filter(s => s.id === machineStatus.id).forEach(s => {
s.status = machineStatus.status
// 检查打开tail
const opened = this.openedFiles.filter(e => e === s.id).length > 0
if (!opened && s.status !== ACTION_STATUS.WAIT.value &&
s.status !== ACTION_STATUS.SKIPPED.value) {
// 打开日志
this.$refs[`appender-${s.id}`][0].openTail()
this.openedFiles.push(s.id)
}
// 设置action
if (!machineStatus.actions || !machineStatus.actions.length || !s.actions || !s.actions.length) {
return
}
for (const actionStatus of machineStatus.actions) {
s.actions.filter(a => a.id === actionStatus.id).forEach(e => {
e.status = actionStatus.status
e.used = actionStatus.used
})
}
})
}
// 设置当前操作
this.setStepsCurrent()
})
},
setStepsCurrent() {
if (!this.record.machines || !this.record.machines.length) {
return
}
for (const machine of this.record.machines) {
const len = machine.actions.length
let curr = len - 1
for (let i = 0; i < len; i++) {
const status = machine.actions[i].status
if (status !== ACTION_STATUS.FINISH.value) {
curr = i
break
}
}
this.current[machine.id] = curr
}
}
},
filters: {
formatActionStatus(status, f) {
return enumValueOf(ACTION_STATUS, status)[f]
}
},
beforeDestroy() {
this.pollId !== null && clearInterval(this.pollId)
this.pollId = null
}
}
</script>
<style lang="less" scoped>
.app-release-container {
display: flex;
background: #F0F2F5;
.release-machines-menu {
width: 240px;
margin: 16px;
padding: 8px;
background: #FFFFFF;
border-radius: 2px;
::v-deep .ant-menu-item {
padding: 0 0 0 12px !important;
}
.menu-item-machine-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
.menu-item-machine-name {
width: 147px;
}
}
}
.release-machine-container {
width: calc(100% - 287px);
margin: 16px 16px 16px 0;
background: #FFF;
border-radius: 2px;
.machine-steps {
height: 46px;
padding: 8px 12px 0 12px;
}
}
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<a-modal v-model="visible"
:closable="false"
:title="null"
:footer="null"
:dialogStyle="{top: '16px', padding: 0}"
:bodyStyle="{padding: 0}"
:destroyOnClose="true"
:forceRender="true"
@cancel="close"
width="96%">
<!-- 日志面板 -->
<AppReleaseLogAppender ref="logger" appenderHeight="calc(100vh - 152px)" height="calc(100vh - 34px)"/>
</a-modal>
</template>
<script>
import AppReleaseLogAppender from '@/components/log/AppReleaseLogAppender'
export default {
name: 'AppReleaseLogAppenderModal',
components: {
AppReleaseLogAppender
},
data() {
return {
visible: false
}
},
methods: {
open(id) {
this.visible = true
setTimeout(() => {
this.$refs.logger.open(id)
}, 300)
},
close() {
this.visible = false
this.$nextTick(() => {
this.$refs.logger.close()
})
}
}
}
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,205 @@
<template>
<div class="app-release-container">
<!-- 步骤 -->
<div class="app-release-steps">
<a-steps :current="current" :status="detail.status | formatActionStatus('stepStatus')">
<template v-for="action in detail.actions">
<a-step :key="action.id"
:title="action.actionName"
:subTitle="action.used ? `${action.used}ms` : ''">
<template v-if="action.status === ACTION_STATUS.RUNNABLE.value" #icon>
<a-icon type="loading"/>
</template>
</a-step>
</template>
</a-steps>
</div>
<!-- 日志 -->
<div class="machine-release-log">
<logAppender ref="appender"
size="default"
:height="appenderHeight"
:relId="id"
:tailType="FILE_TAIL_TYPE.APP_RELEASE_LOG.value"
:downloadType="FILE_DOWNLOAD_TYPE.APP_RELEASE_MACHINE_LOG.value">
<!-- 左侧工具 -->
<template #left-tools>
<div class="machine-log-tools">
<a-tag color="#5C7CFA" v-if="detail.machineName">
{{ detail.machineName }}
</a-tag>
<a-tag color="#40C057" v-if="detail.machineHost">
{{ detail.machineHost }}
</a-tag>
<!-- 命令输入 -->
<a-input-search class="command-write-input"
size="default"
v-if="ACTION_STATUS.RUNNABLE.value === detail.status"
v-model="command"
placeholder="输入"
@search="sendCommand">
<template #enterButton>
<a-icon type="forward"/>
</template>
<!-- 发送 lf -->
<template #suffix>
<a-icon :class="{'send-lf-trigger': true, 'send-lf-trigger-enable': sendLf}"
title="是否发送 \n"
type="pull-request"
@click="() => sendLf = !sendLf"/>
</template>
</a-input-search>
<!-- 停止 -->
<a-popconfirm v-if="ACTION_STATUS.RUNNABLE.value === detail.status"
title="是否要停止执行?"
placement="bottomLeft"
ok-text="确定"
cancel-text="取消"
@confirm="terminateMachine">
<a-button icon="close">停止</a-button>
</a-popconfirm>
</div>
</template>
</logAppender>
</div>
</div>
</template>
<script>
import { enumValueOf, ACTION_STATUS, FILE_DOWNLOAD_TYPE, FILE_TAIL_TYPE } from '@/lib/enum'
import LogAppender from '@/components/log/LogAppender'
export default {
name: 'AppReleaseMachineLogAppender',
components: { LogAppender },
props: {
appenderHeight: String
},
data() {
return {
FILE_TAIL_TYPE,
FILE_DOWNLOAD_TYPE,
ACTION_STATUS,
id: null,
current: 0,
detail: {},
command: null,
sendLf: true,
pollId: null
}
},
methods: {
open(id) {
this.id = id
this.$api.getAppReleaseMachineDetail({
releaseMachineId: this.id
}).then(({ data }) => {
this.detail = data
this.setStepsCurrent()
// 设置轮询状态
if (this.detail.status === ACTION_STATUS.WAIT.value ||
this.detail.status === ACTION_STATUS.RUNNABLE.value) {
this.pollId = setInterval(this.pollStatus, 2000)
}
}).then(() => {
this.$nextTick(() => this.$refs.appender.openTail())
})
},
close() {
// 关闭轮询
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
// 关闭tail
this.$nextTick(() => {
this.$refs.appender.clear()
this.$refs.appender.dispose()
})
this.id = null
this.current = 0
this.detail = {}
},
terminateMachine() {
this.$api.terminateAppReleaseMachine({
releaseMachineId: this.detail.id
}).then(() => {
this.$message.success('已停止')
})
},
sendCommand() {
let command = this.command || ''
if (this.sendLf) {
command += '\n'
}
if (!command) {
return
}
this.command = ''
this.$api.writeAppReleaseMachine({
releaseMachineId: this.detail.id,
command
})
},
pollStatus() {
this.$api.getAppReleaseMachineStatus({
releaseMachineId: this.id
}).then(({ data }) => {
this.detail.status = data.status
// 清除状态轮询
if (this.detail.status !== ACTION_STATUS.WAIT.value &&
this.detail.status !== ACTION_STATUS.RUNNABLE.value) {
clearInterval(this.pollId)
this.pollId = null
}
// 设置action
if (!data.actions || !data.actions.length || !this.detail.actions || !this.detail.actions.length) {
return
}
for (const actionStatus of data.actions) {
this.detail.actions.filter(s => s.id === actionStatus.id).forEach(e => {
e.status = actionStatus.status
e.used = actionStatus.used
})
}
// 设置当前操作
this.setStepsCurrent()
})
},
setStepsCurrent() {
const len = this.detail.actions.length
let curr = len - 1
for (let i = 0; i < len; i++) {
const status = this.detail.actions[i].status
if (status !== ACTION_STATUS.FINISH.value) {
curr = i
break
}
}
this.current = curr
}
},
filters: {
formatActionStatus(status, f) {
return enumValueOf(ACTION_STATUS, status)[f]
}
},
beforeDestroy() {
this.pollId !== null && clearInterval(this.pollId)
this.pollId = null
}
}
</script>
<style lang="less" scoped>
.app-release-steps {
height: 46px;
padding: 8px 12px 0 12px;
}
.machine-log-tools {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<a-modal v-model="visible"
:closable="false"
:title="null"
:footer="null"
:dialogStyle="{top: '16px', padding: 0}"
:bodyStyle="{padding: 0}"
:destroyOnClose="true"
:forceRender="true"
@cancel="close"
width="96%">
<!-- 日志面板 -->
<AppReleaseMachineLogAppender ref="logger" appenderHeight="calc(100vh - 122px)"/>
</a-modal>
</template>
<script>
import AppReleaseMachineLogAppender from '@/components/log/AppReleaseMachineLogAppender'
export default {
name: 'AppReleaseMachineLogAppenderModal',
components: { AppReleaseMachineLogAppender },
data() {
return {
visible: false
}
},
methods: {
open(id) {
this.visible = true
setTimeout(() => {
this.$refs.logger.open(id)
}, 300)
},
close() {
this.visible = false
this.$nextTick(() => {
this.$refs.logger.close()
})
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,181 @@
<template>
<div class="exec-logger-appender">
<LogAppender ref="appender"
size="default"
:height="appenderHeight"
:relId="execId"
:tailType="FILE_TAIL_TYPE.EXEC_LOG.value"
:downloadType="FILE_DOWNLOAD_TYPE.EXEC_LOG.value">
<!-- 左侧工具栏 -->
<template #left-tools>
<div class="appender-left-tools">
<!-- 状态 -->
<a-tag class="machine-exec-status" v-if="status" :color="status | formatExecStatus('color')">
{{ status | formatExecStatus('label') }}
</a-tag>
<!-- used -->
<span class="mx8" title="用时" v-if="BATCH_EXEC_STATUS.COMPLETE.value === status && keepTime">
{{ `${keepTime} (${used}ms)` }}
</span>
<!-- exitCode -->
<span class="mx8" title="退出码"
v-if="exitCode !== null"
:style="{'color': exitCode === 0 ? '#4263EB' : '#E03131'}">
{{ exitCode }}
</span>
<!-- 命令输入 -->
<a-input-search class="command-write-input"
size="default"
v-if="BATCH_EXEC_STATUS.RUNNABLE.value === status"
v-model="command"
placeholder="输入"
@search="sendCommand">
<template #enterButton>
<a-icon type="forward"/>
</template>
<!-- 发送 lf -->
<template #suffix>
<a-icon :class="{'send-lf-trigger': true, 'send-lf-trigger-enable': sendLf}"
title="是否发送 \n"
type="pull-request"
@click="() => sendLf = !sendLf"/>
</template>
</a-input-search>
<!-- 停止 -->
<a-popconfirm v-if="BATCH_EXEC_STATUS.RUNNABLE.value === status"
title="是否要停止执行?"
placement="bottomLeft"
ok-text="确定"
cancel-text="取消"
@confirm="terminate">
<a-button icon="close">停止</a-button>
</a-popconfirm>
</div>
</template>
</LogAppender>
</div>
</template>
<script>
import { enumValueOf, BATCH_EXEC_STATUS, FILE_DOWNLOAD_TYPE, FILE_TAIL_TYPE } from '@/lib/enum'
import LogAppender from './LogAppender'
export default {
name: 'ExecLoggerAppender',
components: {
LogAppender
},
props: {
appenderHeight: String
},
data() {
return {
FILE_TAIL_TYPE,
FILE_DOWNLOAD_TYPE,
BATCH_EXEC_STATUS,
status: null,
execId: null,
command: null,
sendLf: true,
keepTime: null,
used: null,
exitCode: null,
pollId: null
}
},
methods: {
async open(execId) {
this.execId = execId
// 关闭轮询
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
// 打开日志
this.$nextTick(() => this.$refs.appender.openTail())
// 获取状态
await this.getStatus()
// 检查轮询状态
if (this.status === BATCH_EXEC_STATUS.WAITING.value ||
this.status === BATCH_EXEC_STATUS.RUNNABLE.value) {
// 轮询状态
this.pollId = setInterval(this.pollStatus, 2000)
}
},
close() {
// 关闭tail
this.$refs.appender.clear()
this.$refs.appender.dispose()
// 关闭轮询
if (this.pollId) {
clearInterval(this.pollId)
this.pollId = null
}
this.status = null
this.execId = null
this.command = null
this.keepTime = null
this.used = null
this.exitCode = null
},
sendCommand() {
let command = this.command || ''
if (this.sendLf) {
command += '\n'
}
if (!command) {
return
}
this.command = ''
this.$api.writeExecTask({
id: this.execId,
command
})
},
terminate() {
this.status = BATCH_EXEC_STATUS.TERMINATED.value
this.$api.terminateExecTask({
id: this.execId
}).then(() => {
this.$message.success('已停止')
})
},
async getStatus() {
await this.$api.getExecTaskStatus({
idList: [this.execId]
}).then(({ data }) => {
if (data && data.length) {
const status = data[0]
this.status = status.status
this.exitCode = status.exitCode
this.used = status.used
this.keepTime = status.keepTime
}
})
},
async pollStatus() {
await this.getStatus()
if (this.status !== BATCH_EXEC_STATUS.WAITING.value &&
this.status !== BATCH_EXEC_STATUS.RUNNABLE.value) {
clearInterval(this.pollId)
}
}
},
filters: {
formatExecStatus(status, f) {
return enumValueOf(BATCH_EXEC_STATUS, status)[f]
}
},
beforeDestroy() {
this.pollId !== null && clearInterval(this.pollId)
this.pollId = null
}
}
</script>
<style lang="less" scoped>
.appender-left-tools {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<a-modal v-model="visible"
:closable="false"
:title="null"
:footer="null"
:dialogStyle="{top: '32px', padding: 0}"
:bodyStyle="{padding: 0}"
:destroyOnClose="true"
:forceRender="true"
@cancel="close"
width="96%">
<!-- 日志面板 -->
<ExecLoggerAppender ref="logger" appenderHeight="calc(100vh - 106px)"/>
</a-modal>
</template>
<script>
import ExecLoggerAppender from './ExecLoggerAppender'
export default {
name: 'ExecLoggerAppenderModal',
components: {
ExecLoggerAppender
},
data() {
return {
visible: false
}
},
methods: {
open(id) {
this.visible = true
setTimeout(() => {
this.$refs.logger.open(id)
}, 300)
},
close() {
this.visible = false
this.$nextTick(() => {
this.$refs.logger.close()
})
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,98 @@
<template>
<a-modal v-model="visible"
title="清除文件 ASNI 码"
okText="清除"
:width="400"
:dialogStyle="{top: '64px', padding: 0}"
:bodyStyle="{padding: '16px'}"
:okButtonProps="{props: {disabled: loading}}"
:maskClosable="false"
:destroyOnClose="true"
@ok="clean"
@cancel="close">
<a-spin :spinning="loading">
<!-- 提示 -->
<a-alert message="清除日志文件的 ANSI 着色码, 恢复为普通的日志文件"/>
<!-- 文件上传拖拽框 -->
<div class="upload-event-trigger mt8">
<a-upload-dragger class="upload-drag"
accept=".log,.txt"
:beforeUpload="selectFile"
:multiple="true"
:fileList="fileList"
:showUploadList="true">
<p id="upload-trigger-icon" class="ant-upload-drag-icon">
<a-icon type="inbox"/>
</p>
<p class="ant-upload-text">单击或拖动文件到此区域</p>
</a-upload-dragger>
</div>
</a-spin>
</a-modal>
</template>
<script>
import { downloadFile } from '@/lib/utils'
export default {
name: 'FileAnsiCleanModal',
data: function() {
return {
visible: false,
loading: false,
fileList: []
}
},
methods: {
open() {
this.visible = true
},
selectFile(e) {
this.fileList.push(e)
return false
},
removeFile(e) {
for (let i = 0; i < this.fileList.length; i++) {
if (this.fileList[i] === e) {
this.fileList.splice(i, 1)
}
}
},
async clean() {
if (!this.fileList.length) {
this.$message.warning('请先选择文件')
return
}
this.loading = true
for (const file of this.fileList) {
const formData = new FormData()
formData.append('file', file)
this.$message.info(`开始处理 ${file.name}`)
await this.$api.cleanFileAnsiCode(formData).then((e) => {
this.$message.success(`${file.name} 处理完成, 片刻后自动下载`)
downloadFile(e, file.name)
this.fileList.splice(0, 1)
}).catch(() => {
this.$message.error(`${file.name} 处理失败`)
})
}
this.loading = false
},
close() {
this.visible = false
this.loading = false
this.fileList = []
}
}
}
</script>
<style lang="less" scoped>
#upload-trigger-icon {
margin: 0;
}
::v-deep .upload-drag .ant-upload span {
padding: 8px;
}
</style>

View File

@@ -0,0 +1,389 @@
<template>
<div class="logger-view-container-wrapper">
<!-- 日志面板 -->
<div class="logger-view-container">
<!-- 工具 -->
<div class="log-tools" v-if="toolsProps.visible !== false">
<!-- 左侧工具 -->
<div class="log-tools-fixed-left">
<slot name="left-tools" v-if="toolsProps.visibleLeft !== false"/>
</div>
<!-- 右侧工具 -->
<div class="log-tools-fixed-right" v-if="toolsProps.visibleRight !== false">
<!-- 复制 -->
<a-button class="mr8"
v-if="rightToolsProps.copy !== false"
:size="size"
type="primary"
icon="copy"
@click="copy">
复制
</a-button>
<!-- 清空 -->
<a-button class="mr8"
v-if="rightToolsProps.clean !== false"
:size="size"
type="default"
icon="delete"
@click="clear">
清空
</a-button>
<!-- 下载 -->
<div class="log-download-wrapper" v-if="rightToolsProps.download !== false">
<a-button :size="size" v-if="!downloadUrl" type="default" icon="link" @click="loadDownloadUrl">获取下载链接</a-button>
<a target="_blank" :href="downloadUrl" @click="clearDownloadUrl" v-else>
<a-button :size="size" type="default" icon="download">下载</a-button>
</a>
</div>
<!-- 固定日志 -->
<div class="log-fixed-wrapper nowrap" v-if="rightToolsProps.fixed !== false">
<span class="log-fixed-label normal-label ml8 usn">固定</span>
<a-switch class="log-fixed-switch" v-model="fixedLog" :size="size"/>
</div>
<!-- 状态 -->
<div class="log-status-wrapper nowrap" v-if="rightToolsProps.status !== false">
<!-- 状态 执行中 -->
<template v-if="LOG_TAIL_STATUS.RUNNABLE.value === status">
<a-popconfirm title="确认关闭当前日志连接?"
placement="topRight"
ok-text="确定"
cancel-text="取消"
@confirm="close">
<a-badge class="pointer usn"
:status="LOG_TAIL_STATUS.RUNNABLE.status"
:text="LOG_TAIL_STATUS.RUNNABLE.label"/>
</a-popconfirm>
</template>
<!-- 状态 其他 -->
<template v-else>
<a-badge class="usn"
:status="status | formatLogStatus('status')"
:text="status | formatLogStatus('label')"/>
</template>
</div>
</div>
</div>
<!-- 日志容器 -->
<div class="log-container" :style="{height}" ref="logContainer">
<!-- 右键菜单 -->
<a-dropdown v-model="visibleRightMenu" :trigger="['contextmenu']">
<!-- 日志终端 -->
<div class="log-terminal" ref="logTerminal" @click="clickTerminal"/>
<!-- 下拉菜单 -->
<template #overlay>
<a-menu @click="clickRightMenu">
<a-menu-item key="copy">
<span class="right-menu-item"><a-icon type="copy"/>复制</span>
</a-menu-item>
<a-menu-item key="selectAll">
<span class="right-menu-item"><a-icon type="profile"/>全选</span>
</a-menu-item>
<a-menu-item key="clear">
<span class="right-menu-item"><a-icon type="stop"/>清空</span>
</a-menu-item>
<a-menu-item key="toTop">
<span class="right-menu-item"><a-icon type="vertical-align-top"/>去顶部</span>
</a-menu-item>
<a-menu-item key="toBottom">
<span class="right-menu-item"><a-icon type="vertical-align-bottom"/>去底部</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<!-- 搜索框 -->
<TerminalSearch ref="search" :searchPlugin="plugin.search"/>
</div>
</template>
<script>
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { SearchAddon } from 'xterm-addon-search'
import { WebLinksAddon } from 'xterm-addon-web-links'
import { enumValueOf, LOG_TAIL_STATUS } from '@/lib/enum'
import 'xterm/css/xterm.css'
import TerminalSearch from '@/components/terminal/TerminalSearch'
/**
* 右键菜单操作
*/
const rightMenuHandler = {
selectAll() {
this.term.selectAll()
},
copy() {
this.copySelection(false)
},
clear() {
this.clear()
},
toTop() {
this.term.scrollToTop()
},
toBottom() {
this.term.scrollToBottom()
}
}
export default {
name: 'LogAppender',
components: { TerminalSearch },
props: {
config: Object,
height: String,
size: {
type: String,
default: 'small'
},
relId: Number,
tailType: Number,
downloadType: Number,
toolsProps: {
type: Object,
default: () => {
return {
visible: true,
visibleLeft: true,
visibleRight: true
}
}
},
rightToolsProps: {
type: Object,
default: () => {
return {
copy: true,
clean: true,
fixed: true,
download: true,
status: true
}
}
}
},
data() {
return {
LOG_TAIL_STATUS,
client: null,
fixedLog: true,
status: LOG_TAIL_STATUS.WAITING.value,
downloadUrl: null,
visibleRightMenu: false,
term: null,
termConfig: {
rightClickSelectsWord: true,
disableStdin: true,
cursorStyle: 'bar',
cursorBlink: false,
fastScrollModifier: 'shift',
fontSize: 13,
rendererType: 'canvas',
fontFamily: 'courier-new, courier, monospace',
lineHeight: 1.08,
convertEol: true,
theme: {
foreground: '#FFFFFF',
background: '#212529'
}
},
plugin: {
fit: null,
search: null,
links: null
},
token: {}
}
},
methods: {
openTail() {
this.$api.getTailToken({
type: this.tailType,
relId: this.relId
}).then(({ data }) => {
this.token = data
this.initLogTailView(data)
})
},
initLogTailView(data) {
this.$nextTick(() => {
// 打开日志模块
this.term = new Terminal({ ...this.termConfig })
this.term.open(this.$refs.logTerminal)
// 注册自适应组件
this.plugin.fit = new FitAddon()
this.term.loadAddon(this.plugin.fit)
// 注册搜索组件
this.plugin.search = new SearchAddon()
this.term.loadAddon(this.plugin.search)
// 注册 url link组件
this.plugin.links = new WebLinksAddon()
this.term.loadAddon(this.plugin.links)
// 注册自适应监听器
window.addEventListener('resize', this.fitTerminal)
// 注册快捷键
this.term.attachCustomKeyEventHandler((ev) => {
// 注册全选键 ctrl + a
if (ev.keyCode === 65 && ev.ctrlKey && ev.type === 'keydown') {
setTimeout(() => {
this.term.selectAll()
}, 10)
}
// 注册复制键 ctrl + c
if (ev.keyCode === 67 && ev.ctrlKey && ev.type === 'keydown') {
this.copySelection(false)
}
// 注册搜索键 ctrl + shift + f
if (ev.keyCode === 70 && ev.ctrlKey && ev.shiftKey && ev.type === 'keydown') {
this.$refs.search.open()
}
})
// 隐藏光标
this.term.write('\x1b[?25l')
// 调整大小
this.fitTerminal()
// 建立连接
this.initSocket(data)
})
},
initSocket(data) {
// 打开websocket
this.client = new WebSocket(this.$api.fileTail({ token: data.token }))
this.client.onopen = () => {
this.status = LOG_TAIL_STATUS.RUNNABLE.value
this.$emit('open')
}
this.client.onerror = () => {
this.status = LOG_TAIL_STATUS.ERROR.value
}
this.client.onclose = (e) => {
this.status = LOG_TAIL_STATUS.CLOSE.value
if (e.code > 4000 && e.code < 5000) {
// 自定义错误信息
this.term.write(`\x1b[93m${e.reason}\x1b[0m`)
}
this.$emit('close')
}
this.client.onmessage = async event => {
this.term.write(await event.data.text())
if (!this.fixedLog) {
this.term.scrollToBottom()
}
}
},
fitTerminal() {
const dimensions = this.plugin.fit && this.plugin.fit.proposeDimensions()
if (!dimensions) {
return
}
if (dimensions?.cols && dimensions?.rows) {
this.term.resize(dimensions.cols, dimensions.rows)
}
},
clickTerminal() {
this.visibleRightMenu = false
},
copy() {
this.term.selectAll()
this.$copy(this.term.getSelection())
this.term.clearSelection()
},
copySelection(tips = undefined) {
this.$copy(this.term.getSelection(), tips)
},
clear() {
this.term && this.term.clear()
},
close() {
this.client && this.client.readyState === 1 && this.client.close()
},
dispose() {
this.term && this.term.dispose()
this.plugin.fit && this.plugin.fit.dispose()
this.plugin.search && this.plugin.search.dispose()
this.plugin.links && this.plugin.links.dispose()
this.client && this.client.readyState === 1 && this.client.close()
window.removeEventListener('resize', this.fitTerminal)
},
clickRightMenu({ key }) {
this.visibleRightMenu = false
rightMenuHandler[key].call(this)
},
async loadDownloadUrl() {
try {
const downloadUrl = await this.$api.getFileDownloadToken({
type: this.downloadType,
id: this.relId
})
this.downloadUrl = this.$api.fileDownloadExec({ token: downloadUrl.data })
} catch (e) {
// ignore
}
},
clearDownloadUrl() {
setTimeout(() => {
this.downloadUrl = null
})
}
},
filters: {
formatLogStatus(status, f) {
return enumValueOf(LOG_TAIL_STATUS, status)[f]
}
}
}
</script>
<style lang="less" scoped>
.logger-view-container {
height: 100%;
overflow: hidden;
}
.log-tools {
display: flex;
align-items: center;
justify-content: space-between;
align-content: center;
padding: 4px 8px;
.log-tools-fixed-left {
min-width: 20%;
white-space: nowrap;
}
.log-tools-fixed-right {
min-width: 50%;
display: flex;
align-items: center;
justify-content: flex-end;
.log-fixed-wrapper {
display: flex;
align-items: center;
.log-fixed-label {
margin-right: 4px;
}
.log-fixed-switch {
margin: 2px 12px 0 0;
}
}
}
}
.log-container {
width: 100%;
background: #212529;
padding: 4px 0 0 4px;
.log-terminal {
width: 100%;
height: 100%;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More