first commit

This commit is contained in:
2026-03-06 14:01:32 +08:00
commit def60e21c7
1074 changed files with 119423 additions and 0 deletions

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>