搜索结果列表页面开发

This commit is contained in:
付民康
2025-03-12 18:41:20 +08:00
commit 7cebe8fc00
739 changed files with 88149 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
<template>
<d-modal model-value title="保护分支" class="create-protect-branch" :before-close="handleClose">
<template #header>
<gc-modal-header>
<span class="text-G900 text-base font-bold leading-[24px]">创建保护分支</span>
</gc-modal-header>
<div class="line bg-G200"></div>
</template>
<d-form
ref="formRef"
layout="vertical"
:data="formModel"
:rules="rules"
:pop-position="['right']"
>
<d-form-item field="name" label="选择需要保护的分支">
<BranchSelector
v-model="formModel.name"
type="branch"
:repoId="repoId"
@input-change="handleInput"
:branchParams="{
branch_type: 'not_protect'
}"
showExtra
/>
</d-form-item>
<d-form-item field="push_access_levels" label="推送权限">
<d-select v-model="formModel.push_access_levels" :options="options" placeholder="请选择推送权限" />
</d-form-item>
<d-form-item field="merge_access_levels" label="合并权限">
<d-select v-model="formModel.merge_access_levels" :options="options" placeholder="请选择合并权限" />
</d-form-item>
</d-form>
<template #footer>
<div class="flex justify-end px-5 pb-5 pt-0 gap-2">
<d-button @click="handleClose">取消</d-button>
<d-button variant="solid" @click="handleConfirm">确定</d-button>
</div>
</template>
</d-modal>
</template>
<!-- 保护分支页面 -->
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { Message } from 'vue-devui/message';
import { createProtectedBranches } from '@/api/repo';
import { useRepoId } from '@/utils/hooks/useRepoId';
import BranchSelector from '@/components/BranchTagSelector/SingleSelector.vue';
// import { branchRegex } from '@/utils/regex';
const props = withDefaults(defineProps<{
branch?: string; // 需要设定默认分支的分支名称
options: { name: string, value: string }[],
}>(), {
options: () => {
return [
// {
// name: '开发者',
// value: '30'
// },
{
name: '管理员',
value: '50'
},
{
name: '开发者 + 管理员',
value: '30,50'
}
];
}
});
const { repoId } = useRepoId();
const formModel = reactive({
name: props.branch || '',
push_access_levels: '',
merge_access_levels: ''
});
const inputName = ref('');
const rules = {
name: [{ required: true, message: '请选择分支或者输入一个通配符号', trigger: 'change' },{ message: '请选择分支或者输入一个通配符号', trigger: 'change', validator: (rule: any, val: string, cb: Function) => {
if (val || inputName.value) {
return cb();
} else {
return cb(new Error(rule.message));
}
} }],
push_access_levels: [{ required: true, message: '请选择推送权限', trigger: 'change' }],
merge_access_levels: [{ required: true, message: '请选合并择权限', trigger: 'change' }]
};
const emits = defineEmits(['onProtected', 'onClose']);
const handleClose = () => {
emits('onClose');
};
const setBranchProtected = async(config: any) => {
const { name, push_access_levels, merge_access_levels } = config;
const params = {
project_id: repoId.value,
conf: {
names: [name],
access_level_string: {
push_access_levels,
merge_access_levels
},
access_level: {},
merge_setting: {}
}
};
return await createProtectedBranches(params);
};
const formRef = ref<any>(null);
const handleConfirm = () => {
formRef?.value?.validate(async(isValid: boolean) => {
if (isValid) {
const data = {
name: formModel.name || inputName.value,
push_access_levels: formModel.push_access_levels,
merge_access_levels: formModel.merge_access_levels
};
const res = await setBranchProtected(data);
if (!res.error) {
inputName.value = '';
Message.success('创建保护分支成功');
emits('onProtected', data);
emits('onClose');
}
}
});
};
const handleInput = (val: string) => {
inputName.value = val;
};
</script>
<style lang="scss">
.create-protect-branch {
width: 410px;
box-sizing: border-box;
// padding: 0 0 20px;
.devui-modal__body {
padding: 20px 24px 24px;
}
.tip-desc {
// @apply text-G900 px-[22px] my-[18px] leading-[28px] break-all;
}
.devui-modal__header {
padding: 14px 24px;
height: auto!important;
}
.btn-close {
right: 24px;
top: 16px;
}
.line {
width: 100%;
height: 1px;
}
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<d-modal v-model="vModels" class="repo-setting-modal">
<template #header>
<gc-modal-header>
<span class="text-G900 text-base font-bold leading-[24px]">保护tag</span>
</gc-modal-header>
<div class="line bg-G200"></div>
</template>
<d-form
ref="FormRef"
layout="vertical"
:data="formModel"
:rules="rules"
:pop-position="['right']"
class="px-[24px] pt-[20px] pb-24"
>
<d-form-item field="name" label="选择需要保护的 Tag">
<TagSelector type="tag" v-model="formModel.name" :repoId="repoId" @input-change="handleInput" showExtra />
</d-form-item>
<d-form-item field="create_access_level" label="允许创建" class="mb-0">
<d-select v-model="formModel.create_access_level" :options="options" placeholder="请选择推送权限" />
</d-form-item>
</d-form>
<template #footer>
<div class="flex justify-end px-[24px] pb-[24px] gap-2">
<d-button @click="handleCancel">取消</d-button>
<d-button variant="solid" :disabled="disabled" @click="handleConfirm">确定</d-button>
</div>
</template>
</d-modal>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { useModel } from '@/utils/hooks/useModel';
import TagSelector from '@/components/BranchTagSelector/SingleSelector.vue';
const FormRef = ref();
const props = withDefaults(defineProps<{
modelValue?: boolean
'onUpdate:modelValue'?: Function
repoId: string
disabled?: boolean,
options: { name: string, value: string }[]
}>(), {
disabled: false
});
const emits = defineEmits(['confirm', 'update:modelValue']);
const { vModels } = useModel(props, emits);
const formModel = reactive({
name: '',
create_access_level: ''
});
const inputName = ref('');
// 表单校验
const rules = {
name: [{ required: true, message: '请选择Tag或者创建一个通配符', trigger: 'change' },{ message: '请选择Tag或者创建一个通配符', trigger: 'change', validator: (rule: any, val: string, callback: Function) => {
if (val || inputName.value) {
return callback();
} else {
return callback(new Error(rule.message));
}
} }],
create_access_level: [{ required: true, message: '请选择推送权限', trigger: 'change' }]
};
const handleCancel = () => {
vModels.value = false;
};
const handleConfirm = () => {
FormRef?.value?.validate((isValid:any) => {
if (isValid) {
emits('confirm', {
name: formModel.name || inputName.value,
create_access_level: formModel.create_access_level
});
emits('update:modelValue', false);
inputName.value = '';
}
});
};
const handleInput = (val: string) => {
inputName.value = val;
};
</script>
<style lang="scss">
.repo-setting-modal {
width: 410px;
box-sizing: border-box;
.devui-modal__body {
padding: 0;
}
.tip-desc {
// @apply text-G900 px-[22px] my-[18px] leading-[28px] break-all;
}
.devui-modal__header {
padding: 14px 24px;
height: auto!important;
}
.btn-close {
right: 24px;
top: 16px;
}
.line {
width: 100%;
height: 1px;
}
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<d-modal v-model="vModels" :title="title" class="repo-settings-modal"
>
<div class="tip-desc">
<slot></slot>
</div>
<template #footer>
<div class="flex justify-end px-[22px]">
<d-button @click="closeModal()" class="mr-[8px]">取消</d-button>
<d-button :variant="confirmColor === 'primary' ? 'solid' : 'outline'" :color="confirmColor" @click="confirm">{{ confirmText }}</d-button>
</div>
</template>
</d-modal>
</template>
<script setup lang="ts">
import { useModel } from '@/utils/hooks/useModel';
const props = withDefaults(defineProps<{
modelValue?: any
'onUpdate:modelValue'?: Function
title?: string
autoClose?: boolean
confirmText?: string
confirmColor?: 'primary' | 'danger'
}>(), {
title: '删除操作',
autoClose: true,
confirmText: '确定',
confirmColor: 'primary'
});
const emits = defineEmits(['confirm', 'update:modelValue']);
const { vModels } = useModel(props, emits);
const closeModal = () => {
vModels.value = false;
};
const openModal = () => {
vModels.value = true;
};
const confirm = () => {
emits('confirm');
if (props.autoClose) {
vModels.value = false;
}
};
defineExpose({
openModal
});
</script>
<style lang="scss">
.repo-settings-modal {
width: 370px;
box-sizing: border-box;
padding: 0 0 20px;
.devui-modal__body {
padding: 0;
}
.tip-desc {
@apply text-G900 px-[22px] my-[18px] leading-[28px] break-all;
}
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<d-modal v-model="vModels" :title="title" class="repo-settings-modal" :showClose="showCloseIcon" :escapable="escapable" :close-on-click-overlay="closeByOverlay">
<template #header>
<gc-modal-header>
<Icon v-if="showWarnIcon" name="gt-warn" color="#F2050D" class="mr-[8px]"></Icon>
<span class="text-G900 text-base font-bold leading-[24px]">{{ title }}</span>
</gc-modal-header>
<div class="line bg-G200"></div>
</template>
<div v-if="warnText" class="px-24 py-[12px] bg-[#F2050D0C] text-[var(--color-R500)] text-sm font-normal leading-[20px]">
{{ warnText }}
</div>
<div class="pt-20 px-24 pb-24">
<slot></slot>
</div>
<template #footer v-if="showFooter">
<slot name="footerGroup"></slot>
<div class="flex justify-end px-[24px] pb-24" v-if="!customFooter">
<d-button @click="closeModal()" class="mr-[8px]">取消</d-button>
<d-button :loading="confirmLoading" :variant="confirmColor === 'primary' ? 'solid' : 'outline'" :color="confirmColor" @click="confirm" :disabled="disabledButton">
{{ confirmText }}
</d-button>
</div>
</template>
</d-modal>
</template>
<script setup lang="ts">
import { useModel } from '@/utils/hooks/useModel';
defineSlots<{
default?:() => any,
footerGroup?: () => any
}>();
const props = withDefaults(defineProps<{
modelValue?: any
'onUpdate:modelValue'?: Function
title?: string
warnText?: string
autoClose?: boolean
confirmText?: string
cancelText?: string
confirmColor?: 'primary' | 'danger'
showWarnIcon?: boolean
showFooter?: boolean
disabledButton?: boolean
customFooter?: boolean
showCloseIcon?: boolean
escapable?: boolean
closeByOverlay?: boolean,
confirmLoading?: boolean
}>(), {
title: '删除操作',
autoClose: true,
confirmText: '确定',
confirmColor: 'primary',
showFooter: true,
disabledButton: false,
customFooter: false,
showCloseIcon: true,
escapable: true,
closeByOverlay: true,
confirmLoading: false
});
const emits = defineEmits(['closeModal', 'confirm', 'update:modelValue']);
const { vModels } = useModel(props, emits);
const closeModal = () => {
emits('closeModal');
vModels.value = false;
};
const openModal = () => {
vModels.value = true;
};
const confirm = () => {
emits('confirm');
if (props.autoClose) {
vModels.value = false;
}
};
defineExpose({
openModal
});
</script>
<style lang="scss">
.repo-settings-modal {
width: 410px;
box-sizing: border-box;
padding: 0 0;
.devui-modal__body {
padding: 0;
}
.tip-desc {
// @apply text-G900 px-[22px] my-[18px] leading-[28px] break-all;
}
.devui-modal__header {
padding: 14px 24px;
height: auto!important;
}
.btn-close {
right: 24px;
top: 16px;
}
.line {
width: 100%;
height: 1px;
}
}
</style>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
const emit = defineEmits<{(e: 'update:modelValue', selected: string): void }>();
const props = defineProps<{
src: string;
alt: string;
modelValue: string;
}>();
</script>
<template>
<div class="image-radio">
<div class="border-b border-G300"><img :src="src" :alt="alt" /></div>
<div class="w-full flex px-5 py-3">
<d-radio
:modelValue="props.modelValue"
@update:modelValue="(v: string) => emit('update:modelValue', v)"
:value="alt"
>{{ alt }}</d-radio
>
</div>
</div>
</template>
<style scoped lang="scss">
img {
width: 320px;
height: 225px;
object-fit: contain;
}
</style>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
defineProps<{ label: string }>();
</script>
<template>
<div class="checkbox-container">
<d-checkbox v-bind="$attrs">
<span class="label-text">{{ label }}</span>
</d-checkbox>
<p class="sub-text text-CG600"><slot></slot></p>
</div>
</template>
<style scoped lang="scss">
.checkbox-container {
display: inline-block;
.label-text {
font-weight: 500;
}
.sub-text {
margin: 10px 26px;
font-size: 14px;
}
}
</style>

View File

@@ -0,0 +1,38 @@
# SettingItem
### 说明
- 高级设置页面的设置项
``` js
import SettingItem from '@/components/Setting/SettingItem/index.vue';
<SettingItem title="删除项目" buttonText="删除项目" warning @onClick="handleClick">
删除项目将删除其存储库和所有相关资源包括Issue、合并请求等。并且删除后将无法恢复请谨慎操作
</SettingItem>
```
### Props
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|--------------|------------------------|----------------------------|----------|-----------|
| title? | 设置项的左上角标题(可以通过#title插槽自定义 | string | - | - |
| buttonText? | 设置项的右上角按钮文字(可以通过#operation插槽自定义 | string | - | - |
| warning? | 是否启用waring样式 | boolean | - | false |
### Emits
| 方法 | 说明 | 返回值 |
|-----------------|------------------------------------|---------------------|
| onClick | 右上角按钮点击事件 | |
### Slots
| 名称 | 说明 |
|---------------------|---------------------------------------------|
| default | 内容区域 |
| title | 左上角标题 |
| operation | 右上角操作区域 |

View File

@@ -0,0 +1,69 @@
<template>
<div class="g-setting-item" :class="{ 'g-setting-item-warning': warning }">
<div class="setting">
<span class="setting-title">
<slot name="title">{{ title }}</slot>
</span>
<div class="setting-body">
<slot></slot>
</div>
</div>
<div class="operation">
<slot name="operation">
<d-button class="operation-btn" @click="handleClick">{{ buttonText }}</d-button>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
interface IProps {
// 声明props内容
title?: string,
buttonText?: string,
warning?: boolean
}
defineProps<IProps>();
const emit = defineEmits(['click']);
const handleClick = () => {
emit('click');
};
</script>
<style lang="scss" scoped>
$gap: 16px;
$border-radius: 4px;
.g-setting-item {
--item-color: #000;
display: flex;
justify-content: space-between;
padding: $gap;
border-radius: $border-radius;
border: 1px solid var(--item-color);
.setting {
flex: 1;
margin-right: $gap;
&-title {
color: var(--item-color);
font-size: 16px;
font-weight: 500;
}
&-body {
margin-top: $gap;
color: var(--black-60, #606060);
}
}
.operation {
&-btn {
background-color: var(--item-color);
color: #fff;
padding: 8px 32px;
border-radius: 4px;
}
}
&-warning {
--item-color: #FF3241;
}
}
</style>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
defineProps<{text?: string}>();
</script>
<template>
<div class="setting-title text-G900 text-sm">{{text}}<slot></slot></div>
</template>
<style scoped lang="scss">
.setting-title{
// font-size: 16px;
font-weight: 400;
white-space: pre-line;
}
</style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
</script>
<template>
<span class="key-count"><slot></slot></span>
</template>
<style scoped lang="scss">
@import 'devui-theme/styles-var/devui-var.scss';
.key-count {
display: inline-block;
background: $devui-global-bg;
width: 27px;
height: 27px;
line-height: 27px;
text-align: center;
border-radius: 50%;
}
</style>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import RoundTag from '@/components/Setting/SettingTitle/RoundTag.vue';
defineProps<{ title: string, count?: number }>();
const emits = defineEmits(['click']);
</script>
<template>
<span class="setting-title text-G900 text-2xl g-font-bold">
<span class="cursor-pointer" @click="emits('click')">{{ title }}</span><RoundTag class="ml-10" v-if="count !== undefined">{{ count }}</RoundTag><span class="extend-text"><slot></slot></span>
</span>
</template>
<style scoped lang="scss">
.setting-title {
// font-size: 18px;
// font-weight: 500;
display: inline-block;
span {
vertical-align: middle;
}
.extend-text {
display: inline-block;
vertical-align: middle;
}
}
</style>

View File

@@ -0,0 +1,224 @@
<script setup lang="ts">
import { reactive, ref, toRaw, watchEffect } from 'vue';
import { customEventList } from './customEventList';
import { useRouter } from 'vue-router';
import debounce from 'lodash/debounce';
import { urlRegExp } from '@/utils/regex';
const emits = defineEmits(['create', 'change']);
const nameMaxLen = 500;
const props = defineProps<{
data?: Record<string, any>;
editable?: boolean;
loading?: boolean;
}>();
const router = useRouter();
const formRef = ref<any>(null);
const form = reactive<Record<string, any>>({
url: '',
push_events: true,
tag_push_events: false,
merge_requests_events: false,
note_events: false,
content_type: 'application/json',
token: '',
active: true
});
const hookToken = ref('');
type WebHookEventKey = keyof typeof form
const contentTypeList = ref(['application/json', 'application/x-www-form-urlencoded']);
// const eventTypeList = shallowRef<{ key: WebHookEventKey, label: string }[]>([
// // {
// // key: 'all',
// // label: '全部事件'
// // },
// {
// key: 'push_events',
// label: '仅限 push 事件'
// },
// {
// key: 'custom_ctrl_item_events',
// label: '自定义事件'
// }
// ]);
const handleClick = async() => {
const valid = await formRef.value.validate();
if (valid) {
if (props?.editable) {
const { token, ...config } = toRaw(form);
if (token) {
emits('change', { token, ...config });
} else {
emits('change', config);
}
} else {
emits('create', toRaw(form));
}
}
};
const handleCancel = () => {
router.back();
};
const regex = /^[^\*\u4e00-\u9fa5\s]*$/;
const handleChange = (val: string) => {
if (props.editable) {
form.token = val;
}
};
const handleFocus = () => {
if (props.editable) {
if (!form.token) {
hookToken.value = '';
} else {
hookToken.value = form.token;
}
}
};
const handleBlur = () => {
if (props.editable) {
if (!form.token) {
hookToken.value = props?.data?.token ? '*'.repeat(props.data.token.length) : '********';
} else {
hookToken.value = form.token;
}
}
};
const handleInput = debounce((val: string) => {
if (val && formRef.value) {
form.token = val;
if (regex.test(val) && val.length <= 2000) {
formRef.value.clearValidate(['token']);
} else {
formRef.value.validateFields(['token']);
}
}
}, 500);
watchEffect(() => {
if (props.data) {
for (const key in form) {
if (key === 'token' && props.editable) {
form.token = '';
hookToken.value = props?.data?.token ? '*'.repeat(props.data.token.length) : '********';
} else {
form[key] = props.data[key];
}
}
}
});
function restricVal(event:any) {
let value = event.target.value;
if (event.type === 'paste') value = (event.clipboardData || (window as any).clipboardData).getData('text');
if (value.length < nameMaxLen) return;
form.url = value.slice(0, nameMaxLen);
event.preventDefault();
}
const formRules = { // 表单校验
url: [
{ required: true, message: '请填写WebHook的url信息', trigger: 'blur' },
{ max: nameMaxLen, message: 'WebHook的url信息长度不能超过500' },
{ message: '请输入正确的URL地址', pattern: urlRegExp },
],
content_type: [{ required: true, message: '请选择POST请求内容类型', trigger: 'blur' }],
token: [
{ required: !props.editable, message: '请输入Token', trigger: 'blur' },
{ message: 'Token不能包含中文、空格、*', pattern: regex },
{ max: 2000, message: 'WebHook的Token信息长度不能超过2000' }
]
};
</script>
<template>
<div class="web-hook-edit g-content-card p-20">
<d-form class="max-w-[900px]" :pop-position="['right']" :data="form" layout="vertical" ref="formRef" :rules="formRules">
<d-form-item field="url" label="URL">
<d-input maxlength="500" v-model="form.url" placeholder="请输入 WebHook 的 URL 地址" v-on:keypress="restricVal" v-on:paste="restricVal">
<template #suffix><span>{{form.url?.length}}/{{ nameMaxLen }}</span></template>
</d-input>
</d-form-item>
<d-form-item field="token" label="Token">
<div v-if="editable">
<div class="text-CG600 text-[14px] mb-[10px]">
如果你忘记了之前设置的Token, 你可以重新添加添加一个Token, 为避免影响正常使用, 请将所有应用到该Token的配置更新为最新的Token
</div>
<d-input maxlength="2000" placeholder="请输入Token" v-model="hookToken" @change="handleChange" @focus="handleFocus" @blur="handleBlur" @input="handleInput">
<template #suffix><span>{{hookToken?.length}}/2000</span></template>
</d-input>
</div>
<d-input maxlength="2000" placeholder="请输入Token" v-model="form.token" v-else>
<template #suffix><span>{{form.token?.length}}/2000</span></template>
</d-input>
</d-form-item>
<d-form-item field="content_type" class="no-label">
<div class="flex items-center">
<div class="text-G900 mr-[10px]">POST 请求内容类型</div>
<d-select
class="web-hook-select"
v-model="form.content_type"
:placeholder="form.content_type"
>
<gc-option v-for="(item,index) of contentTypeList.filter(name => name !== form.content_type)" :key="index" :value="item" :title="item">
<span>{{ item }}</span>
</gc-option>
</d-select>
</div>
</d-form-item>
<div class="my-[10px]">事件类型</div>
<div class="webhook-checkbox flex flex-wrap">
<d-checkbox class="my-[10px] w-[25%]" v-for="item of customEventList" :key="item.key" v-model="form[item.key as WebHookEventKey]">{{ item.title }}</d-checkbox>
</div>
<d-checkbox class="my-[10px]" v-model="form.active">
<span class="inline-block mr-[1px]">激活</span>
<span class="text-G600">当以上指定事件触发时将发送请求</span>
</d-checkbox>
<div class="flex items-center mt-5 justify-end">
<d-button class="mr-4 w-[64px]" @click="handleCancel"><span>取消</span></d-button>
<d-button variant="solid" :loading="loading" @click="handleClick">{{ editable ? '保存' : ' 创建' }}</d-button>
</div>
</d-form>
</div>
</template>
<style lang="scss">
.web-hook-edit {
.web-hook-select {
width: 320px;
input::placeholder {
color: var(--color-G900);
}
}
.webhook-checkbox {
.devui-checkbox label>span.devui-checkbox__label-text {
line-height: 18px;
}
}
.devui-form__item--vertical {
margin-bottom: 10px;
&:nth-of-type(3) {
.devui-form__label--vertical {
height: 0;
padding-bottom: 10px;
}
}
}
}
.no-label{
:deep(.devui-form__label){
display:none;
}
}
</style>

View File

@@ -0,0 +1,92 @@
export const customEventList = [
{
key: 'push_events',
title: '推送事件'
},
{
key: 'merge_requests_events',
title: 'Pull Request事件'
// desc: '合并请求请求事件包括合并请求的新建、分配负责人、取消负责人、关闭、转换成草稿WIP、新增 Label、移除 Label、新增里程碑、移除里程碑、重新打开事件'
},
{
key: 'tag_push_events',
title: 'Tag推送事件'
},
{
key: 'note_events',
title: '评论事件'
// desc: '新建分支或新建 Tag'
}
// {
// title: '删除分支或 Tag',
// desc: '删除分支或删除 Tag'
// },
// {
// title: '保护分支更新',
// desc: '新增、删除或修改保护分支设置'
// },
// {
// title: 'Forks',
// desc: '项目的 Fork 事件'
// },
// {
// title: '项目成员更新',
// desc: '新增、移除项目成员,或项目成员权限变更'
// },
// {
// title: 'Star',
// desc: '项目新增 Star 或取消 Star'
// },
// {
// key: 'issues_events',
// title: 'Issues',
// desc: 'Issues 事件,包括 Issue 的新增,编辑、删除、转移、置顶、取消置顶、关闭、重新打开、分配负责人、取消负责人、新增 Label、移除 Label、新增里程碑、移除里程碑、锁定和取消锁定事件'
// },
// {
// title: '提交评论',
// desc: '提交或 Diff 的评论事件'
// },
// {
// title: '里程碑',
// desc: '里程碑事件,包括里程碑的新增、关闭、打开、编辑和删除事件'
// },
// {
// title: '看板',
// desc: '新增、更新和删除看板'
// },
// {
// title: 'Pages',
// desc: 'Pages 站点更新'
// },
// {
// title: '看板事件',
// desc: '看板内容更新事件,包括新增、编辑、删除、归档、恢复、转换成 Issue 和排序'
// },
// {
// key: 'push_events',
// title: 'push 事件',
// desc: '代码库的 git push 事件'
// },
// {
// key: 'change_request_events',
// title: '合并请求评审状态事件',
// desc: '合并请求的代码评审事件(评审通过/不通过相关)'
// },
// {
// title: '代码评审评论',
// desc: '代码评审的评论事件'
// },
// {
// key: 'wiki_page_events',
// title: 'Wiki',
// desc: 'Wiki 更新'
// }
// {
// title: '项目导入',
// desc: '项目导入成功、失败或取消'
// },
// {
// title: '公开项目',
// desc: '项目由私密项目更改为公开项目'
// }
];

View File

@@ -0,0 +1,265 @@
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue';
import LogItem from './LogItem.vue';
import LogDetail from './LogDetail.vue';
import { convertRgbaColor } from '@/utils/color';
import { useRoute } from 'vue-router';
const props = defineProps<{ logs: any[], getWebhookLogDetail: Function, repoId: string | null }>();
const display = ref('');
const route = useRoute();
const { webhookId } = route.params;
const tab = ref('request' as 'request' | 'response');
interface RequestConfig {
header: string,
body: string
}
const request = reactive<RequestConfig>({
header: '',
body: ''
});
const response = reactive<RequestConfig>({
header: '',
body: ''
});
const successColor = '#0EB07B';
const failColor = '#F2050D';
const maxLen = ref(80);
const getColorByCode = (code: string) => {
if (Number(code) < 300) {
return {
color: successColor,
bgColor: convertRgbaColor(successColor, 0.1)[0]
};
}
return {
color: failColor,
bgColor: convertRgbaColor(failColor, 0.1)[0]
};
};
function formatObject(obj: Record<string, any>, indent = 1, maxLineWidth = maxLen.value): string {
const formattedProperties: string[] = [];
for (const key in obj) {
const value = obj[key];
formattedProperties.push(formatProperty(key, value, indent, maxLineWidth) as string);
}
const filterEmpty = formattedProperties.filter(item => item);
if (filterEmpty?.length) {
return `{\n${filterEmpty.join(',\n')}\n${' '.repeat((indent - 1) * 2)}}`;
} else {
return `{}`;
}
}
function formatArray(arr: any[], indent: number, maxLineWidth = maxLen.value): string {
if (arr.length === 0) {
return '[]';
}
const formattedItems: string[] = [];
for (const item of arr) {
formattedItems.push(formatProperty('', item, indent, maxLineWidth) as string);
}
const filterEmpty = formattedItems.filter(item => item);
if (filterEmpty?.length) {
return `[\n${filterEmpty.join(',\n')}\n${' '.repeat((indent - 1) * 2)}]`;
} else {
return '[]';
}
}
function formatProperty(key: string, value: any, indent: number, maxLineWidth: number): string | undefined {
const indentation = ' '.repeat(indent * 2);
const keyString = key ? `${indentation}"${key}": ` : indentation;
if (Array.isArray(value)) {
const formattedArray = formatArray(value, indent + 1, maxLineWidth);
return `${keyString}${formattedArray}`;
} else if (typeof value === 'object' && value !== null) {
const formattedObject = formatObject(value, indent + 1, maxLineWidth);
return `${keyString}${formattedObject}`;
} else if (typeof value === 'object' && value === null) {
return `${keyString}null`;
} else if (typeof value === 'number') {
return `${keyString}${value}`;
} else if (typeof value === 'boolean') {
return `${keyString}${value}`;
} else { /** string */
const formattedValue = `${value}`;
try {
const deepObj = JSON.parse(formattedValue);
if (Array.isArray(value)) {
const formattedArray = formatArray(deepObj, indent + 1, maxLineWidth);
return `${keyString}${formattedArray}`;
} else if (typeof deepObj === 'object' && deepObj !== null) {
const formattedObject = formatObject(deepObj, indent + 1, maxLineWidth);
return `${keyString}${formattedObject}`;
}
} catch (e) {
if (!formattedValue) {
return `${keyString}""`;
} else {
const str = formattedValue.replace(/\n/g, '');
if (str.length <= maxLineWidth - keyString.length - 2) {
return `${keyString}"${str}"`;
} else {
const lines = [];
const maxLineLength = maxLineWidth - keyString.length - 2; // 2 for the quotes
const chunks = chunkString(str, maxLineLength);
lines.push(keyString + `"${chunks[0]}`);
for (let i = 1; i < chunks.length; i++) {
lines.push(`${' '.repeat(keyString.length + 1)}${chunks[i]}`);
}
return `${lines.join('\n')}"`;
}
}
}
}
}
function chunkString(str: string, chunkSize: number): string[] {
const chunks: string[] = [];
for (let i = 0; i < str.length; i += chunkSize) {
chunks.push(str.substr(i, chunkSize));
}
return chunks;
}
const getHookSize = () => {
const element = document.getElementsByClassName('web-hook-collapse');
if (element.length) {
const width = element[0].clientWidth - 70;
const len = Math.ceil(width / 8.5);
maxLen.value = len >= 110 ? 110 : len;
} else {
maxLen.value = 110;
}
};
const handleChange = async(val: string) => {
if (val) {
display.value = '';
setTimeout(() => {
display.value = val;
}, 300);
}
const item = props.logs.find(l => l.uuid === val);
tab.value = 'request';
if (item?.id) {
const res = await props.getWebhookLogDetail({ repoId: props.repoId, hook_id: webhookId as string, id: item.id as string });
if (!res.error) {
const { request_headers, response_headers, request_data, response_body } = res.data.data;
if (Object.prototype.hasOwnProperty.call(request_headers, 'X-GitCode-Token')) {
request_headers['X-GitCode-Token'] = '********';
}
if (Object.prototype.hasOwnProperty.call(response_headers, 'X-GitCode-Token')) {
response_headers['X-GitCode-Token'] = '********';
}
request.header = '\n' + formatObject(request_headers) + '\n\n';
request.body = '\n' + formatObject(request_data) + '\n\n';
response.header = '\n' + formatObject(response_headers) + '\n\n';
response.body = '\n' + response_body?.toString() + '\n';
}
} else {
return;
}
};
onMounted(() => {
setTimeout(() => {
getHookSize();
}, 500);
});
</script>
<template>
<div class="web-hook-collapse">
<d-collapse v-model="display" accordion @change="handleChange">
<d-collapse-item v-for="item in logs" :key="item.uuid" :name="item.uuid" class="my-[10px]">
<template #title>
<LogItem :log="item" />
</template>
<div class="relative">
<div class="absolute inline-flex items-center right-[18px] top-[4px]">
<!-- <d-button icon="undo" variant="text" class="mr-[18px]">重新发送请求</d-button> -->
<span class="text-G900 text-[14px]">请求响应返回时间{{ item.execution_duration }}s</span>
</div>
<d-tabs v-model="tab" type="wrapped">
<d-tab id="request" title="请求信息">
<LogDetail type="request" :res="request" />
</d-tab>
<d-tab id="response">
<template #title>
<div class="inline-flex items-center">
<span class="inline-block mr-[8px]">响应返回</span>
<div class="log-tag log-item-tag" :style="{background: getColorByCode(item.response_status).bgColor, color: getColorByCode(item.response_status).color}">{{ item.response_status }}</div>
</div>
</template>
<LogDetail type="response" :res="response" />
</d-tab>
</d-tabs>
</div>
</d-collapse-item>
</d-collapse>
</div>
</template>
<style lang="scss">
.web-hook-collapse {
.devui-collapse {
background-color: transparent;
overflow-y: hidden;
box-shadow: none;
&__item-content {
height: auto !important;
padding: 0 10px;
border-style: solid;
border-color: var(color-G300);
border-width: 1px;
border-top-width: 0;
border-radius: 3px;
}
&__item-title {
border: 1px solid var(--color-G300);
box-shadow: 0px 2px 3px 0px rgba(148,163,184,0.05);
border-radius: 3px;
padding: 0 10px;
}
&--open {
border-bottom-width: 0;
}
.devui-tabs__nav--wrapped {
padding-left: 1px;
}
.devui-tabs__nav--wrapped>li.active {
span {
color: var(--color-G900);
}
}
.devui-tabs__nav--wrapped>li {
border-width: 1px;
border-style: solid;
border-radius: 3px;
background: transparent;
border-bottom-width: 0;
span {
color: var(--color-CG500);
}
}
}
.log-tag {
@apply box-border rounded-[3px] text-[12px] h-[18px] leading-[18px] text-center;
}
.log-item-tag {
padding: 0 4px;
}
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { LogDetailRes } from './type';
const props = defineProps<{
type: 'request' | 'response';
res: LogDetailRes;
}>();
const typeComputed = computed(() => {
const type = props.type;
const first = type[0].toUpperCase();
const left = type.substring(1);
return first + left;
});
</script>
<template>
<div class="box-border px-[18px]">
<section>
<div class="header">{{ typeComputed }} Header</div>
<pre class="web-hook-log-box">{{ res.header }}</pre>
</section>
<section>
<div class="header my-[12px]">{{ typeComputed }} Body</div>
<pre class="web-hook-log-box">{{ res.body }}</pre>
</section>
</div>
</template>
<style scoped lang="scss">
div, pre {
@apply text-[13px];
}
.header {
color: var(--color-G900);
@apply text-[14px] mb-[12px];
}
.web-hook-log-box {
color: var(--color-CG600);
background: var(--color-G100);
border: 1px solid var(--color-G200);
white-space: pre;
@apply box-border px-[24px] rounded-[3px] w-[100%] overflow-x-auto;
}
</style>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { convertRgbaColor } from '@/utils/color';
defineProps<{
log: Record<string, any>;
}>();
const successColor = '#0EB07B';
const failColor = '#F2050D';
const getColorByCode = (code: string) => {
if (Number(code) < 300) {
return {
color: successColor,
bgColor: convertRgbaColor(successColor, 0.1)[0]
};
}
return {
color: failColor,
bgColor: convertRgbaColor(failColor, 0.1)[0]
};
};
</script>
<template>
<div class="log-item">
<div class="flex-1 flex items-center">
<div class="log-tag log-item-tag" :style="{background: getColorByCode(log.response_status).bgColor, color: getColorByCode(log.response_status).color}">{{ log.response_status }}</div>
<d-popover :content="log.uuid" trigger="hover">
<div class="log-item-label">{{ log.uuid }}</div>
</d-popover>
<div class="log-tag log-item-event">{{ log.trigger }}</div>
</div>
<div class="text-CG500 text-[14px]">
{{ log.created_at }}
</div>
</div>
</template>
<style scoped lang="scss">
.log-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-right: 24px;
}
.log-tag {
@apply box-border rounded-[3px] text-[12px] h-[18px] leading-[18px] text-center;
}
.log-item-tag {
max-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0px 4px;
width: auto!important;
}
.log-item-label {
color: var(--color-G900);
margin: 0 16px;
width: 260px;
@apply truncate text-[14px];
}
.log-item-event {
background-color: var(--color-CG200);
color: var(--color-G900);
@apply px-[5px];
}
</style>

View File

@@ -0,0 +1,11 @@
export interface LogItemProp {
uuid: string;
code: number;
event: string;
time: string;
}
export interface LogDetailRes {
header: string;
body: string;
}

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref, shallowRef, onMounted, computed, defineProps } from 'vue';
import WebHookItem from './WebHookItem.vue';
import { Message } from 'vue-devui/message';
const visible = ref(false);
const currentId = ref('');
const loading = ref(true);
// 请求API都由父级决定 抽离方便复用
const props = withDefaults(defineProps<{
getRepoWebhooks: Function,
editWebhook: Function,
deleteWebHook: Function,
repoId: string | null
}>(), {
getRepoWebhooks: () => {},
editWebhook: () => {},
deleteWebHook: () => {},
});
const empty = computed(() => {
return !loading.value && !webHookList.value.length;
});
const emit = defineEmits(['create', 'edit']);
const webHookList = shallowRef<any[]>([]);
const getConfig = async() => {
const res = await props.getRepoWebhooks({ repoId: props.repoId, conf: { page: 1, per_page: 50 }});
if (!res.error) {
webHookList.value = res?.data?.data;
}
loading.value = false;
};
const handleDeal = async(data: Record<string, any>) => {
const { hook_id, ...config } = data;
const res = await props.editWebhook({ repoId: props.repoId, hook_id, conf: config });
if (!res.error) {
Message.success('操作成功');
getConfig();
}
};
const handleDel = (data: { hook_id: string }) => {
const { hook_id } = data;
visible.value = true;
currentId.value = hook_id;
};
const handleConfirm = async() => {
const res = await props.deleteWebHook({ repoId: props.repoId, hook_id: currentId.value });
if (!res.error) {
Message.success('webhook移除成功');
getConfig();
visible.value = false;
}
};
const handleEdit = (data: any) => {
emit('edit', data);
};
onMounted(() => {
getConfig();
});
</script>
<template>
<div class="box-border py-[24px]">
<div class="repo-settings-title">
<div>WebHook</div>
</div>
<div class="g-content-card pb-[18px]">
<div class="text-sm mb-[18px] flex items-center justify-between p-[18px] border-box border-b-[1px] border-solid border-[var(--color-CG200)]">
<div class="mr-2">
WebHook 将允许 GitCode 向你的外部服务进行通知当某些特定事件发生时我们将向你指定的 URL中发送一个 POST 请求
</div>
<d-button variant="solid" color="primary" icon="add" @click="emit('create')">新建 WebHook</d-button>
</div>
<DataPanel :loading="loading" :empty="empty" :skeleton="loading" :card="false">
<div class="border-box px-[18px]">
<div class="repo-table-item" v-for="hook of webHookList" :key="hook.id">
<WebHookItem
:hook="hook"
@edit="handleEdit"
@run="handleDeal"
@pause="handleDeal"
@delete="handleDel"
></WebHookItem>
</div>
</div>
</DataPanel>
</div>
<GModal v-model="visible" title="你正在删除webhook" @confirm="handleConfirm">
<div class="px-[8px]">删除后将无法恢复, 确认继续么?</div>
</GModal>
</div>
</template>
<style lang="scss" scoped>
.repo-table-item {
&:nth-last-of-type(1) {
@apply border-b-[1px];
}
@apply flex items-center py-[14px] px-[24px] justify-between border-t-[1px] border-l-[1px] border-r-[1px] border-solid border-[var(--color-CG200)];
}
.g-content-card{
.devui-button{
min-width: 155px;
}
}
</style>

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { WebHookState } from './type';
import { computed } from 'vue';
const emits = defineEmits(['edit', 'run', 'pause', 'delete']);
const props = defineProps<{ hook: { state: WebHookState, [x: string]: any }}>();
const onRun = () => {
const { url, content_type, push_events, tag_push_events, merge_requests_events, note_events } = props.hook;
emits('run', { hook_id: props.hook.id as string, active: true, url, content_type, push_events, tag_push_events, merge_requests_events, note_events, enable_ssl_verification: true });
};
const onPause = () => {
const { url, content_type, push_events, tag_push_events, merge_requests_events, note_events } = props.hook;
emits('pause', { hook_id: props.hook.id as string, active: false, url, content_type, push_events, tag_push_events, merge_requests_events, note_events, enable_ssl_verification: true });
};
const onDel = () => {
emits('delete', { hook_id: props.hook.id as string });
};
const onEdit = () => {
emits('edit', { hook_id: props.hook.id as string });
};
const defaultTags = [
{
name: '推送事件',
value: 'push_events'
},
{
name: 'Tag推送事件',
value: 'tag_push_events'
},
{
name: 'Pull Request事件',
value: 'merge_requests_events'
},
{
name: '评论事件',
value: 'note_events'
}
];
const tags = computed(() => {
return defaultTags.filter((item) => {
const key = item.value;
return props.hook[key];
});
});
// 对应 devui 完整色板色值bg-5, font-70
// const hookState2BgColorStyleMap = {
// [WebHookState.Pass]: '#f0ffe6',
// [WebHookState.PassWithWarn]: '#fff3e8',
// [WebHookState.Fail]: '#ffeeed'
// };
// const hookState2ColorStyleMap = {
// [WebHookState.Pass]: '#7eba50',
// [WebHookState.PassWithWarn]: '#e37d29',
// [WebHookState.Fail]: '#c73636'
// };
</script>
<template>
<div class="web-hook-item">
<div class="hook-info overflow-hidden" @click="onEdit">
<div class="hook-url" :title="hook.url">
{{ hook.url }}
</div>
<div class="hook-tags break-all leading-5 text-CG500 cursor-pointer">
{{ tags.map(item => item.name).join('・ ') }}
<!-- <li class="hook-tag" v-for="tag of tags" :key="tag.value">
<CustomTag :title="tag.name" />
</li> -->
</div>
</div>
<!-- <div class="state">
<CustomTag
:title="hook.state"
icon="connect"
:bg-color="hookState2BgColorStyleMap[hook.state]"
:icon-color="hookState2ColorStyleMap[hook.state]"
:color="hookState2ColorStyleMap[hook.state]"
/>
</div> -->
<div class="tools">
<!-- <button @click="onEdit">
<d-icon name="edit" color="$devui-form-control-line"></d-icon>
</button> -->
<button v-if="!hook.active" @click="onRun">
<d-popover content="激活hook" :position="['top']" trigger="hover">
<Icon name="gt-run" color="#0EB07B" class="g-pointer"/>
</d-popover>
</button>
<button v-else @click="onPause">
<d-popover content="停止hook" :position="['top']" trigger="hover">
<Icon name="gt-suspend" color="#707A87" class="g-icon-pointer "/>
</d-popover>
</button>
<button @click="onDel">
<d-popover content="移除hook" :position="['top']" trigger="hover">
<Icon name="gt-delete" color="#707A87" class="g-icon-pointer "/>
</d-popover>
</button>
</div>
</div>
</template>
<style scoped lang="scss">
.web-hook-item {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.web-hook-item > *:not(:last-child) {
margin-right: 1rem;
}
.hook-info {
flex: 1;
margin-right: 48px;
cursor: pointer;
width: 0;
}
.hook-url {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-G900);
font-size: 16px;
margin-bottom: 10px;
cursor: pointer;
}
.hook-tag {
// font-size: $devui-font-size-sm;
line-height: 1.75em;
// border-radius: $g-commit-border-radius;
margin-bottom: 0.3rem;
}
.tools > *:not(:last-child) {
margin-right: 1rem;
}
.state,
.tools {
flex-grow: 0;
flex-shrink: 0;
display: flex;
flex-flow: row nowrap;
}
.tools > button {
width: 38px;
height: 38px;
font-size: 20px;
// border: 1px solid $devui-list-item-hover-bg;
// border-radius: $g-commit-border-radius;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -0,0 +1,12 @@
export enum WebHookState {
Pass = '通过',
Fail = '未通过',
PassWithWarn = '通过(有告警)'
}
export interface WebHook {
uuid: string;
url: string;
tags: string[];
state: WebHookState;
}

View File

@@ -0,0 +1,10 @@
export { default as SettingTitle } from './SettingTitle/index.vue';
export { default as SettingText } from './SettingText/index.vue';
export { default as SettingItem } from './SettingItem/index.vue';
export { default as DeleteTip } from './DeleteTip/index.vue';
export { default as GModal } from './GModal/index.vue';
export { default as CreateTags } from './CreateTags/index.vue';
export { default as WebHookOverview } from './WebHookOverview/Index.vue';
export { default as WebHookEdit } from './WebHookEdit/Index.vue';
export { default as WebHookLog } from './WebHookLog/Index.vue';
export { default as ImgRadio } from './ImgRadio/Index.vue';

View File

@@ -0,0 +1,5 @@
<template>
<div>
设置相关组件放这里
</div>
</template>