搜索结果列表页面开发

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,49 @@
<script setup lang="ts">
import BindHw from '@/components/LoginModal/bind.vue';
import { useRoute, useRouter } from 'vue-router';
import { computed } from 'vue';
import { useAccount } from '@/utils/hooks/useAccount';
import { Message } from 'vue-devui/message';
const route = useRoute();
const router = useRouter();
const { RecordInfo } = useAccount();
const formProps = computed(() => route.query as {
mobile: string,
user_id: string,
mask: string
});
const handleSubmit = (conf: any) => {
const { username, email } = conf;
RecordInfo(conf);
Message.success({
message: '欢迎来到Gitcode!'
});
if (`${username}@gitcode.com` === email) { // 需要修改默认邮箱的弹窗
localStorage.setItem('validator_email', 'invalid');
} else {
localStorage.setItem('validator_email', 'valid');
}
router.replace('/');
};
</script>
<template>
<div class="bg-white h-[100%]">
<div class="gm-login-bind-hw">
<BindHw v-bind="formProps" :show-close="false" @submit="handleSubmit" />
</div>
</div>
</template>
<style lang="scss">
.gm-login-bind-hw {
.devui-input,.devui-button{
height: 38px!important;
}
@apply box-border p-[32px] m-auto
sm:w-[414px] w-[100%];
}
</style>

View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import GForm, { type FormListProps } from '@/components/Form';
import type { GLogoProps } from '@/components/LoginModal/types';
import GAuth from '@/components/LoginModal/widget/auth.vue';
import GAgreement from '@/components/LoginModal/widget/agreement.vue';
import { TransAssetsUrl } from '@/utils/asset';
import { ref, reactive } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Button } from 'vue-devui/button';
import { useFormInteraction } from '@/utils/hooks/useForm';
import { toLogin } from '@/api/user';
const IconSource = (import.meta as any).glob('@/assets/imgs/icon/login-modal/*svg', { eager: true });
const IconH = TransAssetsUrl(IconSource, 'logo-gitee');
const IconC = TransAssetsUrl(IconSource, 'logo-csdn');
const IconG = TransAssetsUrl(IconSource, 'logo-github');
const IconM = TransAssetsUrl(IconSource, 'logo-mobile');
const route = useRoute();
const router = useRouter();
const Form = ref<FormListProps[]>([
{
type: 'input',
key: 'username',
label: '用户名/邮箱',
required: true
},
{
type: 'input',
key: 'password',
label: '密码',
required: true,
props: {
'show-password': true,
'autocomplete': true,
'maxlength': 30
}
}
]);
const logos = reactive<GLogoProps[]>([
{
src: IconM,
alt: 'mobile'
},
{
src: IconC,
alt: 'csdn'
},
{
src: IconH,
alt: 'gitee'
},
{
src: IconG,
alt: 'github'
}
]);
const {
errorMsg,
disabled,
extraErrors,
status,
FormRef,
AgreementWarn,
PasswordInputSpacing,
loading,
handleSubmit,
handleFormChange,
handleFormInput,
handleAuthLogin,
handleDisplay,
saveUserInfo
} = useFormInteraction(Form);
const handleConfirm = async() => {
handleSubmit(async(res: any) => {
const result = await toLogin(res);
if (!result.error) {
// const { user_id, mask, mobile, iam_id } = result.data.data;
extraErrors.requestInfo = '';
// if (!iam_id) {
// redirectBind({
// user_id,
// mask,
// mobile: mobile || res?.mobile
// });
// } else {
saveUserInfo(result.data.data, route.query.returnUrl);
// }
} else {
extraErrors.requestInfo = result.error.error_message;
}
});
};
</script>
<template>
<div class="bg-white h-[100%]">
<div class="gm-login-password">
<div class="text-G900 text-[17px] tracking-[.5px] font-bold leading-[20px] my-[20px]">
欢迎登录Gitcode
</div>
<GForm
:DataList="Form"
ref="FormRef"
:show-label="false"
@change="handleFormChange"
@complete="handleFormInput"
layout="vertical"
size="lg"
message-type="none"
>
<template #submit>
<div class="gm-login-password-info h-[21px]">
<div class="gm-login-password-info-left">
{{ errorMsg }}
</div>
<div
class="gm-login-password-info-right"
@click.stop="router.replace('/resetPassword')"
>
忘记密码?
</div>
</div>
<Button
color="primary"
variant="solid"
@click="handleConfirm"
:disabled="disabled"
:loading="loading"
size="lg"
class="w-[100%] mt-[24px]"
>
</Button>
<Button
@click="router.replace('/register')"
size="lg"
class="w-[100%] my-[14px]"
>
</Button>
</template>
</GForm>
<div class="mt-[36px]">
<GAuth :logos="logos" @auth="type => handleAuthLogin(type, () => {}, route.query.returnUrl as string )" />
<div :class="['gm-login-password-args', AgreementWarn ? 'shaking-box' : '']">
<GAgreement v-model="status" @declares="handleDisplay" />
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
.gm-login-password {
.devui-input--error {
// border-color: inherit;
background-color: inherit;
}
.devui-input__inner {
font-size: 14px;
}
.devui-form__label {
height: 0;
}
.devui-form__label--vertical {
padding: 0;
}
input[type="password"] {
letter-spacing: v-bind(PasswordInputSpacing);
}
.devui-form__item--vertical {
margin-bottom: 12px;
&:nth-of-type(2) {
margin-bottom: 8px;
}
}
.devui-checkbox--sm {
margin: auto;
}
&-info {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
box-sizing: border-box;
padding: 0 2px;
div {
font-size: 14px;
line-height: 20px;
height: 20px;
}
&-left {
color: #F2050D;
max-width: 230px;
min-width: 230px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-right {
cursor: pointer;
text-align: right;
color: var(--color-G700);
}
}
&-args {
span {
display: inline-block;
font-size: 12px;
}
}
.shaking-box {
animation: shaking 0.25s ease-in-out;
}
@keyframes shaking {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-10px);
}
75% {
transform: translateX(10px);
}
}
@apply box-border p-[32px] m-auto
sm:w-[414px] w-[100%];
}
.g-footer-box {
background-color: white;
}
</style>

View File

@@ -0,0 +1,249 @@
<script setup lang="ts">
import GForm, { type FormListProps } from '@/components/Form';
import type { GLogoProps } from '@/components/LoginModal/types';
import GAuth from '@/components/LoginModal/widget/auth.vue';
import GAgreement from '@/components/LoginModal/widget/agreement.vue';
import { TransAssetsUrl } from '@/utils/asset';
import { ref, reactive } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Button } from 'vue-devui/button';
import { useFormInteraction } from '@/utils/hooks/useForm';
import { FormRules } from '@/components/LoginModal/form';
import { getQuickLoginMsg, loginByMobile } from '@/api/user';
import { Message } from 'vue-devui/message';
const IconSource = (import.meta as any).glob('@/assets/imgs/icon/login-modal/*svg', { eager: true });
const IconH = TransAssetsUrl(IconSource, 'logo-gitee');
const IconC = TransAssetsUrl(IconSource, 'logo-csdn');
const IconG = TransAssetsUrl(IconSource, 'logo-github');
const route = useRoute();
const router = useRouter();
const Form = ref<FormListProps[]>([
{
type: 'input',
key: 'mobile',
label: '手机号',
required: true,
rules: FormRules.mobile
},
{
type: 'inputButton',
key: 'code',
label: '手机验证码',
required: true,
rules: FormRules.codes,
props: {
'autocomplete': true,
countdown: false,
second: 59,
aliasKey: 'mobile'
}
}
]);
const logos = reactive<GLogoProps[]>([
{
src: IconC,
alt: 'csdn'
},
{
src: IconH,
alt: 'gitee'
},
{
src: IconG,
alt: 'github'
}
]);
const {
errorMsg,
extraErrors,
disabled,
status,
FormRef,
cacheForm,
AgreementWarn,
loading,
handleCountDown,
handleSubmit,
handleFormChange,
handleFormInput,
handleAuthLogin,
handleDisplay,
saveUserInfo
} = useFormInteraction(Form);
const handleSendMsg = (conf: any) => {
handleCountDown(conf, async() => {
const res = await getQuickLoginMsg({ mobile: conf.value });
if (!res.error) {
cacheForm.mobile = conf.value;
if (Number(res?.data.data.type === 1)) {
disabled.value = true;
Message.success('该手机号尚未注册, 即将为你自动跳转到注册页面');
setTimeout(() => {
localStorage.setItem('cache_countdown', JSON.stringify({
mobile: cacheForm.mobile,
second: cacheForm.countdownSecond
}));
router.replace('/register');
}, 3000);
} else {
Message.success('验证码已发送到你的手机, 请注意查收');
}
}
return Boolean(!res.error);
});
};
const handleConfirm = async() => {
handleSubmit(async(res: any) => {
const result = await loginByMobile(res);
if (!result.error) {
// const { user_id, mask, mobile, iam_id } = result.data.data;
extraErrors.requestInfo = '';
// if (!iam_id) {
// redirectBind({
// user_id,
// mask,
// mobile: mobile || res?.mobile
// });
// } else {
saveUserInfo(result.data.data, route.query.returnUrl);
// }
} else {
extraErrors.requestInfo = result.error.error_message;
}
});
};
</script>
<template>
<div class="bg-white h-[100%]">
<div class="gm-login-mobile">
<div class="text-G900 text-[17px] tracking-[.5px] font-bold leading-[20px] my-[20px]">
欢迎登录Gitcode
</div>
<GForm
:DataList="Form"
ref="FormRef"
:show-label="false"
@count-down="handleSendMsg"
@change="handleFormChange"
@complete="handleFormInput"
layout="vertical"
size="lg"
message-type="none"
>
<template #submit>
<div class="gm-login-mobile-info h-[21px]">
<div class="gm-login-mobile-info-left">
{{ errorMsg }}
</div>
</div>
<Button
color="primary"
variant="solid"
@click="handleConfirm"
:disabled="disabled"
:loading="loading"
size="lg"
class="w-[100%] mt-[24px]"
>
</Button>
<Button
@click="router.push('/register')"
size="lg"
class="w-[100%] my-[14px]"
>
</Button>
</template>
</GForm>
<div class="mt-[36px]">
<GAuth :logos="logos" @auth="handleAuthLogin" />
<div :class="['gm-login-mobile-args', AgreementWarn ? 'shaking-box' : '']">
<GAgreement v-model="status" @declares="handleDisplay" />
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
.gm-login-mobile {
.devui-input--error {
// border-color: inherit;
background-color: inherit;
}
.devui-input__inner {
font-size: 14px;
padding: 0 8px;
}
.devui-form__label {
height: 0;
}
.devui-form__label--vertical {
padding: 0;
}
.devui-form__item--vertical {
margin-bottom: 12px;
&:nth-of-type(2) {
margin-bottom: 8px;
}
}
.devui-checkbox--sm {
margin: auto;
}
&-info {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
box-sizing: border-box;
padding: 0 2px;
div {
font-size: 14px;
line-height: 20px;
height: 20px;
}
&-left {
color: #F2050D;
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&-args {
span {
display: inline-block;
font-size: 12px;
}
}
.shaking-box {
animation: shaking 0.25s ease-in-out;
}
@keyframes shaking {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-10px);
}
75% {
transform: translateX(10px);
}
}
@apply box-border p-[32px] m-auto
sm:w-[414px] w-[100%];
}
.g-footer-box {
background-color: white;
}
</style>

View File

@@ -0,0 +1,314 @@
<script setup lang="ts">
import GForm, { type FormListProps } from '@/components/Form';
import GAgreement from '@/components/LoginModal/widget/agreements.vue';
import { ref } from 'vue';
import { Button } from 'vue-devui/button';
import { Message } from 'vue-devui/message';
import { useFormInteraction } from '@/utils/hooks/useForm';
import { checkUsername, verifyRegisterCode, registerByMobile } from '@/api/user';
import { FormRules } from '@/components/LoginModal/form';
import { useGlobalInfoStore } from '@/stores/Global';
const globalStore = useGlobalInfoStore();
globalStore.setShowTools(false);
const cache_countdown = JSON.parse(localStorage.getItem('cache_countdown') as string);
const VerifyPassword:FormListProps[] = [
{
type: 'input',
key: 'password',
label: '密码',
required: true,
rules: FormRules.password,
props: {
'show-password': true,
'autocomplete': true
}
},
{
type: 'input',
key: 'pwd',
label: '确认密码',
required: true,
rules: [
{
trigger: 'change',
message: '验证密码与密码不一致',
validator: (_rule: any, value: string, cb: Function) => {
FormRef.value.ValidateFormKeys(['password']).then((fromData: any) => {
if (fromData.type === 'success') {
clearFormError('password');
if (FormRef.value.Data.password === value) {
clearFormError('pwd');
return cb();
} else {
setFormErrorKey({
pwd: '验证密码与密码不一致'
});
return cb(new Error('验证密码与密码不一致'));
}
} else {
setFormErrorKey({
password: '密码最少包含一个大写字母、一个小写字母、一个数字'
});
return cb(new Error('密码最少包含一个大写字母、一个小写字母、一个数字'));
}
});
}
}
],
props: {
'show-password': true,
'autocomplete': true
}
}
];
const requestCheck = {
trigger: 'change',
validator: async(_: any, val: string, cb: Function) => {
const res = await checkUsername(val);
if (!res.error) {
if (res.data.data.result) {
setFormErrorKey({ name: '用户名已存在' });
return cb(new Error('用户名已存在'));
}
}
return cb();
}
};
const Form = ref<FormListProps[]>([
{
type: 'input',
key: 'username',
label: '用户名',
required: true,
rules: [...FormRules.name, requestCheck]
},
// {
// type: 'input',
// key: 'nickname',
// label: '昵称'
// },
{
type: 'input',
key: 'mobile',
label: '手机号码',
required: true,
rules: FormRules.mobile,
defaultValue: cache_countdown?.mobile || ''
},
{
type: 'inputButton',
key: 'verificationcode',
label: '验证码',
required: true,
rules: FormRules.codes,
props: {
autocomplete: true,
countdown: Boolean(cache_countdown),
second: cache_countdown?.second || 59,
aliasKey: 'mobile'
}
}
// ...VerifyPassword
]);
const hwStatus = ref(false);
const {
errorMsg,
disabled,
extraErrors,
status,
FormRef,
cacheForm,
loading,
setFormErrorKey,
clearFormError,
AgreementWarn,
PasswordInputSpacing,
handleSubmit,
handleCountDown,
toCountDown,
handleFormChange,
handleFormInput,
handleDisplay,
saveUserInfo
} = useFormInteraction(Form, false, hwStatus);
if (cache_countdown) {
cacheForm.countdownSecond = cache_countdown?.second || 59;
toCountDown('verificationcode');
}
const handleSendMsg = (conf: any) => {
handleCountDown(conf, async() => {
const res = await verifyRegisterCode({ mobile: conf.value, type: 'REGISTER' });
if (!res.error) {
cacheForm.mobile = conf.value;
Message.success('验证码已发送到你的手机, 请注意查收');
}
return Boolean(!res.error);
});
};
const handleConfirm = async() => {
if (!hwStatus.value) {
extraErrors.agreement = '请阅读并同意华为云用户协议以及隐私政策声明';
AgreementWarn.value = true;
setTimeout(() => {
AgreementWarn.value = false;
}, 500);
return;
}
handleSubmit(async(res: any) => {
const data = {
username: res.username,
password: res.password,
nickname: res.nickname || res.username,
mobile: res.mobile,
verificationcode: res.verificationcode,
signup_type: sessionStorage.getItem('loginType')
};
const result = await registerByMobile(data);
if (!result.error) {
extraErrors.requestInfo = '';
// const { user_id, mask } = result.data.data;
// redirectBind({
// user_id,
// mask,
// mobile: res.mobile
// });
saveUserInfo(result.data.data);
} else {
extraErrors.requestInfo = result.error.error_message;
}
});
};
</script>
<template>
<div class="bg-white h-[100%]">
<div class="gm-login-register">
<div class="text-G900 text-[17px] tracking-[.5px] font-bold leading-[20px] my-[20px]">
欢迎注册
</div>
<div class="my-[20px] text-[14px] text-G900 leading-[20px]">
GitCode 与华为云共同为用户提供代码托管服务在使用代码托管相关服务时将同步授权并开通华为云服务并与重庆开源共创科技有限公司GitCode的运营主体关联及共享
</div>
<GForm
:DataList="Form"
ref="FormRef"
show-label
@count-down="handleSendMsg"
@change="handleFormChange"
@complete="handleFormInput"
layout="horizontal"
size="lg"
message-type="none"
>
<template #submit>
<div class="gm-login-register-info gm-login-register-ellipsis">
{{ errorMsg }}
</div>
<div class="mb-[24px]">
<div :class="['gm-login-register-args', AgreementWarn ? 'shaking-box' : '']">
<GAgreement v-model="hwStatus" agreement-text="《华为云用户协议》" privacy-text="《华为云隐私政策声明》" @declares="(conf) => handleDisplay(conf.type, 'hw')" />
<GAgreement v-model="status" agreement-text="《GitCode用户协议》" privacy-text="《GitCode隐私政策》" @declares="(conf) => handleDisplay(conf.type)" />
</div>
</div>
<Button
color="primary"
variant="solid"
@click="handleConfirm"
:disabled="disabled"
:loading="loading"
size="lg"
class="w-[100%]"
>
确认
</Button>
</template>
</GForm>
</div>
</div>
</template>
<style lang="scss">
.gm-login-register {
.devui-input--error {
// border-color: inherit;
background-color: inherit;
}
.devui-input__inner {
font-size: 14px;
}
.devui-form__label-span {
padding-left: 10px;
}
.devui-form__label--required {
padding-left: 0;
}
.devui-form__label--required:before {
display: inline-block;
margin-right: 3px;
}
.devui-form__control--horizontal {
margin-left: 10px;
}
.devui-form__label {
line-height: 40px;
height: 40px;
font-size: 14px;
}
input[type="password"] {
letter-spacing: v-bind(PasswordInputSpacing);
}
.devui-form__item--horizontal {
margin-bottom: 12px;
&:nth-last-child(3) {
margin-bottom: 8px;
}
}
&-info {
@apply text-[#F2050D] text-[14px] break-all box-border leading-[20px] pl-[90px] h-[40px];
}
&-ellipsis {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2; /* 设置文本最大行数为2行 */
-webkit-box-orient: vertical;
text-overflow: ellipsis;
white-space: normal; /* 在Safari中需要添加这一行来处理部分情况 */
}
&-args {
span {
display: inline-block;
font-size: 12px;
}
}
.shaking-box {
animation: shaking 0.25s ease-in-out;
}
@keyframes shaking {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-10px);
}
75% {
transform: translateX(10px);
}
}
@apply box-border p-[32px] m-auto
sm:w-[414px] w-[100%];
}
.g-footer-box {
background-color: white;
}
</style>

View File

@@ -0,0 +1,217 @@
<script setup lang="ts">
import GForm, { type FormListProps } from '@/components/Form';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Button } from 'vue-devui/button';
import { Message } from 'vue-devui/message';
import { useFormInteraction } from '@/utils/hooks/useForm';
import { getMobileEmailCode, resetUserPassword } from '@/api/user';
import { FormRules, isMobileEmail } from '@/components/LoginModal/form';
import { useGlobalInfoStore } from '@/stores/Global';
const globalStore = useGlobalInfoStore();
globalStore.setShowTools(false); // 隐藏工具栏,以防遮挡表单
const router = useRouter();
const VerifyPassword:FormListProps[] = [
{
type: 'input',
key: 'password',
label: '密码',
required: true,
rules: FormRules.password,
// help: '密码最少包含一个大写字母、一个小写字母、一个数字, 长度为8~20之间',
props: {
'show-password': true,
'autocomplete': true,
'maxlength': 30
}
},
{
type: 'input',
key: 'pwd',
label: '确认密码',
required: true,
rules: [
{
trigger: 'change',
message: '验证密码与密码不一致',
validator: (_rule: any, value: string, cb: Function) => {
FormRef.value.ValidateFormKeys(['password']).then((fromData: any) => {
if (fromData.type === 'success') {
clearFormError('password');
if (FormRef.value.Data.password === value) {
clearFormError('pwd');
return cb();
} else {
setFormErrorKey({
pwd: '验证密码与密码不一致'
});
return cb(new Error('验证密码与密码不一致'));
}
} else {
setFormErrorKey({
password: '密码最少包含一个大写字母、一个小写字母、一个数字'
});
return cb(new Error('密码最少包含一个大写字母、一个小写字母、一个数字'));
}
});
}
}
],
props: {
'show-password': true,
'autocomplete': true,
'maxlength': 30
}
}
];
const Form = ref<FormListProps[]>([
{
type: 'input',
key: 'mobile_email',
label: '手机/邮箱',
required: true,
rules: FormRules.mobile_email
},
{
type: 'inputButton',
key: 'code',
label: '验证码',
required: true,
rules: FormRules.codes,
props: {
countdown: false,
second: 59,
autocomplete: true,
aliasKey: 'mobile_email'
}
},
...VerifyPassword
]);
const {
errorMsg,
extraErrors,
disabled,
FormRef,
cacheForm,
PasswordInputSpacing,
loading,
setFormErrorKey,
clearFormError,
handleSubmit,
handleCountDown,
handleFormChange,
handleFormInput
} = useFormInteraction(Form, true);
const handleSendMsg = (conf: any) => {
handleCountDown(conf, async() => {
const res = await getMobileEmailCode(conf.value);
if (!res.error) {
cacheForm.mobile = conf.value;
const msg = isMobileEmail(conf.value);
Message.success(`验证码已发送到你的${msg}, 请注意查收`);
}
return Boolean(!res.error);
});
};
const handleConfirm = async() => {
handleSubmit(async(res: any) => {
const result = await resetUserPassword(res);
if (!result.error) {
extraErrors.requestInfo = '';
Message.success('修改密码成功, 正在为你跳转登录');
setTimeout(() => {
router.replace('/login');
}, 3000);
}
});
};
</script>
<template>
<div class="bg-white h-[100%]">
<div class="gm-login-forget">
<div class="text-G900 text-[17px] tracking-[.5px] font-bold leading-[20px] my-[20px]">
重置密码
</div>
<GForm
:DataList="Form"
ref="FormRef"
:show-label="false"
@count-down="handleSendMsg"
@change="handleFormChange"
@complete="handleFormInput"
layout="vertical"
size="lg"
message-type="none"
>
<template #submit>
<div class="gm-login-forget-info gm-login-forget-ellipsis">
{{ errorMsg }}
</div>
<Button
color="primary"
variant="solid"
@click="handleConfirm"
:disabled="disabled"
:loading="loading"
size="lg"
class="w-[100%] my-[12px]"
>
确认
</Button>
</template>
</GForm>
</div>
</div>
</template>
<style lang="scss">
.gm-login-forget {
.devui-input--error {
// border-color: inherit;
background-color: inherit;
}
.devui-input__inner {
font-size: 14px;
}
.devui-form__label {
height: 0;
}
input[type="password"] {
letter-spacing: v-bind(PasswordInputSpacing);
}
.devui-form__label--vertical {
padding: 0;
}
.devui-form__item--vertical {
margin-bottom: 12px;
&:nth-last-child(3) {
margin-bottom: 8px;
}
}
&-info {
@apply text-[#F2050D] text-[14px] break-all box-border leading-[20px] pl-[2px] h-[40px];
}
&-ellipsis {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2; /* 设置文本最大行数为2行 */
-webkit-box-orient: vertical;
text-overflow: ellipsis;
white-space: normal; /* 在Safari中需要添加这一行来处理部分情况 */
}
@apply box-border p-[32px] m-auto
sm:w-[414px] w-[100%];
}
.g-footer-box {
background-color: white;
}
</style>

View File

@@ -0,0 +1,304 @@
<template>
<div class="actlis" v-if="titles?.length > 0">
<div class="actlis-content" ref="scrollContainerDiv">
<div class="flex" id="cardContent">
<Card :id="'card' + index" class="actlis-card" :class="[index !== carouselItems.length - 1 ? 'mr-[8px]' : '', index === 0 ? 'ml-[16px]':'', index === carouselItems.length - 1 ? 'mr-[16px]' : '']"
v-for="(item, index) in carouselItems" :key="index">
<div class="actlis-flex">
<div class="actlis-item cursor-pointer hover:text-link" @click="openHref(item)">
<div class="actlis-title">
<div>
<GAvatar :src="icons[index]" :width="19" :height="19" />
<span class="actlis-span">{{ titles[index] }}</span>
</div>
<span v-if="titles[index] !== '热门直播'" class="actlis-time"><Time :time="item?.created_at" /></span>
<span v-else class="actlis-time"><Time :time="item?.activity_time" /></span>
</div>
<div class="actlis-imgdiv">
<img class="actlis-img" :src="item?.picture_url" v-if="titles[index] !== '热门文章'" />
<div class="actlis-titdiv actlis-column text-CG800 text-base" v-else>
<div :id="'titref' + index" class="actlis-2line font-bold">{{ item?.title }}</div>
<div class="actlis-3line mt-[16px]" :style="{ '-webkit-line-clamp': lineNums[index] }">{{ item?.desc }}
</div>
</div>
</div>
</div>
<!-- <GLink v-if="visitableReport" @click="emit('report')" class="report">举报</GLink> -->
</div>
</Card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'activity-list' });
import { ref, useSlots, type StyleValue, computed, nextTick, watch, onMounted } from 'vue';
import { orgInfoStore } from '@/stores/Org';
const orgStore = orgInfoStore();
const IconActive = new URL('@/assets/imgs/org/icon_active.png', import.meta.url).href;
const IconAi = new URL('@/assets/imgs/org/icon_ai.png', import.meta.url).href;
const IconVideo = new URL('@/assets/imgs/org/icon_video.png', import.meta.url).href;
interface ActiData {
activity_time?: string;
link?: string;
picture_url?: string;
title?: string;
[propName: string]: any;
}
interface IData {
operationContent?: {
activity: ActiData[],
featured: ActiData[],
calendar: ActiData[],
live: ActiData[]
},
visitableReport?: boolean; // 举报 默认可见
}
// 滚动div
const scrollContainerDiv = ref();
const scrollToActiveCard = () => {
const scrollContainer = scrollContainerDiv.value;
const cardContent = scrollContainer.querySelector('#cardContent');
const activeCard = scrollContainer.querySelector('#card1');
if (scrollContainer && activeCard) {
const containerWidth = cardContent.clientWidth;
const cardWidth = activeCard.clientWidth;
// 计算滚动位置使第二个卡片居中
const scrollLeft = (containerWidth - cardWidth) / 2;
scrollContainer.scrollLeft = scrollLeft - 19;
}
};
const lineHeight = (id: string) => {
const el = document.querySelector('#' + id);
if (el && el.clientHeight > 24) {
return 4;
}
return 5;
};
const props = withDefaults(defineProps<IData>(), {
operationContent: () => ({
activity: [],
featured: [],
calendar: []
}),
visitableReport: true
});
// 链接跳转
const openHref = (item) => {
if (item.id) {
window.open(`/organization/${orgStore.orgInfo.full_path}/${item.id}.html`, '_blank', '');
} else {
window.open(item.link, '_blank', '');
}
};
const slots = useSlots();
const emit = defineEmits<{(e: 'report', /* orgname : string */): void }>();
// const titles = ['最新活动', '热门文章', '热门直播'];
// const icons = [IconActive, IconAi, IconVideo];
const icons = computed(() => {
const iconAry = [];
props.operationContent.featured && props.operationContent.featured.forEach((item, index) => {
if (index < 3) {
iconAry.push(IconAi);
}
});
if (props.operationContent.activity && props.operationContent.activity.length > 0) {
iconAry[0] = IconActive;
}
if (props.operationContent.featured && props.operationContent.featured.length > 0) {
iconAry[1] = IconAi;
}
if (props.operationContent.live && props.operationContent.live.length > 0) {
iconAry[2] = IconVideo;
}
return iconAry.filter(item => item);
});
const titles = computed(() => {
const titAry = [];
props.operationContent.featured && props.operationContent.featured.forEach((item, index) => {
if (index < 3) {
titAry.push('热门文章');
}
});
if (props.operationContent.activity && props.operationContent.activity.length > 0) {
titAry[0] = '最新活动';
}
if (props.operationContent.featured && props.operationContent.featured.length > 0) {
titAry[1] = '热门文章';
}
if (props.operationContent.live && props.operationContent.live.length > 0) {
titAry[2] = '热门直播';
}
return titAry.filter(item => item);
});
const carouselItems = computed(() => {
const dataList = [];
let featIndex: number = 0;
props.operationContent.featured && props.operationContent.featured.forEach((item, index) => {
if (index < 3) {
featIndex = index;
dataList.push(item);
}
});
if (props.operationContent.activity && props.operationContent.activity.length > 0) {
featIndex = 0;
dataList[0] = props.operationContent.activity[0];
}
if (props.operationContent.featured && props.operationContent.featured.length > 1) {
dataList[1] = props.operationContent.featured[featIndex === 0 ? 0 : 1];
}
if (props.operationContent.live && props.operationContent.live.length > 0) {
dataList[2] = props.operationContent.live[0];
}
return dataList.filter(item => item);
});
const lineNums = ref<number[]>([]);
watch(() => carouselItems.value, (val) => {
if (val && val.length > 0) {
nextTick(() => {
val.forEach((item: any, index: number) => {
lineNums.value.push(lineHeight('titref' + index));
});
scrollToActiveCard();
});
}
}, { deep: true });
onMounted(() => {
});
</script>
<style lang="scss" scoped>
$imgHeight: 181px;
.actlis {
width: 100%;
.actlis-content {
width: 100%;
overflow-x: auto;
/* 启用横向滚动条 */
white-space: nowrap;
/* 防止内容换行 */
display: flex;
}
.actlis-flex {
width: 100%;
display: flex;
}
.actlis-card {
flex: 0 0 auto;
/* 不自动缩放卡片的宽度 */
width: 362px;
}
.actlis-item {
flex: 1;
width: 0;
height: 100%;
display: flex;
flex-direction: column;
.actlis-title {
display: flex;
align-items: center;
justify-content: space-between;
line-height: 20px;
:deep(.devui-avatar) {
vertical-align: middle;
}
.icon-comment {
font-size: 16px;
}
.actlis-span {
margin-left: 8px;
color: var(--color-CG800);
font-size: var(--text-sm);
font-weight: 500;
}
.actlis-time {
color: var(--color-G700);
font-size: var(--text-sm);
font-weight: 400;
}
}
.actlis-imgdiv {
width: 100%;
flex: 1;
height: 0;
margin-top: 18px;
display: flex;
align-items: center;
justify-content: center;
background: #F8F9FB;
}
.actlis-img {
width: 100%;
height: $imgHeight;
object-fit: cover;
// height: 120px;
}
.actlis-titdiv {
width: 100%;
height: $imgHeight;
padding: 8px;
font-weight: 500;
// display: flex;
// align-items: center;
}
}
}
.actlis-noline {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actlis-2line {
width: 100%;
word-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2; //(行数)
-webkit-box-orient: vertical;
white-space: normal;
word-break: break-all;
}
.actlis-3line {
width: 100%;
word-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 4; //(行数)
-webkit-box-orient: vertical;
white-space: normal;
word-break: break-all;
}
.actlis-column {
flex-direction: column;
// justify-content: center;
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<div class="org-header-info px-[16px] py-[20px]">
<!-- 头像和信息 -->
<div class="orghea-flex">
<div class="orghea-info mr-[16px]">
<div class="org-namediv orghea-noline text-G900 text-2xl mr-[16px] mb-[12px] font-bold leading-8">
{{ data?.name }}
</div>
<div class="orghea-item" v-if="data?.path">
<Icon class="mr-[10px]" name="gt-organizations"></Icon>
<span class="orghea-span orghea-noline text-G900 text-sm font-[400] leading-5">{{ data?.full_path }}</span>
</div>
<div class="orghea-item mt-[4px]" v-if="data?.location">
<Icon class="mr-[10px]" name="gt-location"></Icon>
<span class="orghea-span orghea-noline text-G900 text-sm font-[400] leading-5">{{ data?.location }}</span>
</div>
<div class="orghea-item mt-[4px]" v-if="data?.email">
<Icon class="mr-[10px]" name="gt-mail"></Icon>
<a class="orghea-span orghea-noline text-G900 text-sm font-[400] leading-5" :href="data?.email ? 'mailto:' + data?.email : undefined">{{ data?.email }}</a>
</div>
<div class="orghea-item mt-[4px]" v-if="data?.web_url">
<Icon class="mr-[10px]" name="gt-link"></Icon>
<GLink class="orghea-span orghea-noline text-G900 text-sm font-[400] leading-5" :href="data?.web_url" v-if="data?.web_url">{{ data?.web_url }}</GLink>
</div>
</div>
<!-- 头像 -->
<span>
<GAvatar :src="data?.avatar" :name="data?.name" :width="100" :height="100" :is_round="false" class="avatar">
</GAvatar>
</span>
</div>
<!-- 简介 -->
<div class="org-description mt-[20px] text-G900 text-sm font-[400] leading-6">{{ data?.description || '暂无简介' }}</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'org-header-info' });
import { useSlots, watch } from 'vue';
interface IProps {
data?: {
name?: string, // 组织名称
description?: string, // 组织简介
location?: string, // 地址
avatar?: string, // 组织logo
email?: string, // 邮件
web_url?: string, // 网址
[x: string]: any
},
hideAvatar?: Boolean // 隐藏头像
}
const props = defineProps<IProps>();
// watch(() => props.data, () => {
//
// }, {
// deep: true
// });
const slots = useSlots();
</script>
<style lang="scss" scoped>
.org-header-info {
width: 100%;
overflow: hidden;
.orghea-flex {
display: flex;
justify-content: space-between;
.orghea-info {
flex: 1;
width: 0;
}
}
.orghea-noline {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.orghea-item {
width: 100%;
line-height: 20px;
display: inline-flex;
align-items: center;
.orghea-span {
flex: 1;
width: 0;
}
}
}
.avatar {
border: 0.5px solid #d3d3d3;
border-radius: 4px;
overflow: hidden;
}
.org-description {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>

View File

@@ -0,0 +1,217 @@
<template>
<div class="reainf px-[16px]">
<Panel class="docinf overflow-hidden" v-if="repoInfo && showReadme">
<template #header>
<div class="docinf-lefthead">
<Icon name="gt-file-c" class="mr-[10px]"></Icon>
<span class="docinf-rspan font-[600]">README.md</span>
</div>
</template>
<template #headerRight>
<span v-if="isAdmin" class="cursor-pointer"
@click="naviTo('repoFile', { namespace: repoInfo.path_with_namespace.split('/')[0], repoName: repoInfo.path_with_namespace.split('/')[1], branchName: repoInfo.default_branch, filePath: 'README.md' })"><Icon
name="gt-edit"></Icon></span>
</template>
<div ref="eleRef" class="docinf-content" :class="{'show-all': showMore}" v-loading="readmeLoading">
<MdRender v-model="readmeText"></MdRender>
<div v-if="isOver && !showMore" class="docinf-content-btn text-lighter hover:text-CG900" @click="onShowMore">
查看全部
</div>
</div>
</Panel>
</div>
<!-- <GLink v-if="visitableReport" @click="emit('report')" class="report">举报</GLink> -->
</template>
<script setup lang="ts">
defineOptions({ name: 'docker-info' });
import { useShowMore } from '@/utils/hooks/useShowMore';
import MdRender from '@/components/MdRender/index.vue';
import { ref, reactive, useSlots, watch, onUnmounted } from 'vue';
import { storeToRefs } from 'pinia';
import Panel from '@/components/Panel/index.vue';
import { useRouter } from 'vue-router';
import { getRepo, getRepoReadme } from '@/api/repo';
import { reqCatch } from '@/utils/catch';
import utf8 from 'crypto-js/enc-utf8';
import Base64 from 'crypto-js/enc-base64';
import { getOrgInfo } from '@/views/Org/hooks/orgInfo';
import { orgInfoStore } from '@/stores/Org';
// 获取组织path方法
const { namespace } = getOrgInfo();
interface IData {
// full_path?: string, // 组织路径
visitableReport?: boolean; // 举报 默认可见
}
interface RepoData {
name?: string,
visibility?: string,
readme_url?: string,
tag_count?: number,
watch_count?: number,
forks_count?: number,
star_count?: number,
branch_count?: number,
open_merge_requests_count?: number,
http_url_to_repo?: string,
ssh_url_to_repo?: string,
description?: string,
tag_list?: [],
web_url?: string,
default_branch?: string,
namespace?: {}
[propName: string]: any;
}
const { isAdmin } = storeToRefs(orgInfoStore());
const props = withDefaults(defineProps<IData>(), {
visitableReport: true
});
const eleRef = ref(null);
const { isOver, stop, showMore, onShowMore } = useShowMore(eleRef);
onUnmounted(() => {
stop && stop();
});
// 监听组织路径变化
// 监听路由后面需要优化成路由监听
let lastNameSpace = namespace.value;
watch(() => namespace.value, (val, oldVal) => {
if (val !== lastNameSpace) {
showReadme.value = false;
readmeText.value = '';
getProInfo();
}
lastNameSpace = val;
}, {
deep: true
});
// 获取组织名同名项目
const repoInfo = ref<RepoData>();
const getProInfo = async() => {
if (!namespace.value) return;
const proName = namespace.value.split('%2F');
const params = {
repoId: `${namespace.value}%2F${proName[proName.length - 1]}`
};
const { data, error } = await reqCatch(getRepo, params);
if (!error && data?.data) {
repoInfo.value = data.data.data;
getProReadme();
}
};
getProInfo();
// 获取rederme文件
const readmeLoading = ref<boolean>(true);
const readmeText = ref<string>('');
const showReadme = ref<boolean>(false);// 是否展示readme
const getProReadme = async() => {
if (!namespace.value) return;
const proName = namespace.value.split('%2F');
const params = {
repoId: `${namespace.value}%2F${proName[proName.length - 1]}`
};
const { data, error } = await getRepoReadme(params);
readmeLoading.value = false;
showReadme.value = !!data;
if (!error && data?.data) {
readmeText.value = utf8.stringify(Base64.parse(data.data.content));
}
};
// const slots = useSlots();
// const emit = defineEmits<{(e: 'report', /* orgname : string */): void }>();
const router = useRouter();
const naviTo = (name: string, params?: any) => {
router.push({ name, params });
};
const mode = ref('readonly');
</script>
<style lang="scss" scoped>
.reainf {
// :deep(.g-panel-header) {
// padding: 12px 22px;
// }
}
.docinf {
.docinf-lefthead {
line-height: 20px;
display: flex;
align-items: center;
.docinf-dspan {
color: var(--color-CG600);
font-size: var(--text-sm);
font-weight: 500;
margin-right: 4px;
}
.docinf-rspan {
color: var(--color-G900);
font-size: var(--text-sm);
}
}
.docinf-content {
position: relative;
max-height: 400px;
overflow: hidden;
&.show-all {
max-height: 100%;
}
&-btn {
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
z-index: 2;
width: 100%;
text-align: center;
background: #fff;
&:hover {
cursor: pointer;
}
}
.docinf-section {}
.docinf-section1 {
margin-top: 32px;
}
.docinf-section-title {
color: var(--color-G900);
font-size: var(--text-2xl);
font-weight: 500;
}
.docinf-section-line {
width: 100%;
height: 1px;
background: #F0F1F2;
margin-top: 16px;
}
.docinf-section-content {
color: var(--color-G900);
font-size: var(--text-sm);
font-weight: 400;
margin-top: 16px;
}
.docinf-section-origin {
padding-left: 16px;
}
.docinf-section-subtitle {
color: var(--color-G900);
font-size: var(--text-sm);
font-weight: 500;
margin-top: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,282 @@
<template>
<div class="px-[16px]">
<Panel class="selite overflow-hidden" :blank="false">
<template #header>
<div class="selite-lefthead">
<Icon name="gt-folder-c" size="16px" class="mr-[10px]" />
<span class="selite-dspan font-[600]">精选项目</span>
</div>
</template>
<template #headerRight>
<repo-select-modal mobile @update="onUpdate" @change="onChange" :setType="'group_project'" :repoParams="repoParams" :hack-req-func="getOrgProjectAllListDataNoPage" v-if="isAdmin"/>
</template>
<div class="selite-content">
<template v-for="(item, index) in repoParams.repoHandpickList" :key="item.id">
<repo-item :iconHandleList="item.iconHandleList" :hideRight="true" :id="item.id" :imgSrc="item.imgSrc"
:title="item.title" :tag="item.tag" :desc="item.desc" :isStar="item.isStar" :web_url="item.web_url"
:class="{ 'is-last': index === repoParams.repoHandpickList.length - 1 }" @handle-star="({isStar}) => item.isStar = isStar" />
</template>
<template v-if="repoParams.repoHandpickList.length === 0">
<div class="p-6">暂无数据</div>
</template>
</div>
</Panel>
<Panel v-if="false" class="selite mt-20 overflow-hidden" :blank="false">
<template #header>
<div class="selite-lefthead">
<Icon name="gt-folder-c" size="16px" class="mr-20" />
<span class="selite-dspan">所有项目</span>
</div>
</template>
<template #headerRight>
<span>共 {{ pager.total }} 个项目</span>
</template>
<div class="selite-content" v-loading="allLoading">
<template v-for="(item, index) in repoPageAllList" :key="item.id">
<repo-item :iconHandleList="item.iconHandleList" :hideRight="true" :id="item.id" :imgSrc="item.imgSrc"
:title="item.title" :tag-list="item.tagList" :desc="item.desc" :isStar="item.isStar" :web_url="item.web_url"
:class="{ 'is-last': index === repoPageAllList.length - 1 }" @handle-star="({isStar}) => item.isStar = isStar" />
</template>
<template v-if="repoPageAllList.length === 0">
<div class="p-6">暂无数据</div>
</template>
</div>
</Panel>
<d-pagination v-if="false" class="px-[20px] py-[16px] flex justify-center" v-show="pager.total > 10"
:total="pager.total" v-model:pageSize="pager.pageSize" v-model:pageIndex="pager.pageIndex" :can-view-total="true"
:can-change-page-size="true" @page-index-change="getOrgProjectAllListData"
@page-size-change="sizeChangeFun" :max-items="5" />
</div>
<!-- <GLink v-if="visitableReport" @click="emit('report')" class="report">举报</GLink> -->
</template>
<script setup lang="ts">
defineOptions({ name: 'selected-items' });
import { ref, reactive, useSlots, type StyleValue, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import Panel from '@/components/Panel/index.vue';
import RepoItem from '@/components/RepoItem/index.vue';
import RepoSelectModal from '@/views/User/components/repo-select-modal.vue';
import { useUserInfo } from '@/views/User/hooks/useUserInfo';
import { emitEvent } from '@/utils/eventBus';
import { storeToRefs } from 'pinia';
import { getRepos, starRepo, unstarRepo } from '@/api/repo';
import { dataHandler } from '@/components/RepoItem/datahandle';
import { getOrgHandpickProjectList, getOrgProjectList } from '@/api/org/index';
import { reqCatch } from '@/utils/catch';
import * as types from '@/api/org/types';
import { getOrgInfo } from '@/views/Org/hooks/orgInfo';
import { orgInfoStore } from '@/stores/Org';
// interface propsData {
// // full_path?: string
// }
// const props = withDefaults(defineProps<propsData>(), {
// });
// 获取组织path方法
const { namespace } = getOrgInfo();
// 精选项目
const { isSelf, userInfo } = useUserInfo();
const route = useRoute();
const { isAdmin } = storeToRefs(orgInfoStore());
// 获取组织下精选项目
const repoParams = reactive<{
repoHandpickList: any,
repoAllList: any
}>({
repoHandpickList: [],
repoAllList: [] // 不带分页的所有项目
});
// 带分页的所有项目
const repoPageAllList = ref<any>([]);
const getOrgProjectListData = async() => {
const params: types.commonGroupReqType = {
type: 'group_project',
simple: false,
group_id: namespace.value
};
const { data, error } = await reqCatch(getOrgHandpickProjectList, params);
if (!error) {
if (data) {
// Object.assign(orgData, res.data.data);
const formatData = data?.data?.map((item: any) => {
return {
...item.data,
object_id: item.object_id,
resource_id: item.resource_id,
type: item.type
};
});
repoParams.repoHandpickList = dataHandler(formatData);
}
}
};
getOrgProjectListData();
// 每页条数变化
const sizeChangeFun = (size: number) => {
pager.pageIndex = 1;
getOrgProjectAllListData();
};
// 获取组织下所有项目
const pager = reactive({
total: 0,
pageSize: 10,
pageIndex: 1
});
const allLoading = ref<boolean>(true);
const getOrgProjectAllListData = async() => {
const params: types.commonGroupReqType = {
orgId: namespace.value,
page: pager.pageIndex,
per_page: pager.pageSize,
simple: false,
include_subgroups: true
};
allLoading.value = true;
const { data, error } = await reqCatch(getOrgProjectList, params);
if (!error) {
if (data) {
pager.total = data.data.total;
// Object.assign(orgData, res.data.data);
const formatData = data?.data?.content.map((item: any) => {
return {
...item,
object_id: item.object_id,
resource_id: item.resource_id,
type: item.type
};
});
repoPageAllList.value = dataHandler(formatData);
}
}
allLoading.value = false;
};
getOrgProjectAllListData();
// 监听组织路径变化
// 监听路由后面需要优化成路由监听
let lastNameSpace = namespace.value;
watch(() => namespace.value, (val, oldVal) => {
if (val !== lastNameSpace) {
onUpdate();
}
lastNameSpace = val;
}, {
deep: true
});
// 项目设置后更新
const onUpdate = () => {
// init();
getOrgProjectListData();
getOrgProjectAllListData();
};
// 点击精选按钮
const onChange = () => {
getOrgProjectAllListDataNoPage();
};
// 获取不分页所有项目
const getOrgProjectAllListDataNoPage = async(search: string = '', callback?: Function) => {
const params: types.commonGroupReqType = {
search,
orgId: namespace.value,
simple: false,
include_subgroups: true
};
const { data, error } = await reqCatch(getOrgProjectList, params);
if (!error) {
if (data) {
pager.total = data.data.total;
// Object.assign(orgData, res.data.data);
const formatData = data?.data?.content.map((item: any) => {
return {
...item,
object_id: item.object_id,
resource_id: item.resource_id,
type: item.type
};
});
repoParams.repoAllList = dataHandler(formatData);
callback?.();
}
}
};
// 处理项目star
const loading = ref(false);
const toggleRepoStar = ({ id, isStar }) => {
if (!userInfo || !userInfo.username) {
emitEvent('login');
return false;
}
if (loading.value) return false;
loading.value = true;
if (isStar) {
unstarRepo({ repoId: id })
.then(() => {
onUpdate();
}).finally(() => {
loading.value = false;
});
} else {
starRepo({ repoId: id })
.then(() => {
onUpdate();
}).finally(() => {
loading.value = false;
});
}
};
const slots = useSlots();
const emit = defineEmits<{(e: 'report', /* orgname : string */): void }>();
const router = useRouter();
const goRepoItem = (item: any) => {
router.push({ name: 'repo', params: { namespace: item.path_with_namespace.split('/')[0], repoName: item.path_with_namespace.split('/')[1] }});
};
</script>
<style lang="scss" scoped>
.selite {
.selite-lefthead {
line-height: 20px;
display: flex;
align-items: center;
.selite-icon {
margin-right: 21px;
}
.selite-dspan {
color: var(--color-G900);
font-size: var(--text-sm);
}
}
.selite-content {
.is-last {
:deep(.g-repo-item) {
border-bottom: none;
}
}
:deep(.g-repo-item) {
border-top: none;
border-left: none;
border-right: none;
border-radius: 0;
box-shadow: none;
}
}
.selite-flex {
display: flex;
align-items: center;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<div>
<OrgHeaderInfo :hideAvatar="true" :data="orgData">
<template #follow>
<d-button :variant="orgStore.isFollow ? undefined : 'solid'" color="primary"
@click="followClickEvent(orgStore.isFollow)" class="follow-btn">{{
orgStore.isFollow ? '取消关注' : '关注' }}</d-button>
</template>
</OrgHeaderInfo>
<ActivityList class="mt-[4px] mb-[16px]" v-if="communityState" :operationContent="operationContent" />
<ReadmeInfo class="mt-[4px]" />
<SelectedItems class="mt-[20px]" />
</div>
</template>
<script setup lang="ts">
import OrgHeaderInfo from '@/views/Mobile/Org/components/OrgHeaderInfo.vue';
import ActivityList from '@/views/Mobile/Org/components/ActivityList.vue';
import ReadmeInfo from '@/views/Mobile/Org/components/ReadmeInfo.vue';
import SelectedItems from '@/views/Mobile/Org/components/SelectedItems.vue';
import { ref, reactive, onMounted, onUnmounted, watch, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { reqCatch } from '@/utils/catch';
import * as types from '@/api/org/types';
import { getOrg, getOrgMemberList, getOrgLanguages, getOrgOperationContent } from '@/api/org/index';
import { orgInfoStore, type OrgInfo } from '@/stores/Org/index';
import { useUserInfo } from '@/views/User/hooks/useUserInfo';
import { getOrgInfo } from '@/views/Org/hooks/orgInfo';
// 获取仓库
const orgStore = orgInfoStore();
// 获取接口方法
const { namespace, languageList, followDevCommunity, getOrglanguageList } = getOrgInfo();
// 获取orgid
const route = useRoute();
const router = useRouter();
// 从仓库获取组织信息
const orgData = computed(() => {
return orgStore.orgInfo || {};
});
// 社区状态
const communityState = computed(() => {
const param = orgData?.value?.module_setting?.modules.find((mod: { key: string;[x: string]: any }) => mod.key === 'COMMUNITY');
return !!(param && (param as any).value === '1');
});
// 监听用户权限等级
watch(() => orgData.value.my_role, (val) => {
if (val && val.access_level > 0) {
getOrgMemberListData();
}
}, { deep: true });
// 获取用户信息
const { userInfo } = useUserInfo();
// 关注点击
const followClickEvent = (val: boolean) => {
followDevCommunity(val);
};
// 成员列表跳转
const naviTo = (name: string, params?: any) => {
router.push({ name, params });
};
// 监听社区是否开启
watch(() => communityState.value, (val) => {
if (val) {
getOrgOperationContentList();
}
}, { deep: true });
// 获取组织运营内容
const operationContent = ref({
activity: [],
calendar: [],
featured: [],
live: []
});
const getOrgOperationContentList = async() => {
if (communityState.value) {
const params: types.commonGroupReqType = {
orgId: namespace.value
};
const { data, error } = await reqCatch(getOrgOperationContent, params);
if (!error && data) {
operationContent.value = data.data;
}
}
};
// 动画样式头像闪光
const orgImgDivBackground = ref();
let aniTimeOut: any;
let lightTimeOut: any;
function lightAni() {
if (lightTimeOut) clearInterval(lightTimeOut);
let str: number = 0;
let end: number = 10;
let count: number = 0;
lightTimeOut = setInterval(() => {
if (count > 40) {
clearInterval(lightTimeOut);
return;
}
str += 2.5;
end += 2.5;
count++;
orgImgDivBackground.value = {
background: `linear-gradient(135deg, transparent ${0}%, transparent ${str}%, #f1e2e222 ${str}%, #c5f1ea55 ${end}%, transparent ${end}%, transparent ${100}%)`
};
}, 10);
}
// 项目成员
const memList = ref<{ name: string, username: string, iam_id: string, avatar_url: string, [p: string]: any }[]>([]);
const memCount = ref(0);
const getOrgMemberListData = async() => {
// 用户权限等级大于0才能查看成员
if (orgData.value.my_role && orgData.value.my_role.access_level > 0) {
const params = {
orgId: namespace.value
};
const res = await reqCatch(getOrgMemberList, params);
if (!res.error) {
memList.value = res.data?.data.content;
memCount.value = res.data?.data.total || 0;
}
}
};
watch(() => orgStore.orgNameSpace, (val) => {
fetchInitData();
}, {
deep: true,
flush: 'post'
});
function fetchInitData() {
getOrglanguageList(namespace.value);
getOrgMemberListData();
getOrgOperationContentList();
clearInterval(aniTimeOut);
aniTimeOut = setInterval(() => { lightAni(); }, 6000);
}
onMounted(() => {
fetchInitData();
});
onUnmounted(() => {
aniTimeOut && clearInterval(aniTimeOut);
lightTimeOut && clearInterval(lightTimeOut);
});
</script>
<style lang="scss" scoped>
.test {}
</style>

View File

@@ -0,0 +1,109 @@
<template>
<div class="page-main-header">空白 Issue</div>
<page-layout>
<div class="page-main-content g-content-card">
<d-form layout="vertical" :data="issueData" :rules="rules" ref="formRef">
<d-form-item class="edit-form-item" field="title" label="标题">
<d-input v-model="issueData.title" placeholder="请填写 Issue 标题" maxlength="201" size="lg" />
</d-form-item>
<d-form-item class="edit-form-item" field="description" label="内容">
<MdEditor v-model="issueData.description" @content-change="mdChange" :project-id="decodeURIComponent(repoId)" toggle-repo-permission></MdEditor>
</d-form-item>
<!-- <d-form-item class="edit-form-item" field="confidential" label="Issue 可见性"><d-select class="edit-form-select" placeholder="请选择" v-model="issueData.confidential" :options="options" size="sm"></d-select></d-form-item> -->
</d-form>
<div class="edit-btngroup">
<d-button :disabled="issueData?.title?.length < 1 || issueData?.title?.length > 200 || !issueData.description || issueData.description.length > 1024 * 1024" @click="handleSubmit" :loading="loading"
color="primary" variant="solid" class="w-[80px]">新建</d-button>
<d-button @click="$router.push({ name: 'repoIssues'})"
class="w-[80px]">取消</d-button>
</div>
</div>
<template #right>
<AsideSetSkeleton v-model="issueData" type="create" hiddenBoards hiddenMerge hiddenBranch :repoId="repoId" />
</template>
</page-layout>
</template>
<script setup lang="ts">
defineOptions({ name: 'create-issue' });
import AsideSetSkeleton from '@/views/Repo/components/IssueAsideSetGroup/index.vue';
import PageLayout from '@/components/PageLayout/index.vue';
import MdEditor from '@/components/MdEditor/index.vue';
import { ref, provide } from 'vue';
import { useRouter } from 'vue-router';
import { createIssue } from '@/api/issue/index';
import { getRepoRole } from '@/api/repo/index';
import { useRepoId } from '@/utils/hooks/useRepoId';
import { reqCatch } from '@/utils/catch';
import { formatIssuePutData } from '@/utils';
import { repoInfoStore } from '@/stores/Repo/index';
import { storeToRefs } from 'pinia';
import { useAccountStore } from '@/stores/user';
const userInfo = useAccountStore();
const { accountInfo } = storeToRefs(userInfo);
const { repoId } = useRepoId();
const loading = ref(false);
const router = useRouter();
const options = [{ name: '公开的', value: false }, { name: '私密的', value: true }];
const { isAdmin, isDeveloper, isVisitor } = repoInfoStore();
const rules = {
title: [
{ required: true, message: '标题不能为空!', trigger: 'blur' },
{ required: true, min: 1, max: 200, message: '标题限制 1~200 个字符!', trigger: 'blur' }
],
description: [{ required: true, message: '信息不能为空!', trigger: 'blur' }]
};
const formRef = ref(null);
const issueData = ref({ title: '', confidential: false, description: '' });
function handleSubmit() {
formRef?.value?.validate(async(isValid: boolean) => {
if (isValid) {
loading.value = true;
const res = await reqCatch(createIssue, formatIssuePutData({
project_id: repoId.value,
title: issueData.value.title,
description: issueData.value.description,
...issueData.value
}));
if (!res.error) {
router.push({ name: 'repoIssueDetail', params: { serialNumber: res?.data?.data?.iid }});
}
loading.value = false;
}
});
}
function mdChange(text: string) {
issueData.value.description = text;
}
provide('isVisitor', isVisitor);
provide('isDeveloper', isDeveloper);
provide('isAdmin', isAdmin);
</script>
<style lang="scss" scoped>
.page-main-header {
margin-top: 24px;
margin-bottom: 12px;
font-size: 24px;
line-height: 32px;
@apply text-G900;
}
.page-main-content {
@apply bg-white p-20;
:deep(.devui-form__label-span){
@apply text-G900
}
}
.edit-btngroup {
@apply flex gap-2 flex-row-reverse;
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<DiscussionItem
v-bind="$props"
:body="temp?.body"
:can-delete="author?.username === user?.username"
:can-edit="author?.username === user?.username"
@save-content="saveDescription"
eventIcon="gt-comment"
:loading="loading"
:targetId="temp?.iid"
@on-remove="handleDelete"
@quote-reply="$emit('quote-reply',temp?.body)"
/>
</template>
<script lang="ts" setup>
defineOptions({ name: 'IssueDiscussionItem' });
import { ref, computed, inject } from 'vue';
import DiscussionItem from '@/views/Repo/components/DiscussionItem/index.vue';
import { updateIssueDiscussions, deleteIssueDiscussions } from '@/api/issue';
import { reqCatch } from '@/utils/catch';
import type { IAuthor } from '@/api/issue/types';
const isDeveloper = inject('isDeveloper');
const isIssueAuthor = inject('isIssueAuthor');
const repoId = inject('repoId');
const issueId = inject('issueId');
const props = defineProps<{
data:object,
author: IAuthor
user: IAuthor
}>();
const key = ref(0);
const loading = ref(false);
const temp = ref(props.data);
const emit = defineEmits<{
'delete':[]
'quote-reply':[value:string]
}>();
const saveDescription = async(str: string) => {
loading.value = true;
const res = await reqCatch(updateIssueDiscussions, {
project_id: repoId,
issue_iid: issueId,
note_id: props.data.id,
body: str
});
temp.value = res?.data?.data;
key.value++;
loading.value = false;
};
const handleDelete = async(data:Object) => {
const res = await deleteIssueDiscussions({
project_id: repoId,
issue_iid: issueId,
note_id: data?.id
});
if (!res.error) {
emit('delete');
}
};
</script>

View File

@@ -0,0 +1,17 @@
<template>
<DiscussionItem :event-icon="eventIcon" />
</template>
<script lang="ts" setup>
defineOptions({ name: 'IssueEventItem' });
import DiscussionItem from '@/views/Repo/components/DiscussionItem/index.vue';
import { issueEventOption } from '@/constant/issue';
import { computed } from 'vue';
const props = defineProps<{
eventType: string;
}>();
const eventIcon = computed(() => {
return issueEventOption[props.eventType]?.icon;
});
</script>

View File

@@ -0,0 +1,50 @@
<template>
<DiscussionItem
:author="author"
:canEdit="isDeveloper || isIssueAuthor"
:can-delete="false"
:body="temp?.description"
:repo-id="repoId"
@save-content="saveDescription"
eventIcon="gt-comment"
:loading="loading"
:key="key"
:targetId="temp?.iid"
event-msg="评论"
@quote-reply="$emit('quote-reply',temp?.description)"
/>
</template>
<script lang="ts" setup>
defineOptions({ name: 'IssueDescript' });
import { ref, inject, watch } from 'vue';
import type { IAuthor } from '@/api/issue/types';
import DiscussionItem from '@/views/Repo/components/DiscussionItem/index.vue';
import { updateIssue } from '@/api/issue';
import { reqCatch } from '@/utils/catch';
const repoId = inject('repoId');
const issueId = inject('issueId');
const props = defineProps<{
data: object;
user: IAuthor;
author: IAuthor;
}>();
defineEmits<{
'quote-reply':[value:string]
}>();
const key = ref(0);
const loading = ref(false);
const temp = ref(props.data);
const isDeveloper = inject('isDeveloper');
const isIssueAuthor = inject('isIssueAuthor');
const saveDescription = async(str: string) => {
loading.value = true;
const res = await reqCatch(updateIssue, {
project_id: repoId,
issue_iid: issueId,
description: str
});
temp.value = res?.data?.data;
// key.value++;
loading.value = false;
};
</script>

View File

@@ -0,0 +1,495 @@
<template>
<page-layout class="issue-detail-head">
<d-skeleton :loading="loading && !issueData.title">
<issue-description
:key="issueDescKey"
:title="issueData?.title"
:created-time="issueData?.created_at"
:closed-time="issueData?.closed_at"
:user-name="issueData?.author?.name"
:status-text="issueStat?.label"
:statusIconColor="issueStat?.color"
:statusBgColor="issueStat?.bgColor"
:statusIcon="issueStat?.icon" :index-label="issueData?.iid + ''"
:can-edit="isIssueAuthor || isDeveloper" @handle-save="updateTitle"
/>
<template #placeholder>
<div style="display: flex; gap: 0 16px;">
<gc-skeleton-item style="width: 88px; height: 60px;"></gc-skeleton-item>
<div style="flex: 1;">
<gc-skeleton-item variant="square" style="height: 32px;"></gc-skeleton-item>
<gc-skeleton-item style="width: 150px; height: 20px; margin-top: 8px;"></gc-skeleton-item>
</div>
</div>
</template>
</d-skeleton>
<template #right>
<div style="text-align: right;">
<d-skeleton :loading="loading && !issueData.title">
<d-button icon="add" variant="solid" class="search-bar-create"
@click="$router.push({ name: 'repoIssueCreate' })" v-if="accountInfo?.username">
新建 Issue
</d-button>
<template #placeholder>
<gc-skeleton-item style="display: inline-block; width: 120px; height: 30px;"></gc-skeleton-item>
</template>
</d-skeleton>
</div>
</template>
</page-layout>
<page-layout class="issue-detail-main">
<ScrollContainer @touch-bottom="onTouchBottom" :bottom="700" :finished="pageData.finished"
:loading="pageData.loading">
<d-skeleton :loading="loading && !issueData.title">
<div class="g-content-card">
<!-- issue 描述 -->
<CommentDescription v-if="issueData?.id" :data="issueData" :author="issueData?.author" :user="accountInfo" @quote-reply="addQuoteReply" />
<template v-for="item in pageData.commentlist" :key="item.id">
<!-- 评论 -->
<DiscussionItem
v-if="item?.notes"
:noteNum="item.notes.length"
:author="item?.author || (item.notes && item.notes[0]?.author)"
:user="accountInfo"
:data="item.notes[0]"
:created_at="item.created_at || item?.notes[0].created_at"
:can-edit="item.notes[0]?.author.username === accountInfo.username"
:noteId="item.id"
:targetId="item?.notes[0].id"
:repo-id="repoId"
event-msg="评论"
@delete="deleteCommentSelf(item)"
@quote-reply="addQuoteReply"
/>
<!-- issue event -->
<EventItem
v-else
:author="item?.author"
:event-msg="item.body"
:create-time="item?.created_at"
:eventType="item?.action"
/>
</template>
</div>
<template #placeholder>
<div style="display: flex;justify-content: space-between;gap: 0 16px;margin-bottom: 1em;">
<gc-skeleton-item style="width: 200px; height: 16px;"></gc-skeleton-item>
<gc-skeleton-item style="width: 100px; height: 16px;"></gc-skeleton-item>
</div>
<d-skeleton></d-skeleton>
<template v-for="i in 2" :key="i">
<div style="display: flex;justify-content: space-between;gap: 0 16px;margin-bottom: 1em;margin-top: 1em;">
<gc-skeleton-item style="width: 200px; height: 16px;"></gc-skeleton-item>
<gc-skeleton-item style="width: 100px; height: 16px;"></gc-skeleton-item>
</div>
<d-skeleton></d-skeleton>
</template>
<div style="display: flex;align-items: center; gap: 0 16px; margin-bottom: 1em;margin-top: 1em;">
<gc-skeleton-item variant="circle" style="width: 40px; height: 40px;"></gc-skeleton-item>
<gc-skeleton-item style="width: 50px; height: 30px;" v-for="i in 8" :key="i"></gc-skeleton-item>
</div>
<d-skeleton style="padding-left: 56px;"></d-skeleton>
</template>
</d-skeleton>
<template v-if="pageData.loading && issueData.title">
<template v-for="i in 1" :key="i">
<div style="display: flex;justify-content: space-between;gap: 0 16px;margin-bottom: 1em;margin-top: 1em;">
<gc-skeleton-item style="width: 200px; height: 16px;"></gc-skeleton-item>
<gc-skeleton-item style="width: 100px; height: 16px;"></gc-skeleton-item>
</div>
<d-skeleton></d-skeleton>
</template>
</template>
<!-- issue 添加评论 -->
<section :class="{'mt-[24px]':true, 'edit-loading': pageData.loading }">
<AddDiscussion
@add-discussion="addDiscussion"
@update-state="handleUpdateIssue"
:loading="addIng" ref="RefAddDiscussion"
:state="issueData?.state + ''"
:updateDisabled="!(isDeveloper||isIssueAuthor)"
:submit-disabled="!accountInfo.username"
/>
</section>
</ScrollContainer>
<template #right>
<d-skeleton :loading="loading && !issueData.title">
<IssueAsideSetGroup v-model="issueData" :participants="participants" :project_id="issueData.project_id"
:repoId="repoId" :issueId="$route.params.serialNumber" />
<template #placeholder>
<div style="display: flex; flex-direction: column; gap: 16px 0;">
<template v-for="i in 3" :key="i">
<div style="display: flex;justify-content: space-between;gap: 0 16px;">
<gc-skeleton-item style="flex: 1; height: 24px;"></gc-skeleton-item>
<gc-skeleton-item style="width: 24px; height: 24px;"></gc-skeleton-item>
</div>
<d-skeleton :rows="1"></d-skeleton>
<br />
</template>
<br />
<gc-skeleton-item v-for="i in 3" :key="i" style="width: 150px; height: 16px;"></gc-skeleton-item>
</div>
</template>
</d-skeleton>
</template>
</page-layout>
</template>
<script lang="ts" setup>
defineOptions({ name: 'IssueDetail' });
import { useRouter, useRoute } from 'vue-router';
import { ref, reactive, onMounted, watch, computed, onBeforeMount, nextTick, provide, type Ref } from 'vue';
import PageLayout from '@/components/PageLayout/index.vue';
import IssueDescription from '@/components/IssueDescription/index.vue';
import IssueAsideSetGroup from '@/views/Repo/components/IssueAsideSetGroup/index.vue';
import ScrollContainer from '@/components/ScrollContainer/index.vue';
import CommentDescription from './IssueDescription.vue';
import DiscussionItem from './DiscussionItem.vue';
import EventItem from './EventItem.vue';
import AddDiscussion from '@/views/Repo/components/AddDiscussion/index.vue';
import { Message } from 'vue-devui/message';
import { useAccountStore } from '@/stores/user';
import { useRepoId } from '@/utils/hooks/useRepoId';
import { reqCatch } from '@/utils/catch';
import { getRepoRole } from '@/api/repo/index';
import { repoInfoStore } from '@/stores/Repo/index';
import { issueStateOption } from '@/constant/issue';
import type { ISSUE_STATE } from '@/constant/issue';
import type { IIssue, INote, Idiscussions } from '@/api/issue/types';
import { useOrgId } from '@/utils/hooks/useOrgId';
import { fetchIssue, updateIssue, fetchIssueDiscussions, issueParticipants, createIssueDiscussions, updateIssueStat } from '@/api/issue';
import { promiseTimeout } from '@vueuse/core';
const { isAdmin, isDeveloper, isVisitor } = repoInfoStore();
const { orgId } = useOrgId('/');
const { repoId } = useRepoId();
const { accountInfo = {}} = useAccountStore();
const route = useRoute();
const loading = ref(false);
const addIng = ref(false);
const RefAddDiscussion = ref();
const roleData = ref({});
const issueDescKey = ref<number>(0);
const issueData: Ref<IIssue> = ref({});
const participants = ref([]); // 参与者
const pageData = reactive<{
commentlist: Idiscussions | []; // 评论
page_num: number;
page_size: number;
page_count: number;
total: number;
finished: boolean;
loading: boolean;
end_id?: number;
end_system_id?: number;
}>({
commentlist: [],
page_count: 0,
page_num: 1,
page_size: 100,
total: 0,
finished: false,
loading: false
});
const isIssueAuthor = computed(() => issueData.value.author?.username === accountInfo.username);
const issueStat = computed(() => ({
label: issueStateOption[issueData.value?.state]?.label,
color: issueStateOption[issueData.value?.state ]?.color,
bgColor: issueStateOption[issueData.value?.state]?.bgColor,
icon: issueStateOption[issueData.value?.state]?.icon
}));
onBeforeMount(() => {
fetchIssueData();
accountInfo?.username && fetchRole();
});
onMounted(async() => {
fetchParticipants();
await nextTick();
fetchIssueCommentList();
});
const fetchRole = async() => {
const res = await reqCatch(getRepoRole, { repoId: repoId.value });
if (!res.error) {
roleData.value = res.data.data;
}
};
/**
* issue 详情
*/
const fetchIssueData = async() => {
loading.value = true;
const res = await reqCatch(fetchIssue, {
project_id: repoId.value,
issue_iid: route.params.serialNumber
});
if (!res.error) {
issueData.value = res.data.data;
issueDescKey.value++;
}
loading.value = false;
};
/**
* 参与者 列表
*/
const fetchParticipants = async() => {
const res = await reqCatch(issueParticipants, {
project_id: repoId.value,
issue_iid: route.params.serialNumber
});
if (!res.error) {
participants.value = res.data.data;
}
};
/**
* 获取评论列表
* @params {string} type 已经存在的数据
*/
const fetchIssueCommentList = async(type?: 'exist') => {
pageData.loading = true;
const res = await reqCatch(fetchIssueDiscussions, {
issue_iid: route.params.serialNumber,
project_id: repoId.value,
type: 'user',
page: pageData.page_num,
per_page: pageData.page_size,
end_id: pageData.end_id,
end_system_id: pageData.end_system_id
});
if (!res.error) {
const { content, page_count, page_num, total } = res.data.data;
const { data = [], end_id, end_system_id } = content;
if (type === 'exist') {
// 获取重复分页,添加不存在的数据
const newdata = data.filter(
(item: Idiscussions) =>
pageData.commentlist.findIndex(
(value: Idiscussions) => value.id === item.id
) === -1
);
newdata.length > 0 && pageData.commentlist.push(...newdata);
} else {
// 添加数据
pageData.commentlist.push(...data);
}
pageData.total = total;
if (page_num >= page_count) {
pageData.loading = false;
pageData.finished = true;
}
pageData.end_id = end_id;
pageData.end_system_id = end_system_id;
}
pageData.loading = false;
};
/**
* 添加 评论
*/
const addDiscussion = async(obj:{body:string}) => {
if (addIng.value) return;
addIng.value = true;
const res = await reqCatch(createIssueDiscussions, {
project_id: repoId.value,
issue_iid: issueData.value.iid,
body: obj.body
});
addIng.value = false;
if (!res.error) {
pageData.commentlist.push(res.data.data);
RefAddDiscussion.value.clear();
if (!participants.value.some((e) => e?.username === accountInfo?.username)) {
// 有新增评论者 更新参与者
fetchParticipants();
}
}
};
/**
* 删除评论
*/
const deleteCommentSelf = (item: object) => {
const index = pageData.commentlist?.findIndex(e => e.id === item.id);
if (index > -1) {
pageData.commentlist.splice(index, 1);
}
};
/**
* 引用回复
*/
const addQuoteReply = (content:string) => {
if (!content.startsWith('>')) {
content = '> ' + content;
}
RefAddDiscussion.value.setBody(content + '\n\n');
};
/**
* issue 状态切换
*/
const handleUpdateIssue = async(obj:any) => {
const res = await reqCatch(updateIssueStat, {
project_id: repoId.value,
issue_iid: issueData.value.iid,
discussion_locked: obj.discussion_locked,
state_event: obj.state_event,
discussions: obj.body
});
if (!res.error) {
issueData.value = res.data.data;
RefAddDiscussion.value.clear();
updatePage();
}
};
const updateStat = () => {
fetchIssueData();
updatePage();
};
/**
* 修改 标题
*/
const updateTitle = async(title: string) => {
if (!title) {
Message.warning('标题不能为空!');
return issueDescKey.value++;
} else if (!/^.{1,200}$/.test(title)) {
Message.warning('标题限制 1~200 个字符!');
return issueDescKey.value++;
} else if (issueData.value.title === title) {
return issueDescKey.value++;
}
const res = await reqCatch(updateIssue, {
project_id: issueData.value.project_id,
issue_iid: issueData.value.iid,
title }
);
if (!res.error) {
issueData.value.title = res.data.data.title;
issueData.value.canEdit = false;
issueDescKey.value++;
updateStat();
}
};
/**
* 触底加载更多
*/
const onTouchBottom = () => {
if (pageData.loading || pageData.finished) return;
pageData.page_num++;
};
/**
* 有新增内容时重新获取
*/
const updatePage = () => {
if (pageData.page_num * pageData.page_size > pageData.commentlist.length) {
fetchIssueCommentList('exist');
} else {
pageData.page_num++;
}
};
watch(() => pageData.page_num, () => {
fetchIssueCommentList();
});
provide('roleData', roleData);
provide('repoId', repoId.value);
provide('issueId', route.params.serialNumber);
provide('isVisitor', isVisitor);
provide('isDeveloper', isDeveloper);
provide('isAdmin', isAdmin);
provide('isIssueAuthor', isIssueAuthor);
provide('updatePage', updatePage);
</script>
<style lang="scss" scoped>
.issue {
&-detail-head {
margin-top: 24px;
}
&-detail-main {
margin-top: 24px;
margin-bottom: 24px;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading {
animation: spin 2s linear infinite;
transform-origin: center center;
}
.loading-comment {
display: flex;
justify-content: center;
align-items: center;
line-height: 48px;
}
.add-btn-vertical-line {
display: inline-block;
width: 1px;
height: 20px;
background: #e6e7e8;
margin-right: 10px;
vertical-align: middle;
}
.edit-loading {
position: relative;
&::after {
content: '';
display: block;
position: absolute;
top: 0;
bottom: 0;
width: 100%;
background-color: rgba($color: #fff, $alpha: 0.7);
z-index: 99;
}
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<page-layout>
<div class="page-title flex justify-between">
<div class="title text-G900">Issue 模板</div>
<d-button @click="$router.push({ name: 'repoDashboard' })">编辑模板</d-button>
</div>
<div class="template-list">
<Panel v-for="item in list" :key="item.templatePath" class="template-item">
<div class="template-header flex justify-between">
<div class="template-name text-G900">{{ item.mdName }}</div>
<d-button variant="text" color="primary"
@click="$router.push({ name: 'repoIssueCreate', query: { template_name: item.mdName } })">使用</d-button>
</div>
<p class="template-about text-CG600">{{ item.mdAbout }}</p>
</Panel>
</div>
</page-layout>
</template>
<script lang="ts" setup>
defineOptions({ name: 'IssueTemplateList' });
import { ref } from 'vue';
import IssueTemplate from '@/model/issueTemplate';
import { Panel } from 'vue-devui/panel';
import PageLayout from '@/components/PageLayout/index.vue';
const issueMock = new IssueTemplate();
const temp = new Array(4).fill(null).map(item => {
issueMock.mock();
const data = JSON.parse(JSON.stringify(issueMock.getData()));
return data;
});
const list = ref(temp);
</script>
<style lang="scss" scoped>
.page-title {
margin-top: 32px;
margin-bottom: 32px;
.title {
font-size: 22px;
font-weight: 500;
color: #2D2D2E;
line-height: 32px;
}
}
.template-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
.template-name {
font-size: 18px;
font-weight: 500;
color: #2D2D2E;
line-height: 26px;
}
.template-about {
margin-top: 8px;
}
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<div class="pin-list g-hidden-scrollbar" v-if="pinList[0]">
<div class="g-card pin-item" v-for="item in pinList" :key="item.object_id">
<IssueBlurb
:id="'#' + item.data.iid"
:title="item?.data?.title"
:stateIcon="issueStateOption[item?.data?.state].icon"
:stateColor="issueStateOption[item?.data?.state].color"
:idUrl="$router.resolve({ name: 'repoIssueDetail', params: { serialNumber: item.data.iid } }).href"
:author="item.data.author"
:createAt='item.data.created_at'
></IssueBlurb>
</div>
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: 'PinList' /* 精选列表 */ });
import { ref } from 'vue';
import IssueBlurb from '@/views/Mobile/Repo/components/IssueBlurb/index.vue';
import { issueStateOption } from '@/constant/issue';
import { useAccountStore } from '@/stores/user';
import { useRepoId } from '@/utils/hooks/useRepoId';
import { reqCatch } from '@/utils/catch';
import { useOrgId } from '@/utils/hooks/useOrgId';
const { orgId } = useOrgId('/');
import { pinIssueList } from '@/api/issue';
const { repoId } = useRepoId();
const { accountInfo } = useAccountStore();
const pinList = ref([]);
// 精选排序列表
const fetchPinIssueList = async() => {
const res = await reqCatch(pinIssueList, { project_id: repoId.value });
if (!res.error) {
pinList.value = res.data.data.slice(0, 6);
}
};
accountInfo.username && fetchPinIssueList();
</script>
<style lang="scss" scoped>
.pin-list {
@apply overscroll-x-auto whitespace-nowrap overflow-x-auto pb-[6px] px-[16px] mt-[20px] mb-[-6px];
.pin-item {
width: calc(100vw - 32px);
@apply inline-flex flex-col justify-between h-[92px] ml-2 px-[20px] py-[16px];
&:first-of-type {
margin-left: 0;
}
}
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<main class="list-main">
<section class="header">
<!-- <d-button class="p-2">
<Icon name="gt-search" />
</d-button> -->
<RichLabel icon="gt-tag-c" @click="$router.push({ name: 'repoLabels' })"
>Labels</RichLabel
>
<RichLabel
icon="gt-milestone-c"
@click="$router.push({ name: 'repoMilestone' })"
>里程碑</RichLabel
>
<span class="header-right">
<d-button icon="add" variant="solid" color="primary" @click="create">
Issue
</d-button>
</span>
</section>
<section class="pin-list-box">
<PinList />
</section>
<IssueFilterList type="project" :repo-id="repoId" class="mt-[12px]"></IssueFilterList>
</main>
</template>
<script setup lang="ts">
defineOptions({ name: 'IssueList' });
import IssueFilterList from '@/views/Mobile/Repo/components/IssueFilterList/index.vue';
import PinList from './components/PinList.vue';
import { useRepoId } from '@/utils/hooks/useRepoId';
const { repoId } = useRepoId();
import { useAccountStore } from '@/stores/user';
import { emitEvent } from '@/utils/eventBus';
import { useRouter } from 'vue-router';
const router = useRouter();
const userStore = useAccountStore();
const create = () => {
if (userStore.isLogin) {
router.push({ name: 'repoIssueCreate' });
} else {
emitEvent('login');
}
};
</script>
<style lang="scss" scoped>
.list-main {
@apply py-[20px] px-[16px];
}
.header {
@apply flex items-center gap-2;
.header-right {
@apply flex-grow text-right;
}
}
.scrollbar-container {
@apply mx-[-16px] mt-[20px];
}
.pin-list-box {
@apply mx-[-16px];
}
</style>

View File

@@ -0,0 +1,65 @@
export interface IIssue {
id?: string
// 问题的标题
title: string,
// 问题的描述
description: string,
// 问题创建的日期和时间
created_at?: string,
// 解决讨论的合并请求的IID
merge_request_to_resolve_discussions_of?: number,
// 要解决的讨论的ID也传递 merge_request_to_resolve_discussions_of
discussion_to_resolve?: string,
// integer($int32)项目问题的内部ID。只有管理员和项目所有者才能使用
iid: number,
// 指派问题的用户ID
assignee_ids?: {
[propName: string]: any
},
// 分配问题的里程碑的ID
milestone_id?: number,
// 问题类别的名称
issue_category?: string,
// 问题阶段的名称
issue_stage?: string,
// 问题严重性的名称
issue_severity?: string,
// 日期字符串
due_date?: string,
// 问题 - 机密
confidential?: true,
// 指示问题的讨论是否已锁定
discussion_locked: true,
// 项目pbi的ID
pbi_id?: 0,
// 提出问题的用户的ID或用户名
proposer_id?: {},
// 项目 id
root_project_id: number
visibility?: 'public' | 'private'
status?: string
creator?: string
participants?: any[] // 参与者
createTime?: string
responser?: string // 负责人
labels?: ILabel[]
milestone?: IMilestone
kanban?: string
mergeRequests?: string
branches?: string
// 标签名的逗号分隔列表
// labels: {
// description:
// },
[propName: string]: any
}
export interface IMilestone {
[propName: string]: any
}
export interface ILabel {
[propName: string]: any
}

View File

@@ -0,0 +1,307 @@
<!-- 合并页的分支列表 -->
<template>
<div class="pt-6 flex justify-center">
<div class="fordeep w-full">
<h1 class="mr-title">新建Pull Request</h1>
<Card class="w-full flex">
<div class="branch-item">
<div class="font-bold">源分支</div>
<div class="branch">
<div class="block">
<d-icon name="version-history" />
<div class="text">{{ originRepo }}</div>
</div>
<div class="block">
<Icon name="gt-branches" />
<div class="text">{{ originBranch }}</div>
</div>
</div>
</div>
<div class="mt-[37px] mx-20">
<Icon name="gt-exit" />
</div>
<div class="branch-item">
<div class="font-bold">目标分支</div>
<div class="branch">
<div class="block">
<d-icon name="version-history" />
<div class="text">{{ targetRepo }}</div>
</div>
<div class="block">
<Icon name="gt-branches" />
<div class="text">{{ targetBranch }}</div>
</div>
<div class="ml-20 mt-[5px]">
<router-link :to="{ name: 'repoMergeCreate' }"
><div class="changebranch cursor-pointer text-CG600 hover:text-link">
变更分支
</div></router-link
>
</div>
</div>
</div>
</Card>
<div class="merge-info">
<Card class="info-left">
<div class="text">
<div style="padding: 10px; background-color: #f5f7f9">
<d-input v-model="mergeTitle" autofocus placeholder="请填写Pull Request标题"></d-input>
<div class="mt-2 text-gray-500 text-xs">
<d-button @click="addWIP" v-if="!ifWIP" variant="text" color="primary"
>在标题前添加[WIP]</d-button
>
<d-button @click="delWIP" v-if="ifWIP" variant="text" color="primary"
>移除[WIP]</d-button
>
如果MR暂不想被合入可以在标题前添加[WIP]WIP, Work In Progress
</div>
</div>
<md-editor v-model="description" placeholder="输入描述" :project-id="decodeURIComponent(repoId)" toggle-repo-permission></md-editor>
</div>
<d-checkbox
class="mt-20"
label="接受Pull Request合入时删除源分支"
:isShowTitle="false"
v-model="remove_source_branch"
/>
<d-checkbox
v-if="setting?.merge_request_setting?.disable_squash_merge === false"
class="mt-20"
label="接受Pull Request合入时Squash提交"
:isShowTitle="false"
v-model="squash"
/>
<d-textarea
v-if="setting?.merge_request_setting?.disable_squash_merge === false"
v-show="squash"
class="mt-20"
v-model="squash_commit_message"
placeholder="输入squash信息"
/>
<div class="flex justify-end mt-20">
<d-button
class="mt-5"
@click="submit"
:loading="submitLoading"
variant="solid"
:disabled="!mergeTitle || !description"
>创建</d-button
>
</div>
</Card>
<div class="w-60 ml-5">
<MergeAsideSetGroup
@data-change="handleRightSetting"
:repo-id="repoId"
:target_project_id="targetProjectId"
type="create"
:sourceBranch="originBranch"
:targetBranch="targetBranch"
/>
</div>
</div>
<BranchContrastStatistics
v-if="false && countObj"
class="mt-[24px]"
:commitNum="countObj.commits_count"
:changeNum="countObj.diffs_count"
/>
<CommitList class="mt-6 mb-20" v-if="commitList" :list="commitList" />
<CompareFileDiff class="mt-6" :targetRepoId="targetProjectId" />
</div>
</div>
</template>
<script setup>
import { reactive, ref, watch, computed } from 'vue';
import { useRoute } from 'vue-router';
import MdEditor from '@/components/MdEditor/index.vue';
import CommitList from '@/components/CommitList/index.vue';
import BranchContrastStatistics from '@/components/BranchContrastStatistics/index.vue';
import MergeAsideSetGroup from '@/views/Repo/components/MergeAsideSetGroup/index.vue';
import { getRepoCompare } from '@/api/repo/index';
import { createMerge } from '@/api/merge/index';
import { Message } from 'vue-devui/message';
import router from '@/router';
import { useReq, reqLoading } from '@/utils/hooks/useReq';
import { useRepoId } from '@/utils/hooks/useRepoId';
import CompareFileDiff from '../components/CompareFileDiff.vue';
import { getMergeSetting } from '@/api/repo';
import take from 'lodash/take';
import map from 'lodash/map';
import { watchOnce } from '@vueuse/core';
import { prop } from '../components/support';
const { repoId } = useRepoId();
const route = useRoute();
const { originBranch, originRepo, targetRepo, targetBranch, targetProjectId } = route.query;
// 右侧设置信息
const additionalInfo = reactive({
merge_request_assignee_list: [],
approval_merge_request_reviewers: [],
labels: []
});
const handleRightSetting = (item) => {
additionalInfo.assignees = item.merge_request_assignee_list;
additionalInfo.reviewers = item.approval_merge_request_reviewers;
additionalInfo.labels = item.labels;
};
// commits
const { data: commitList } = useReq(
getRepoCompare,
reactive({
repoId,
target_id: targetProjectId,
from: originBranch,
to: targetBranch,
view: 'commits'
}),
null,
(data) =>
data?.commits?.map((x) => ({
...x,
createTime: x.committed_date
}))
);
watchOnce(commitList, () => {
if (description.value) return;
if (!commitList.value.length) return;
description.value = take(commitList.value, 5).map(prop('title')).join('\n');
if (commitList.value.length > 5) description.value = description.value + '\n...';
});
// count 数量统计
const { data: countObj } = useReq(
getRepoCompare,
reactive({
repoId,
target_id: targetProjectId,
from: originBranch,
to: targetBranch,
view: 'count'
})
);
// 左侧md
const mergeTitle = ref('');
const ifWIP = computed(() => mergeTitle.value.includes('WIP'));
const addWIP = () => {
mergeTitle.value = '[WIP]' + mergeTitle.value;
};
const delWIP = () => {
mergeTitle.value = mergeTitle.value.replace(/\[WIP\]/g, '');
};
const description = ref('');
const remove_source_branch = ref(false);
const squash = ref(false);
const squash_commit_message = ref('');
const submitLoading = ref(false);
const submit = () => {
const params = reactive({
repoId: encodeURIComponent(originRepo),
target_project_id: Number(targetProjectId),
title: mergeTitle,
source_branch: originBranch,
target_branch: targetBranch,
description,
labels: map(additionalInfo.labels, 'name').join(','), // label名称逗号隔开
assignee_ids: map(additionalInfo.assignees, 'id').join(','),
approval_reviewer_ids: map(additionalInfo.reviewers, 'id').join(','),
remove_source_branch,
squash_commit_message,
squash
});
if (!params.title) return Message.warning('请填写标题');
reqLoading(createMerge, params, submitLoading).then((res) => {
const data = res.data;
router.push({
name: 'repoMergeDetail',
params: {
namespace: targetRepo.split('/')[0],
repoName: targetRepo.split('/')[1],
mergeId: data.iid
}
});
});
};
const { data: setting } = useReq(getMergeSetting, { repoId: targetProjectId });
watch(setting, () => {
squash.value = setting.value.merge_request_setting.auto_squash_merge;
remove_source_branch.value = setting.value.merge_request_setting.delete_source_branch_when_merged;
});
</script>
<style lang="scss" scoped>
.mr-title {
margin: 0 0 16px 0;
font-size: var(--text-2xl);
color: var(--color-G900);
font-weight: 500;
line-height: 32px;
}
.merge-info {
display: flex;
justify-content: space-between;
margin-top: 20px;
.info-left {
flex: 1;
}
}
.branch {
margin-top: 10px;
display: flex;
}
.block {
width: 257.25px;
display: flex;
align-items: center;
background-image: var(--color-linear100);
border: 1px solid var(--color-G300);
padding: 4px 12px;
border-radius: 4px;
& + .block {
margin-left: 6px;
}
.text {
margin-left: 6px;
}
}
.label-selected-item {
padding: 4px 10px;
color: #fff;
margin-top: 4px;
margin-right: 8px;
border-radius: 10px;
height: 20px;
display: inline-flex;
align-items: center;
width: fit-content;
}
.label-option-color {
height: 10px;
width: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 4px;
}
.changebranch {
color: var(--devui-brand-active, #526ecc);
}
.fordeep {
:deep(.devui-checkbox__label-text) {
font-size: 14px;
font-weight: 400;
color: #2d2d2e;
}
}
</style>

View File

@@ -0,0 +1,178 @@
<template>
<div class="mt-20">
<div class="w-[1200px]">
<h1 class="mr-title">新建Pull Request</h1>
<div class="text-CG600 text-xs mt-[16px]">
你可以选择两个内容存在差异的分支比较并查看它们的更改并创建一个新的Pull Request
</div>
<DataPanel :loading="loading" skeleton="true" class="mt-20" :card="false">
<BranchCompare
v-model:repoId="branchCompareData.repoId"
v-model:targetRepoId="branchCompareData.targetRepoId"
v-model:sourceId="branchCompareData.originId"
v-model:targetId="branchCompareData.targetId"
:repoList="originRepoList"
:targetRepoList="targetRepoList"
:branchList="originBranchList"
:targetBranchList="targetBranchList"
:showRepo="isFork"
:isFork="isFork"
@onSwitch="onSwitch"
>
</BranchCompare>
<div class="mt-20 text-right">
<d-button @click="toNext" variant="solid">下一步</d-button>
</div>
</DataPanel>
</div>
</div>
</template>
<script setup>
import { reactive, ref, watch } from 'vue';
import BranchCompare from '@/components/BranchCompareFork/index.vue';
import getRepoId from '@/utils/getRepoId';
import { getRepo } from '@/api/repo';
import { getBranches } from '@/api/branch';
import { useRouter } from 'vue-router';
import { Message } from 'vue-devui/message';
const router = useRouter();
const loading = ref(false);
const branchCompareData = reactive({
repoId: getRepoId('/'), // 源项目
targetRepoId: getRepoId('/'), // 目标项目
originId: '', // 源分支
targetId: '' // 目标分支
});
const onSwitch = () => {
[branchCompareData.originId, branchCompareData.targetId] = [
branchCompareData.targetId,
branchCompareData.originId
];
};
const isFork = ref(false); // 是否为fork项目
const originRepoList = ref([{ label: getRepoId('/'), value: getRepoId('/') }]);
const targetRepoList = ref([{ label: getRepoId('/'), value: getRepoId('/') }]);
const targetRepos = ref([]); // 最后获取目标项目id使用等接口支持path后可去掉
const originBranchList = ref([
{ label: '', value: '' } // 有初始值可以撑开select下拉框
]);
const targetBranchList = ref([{ label: '', value: '' }]);
/**
* 获取源分支或目标分支列表
* @param {*} type origin | target
* @param {*} repoId
*/
const getBranchList = async (type = 'origin', repoId = getRepoId()) => {
loading.value = true;
const params = {
repoId,
sort: type === 'origin' ? 'mr_source' : 'mr_target',
per_page: 100 // TODO: 加下拉加载
};
const res = await getBranches(params);
if (!res.error) {
const repoData = res?.data?.data;
const branchId = type === 'origin' ? 'originId' : 'targetId';
const branchList = [];
repoData.content.forEach((item) => {
branchList.push({
label: item.name,
value: item.name
});
});
if (type === 'origin') {
originBranchList.value = branchList;
} else {
targetBranchList.value = branchList;
}
branchCompareData[branchId] =
type === 'origin' ? originBranchList.value[0].value : targetBranchList.value[0].value;
}
loading.value = false;
};
const getTargetRepo = async (targetId) => {
const params = { repoId: targetId };
const res = await getRepo(params);
if (!res.error) {
const repoData = res?.data?.data;
targetRepos.value.unshift({ ...repoData });
targetRepoList.value.unshift({
label: repoData.path_with_namespace,
value: repoData.path_with_namespace
});
branchCompareData.targetRepoId = repoData.path_with_namespace;
getBranchList('target', repoData.id);
}
};
const getOriginRepo = async (repoId = getRepoId()) => {
loading.value = true;
const params = { repoId };
const res = await getRepo(params);
if (!res.error) {
const repoData = res?.data?.data;
targetRepos.value.unshift({ ...repoData });
isFork.value = !!repoData.forked_from_project;
if (isFork.value) {
getTargetRepo(repoData.forked_from_project.id);
} else {
getBranchList('target', repoId);
}
getBranchList();
}
};
getOriginRepo();
watch(
() => branchCompareData.repoId,
(val) => {
getBranchList('origin', encodeURIComponent(val));
}
);
watch(
() => branchCompareData.targetRepoId,
(val) => {
getBranchList('target', encodeURIComponent(val));
}
);
const toNext = () => {
const { repoId, targetRepoId, originId, targetId } = branchCompareData;
const originPath = `${repoId}${originId}`;
const targetPath = `${targetRepoId}${targetId}`;
if (!originId) return Message.warning('请选择源分支');
if (!targetId) return Message.warning('请选择目标分支');
if (originPath === targetPath) {
return Message.warning('目标分支和源分支不能相同');
}
const selectTargetRepo = targetRepos.value.filter(
(item) => item.path_with_namespace === targetRepoId
);
router.push({
name: 'repoMergeCompare',
query: {
originRepo: repoId,
targetRepo: targetRepoId,
targetProjectId: selectTargetRepo[0].id,
originBranch: originId,
targetBranch: targetId
}
});
};
</script>
<style lang="scss" scoped>
.mr-title {
margin: 24px 0 16px 0;
font-size: var(--text-2xl);
color: var(--color-G900);
font-weight: 500;
line-height: 32px;
}
</style>

View File

@@ -0,0 +1,355 @@
<template>
<!-- 提高高度出现滚动条防止tab切换抖动 -->
<div style="min-height: calc(100vh - 56px - 48px - 180px + 1px)">
<div class="bg-white mr-header">
<div class="pt-6 px-5">
<d-skeleton :loading="!detail?.title">
<MrDescription
v-if="detail?.title"
:key="editKey"
:title="detail?.title"
:created-time="detail?.created_at"
:closed-time="detail?.closed_at"
:user-name="pickNickName(detail?.author)"
:status-text="mrState?.label"
:statusIconColor="mrState.color"
:statusBgColor="mrState.bgColor"
:statusIcon="mrState.icon"
:index-label="detail?.iid"
:can-edit="isAdminOrAuthor"
:sourceRepo="detail?.source_project?.path_with_namespace"
:targetRepo="detail?.target_project?.path_with_namespace"
:sourceBranch="detail?.source_branch"
:targetBranch="detail?.target_branch"
:addedLines="detail?.added_lines"
:removedLines="detail?.removed_lines"
@handle-save="updateTitle"
/>
<template #placeholder>
<div style="display: flex; gap: 0 16px; padding: 16px">
<gc-skeleton-item style="width: 88px; height: 60px"></gc-skeleton-item>
<div style="flex: 1">
<gc-skeleton-item variant="square" style="height: 32px"></gc-skeleton-item>
<gc-skeleton-item
style="width: 150px; height: 20px; margin-top: 8px"
></gc-skeleton-item>
</div>
</div>
</template>
</d-skeleton>
</div>
<!-- tab -->
<div class="flex pr-5 justify-between items-baseline">
<d-tabs type="wrapped" id="mr-tabs" class="mt-5 ml-5 mr-tab" v-model="tabid">
<d-tab id="discuss" title="讨论">
<template v-slot:title>
<div class="flex items-center">
<Icon name="gt-comment-c" class="mr-xxs mr-1" />
<div class="tab-text flex items-center">
<div>讨论</div>
<div class="ml-2">{{ countObj?.notes_count || 0 }}</div>
</div>
</div>
</template>
</d-tab>
<d-tab id="commit" title="提交">
<template v-slot:title>
<div class="flex items-center">
<Icon name="gt-commit-c" class="mr-xxs mr-1" />
<div class="tab-text flex items-center">
<div>提交</div>
<div class="ml-2">{{ countObj?.commits_count || 0 }}</div>
</div>
</div>
</template>
</d-tab>
<d-tab id="check" title="检查">
<template v-slot:title>
<div class="flex items-center">
<Icon name="gt-check-c" class="mr-xxs mr-1" />
<div class="tab-text">检查</div>
</div>
</template>
</d-tab>
<d-tab id="diff" title="文件改动">
<template v-slot:title>
<div class="flex items-center">
<Icon name="gt-contribute-c" class="mr-xxs mr-1" />
<div class="tab-text flex items-center">
<div>文件改动</div>
<div class="ml-2">{{ countObj?.diffs_count || 0 }}</div>
</div>
</div>
</template>
</d-tab>
</d-tabs>
<Statistic :mutateTagForDissChange="mutateTagForDissChange" />
</div>
</div>
<div class="mt-[24px]" v-show="tabid !== 'diff'">
<page-layout>
<!-- mr 描述 -->
<d-skeleton :loading="!detail?.title">
<MrDescriptionItem
v-if="tabid === 'discuss' && detail?.title"
class="g-content-card mb-[12px]"
:created_at="detail?.created_at"
:author="detail?.author"
@update-description="() => emitEvent('updateMrAction')"
:data="detail"
:user="accountInfo"
@quote-reply="quoteReply"
>
</MrDescriptionItem>
<template #placeholder>
<div class="g-card px-4 py-4">
<div class="flex justify-between gap-4 mb-4">
<gc-skeleton-item style="width: 200px; height: 16px" v-for="i in 2" :key="i" />
</div>
<d-skeleton></d-skeleton>
</div>
</template>
</d-skeleton>
<div v-if="tabid === 'discuss'">
<MergeStream
:repo-id="repoId"
:iid="iid"
@change="handleChange"
@approvalChange="approvalChange"
ref="RefMrStream"
/>
</div>
<!-- mr 评论 -->
<DetailDiscuss v-if="tabid === 'discuss'" :repoInfo="detail" ref="RefDetailDiscuss" />
<DetailCommits v-if="tabid === 'commit'" />
<DetailCheck v-if="tabid === 'check'" />
<template #right>
<div class="border-l-[#F1F1F8]">
<d-skeleton :loading="!detail?.title">
<MergeAsideSetGroup
:key="mutateTagForApproveChange"
type="update"
:sourceBranch="detail.source_branch"
:targetBranch="detail.target_branch"
:repo-id="detail?.source_project?.id"
:target_project_id="detail?.target_project?.id"
:merge-id="iid"
:info="detail"
v-if="isVisitorOperate"
/>
<template #placeholder>
<div style="display: flex; flex-direction: column; gap: 16px 0">
<template v-for="i in 3" :key="i">
<div style="display: flex; justify-content: space-between; gap: 0 16px">
<gc-skeleton-item style="flex: 1; height: 24px"></gc-skeleton-item>
<gc-skeleton-item style="width: 24px; height: 24px"></gc-skeleton-item>
</div>
<d-skeleton :rows="1"></d-skeleton>
<br />
</template>
<br />
<gc-skeleton-item
v-for="i in 3"
:key="i"
style="width: 150px; height: 16px"
></gc-skeleton-item>
</div>
</template>
</d-skeleton>
</div>
</template>
</page-layout>
</div>
<div v-if="detail && tabid === 'diff'" class="bg-white">
<DetailFileDiff @onMergeAble="mergeAbleChange" />
</div>
</div>
</template>
<script setup>
defineOptions({ name: 'MR-Detail' });
import { ref, reactive, watch, computed, onMounted, provide } from 'vue';
import PageLayout from '@/components/PageLayout/index.vue';
import DetailFileDiff from '@/views/Repo/Merge/components/DetailFileDiff.vue';
import DetailCommits from '@/views/Repo/Merge/components/DetailCommits.vue';
import DetailDiscuss from '@/views/Repo/Merge/components/DetailDiscuss.vue';
import DetailCheck from '@/views/Repo/Merge/components/DetailCheck.vue';
import Statistic from '@/views/Repo/Merge/components/Statistic.vue';
import MergeAsideSetGroup from '@/views/Repo/components/MergeAsideSetGroup/index.vue';
import MergeStream from '@/views/Repo/Merge/components/MergeStream.vue';
import MrDescription from '@/components/IssueDescription/index.vue';
import MrDescriptionItem from '@/views/Repo/Merge/components/MrDescriptionItem.vue'; // 评论第一条
import { Message } from 'vue-devui/message';
import { useReq } from '@/utils/hooks/useReq';
import { useRepoId } from '@/utils/hooks/useRepoId';
import { mergeDetail, putMerge } from '@/api/merge/index';
import { useRouter, useRoute } from 'vue-router';
import { escapeResData, pickNickName } from '@/utils';
import { useAccountStore } from '@/stores/user';
import { emitEvent } from '@/utils/eventBus';
import { mrStateOption } from '@/constant/mr';
import { repoInfoStore } from '@/stores/Repo';
const { isAdmin, isVisitorOperate } = repoInfoStore();
const { accountInfo } = useAccountStore();
const isAdminOrAuthor = computed(() => {
return isAdmin || detail.value?.author?.id === accountInfo?.arts_id;
});
const isMrAuthor = computed(() => detail.value.author?.username === accountInfo.username);
const router = useRouter();
const route = useRoute();
const { repoId } = useRepoId();
const iid = router.currentRoute.value.params.mergeId;
const tabid = ref(route.meta.path || 'discuss');
watch(tabid, () => {
router.replace({
name: `repoMergeDetail${tabid.value
.split('')
.map((x, i) => (i === 0 ? x.toLocaleUpperCase() : x))
.join('')}`
});
});
const RefMrStream = ref(null);
const RefDetailDiscuss = ref(null);
const mutateTagForDissChange = ref(0);
// detail
const params = reactive({ view: 'basic', repoId, iid });
const { data: detail } = useReq(mergeDetail, params);
provide('isMrAuthor', isMrAuthor);
provide('mrInfo', detail);
provide('repoId', repoId);
provide('iid', iid);
const editKey = ref(0);
const mrState = computed(() => {
const state = detail.value?.state;
const icon = mrStateOption[state]?.icon;
const color = mrStateOption[state]?.color;
const label = mrStateOption[state]?.label;
const bgColor = mrStateOption[state]?.bgColor;
return { icon, color, label, bgColor };
});
const updateTitle = async(value) => {
if (!value) {
Message.warning('标题不能为空!');
return editKey.value++;
} else if (!/^.{1,200}$/.test(value)) {
Message.warning('限制1~200个字符');
return editKey.value++;
} else if (value === detail.value.title) {
return editKey.value++;
}
const res = await putMerge(reactive({ repoId, iid, title: value }));
if (!res.error) {
detail.value.title = value;
editKey.value++;
}
editKey.value++;
};
const handleChange = (title) => {
detail.value.title = title;
};
// count 数量统计
const countObj = ref({});
watch(detail, () => {
if (!detail?.value?.id) return;
mergeDetail(
reactive({
repoId,
iid,
only_count: true
})
).then((res) => {
const data = escapeResData(res);
if (data) countObj.value = data;
});
});
// mergeableState
const mergeAbleChange = () => {
mutateTagForDissChange.value = +new Date();
if (RefMrStream.value) {
RefMrStream.value.getMergeRequestStatus();
RefMrStream.value.getReviewers();
RefMrStream.value.getMergeRequestDetail();
}
};
provide('mergeAbleChange', mergeAbleChange);
const mutateTagForApproveChange = ref(0);
const approvalChange = () => {
mutateTagForApproveChange.value += +new Date();
};
const quoteReply = (content) => RefDetailDiscuss.value.addQuoteReply(content);
onMounted(() => {});
</script>
<style scoped lang="scss">
.fadebox {
max-height: 1000px;
overflow: hidden;
transition: all 0.5s;
}
.fadeout {
max-height: 0;
}
.mr-header {
position: relative;
border-bottom: 1px solid #e6e7e8;
}
.mr-tab {
margin-bottom: -1px;
.tab-text {
font-size: 14px;
font-weight: 500;
color: #9fa7b3;
&:hover {
color: #2d2d2e;
}
}
&:deep(.devui-tabs__nav--wrapped) {
border-bottom-width: 1px;
border-bottom-color: #e6e7e8;
overflow: visible;
}
:deep(.devui-tab__content) {
display: none;
}
:deep(.devui-tabs__nav--wrapped > li.active) {
border: 1px solid #e6e7e8;
background: #f6f6f8;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border-bottom-color: $devui-global-bg;
margin-bottom: -1px;
}
:deep(.devui-tabs__nav--wrapped > li) {
min-width: 112px;
border: 1px solid transparent;
margin: 1px 1px 0 1px;
border-bottom: none;
}
:deep(.devui-tabs__nav--wrapped > li.active) {
.tab-text {
font-size: 14px;
font-weight: 500;
color: #2d2d2e;
}
}
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<div v-if="diffs">
<div class="flex justify-between items-center">
<div class="flex items-center">
<StatusSelect v-model:value="selected" />
<div class="flex items-center ml-4">
<div class="text-[#2D2D2E] flex items-center">
<div></div>
<div class="text-[#517ca0] mx-1">{{ diffs.diffs?.length }}</div>
<div>个文件变更</div>
</div>
<p class="text-green-500 ml-4">+{{ diffs.added_lines }}</p>
<p class="text-red-500 ml-1">-{{ diffs.removed_lines }}</p>
</div>
<span class="w-px h-4 bg-gray-300 mx-3"></span>
<div>
<d-input v-model="keyWord" placeholder="搜索文件名..." clearable prefix="search" />
</div>
</div>
<div class="flex items-center">
<d-popover content="展开/收起全部" trigger="hover" :position="['top']">
<Icon class="cursor-pointer" name="gt-frame-expand2" @click="handleExpand"></Icon>
</d-popover>
<span class="w-px h-4 bg-gray-300 mx-3"></span>
<d-popover content="设置" trigger="hover" :position="['top']">
<ShowFormat />
</d-popover>
</div>
</div>
<div class="mt-4" v-loading="diffsloading">
<DiffView
@foldChange="(e) => (item.fold = e)"
class="mb-2"
v-for="(item,index) in filterDiffs"
:index="index"
:key="item.file_path"
:diffObj="item"
:repoId="repoId"
:commit_id="diffs.commit?.id"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, toValue, watch, reactive, onMounted, toRef } from 'vue';
import { useReq } from '@/utils/hooks/useReq';
import { useRepoId } from '@/utils/hooks/useRepoId';
import ShowFormat from './ShowFormat.vue';
import StatusSelect from './StatusSelect.vue';
import { getRepoCompare } from '@/api/repo/index';
import { useRouter, useRoute } from 'vue-router';
import { useMrChangeStore } from '@/stores/merge';
import DiffView from './DiffView.vue';
import { diffFilter } from './support';
const mrChangeStore = useMrChangeStore();
const props = withDefaults(
defineProps<{
targetRepoId?: string | number;
}>(),
{
targetRepoId: 'labelTag'
}
);
const { repoId } = useRepoId();
const route = useRoute();
const { originBranch, repo, targetBranch } = route.query;
const fromBranch = ref(originBranch);
const toBranch = ref(targetBranch);
// 筛选
const selected = ref([]);
const keyWord = ref('');
const allFold = ref(false);
const handleExpand = () => {
diffs?.value?.diffs?.forEach((x) => (x.fold = allFold.value));
allFold.value = !allFold.value;
};
const ignore_whitespace_change = toRef(mrChangeStore.$state, 'ignore_whitespace_change');
// diffs (文件)
const diffsParams = reactive({
repoId,
from: fromBranch,
to: toBranch,
ignore_whitespace_change: ignore_whitespace_change,
target_id: props?.targetRepoId
});
const { data: diffs, loading: diffsloading } = useReq(getRepoCompare, diffsParams);
const filterDiffs = computed(() => {
if (!diffs.value?.diffs) return [];
return diffs.value.diffs.filter((m) => diffFilter(m, selected, keyWord));
});
</script>

View File

@@ -0,0 +1,145 @@
<template>
<!-- <div class="merge-check g-card">
<merge-check
v-if="!mergeSetting.wip_passed"
key="wip"
:type="config.wip.type"
:status="config.wip.status"
class="merge-check-item"
>
</merge-check>
<merge-check
v-if="mergeSetting.need_resolve_passed"
key="comment"
:type="config.comment.type"
:status="config.comment.status"
class="merge-check-item"
>
</merge-check>
<merge-check
v-if="mergeSetting.required_reviewers_count > 0"
key="reviewer"
:type="config.reviewer.type"
:status="config.reviewer.status"
:need-pass="config.reviewer.needPass"
:has-pass="config.reviewer.hasPass"
class="merge-check-item"
>
</merge-check>
</div> -->
<DataPanel :loading="loading" :empty="checkEmpty" skeleton="true">
<merge-check
v-if="!mergeSetting.wip_passed"
key="wip"
:type="config.wip.type"
:status="config.wip.status"
class="merge-check-item"
>
</merge-check>
<merge-check
v-if="mergeSetting.need_resolve_passed"
key="comment"
:type="config.comment.type"
:status="config.comment.status"
class="merge-check-item"
>
</merge-check>
<merge-check
v-if="mergeSetting.required_reviewers_count > 0"
key="reviewer"
:type="config.reviewer.type"
:status="config.reviewer.status"
:need-pass="config.reviewer.needPass"
:has-pass="config.reviewer.hasPass"
class="merge-check-item"
>
</merge-check>
</DataPanel>
</template>
<script setup lang="ts">
import { computed, ref, toValue, watch, reactive, onMounted } from 'vue';
import { useRepoId } from '@/utils/hooks/useRepoId';
import { useRouter } from 'vue-router';
import MergeCheck from '@/components/MergeCheck/index.vue';
import { mergeableState, getReviewers } from '@/api/merge';
const router = useRouter();
const { repoId } = useRepoId();
const iid = router.currentRoute.value.params.mergeId;
const mergeSetting = reactive({
reviewers_passed: false, // 是否满足最低评审人数
resolve_passed: false, // 是否已解决所有评审问题
wip_passed: true, // wip是否通过
required_reviewers_count: 0, // 最低评审人数量
need_resolve_passed: false // 是否需要解决所有评审问题才能合并
// need_pipeline_succeeds: false // 是否需要流水线成功才能合并
});
// 检查记录是否为空
const checkEmpty = computed(() => {
return mergeSetting.wip_passed && mergeSetting.required_reviewers_count <= 0 && !mergeSetting.need_resolve_passed;
});
const config = reactive({
wip: {},
comment: {},
reviewer: {}
});
/* 根据mr状态设置显示哪些检查门禁 */
const checkConfig = () => {
if (!mergeSetting.wip_passed) {
config.wip = {
type: 'wip',
status: 'waiting'
};
}
if (mergeSetting.need_resolve_passed) {
config.comment = {
type: 'comment',
status: mergeSetting.resolve_passed ? 'success' : 'fail'
};
}
config.reviewer.type = 'reviewer';
config.reviewer.status = mergeSetting.reviewers_passed ? 'success' : 'fail';
config.reviewer.link = { name: 'repoSettingMerge' };
return config;
};
const loading = ref(false);
loading.value = true;
const getMergeSetting = () => {
const params = {
repoId: repoId.value,
iid
};
getReviewers(params).then((res) => {
const data = res.data;
config.reviewer.hasPass = data?.count?.total_reviewed_count;
});
mergeableState(params).then((res) => {
const data = res.data;
mergeSetting.reviewers_passed = data.approval_reviewers_required_passed;
mergeSetting.resolve_passed = data.resolve_discussion_passed;
mergeSetting.wip_passed = data.work_in_progress_passed;
mergeSetting.need_resolve_passed = data?.merge_request_switch?.only_allow_merge_if_all_discussions_are_resolved;
mergeSetting.required_reviewers_count = data?.merge_request_switch?.approval_required_reviewers_count;
config.reviewer.needPass = mergeSetting.required_reviewers_count;
checkConfig();
}).catch((err) => { console.error(err); }).finally(() => {
loading.value = false;
});
};
getMergeSetting();
</script>
<style lang="scss" scoped>
.merge-check {
&-item {
border-bottom: 1px solid var(--color-G300);
&:last-of-type {
border-bottom: 0;
}
}
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div>
<DataPanel skeleton :empty="!loading && !data?.content?.length" :loading="loading">
<CommitList v-if="data?.content" :list="data?.content" />
</DataPanel>
<div class="flex mt-20 mb-20 justify-center">
<d-pagination
size="md"
:page-size-options="[10, 20, 50]"
:show-page-selector="false"
:total="total"
v-model:pageIndex="page"
v-model:pageSize="per_page"
:can-view-total="true"
:can-change-page-size="true"
:max-items="5"
@page-size-change="page = 1"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, toValue, reactive, inject } from 'vue';
import { useReq } from '@/utils/hooks/useReq';
import { commits } from '@/api/merge/index';
import CommitList from '@/components/CommitList/index.vue';
import { watchOnce } from '@vueuse/core';
const repoId = toValue(inject('repoId'));
const iid = inject('iid');
//
const total = ref(0);
const page = ref(1);
const per_page = ref(10);
// commits
const { data, loading } = useReq(commits, reactive({ repoId, iid, page, per_page }));
watchOnce(data, () => {
total.value = data.value?.total;
});
</script>

View File

@@ -0,0 +1,294 @@
<template>
<ScrollContainer
@touch-bottom="onTouchBottom"
:bottom="700"
:finished="pageData.finished"
:loading="pageData.loading"
>
<d-skeleton :loading="!repoInfo?.id">
<div class="g-content-card mt-[12px]">
<template v-for="item in pageData.commentlist" :key="item.id">
<!-- 评论 -->
<DiscussionItem
v-if="item?.notes"
:noteNum="item.notes.length"
:author="item?.author || (item.notes && item.notes[0]?.author)"
:user="accountInfo"
:data="item.notes[0]"
:hasDiff="!!item?.diff_file"
:origin="item"
:created_at="item.created_at || item?.notes[0].created_at"
:can-edit="item.notes[0]?.author.username === accountInfo.username"
:targetId="item?.notes[0].id"
@delete="deleteCommentSelf(item)"
@quote-reply="addQuoteReply"
@add-reply="addReply($event, item)"
>
<!-- 回复 -->
<DiscussionItem
v-for="sub in item.notes?.slice(1)"
:key="sub.id"
:author="sub?.author"
:user="accountInfo"
:data="sub"
:origin="item"
:hasDiff="false"
:diff="undefined"
:diffFile="undefined"
:addedLines="undefined"
:removedLines="undefined"
:created_at="item.created_at"
@quote-reply="addQuoteReply"
@delete="deleteCommentSelfSub(sub, item)"
eventIcon=""
:resolvable="false"
isReply
>
</DiscussionItem>
</DiscussionItem>
<!-- issue event -->
<EventItem
v-else
:author="item?.author"
:event-msg="item.body"
:created_at="item?.created_at"
:eventType="item?.action"
/>
</template>
</div>
<template #placeholder>
<div class="g-card px-4 py-4">
<div class="flex justify-between gap-4 mb-4">
<gc-skeleton-item style="width: 200px; height: 16px" v-for="i in 2" :key="i" />
</div>
<d-skeleton></d-skeleton>
<template v-for="i in 2" :key="i">
<div class="flex justify-between gap-4 mt-4 mb-4">
<gc-skeleton-item style="width: 200px; height: 16px" v-for="i in 2" :key="i" />
</div>
<d-skeleton />
</template>
<div class="flex justify-between gap-4 mt-4">
<gc-skeleton-item variant="circle" class="w-[40px] h-[40px]"></gc-skeleton-item>
<gc-skeleton-item style="width: 50px; height: 30px" v-for="i in 8" :key="i" />
</div>
<d-skeleton class="pl-[56px]" />
</div>
</template>
</d-skeleton>
<template v-if="pageData.loading && repoInfo?.description">
<template v-for="i in 1" :key="i">
<div class="flex justify-between gap-4 mt-4 mb-4">
<gc-skeleton-item style="width: 100px; height: 16px"></gc-skeleton-item>
</div>
<d-skeleton></d-skeleton>
</template>
</template>
<!-- issue 添加评论 -->
<section :class="{ 'mt-[20px]': true, 'edit-loading': pageData.loading }">
<AddDiscussion
v-if="repoInfo?.state === 'opened'"
:submit-disabled="!accountInfo.username"
@add-discussion="addDiscussion"
:loading="addIng"
ref="RefAddDiscussion"
:need-resolve="isVisitor"
:submitDisabled="!isVisitor"
/>
</section>
</ScrollContainer>
</template>
<script setup lang="ts">
defineOptions({ name: 'MR-Discussions' });
import { ref, watch, reactive, onMounted, onUnmounted, inject, provide, type Ref } from 'vue';
import ScrollContainer from '@/components/ScrollContainer/index.vue';
import DiscussionItem from './DiscussionItem.vue';
import AddDiscussion from '@/views/Repo/components/AddDiscussion/index.vue';
import EventItem from './EventItem.vue';
import { addEventListener, offEvent } from '@/utils/eventBus';
import { reqCatch } from '@/utils/catch';
import { escapeResData } from '@/utils';
import { useAccountStore } from '@/stores/user';
import { repoInfoStore } from '@/stores/Repo';
import { changeMrRebase, mrDiscussions } from '@/api/merge/index';
import { addMRDiscussion, addMRDiscussionNote } from '@/api/merge';
defineProps<{ repoInfo: object }>();
const { isVisitor } = repoInfoStore();
const { accountInfo = {}} = useAccountStore();
const repoId = inject('repoId') as Ref;
const iid = inject('iid');
const mergeAbleChange = inject('mergeAbleChange') as Function;
const addIng = ref(false);
const RefAddDiscussion = ref();
const pageData = reactive<{
commentlist: object[]; // 评论
page_num: number;
page_size: number;
page_count: number;
total: number;
finished: boolean;
loading: boolean;
end_id?: number;
end_system_id?: number;
}>({
commentlist: [],
page_count: 0,
page_num: 3,
page_size: 20,
total: 0,
finished: false,
loading: false
});
const fetchDiscussionList = async(type?: string) => {
pageData.loading = true;
const res = await reqCatch(mrDiscussions, {
project_id: repoId.value,
merge_request_iid: iid,
page: pageData.page_num,
per_page: pageData.page_size,
end_id: pageData.end_id,
end_system_id: pageData.end_system_id,
type: 'user'
});
pageData.loading = false;
const reData = escapeResData(res);
if (!res.error) {
const { content = {}, total, page_count } = reData;
const { end_id, end_system_id } = content;
const { data = [] } = content;
if (type === 'exist') {
data.forEach((element) => {
const index = pageData.commentlist.findIndex((item) => item.id === element.id);
if (index > -1) {
pageData.commentlist.splice(index, 1, element);
} else {
pageData.commentlist.push(element);
}
});
} else {
pageData.commentlist.push(...data);
}
pageData.total = total;
pageData.page_count = page_count;
pageData.end_id = end_id;
pageData.end_system_id = end_system_id;
}
};
// 有新增内容时重新获取
const updatePage = () => {
if (pageData.page_num * pageData.page_size > pageData.commentlist.length) {
fetchDiscussionList('exist');
} else {
pageData.page_num++;
}
};
// 触底加载更多
const onTouchBottom = () => {
if (pageData.loading || pageData.finished) return;
pageData.page_num++;
};
// 添加子评论
const addReply = (msg: any, item: any) => {
item.notes.push(msg);
};
/**
*
* 保存提交新评论
*/
const addDiscussion = async(obj: {
discussion_locked?: boolean;
state_event?: string;
body?: string;
resolvable?: boolean;
}) => {
if (addIng.value) return;
const addApi = obj.resolvable ? addMRDiscussion : addMRDiscussionNote;
addIng.value = true;
const res = await reqCatch(addApi, {
project_id: repoId.value,
merge_request_iid: iid,
body: obj.body,
need_to_resolve: obj.resolvable
});
addIng.value = false;
if (!res.error) {
const data = res.data?.data;
if (data.notes) {
pageData.commentlist.push(data);
} else if (data.discussion_id) {
pageData.commentlist.push({
id: data.discussion_id,
notes: [data]
});
}
RefAddDiscussion.value.clear();
}
};
const addQuoteReply = (content: string) => {
if (!content.startsWith('>')) {
content = '> ' + content;
}
RefAddDiscussion.value.setBody(content + '\n\n');
};
/**
* 删除评论
*/
const deleteCommentSelf = (item: object) => {
const index = pageData.commentlist?.findIndex((e) => e.id === item.id);
if (index > -1) {
pageData.commentlist.splice(index, 1);
}
};
/**
* 删除子评论
*/
const deleteCommentSelfSub = (sub: object, item: any) => {
const index = item.notes?.findIndex((e) => e.id === sub.id);
if (index > -1) {
item.notes?.splice(index, 1);
}
};
onMounted(() => {
setTimeout(() => {
fetchDiscussionList();
}, 1000);
});
watch(
() => pageData?.commentlist?.length,
// 删除或新增评论
() => mergeAbleChange()
);
watch(
() => pageData.page_num,
(_new) => {
if (_new <= pageData.page_count) fetchDiscussionList();
}
);
defineExpose({
updatePage,
addQuoteReply
});
addEventListener('updateMrAction', () => updatePage());
onUnmounted(() => offEvent('updateMrAction'));
provide('updatePage', updatePage);
</script>

View File

@@ -0,0 +1,442 @@
<template>
<div>
<div v-if="!diffs" class="p-5"><d-skeleton></d-skeleton></div>
<d-splitter v-if="diffs" class="splitter-border" style="height: 100%">
<template v-slot:DSplitterPane>
<d-splitter-pane
collapseDirection="before"
size="320px"
minSize="320px"
:collapsible="true"
@size-change="setSplitterCollapse"
@collapsed-change="collapsedChange"
>
<div class="pane-content">
<div v-if="diffTree" style="min-width: 320px" class="left-tree pl-5 pr-5 pt-5">
<d-search
class="mb10"
style="width: 100%"
is-keyup-search
placeholder="查找文件"
:delay="100"
@search="onSearch"
></d-search>
<d-tree @node-click="onTreeClick" ref="treeRef" :data="diffTree" class="mt-2">
<template #content="{ nodeData }">
<div class="ml-2" :title="nodeData?.file_path">{{ nodeData?.file_path }}</div>
</template>
<template #icon="{ nodeData }">
<span class="inline-block w-5">
<Icon v-if="nodeData.isLeaf" name="gt-file-c" />
<Icon v-else :name="nodeData.expanded ? 'gt-folder-open-c' : 'gt-folder-c'" />
</span>
</template>
</d-tree>
</div>
</div>
</d-splitter-pane>
<d-splitter-pane minSize="15%">
<div class="pane-content">
<div class="flex-1 pt-5 bg-[#f6f6f8]">
<div class="flex px-5 justify-between items-center">
<div class="flex items-center">
<StatusSelect v-model:value="selected" />
<div class="flex items-center ml-4">
<div class="text-[#2D2D2E] flex items-center">
<div class="text-[#517ca0] mx-1">{{ diffs.changes?.length }}</div>
个文件变更
</div>
<p class="text-green-500 ml-4">+{{ diffs.added_lines }}</p>
<p class="text-red-500 ml-1">-{{ diffs.removed_lines }}</p>
</div>
<span class="w-px h-5 bg-gray-300 mx-4"></span>
<div class="flex items-center gap-2">
<d-dropdown style="width: 150px" :position="['bottom-start']" align="start" close-scope="all">
<div class="flex items-center justify-between gap-2 cursor-pointer">
<div class="commitTitle text-[#707a87] hover:text-[#2d2d2e]">
{{ preCommitName }}
</div>
<Icon name="gt-select-arrow-down-12" size="12px"></Icon>
</div>
<template #menu>
<ul>
<li
class="p-2 cursor-pointer hover:bg-[#f6f6f8]"
v-for="item in firstCommitOptions"
:key="item.value"
:value="item.value"
:name="item.name"
@click="
preCommit = item.value;
baseLineChange();
"
>
<div class="px-2">
<div style="font-weight: 700">{{ item.name }}</div>
<div v-if="item.head_commit_sha">
{{ item.head_commit_sha.slice(0, 8) }}
</div>
<div v-if="item.created_at">{{ formatTime(item.created_at) }}</div>
</div>
</li>
</ul>
</template>
</d-dropdown>
<Icon name="gt-exit" size="12px"></Icon>
<d-dropdown style="width: 150px" :position="['bottom-start']" align="start">
<div class="flex items-center justify-between gap-2 cursor-pointer">
<div class="commitTitle text-[#707a87] hover:text-[#2d2d2e]">
{{ afterCommitName }}
</div>
<Icon name="gt-select-arrow-down-12" size="12px"></Icon>
</div>
<template #menu>
<ul>
<li
class="p-2 cursor-pointer hover:bg-[#f6f6f8]"
v-for="item in lastCommitOptions"
:key="item.value"
:value="item.value"
:name="item.name"
@click="
afterCommit = item.value;
baseLineChange();
"
>
<div class="px-2">
<div style="font-weight: 700">{{ item.name }}</div>
<div v-if="item.head_commit_sha">
{{ item.head_commit_sha.slice(0, 8) }}
</div>
<div v-if="item.created_at">{{ formatTime(item.created_at) }}</div>
</div>
</li>
</ul>
</template>
</d-dropdown>
</div>
<div class="ml-4"></div>
</div>
<div class="flex items-center">
<d-popover content="展开/收起全部文件" trigger="hover" :position="['top']">
<span class="flex items-center cursor-pointer" @click="handleExpand">
<Icon name="gt-frame-expand2"></Icon>
</span>
</d-popover>
<span class="w-px h-5 bg-gray-300 mx-3"></span>
<d-popover content="设置" trigger="hover" :position="['top']">
<ShowFormat />
</d-popover>
</div>
</div>
<div class="mt-4 flex">
<div v-if="diffsloading" class="p-5 h-80 w-[100%]"><d-skeleton></d-skeleton></div>
<DataPanel
class="filter-diff-list"
:loading="diffsloading"
:empty="!filterDiffs?.length"
:skeleton="diffsloading"
:card="false"
>
<div class="flex-1 codeblock p-5 pt-0" :key="`${preCommit}-${afterCommit}`" v-show="!diffsloading">
<div
:class="{ selectItem: selectedFile === item.file_path }"
class="mb-3 overflow-auto rounded-[8px]"
style="box-shadow: 0px 2px 6px 0px rgba(0, 0, 0, 0.09)"
v-for="(item, index) in filterDiffs"
:key="item.file_path"
@click="codelistClick(item.file_path)"
>
<DiffView
:index="index"
:repoId="repoId"
:diffObj="item"
:discussions="diss?.length ? diss.find((x) => x.path === item.file_path) : null"
:showIssue="true"
:allowComment="canComment"
:showViewSource="true"
:showReadBtn="true"
@onMergeAble="$emit('onMergeAble')"
@foldChange="(e) => (item.fold = e)"
/>
</div>
</div>
</DataPanel>
</div>
</div>
</div>
</d-splitter-pane>
</template>
</d-splitter>
<!-- 勿删 Status 枚举需要 -->
<div
class="hidden text-[#fa9841] bg-[#fa984133] bg-[#3ac29533] bg-[#f66f6a33] bg-[#71757f33] text-[#3ac295] text-[#f66f6a] text-[#71757f]"
></div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, toValue, watch, reactive, onMounted, provide, inject } from 'vue';
import { useReq } from '@/utils/hooks/useReq';
import { changes } from '@/api/merge/index';
import ShowFormat from './ShowFormat.vue';
import StatusSelect from './StatusSelect.vue';
import { changesTrees, versions, getDis } from '@/api/merge/index';
import DiffView from './DiffView.vue';
import { useAccountStore } from '@/stores/user';
import { escapeResData } from '@/utils';
import { diffFilter } from './support';
import { repoInfoStore } from '@/stores/Repo';
import { useMrChangeStore } from '@/stores/merge';
import { formatTime } from './support';
const mrChangeStore = useMrChangeStore();
const user = useAccountStore();
const accountInfo = user.accountInfo;
const arts_id = accountInfo?.arts_id;
const { isPrivate, isVisitor } = repoInfoStore();
const canComment = computed(() => {
if (isPrivate) return isVisitor;
else return !!arts_id;
});
//
const repoId = inject('repoId');
const iid = inject('iid');
provide('arts_id', arts_id);
provide('accountInfo', accountInfo);
const mrInfo = inject('mrInfo');
provide('source_branch', mrInfo?.value?.source_branch);
// 筛选
const selected = ref([]);
const keyWord = ref('');
const allFold = ref(false);
const handleExpand = () => {
diffs?.value?.changes?.forEach((x) => (x.collapsed = allFold.value));
allFold.value = !allFold.value;
};
// 基线
const preCommit = ref(null);
const afterCommit = ref(null);
provide('preCommit', preCommit);
provide('afterCommit', afterCommit);
const preCommitName = computed(() => {
return commitOptions?.value?.find((x) => x.value === preCommit.value)?.name || '';
});
const afterCommitName = computed(() => {
return commitOptions?.value?.find((x) => x.value === afterCommit.value)?.name || '';
});
const from_diff_id = computed(() => {
if (!commitOptions?.value?.length) return null;
if (preCommit.value === -1 && afterCommit.value === commitOptions.value[0].value) return null;
return afterCommit.value;
});
const to_diff_id = computed(() => {
if (preCommit.value === -1) return null;
return preCommit.value;
});
const { data: commitOptions } = useReq(versions, reactive({ repoId, iid }), null, (res) => {
const data = res.map((x, i) => ({ ...x, value: x.id }));
for (let i = 0; i < data.length; i++) {
if (i === 0) data[i].name = '最新版本';
else data[i].name = '版本' + (data.length - i);
}
return [...data, { value: -1, name: '基线' }];
});
watch(commitOptions, () => {
if (!commitOptions.value?.length) return;
//
preCommit.value = -1;
afterCommit.value = commitOptions.value[0].value;
});
const firstCommitOptions = computed(() => {
if (!commitOptions.value?.length) return [];
const firstOptions = commitOptions.value.filter((x) => x.value < afterCommit.value);
return [...firstOptions];
});
const lastCommitOptions = computed(() => {
if (!preCommit.value || !commitOptions.value?.length) return [];
const afterOptions = commitOptions.value.filter((x) => x.value > preCommit.value);
return [...afterOptions];
});
const baseLineDateNow = ref(+new Date());
const baseLineChange = () => {
loadDiffTree();
loadDiff();
baseLineDateNow.value = +new Date();
};
// diffs files tree
const formatTree = (childrenProp, pickProps) => (sourceList) => {
if (!Array.isArray(sourceList)) return sourceList;
return sourceList.map((node) => {
return {
...node,
children: formatTree(childrenProp, pickProps)(node[childrenProp]),
...pickProps(node)
};
});
};
const findTreeNode = (id, nodes) => {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children?.length) {
const deepRes = findTreeNode(id, node.children);
if (deepRes) return deepRes;
}
}
return null;
};
const diffTree = ref(null);
const loadDiffTree = () => {
changesTrees(
reactive({
repoId,
iid,
view: 'simple',
from_diff_id,
to_diff_id
})
)
.then((res) => {
const data = escapeResData(res);
const pickProps = (node) => ({
label: node.title,
id: node.file_path,
expanded: true,
selected: false
});
diffTree.value = formatTree('items', pickProps)(data.tree);
})
.catch((e) => {});
};
//
const treeRef = ref();
const onSearch = (value) => {
treeRef.value.treeFactory.searchTree(value, { isFilter: true });
};
const selectedFile = ref('');
const onTreeClick = (node) => {
selectedFile.value = node?.file_path;
const thenode = diffs?.value?.changes?.find((x) => x.file_path === selectedFile.value);
if (thenode) thenode.fold = false;
if (node.childNodeCount > 0) {
node.expanded = !node.expanded;
}
//
};
const codelistClick = (file_path) => {
selectedFile.value = file_path;
//
const thenode = findTreeNode(file_path, diffTree.value);
//
treeRef.value.treeFactory.selectNode(thenode);
};
// diffs (文件)
const diffs = ref();
const diffsloading = ref(false);
const loadDiff = () => {
diffsloading.value = true;
const diffsParams = reactive({
repoId,
iid,
view: 'simple',
ignore_whitespace_change: mrChangeStore.ignore_whitespace_change,
from_diff_id,
to_diff_id
});
changes(diffsParams)
.then((res) => {
diffsloading.value = false;
const data = escapeResData(res);
diffs.value = data;
//
})
.catch((e) => {
diffsloading.value = false;
});
};
const filterDiffs = computed(() => {
if (!diffs?.value?.changes) return [];
return diffs.value.changes?.filter((m) => diffFilter(m, selected));
});
//
// diss
const { data: diss } = useReq(getDis, reactive({ repoId, iid }));
watch(
() => mrChangeStore.ignore_whitespace_change,
() => {
diffs.value = [];
loadDiff();
}
);
onMounted(() => {
loadDiffTree();
loadDiff();
});
const collapsedChange = (e) => {
if (e) setSplitterCollapse('4px');
else setSplitterCollapse('320px');
};
const setSplitterCollapse = (left) => {
setTimeout(() => {
document.querySelector('.devui-splitter__collapse').style.left = left;
}, 0);
};
</script>
<style scoped lang="scss">
@import 'devui-theme/styles-var/devui-var.scss';
.codeblock {
// background-color: $devui-global-bg;
min-height: calc(100vh - 144px - 104px - 52px);
}
.selectItem {
:deep(.devui-code-review__header) {
background-image: linear-gradient(to right, rgba(103, 136, 242, 0.2), transparent);
}
}
.left-tree {
border-right: 1px solid $devui-dividing-line;
position: sticky;
top: 10px;
min-height: calc(100vh - 144px - 104px);
}
.splitter-border {
:deep(.resizable .devui-splitter__collapse) {
position: fixed;
left: 320px;
top: 50%;
}
:deep(.none-resizable .devui-splitter__collapse) {
position: fixed;
left: 4px;
top: 50%;
}
}
.commitTitle {
font-size: 14px;
font-weight: 400;
}
.filter-diff-list {
width: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<div class="comment-container bg-white overflow-auto" :id="'mr_' + props.diffObj.file_path">
<StatusIcon :type="getChangeType(props.diffObj)" />
<d-code-review
:fold="props.diffObj.fold"
:diff="`--- a/a\n+++ b/${props.diffObj.file_path}\n${props.diffObj.diff}`"
:output-format="mrChangeStore.mergeDiffOutputFormat"
:showBlob="showBlob"
:allow-comment="!!props.allowComment"
@fold-change="(e) => emit('foldChange', e)"
:expand-loader="codeLoader"
@add-comment="onAddComment"
@after-view-init="afterViewInit"
>
<template #headOperate>
<div class="flex gap-5 justify-center">
<d-popover
v-if="props.showIssue && props.discussions"
content="展开/收起所有评论"
trigger="hover"
:position="['top']"
>
<Icon name="gt-comment" @click="foldDiscussionsFile"></Icon>
</d-popover>
<!-- <d-popover content="转码" trigger="hover" :position="['top']">
<Icon name="gt-unfold-bar" @click="translate"></Icon>
</d-popover> -->
<d-popover v-if="showViewSource" content="查看源码" trigger="hover" :position="['top']">
<Icon name="gt-code" @click="toSourceFile"></Icon>
</d-popover>
<d-popover content="全屏显示" trigger="hover" :position="['top']">
<Icon
name="gt-frame-expand1"
tip="全屏显示"
@click="fullscreen('mr_' + props.diffObj.file_path)"
></Icon>
</d-popover>
<div v-if="showReadBtn" class="h-5 spline"></div>
<d-checkbox
v-if="showReadBtn"
label="已读"
:isShowTitle="false"
@change="onReadStateChange"
v-model="isRead"
/>
</div>
</template>
<template #blob>
<div v-if="props.diffObj.binary" class="p-2 text-gray-400">
Binary files do not support preview
</div>
<div
v-else-if="props.diffObj.too_large || (props.diffObj.collapsed && !props.diffObj.diff)"
>
<div class="flex p-2 text-gray-500">
此文件变更行数或变更字符数较多,你可以直接
<!-- <div>查看源码对比 或直接</div> -->
<div class="ml-2 cursor-pointer" @click="toSourceFile">查看源码</div>
</div>
</div>
</template>
</d-code-review>
</div>
</template>
<script setup lang="ts">
import { render, h, watch, ref, inject, toValue } from 'vue';
import { useRouter } from 'vue-router';
import FileDiffDiscussions from './FileDiffDiscussions.vue';
import StatusIcon from './StatusIcon.vue';
import { diffLines } from '@/api/merge/index';
import { escapeResData, fullscreen } from '@/utils/index';
import { getStore, setStore } from '@/utils/storage';
import { useMrChangeStore } from '@/stores/merge';
import { getChangeType } from './support';
const mrChangeStore = useMrChangeStore();
const emit = defineEmits(['onMergeAble', 'foldChange']);
const props = defineProps([
'repoId',
'commit_id',
'diffObj',
'showIssue',
'discussions',
'showReadBtn',
'showViewSource',
'allowComment'
]);
const iid = inject('iid');
const arts_id = inject('arts_id');
const accountInfo = inject('accountInfo');
const mrInfo = inject('mrInfo');
const preCommit = inject('preCommit');
const afterCommit = inject('afterCommit');
const source_branch = inject('source_branch');
const router = useRouter();
const showBlob = ref(
props.diffObj.binary ||
props.diffObj.too_large ||
(props.diffObj.collapsed && !props.diffObj.diff)
);
let codeReviewIns = {};
const createCommentBlock = (discussions, insertPositionInfo) => {
const onMergeAble = () => emit('onMergeAble');
const container = document.createElement('div');
render(
h(FileDiffDiscussions, {
discussions,
insertPosition: `${props.diffObj.file_path}-${insertPositionInfo.lineNumber}-${insertPositionInfo.lineSide}`,
insertPositionInfo,
codeReviewProp: {
repoId: toValue(props.repoId),
iid,
arts_id,
accountInfo,
onMergeAble,
diffObj: props.diffObj
}
}),
container
);
return container;
};
const onAddComment = (param) => {
if (!arts_id) return;
//
const { left, right } = param;
let lineSide = 'right';
let lineNumber = right;
if (left !== -1) {
lineSide = 'left';
lineNumber = left;
}
//
const addingFileLineInfo = `${props.diffObj.file_path}-${lineNumber}-${lineSide}`;
// 重复点击
if (mrChangeStore.addingDiscussionsFile === addingFileLineInfo) return;
mrChangeStore.addingDiscussionsFile = addingFileLineInfo;
if (disscussionsLineRecord.exsit(lineNumber, lineSide)) {
// new discussion
// 啥也不做, 交给子组件处理
} else {
// new discussions
const insertPositionInfo = { lineNumber, lineSide };
insertDiscussions([], insertPositionInfo);
}
};
const afterViewInit = (e) => {
codeReviewIns = e;
renderData();
};
const disscussionsLineRecord = {
record: {},
exsit: (lineNumber, lineSide) => disscussionsLineRecord.record[`${lineNumber}-${lineSide}`],
mark: (lineNumber, lineSide) =>
(disscussionsLineRecord.record[`${lineNumber}-${lineSide}`] = true)
};
const insertDiscussions = (discussions, insertPositionInfo) => {
const { lineNumber, lineSide } = insertPositionInfo;
codeReviewIns.insertComment(
lineNumber,
lineSide,
createCommentBlock(discussions, insertPositionInfo)
);
disscussionsLineRecord.mark(lineNumber, lineSide);
};
// 数据是异步加载的, 当容器和数据都准备好后, 才进行渲染
let renderDataOnce = false;
const renderData = () => {
if (renderDataOnce) return;
if (!codeReviewIns || !props.discussions) return;
renderDataOnce = true;
const render = () => {
const theold = props.discussions.old || [];
const thenew = props.discussions.new || [];
const theList = [
...theold.map((x) => ({ ...x, lineSide: 'left', lineNumber: x.line })),
...thenew.map((x) => ({ ...x, lineSide: 'right', lineNumber: x.line }))
];
theList.forEach((x) => {
const { discussions, lineNumber, lineSide } = x;
insertDiscussions(discussions, { lineNumber, lineSide });
});
};
// 目前 code-review 组件有bug, init 事件里直接插入元素不显示
setTimeout(render, 0);
};
watch(() => props.discussions, renderData);
//
const mrCacheKey = 'mr-files-isread';
const fileKey = `${arts_id}-${toValue(mrInfo)?.project_id}-${iid}-${toValue(preCommit)}-${toValue(
afterCommit
)}-${props.diffObj.file_path}`;
const today = +new Date();
const expireTime = 2592000000; // 30天 -- 15552000000 // 180天
const onReadStateChange = (e) => {
const mrCache = getStore(mrCacheKey) || {};
if (e) mrCache[fileKey] = +new Date();
else delete mrCache[fileKey];
Object.keys(mrCache).forEach((m) => {
if (today - mrCache[m] > expireTime) delete mrCache[m];
});
setStore(mrCacheKey, mrCache);
};
const getReadState = () => {
const mrCache = getStore(mrCacheKey) || {};
return mrCache.hasOwnProperty(fileKey);
};
const isRead = ref(getReadState());
const translate = () => {};
const toSourceFile = () => {
const href = router.resolve({
name: 'repoFile',
params: {
branchName: source_branch,
filePath: props.diffObj.file_path
}
});
window.open(href?.href?.replace(/%2F/g, '/'), '_blank');
};
//
const foldDiscussionsFile = () => {
mrChangeStore.foldDiscussionsFile.curClickFile = props.diffObj.file_path;
mrChangeStore.foldDiscussionsFile.record[props.diffObj.file_path] =
!mrChangeStore.foldDiscussionsFile.record[props.diffObj.file_path];
};
//
const codeLoader = (position, update) => {
const [lStart, lEnd, rStart, rEnd] = position;
// 文件末尾
if (lStart === undefined) return update('');
const param = {
repoId: toValue(props.repoId),
file_path: props.diffObj.file_path,
commit_id: props.commit_id || props.diffObj.content_sha,
right_start: rStart,
right_end: rEnd
};
diffLines(param)
.then((res) => {
const data = escapeResData(res);
if (!data?.text) return update('');
const content =
'--- a/src/diff2html.js\n+++ b/src/diff2html.js\n@@ -' +
Math.min(lStart, lEnd) +
',' +
Math.abs(lStart - lEnd - 1) +
' +' +
Math.min(rStart, rEnd) +
',' +
Math.abs(rStart - rEnd - 1) +
' @@\n ' +
data.text;
update(content);
})
.catch((e) => {
// update('');
});
};
</script>
<style lang="scss" scoped>
@import 'devui-theme/styles-var/devui-var.scss';
.comment-container {
border-radius: var(--border-radius);
:deep(.devui-code-review__header) {
border-radius: 0;
}
:deep(.d2h-file-wrapper) {
margin-bottom: unset;
}
:deep(.d2h-file-side-diff) {
margin-bottom: unset;
overflow-x: auto;
}
:deep(.d2h-code-side-emptyplaceholder) {
background-color: #fafafa;
}
:deep(.d2h-emptyplaceholder) {
background-color: #fafafa;
}
:deep(.devui-code-review__header) {
border-bottom: unset;
}
:deep(.devui-code-review) {
border-radius: 8px;
}
:deep(.devui-code-review__header) {
border-radius: 8px;
}
:deep(.devui-checkbox__label-text) {
color: #707a87;
}
}
.spline {
border-left: 1px solid #d1d2d4;
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<DiscussionItem
v-bind="$props"
:eventIcon="eventIcon === undefined ? 'gt-comment' : eventIcon"
:data="data"
:origin="origin"
:body="temp?.body"
:repo-id="repoId"
@save-content="saveDescription"
:can-delete="author?.username === user?.username"
:can-edit="author?.username === user?.username"
:loading="loading"
:targetId="temp?.iid"
@on-remove="handleDelete"
@quote-reply="$emit('quote-reply', temp?.body)"
@reply-discussion="handleReply"
:resolvable="temp.resolvable"
:resolved="resolvable === false ? false : temp.resolved"
@resolve-stat-change="resolveStatChange"
:event-msg="hasDiff ? undefined : '评论'"
:diff="hasDiff ? temp.diff : ''"
:diffFile="hasDiff ? temp.diff_file : ''"
:addedLines="hasDiff ? origin.added_lines : undefined"
:removedLines="hasDiff ? origin.removed_lines : undefined"
:sourceBranch="sourceBranch"
:formatIng="formatIng"
@doFormat="doFormat"
:hasDiff="hasDiff"
:canResolve="canResolve"
ref="RefDiscussion"
>
<slot></slot>
</DiscussionItem>
</template>
<script lang="ts" setup>
defineOptions({ name: 'MrDiscussionItem' });
import { ref, computed, inject, type Ref } from 'vue';
import DiscussionItem from '@/views/Repo/components/DiscussionItem/index.vue';
import {
updateMRDiscussion,
delMRDiscussionsNote,
replayDiscussionsNote,
changes
} from '@/api/merge';
import { reqCatch } from '@/utils/catch';
import debounce from 'lodash/debounce';
import { repoInfoStore } from '@/stores/Repo';
const { isDeveloper } = repoInfoStore();
const isMrAuthor = inject('isMrAuthor') as Ref;
const mrInfo = inject('mrInfo') as Ref;
const repoId = inject('repoId') as Ref;
const iid = inject('iid') as number;
const mergeAbleChange = inject('mergeAbleChange') as Function;
const updatePage = inject('updatePage') as Function;
const props = defineProps<{
data: any;
origin: any;
hasDiff?: boolean;
eventIcon?: string;
author: any;
user: any;
resolvable?: any;
}>();
const key = ref(0);
const RefDiscussion = ref();
const loading = ref(false);
const formatIng = ref(false);
const temp = ref(props.data);
const sourceBranch = computed(() => mrInfo.value?.source_branch);
/**
* 1、建议级别MR作者、评论者、开发者及以上权限可操作。
* 2、一般及以上级别评论者、开发者及以上权限可操作MR作者不可操作即使有以上权限也不行。
*/
const canResolve = computed(() => {
if (mrInfo.value?.author?.username !== props.author?.username) {
return false;
} else if (isDeveloper) {
return true;
} else {
return false;
}
});
const emit = defineEmits<{
'delete': [];
'quote-reply': [value: string];
'add-reply': [obj: object];
}>();
/**
* 修改评论
*/
const saveDescription = async(str: string) => {
loading.value = true;
const res = await reqCatch(updateMRDiscussion, {
project_id: repoId.value,
merge_request_iid: iid,
discussion_id: props.origin.id,
body: str
});
loading.value = false;
if (!res.error) {
temp.value.body = str;
key.value++;
RefDiscussion.value.reset();
}
};
/**
* 修改问题解决状态
*/
const resolveStatChange = debounce(async(blod: boolean) => {
const res = await reqCatch(updateMRDiscussion, {
project_id: repoId.value,
merge_request_iid: iid,
discussion_id: props.origin.id,
resolved: blod
});
if (!res.error) {
temp.value = res.data.data.notes[0];
mergeAbleChange();
updatePage();
}
}, 500);
/**
* 删除评论
*/
const handleDelete = async() => {
const res = await reqCatch(delMRDiscussionsNote, {
project_id: repoId.value,
merge_request_iid: iid,
note_id: props.data.id
});
if (!res.error) {
emit('delete');
}
};
/**
* 回复
*/
const handleReply = async(str: string) => {
const res = await reqCatch(replayDiscussionsNote, {
project_id: repoId.value,
merge_request_iid: iid,
discussion_id: props.origin.id,
body: str
});
if (!res.error) {
const msg = res.data.data;
RefDiscussion.value.reset();
key.value++;
emit('add-reply', msg);
}
};
/**
* 转码
*/
const doFormat = async() => {
formatIng.value = true;
const res = changes({
file_path: sourceBranch,
ignore_whitespace_change: false,
view: 'simple',
force_encode: true,
repoId: repoId.value,
iid: iid
});
formatIng.value = false;
};
</script>

View File

@@ -0,0 +1,12 @@
<template>
<DiscussionItem :event-icon="eventIcon" v-bind="$props" />
</template>
<script lang="ts" setup>
defineOptions({ name: 'MrEventItem' });
import DiscussionItem from '@/views/Repo/components/DiscussionItem/index.vue';
import { mrEventOption } from '@/constant/mr';
import { computed } from 'vue';
const props = defineProps<{ eventType: string }>();
const eventIcon = computed(() => mrEventOption[props.eventType]?.icon || '');
</script>

View File

@@ -0,0 +1,149 @@
<template>
<div v-if="discussions" @click="isFold = !isFold" class="relative">
<div v-if="discussions.length >= 2" class="flex absolute -top-[18px] cursor-pointer">
<GAvatar
v-for="(discussion, index) in discussions.slice(0, 2)"
:key="discussion.id"
class="avatar"
:src="discussion.proposer?.avatar_url"
:name="discussion.proposer.namme"
:width="16"
:height="16"
></GAvatar>
</div>
</div>
<div
v-if="
props.codeReviewProp.arts_id && mrChangeStore.addingDiscussionsFile === props.insertPosition
"
class="edit-comment-container"
>
<EditorMd v-model="commentValue" placeholder=""></EditorMd>
<div class="mt-2 flex justify-end">
<Button variant="solid" :disabled="!commentValue?.trim()" @click="onConfirm">确定</Button>
<Button class="ml-2" @click="mrChangeStore.addingDiscussionsFile = ''">取消</Button>
</div>
</div>
<div v-show="!isFold">
<div
class="comment-block bg-white"
v-for="(discussion, index) in discussions"
:key="discussion.id"
>
<FileDiffDiscussionsItem :discussion="discussion" :allProp="props.codeReviewProp" />
</div>
</div>
</template>
<script setup lang="ts">
import FileDiffDiscussionsItem from './FileDiffDiscussionsItem.vue';
import GAvatar from '@/components/GAvatar/index.vue';
import { provide, ref, watch } from 'vue';
import { Button } from 'vue-devui/button';
import { EditorMd } from 'vue-devui/editor-md';
import { useMrChangeStore } from '@/stores/merge';
import { escapeResData } from '@/utils';
import { postDis } from '@/api/merge/index';
const mrChangeStore = useMrChangeStore();
const props = defineProps({
discussions: {
type: Array,
default: () => []
},
codeReviewProp: {
type: Object,
default: () => ({})
},
insertPosition: {
type: String,
default: ''
},
insertPositionInfo: {
type: Object,
default: () => ({})
}
});
const isFold = ref(false);
const discussions = ref(props.discussions);
provide('onDelHeadNote', (disId) => {
discussions.value = discussions.value.filter((m) => m.id != disId);
});
Object.keys(props.codeReviewProp).forEach((m) => {
provide(m, props.codeReviewProp[m]);
});
const commentValue = ref();
const onConfirm = () => {
const param = {
repoId: props.codeReviewProp.repoId,
iid: props.codeReviewProp.iid,
body: commentValue.value,
line_types: props.insertPositionInfo.lineSide === 'left' ? 'old' : 'new',
position: {
...props.codeReviewProp.diffObj.diff_refs,
position_type: 'text',
new_path: props.codeReviewProp.diffObj.new_path,
old_path: props.codeReviewProp.diffObj.old_path,
new_line:
props.insertPositionInfo.lineSide === 'right' ? props.insertPositionInfo.lineNumber : -1,
old_line:
props.insertPositionInfo.lineSide === 'left' ? props.insertPositionInfo.lineNumber : -1,
ignore_whitespace_change: props.codeReviewProp.ignore_whitespace_change
},
assignee_id: props.codeReviewProp.arts_id,
proposer_id: props.codeReviewProp.arts_id,
severity: 'suggestion'
};
//
postDis(param)
.then((res) => {
mrChangeStore.addingDiscussionsFile = '';
commentValue.value = '';
//
const data = escapeResData(res);
discussions.value = [data, ...discussions.value];
isFold.value = false;
props.codeReviewProp.onMergeAble();
})
.catch((e) => {
// mrChangeStore.addingDiscussionsFile = '';
});
};
// 展开/折叠 所有讨论
watch(
() => mrChangeStore.foldDiscussionsFile,
() => {
if (mrChangeStore.foldDiscussionsFile.curClickFile === props.codeReviewProp.diffObj.file_path) {
isFold.value =
mrChangeStore.foldDiscussionsFile.record[mrChangeStore.foldDiscussionsFile.curClickFile];
}
},
{ deep: true }
);
</script>
<style scoped lang="scss">
@import 'devui-theme/styles-var/devui-var.scss';
.edit-comment-container {
padding: 12px 16px;
}
.comment-block {
border-top: 1px solid $devui-dividing-line;
border-bottom: 1px solid $devui-dividing-line;
box-sizing: border-box;
& + .comment-block {
// margin-top: 20px;
}
}
.avatar {
& + .avatar {
margin-left: -6px;
}
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<div v-if="notes.length">
<FileDiffDiscussionsItemNote
v-for="(note, index) in notes"
:key="note.id"
:note="note"
:discussion="props.discussion"
:isFirstNote="index === 0"
@onDelNote="onDelNote"
/>
<!-- 回复 -->
<div v-if="canComment && !showReplayMD" class="flex items-center py-4 pl-8">
<GAvatar
:src="accountInfo?.avatar"
:name="accountInfo?.nickname"
:width="20"
:height="20"
></GAvatar>
<Input
class="ml-2 mr-4"
@click="
showReplayMD = true;
replayText = '';
"
placeholder="回复..."
></Input>
</div>
<div v-if="canComment && showReplayMD" class="px-11 mt-4">
<EditorMd v-model="replayText" class="mb-4"></EditorMd>
<div class="flex justify-end">
<Button
@click="submitNote"
:disabled="!replayText?.trim()"
variant="solid"
class="mb-4"
color="primary"
>确定</Button
>
<Button @click="showReplayMD = false" class="ml-2 mb-4" color="primary">取消</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive, toValue, inject, computed } from 'vue';
import { Button } from 'vue-devui/button';
import { Input } from 'vue-devui/input';
import GAvatar from '@/components/GAvatar/index.vue';
import { EditorMd } from 'vue-devui/editor-md';
import { postNote } from '@/api/merge/index';
import { repoInfoStore } from '@/stores/Repo';
import { escapeResData } from '@/utils';
import FileDiffDiscussionsItemNote from './FileDiffDiscussionsItemNote.vue';
const repoId = inject('repoId');
const iid = inject('iid');
const arts_id = inject('arts_id');
const accountInfo = inject('accountInfo');
const onMergeAble = inject('onMergeAble');
const onDelHeadNote = inject('onDelHeadNote');
const { isPrivate, isVisitor } = repoInfoStore();
const canComment = computed(() => {
if (isPrivate) return isVisitor;
else return !!arts_id;
});
const props = defineProps({
discussion: {
type: Object,
default: () => ({})
}
});
const onDelNote = (id) => {
notes.value = notes.value.filter((m) => m.id !== id);
if (!notes.value.length) {
onMergeAble();
onDelHeadNote(props.discussion.id);
}
};
const showReplayMD = ref(false);
const replayText = ref('');
const notes = ref(props.discussion.notes);
const submitNote = () => {
showReplayMD.value = false;
postNote(
reactive({
repoId: repoId,
iid: iid,
disId: props.discussion.id,
//
body: replayText.value,
severity: 'suggestion',
assignee_id: arts_id
})
)
.then((res) => {
const data = escapeResData(res);
if (data.id) {
notes.value = [...notes.value, data];
}
//
})
.catch((error) => {});
};
onMounted(() => {});
</script>
<style scoped lang="scss">
@import 'devui-theme/styles-var/devui-var.scss';
</style>

View File

@@ -0,0 +1,260 @@
<template>
<div class="comment-box">
<GAvatar
class="pt-2"
:src="props.note.author?.avatar_url"
:name="props.note.author.name"
:width="20"
:height="20"
></GAvatar>
<div class="comment-info">
<div class="comment-box-header">
<div class="comment-user">
<span class="comment-user-name">{{ props.note.author.name }}</span>
<span class="comment-user-date">{{ formatTime(props.note.created_at) }}</span>
</div>
<div class="comment-operate">
<div class="comment-state" v-if="isAdmin?.value && props.isFirstNote">
<Switch v-model="resolved" @change="(e) => resolveChange(e)" size="sm">
<template #checkedContent>
<i class="icon-right"></i>
</template>
<template #uncheckedContent>
<i class="icon-error"></i>
</template>
</Switch>
<span>{{ resolved ? '已解决' : '未解决' }}</span>
</div>
<Button v-if="arts_id === props.note.author.id" icon="edit" @click="inEdit = true" variant="text"></Button>
<Button
v-if="arts_id === props.note.author.id"
@click="delVisible = true"
icon="delete"
variant="text"
></Button>
</div>
</div>
<div class="comment-box-content">
<MdRender v-if="!inEdit" :content="props.note.body"></MdRender>
<div v-if="inEdit">
<EditorMd v-model="noteEditText" class="mb-4"></EditorMd>
<div class="flex justify-end">
<Button @click="updateNote" :disabled="!noteEditText?.trim()" variant="solid" class="mb-4" color="primary"
>确定</Button
>
<Button @click="inEdit = false" class="ml-2 mb-4">取消</Button>
</div>
</div>
</div>
</div>
</div>
<!-- 删除 -->
<Modal v-model="delVisible">
<template #header>
<ModalHeader>
<span>提示</span>
</ModalHeader>
</template>
<div>确认删除</div>
<template #footer>
<ModalFooter style="text-align: right; padding-right: 20px">
<Button @click="() => onDelNote() & (delVisible = false)" variant="solid" color="primary">确认</Button>
<Button @click="delVisible = false">取消</Button>
</ModalFooter>
</template>
</Modal>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, reactive, inject } from 'vue';
import GAvatar from '@/components/GAvatar/index.vue';
import { Switch } from 'vue-devui/switch';
import { Button } from 'vue-devui/button';
import { Message } from 'vue-devui/message';
import { Modal, ModalHeader, ModalFooter } from 'vue-devui/modal';
import { EditorMd } from 'vue-devui/editor-md';
import { MdRender } from 'vue-devui/editor-md';
import { putDis, delNote, putNote } from '@/api/merge/index';
import { escapeResData } from '@/utils';
import { repoInfoStore } from '@/stores/Repo';
import { formatTime } from './support';
const { isAdmin } = repoInfoStore();
const repoId = inject('repoId');
const iid = inject('iid');
const arts_id = inject('arts_id');
const onMergeAble = inject('onMergeAble');
const props = defineProps({
discussion: {
type: Object,
default: () => ({})
},
note: {
type: Object,
default: () => ({})
},
isFirstNote: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['onDelNote']);
const inEdit = ref(false);
const delVisible = ref(false);
const resolved = ref(props.discussion?.resolved || false);
const onDelNote = () => {
delNote(reactive({ repoId, iid, noteId: props.note.id }))
.then((res) => {
// notes.value = notes.value.filter((m) => m.id !== theNote.value?.id);
Message.success('操作成功');
emit('onDelNote', props.note.id);
//
})
.catch((error) => {});
};
const resolveChange = (e) => {
putDis(
reactive({
repoId,
iid,
disId: props.discussion.id,
resolved: e
})
).then((res) => {
Message.success('操作成功');
onMergeAble();
});
};
const showReplayMD = ref(false);
const noteEditText = ref(props.note.body);
const updateNote = () => {
showReplayMD.value = false;
putNote(
reactive({
repoId: repoId,
iid: iid,
noteId: props.note.id,
//
body: noteEditText.value,
severity: 'suggestion',
assignee_id: arts_id
})
)
.then((res) => {
const data = escapeResData(res);
if (data.id) {
// Message.success('')
props.note.body = noteEditText.value;
inEdit.value = false;
}
})
.catch((error) => {});
};
onMounted(() => {});
</script>
<style scoped lang="scss">
@import 'devui-theme/styles-var/devui-var.scss';
.comment-box {
display: flex;
padding: 12px 16px 0px;
border-bottom: 1px solid $devui-dividing-line;
&:first-child {
}
&:not(:first-child) {
padding-left: 32px;
}
img {
width: 32px;
height: 32px;
border-radius: 50%;
}
.comment-info {
display: flex;
flex-direction: column;
width: 100%;
.comment-box-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 4px 0;
.comment-user {
padding: 0 12px;
.comment-user-name {
font-size: $devui-font-size;
font-weight: 500;
color: $devui-text;
}
.comment-user-date {
&::before {
content: '·';
padding: 0 4px;
}
font-size: $devui-font-size-sm;
color: $devui-aide-text;
}
.comment-user-severity {
:deep(.devui-tag--md) {
margin-left: 8px;
font-size: $devui-font-size-sm;
height: 20px;
line-height: 20px;
color: $devui-aide-text;
background-color: $devui-label-bg;
cursor: default;
&:hover {
color: initial;
}
}
}
}
.comment-operate {
display: flex;
align-items: center;
font-size: $devui-font-size-page-title;
color: $devui-icon-text;
.comment-state {
display: flex;
align-items: center;
margin-right: 12px;
span {
display: inline-block;
font-size: $devui-font-size-sm;
color: $devui-aide-text;
line-height: 20px;
margin-left: 4px;
}
}
button:last-of-type {
margin-left: 4px;
}
}
}
.comment-box-content {
padding-left: 12px;
word-break: break-word;
}
}
}
</style>

View File

@@ -0,0 +1,737 @@
<template>
<DataPanel :loading="loading" :skeleton="loading" :card="false">
<div class="g-content-card card-box" v-if="detail?.state === 'opened'">
<div class="flex items-center flex-1">
<div
class="flex justify-center items-center"
:class="[
'card-icon',
mergeAble?.approval_reviewers_required_passed
? 'card-icon-success'
: 'card-icon-warning'
]"
>
<Icon
v-if="mergeAble?.approval_reviewers_required_passed"
name="gt-success"
size="14px"
color="#fff"
/>
<Icon v-else name="gt-warn2" size="14px" color="#fff" />
</div>
<div class="card-label font-[600]">{{ needReview.text }}</div>
<div class="text-CG600">审查并提供反馈, 已确保代码质量和发现问题</div>
</div>
<div v-if="needReview.showReviewButton">
<template v-if="needReview.showAll">
<d-button
class="mr-[8px]"
variant="solid"
color="primary"
@click="handleComment('complete')"
:loading="approvalLoading"
>通过</d-button
>
<d-button
variant="solid"
color="secondary"
@click="handleComment('reject')"
:loading="rejectLoading"
>拒绝</d-button
>
</template>
<template v-else>
<d-button
variant="solid"
color="secondary"
@click="handleComment('reset')"
:loading="approvalLoading"
>撤销</d-button
>
</template>
</div>
</div>
<div
class="g-content-card flex flex-col py-4 px-5"
v-if="
detail?.state === 'opened' &&
(haveMergeAuth || mergeConfig.showRebase || mergeConfig.canForce || isAdminOrAuthor)
"
>
<div class="flex">
<div class="flex items-center">
<div
class="flex justify-center items-center"
:class="['card-icon ', mergeAble?.state ? 'card-icon-success' : 'card-icon-error']"
>
<Icon v-if="mergeAble?.state" name="gt-success" size="14px" color="#fff" />
<Icon v-else name="gt-warn2" size="14px" color="#fff" />
</div>
<div class="flex" v-if="warningInfo.errorInfo">
<div class="card-label w-[72px] font-[600] whitespace-nowrap pr-[8px]">合并受阻:</div>
<div class="card-labels">{{ warningInfo.errorInfo }}</div>
</div>
<div class="card-label font-[600]" v-else>当前Pull Request中的源分支与目标分支没有冲突</div>
</div>
</div>
<div v-if="haveMergeAuth" class="flex flex-col gap-2">
<d-checkbox
v-if="haveMergeAuth && !sourceProtectedBranchInfo?.name"
class="my-[12px]"
v-model="mergeOptions.should_remove_source_branch"
>合入后删除源分支</d-checkbox
>
<d-checkbox
v-if="haveMergeAuth && mergeAble?.merge_request_switch?.disable_squash_merge === false"
class="my-[12px]"
v-model="mergeOptions.squash"
>
<span class="inline-block card-label">Squash 合并</span>
<span class="text-[#2562C4] inline-block text-[13px] px-[18px]">修改 Squash 信息</span>
</d-checkbox>
<d-textarea
style="width: 100%"
v-if="
mergeAble?.merge_request_switch?.disable_squash_merge === false && mergeOptions.squash
"
:autosize="{ minRows: 3, maxRows: 6 }"
resize="vertical"
v-model="mergeOptions.squash_commit_message"
placeholder="请输入 Squash Commit 的相关备注信息"
></d-textarea>
</div>
<div class="flex justify-end">
<d-button
class="ml-[8px]"
@click="onMerge(false)"
:loading="mergeLoading"
variant="solid"
:disabled="!mergeConfig.canMerge"
v-if="haveMergeAuth"
color="primary"
>
合入
</d-button>
<d-button
@click="handleChangeRebase"
:loading="rebaseLoading"
class="ml-[8px]"
variant="solid"
color="primary"
v-if="mergeConfig.showRebase"
>变基</d-button
>
<d-button
class="ml-[8px]"
@click="onMerge(true)"
:loading="mergeLoading"
variant="solid"
v-if="mergeConfig.canForce"
color="danger"
>
强制合并
</d-button>
<d-button
v-if="isAdminOrAuthor"
class="ml-[8px]"
:loading="editLoading"
@click="enableWip"
>{{ enabledWIP ? '设为草稿' : '准备就绪' }}</d-button
>
<d-button v-if="isAdminOrAuthor" class="ml-[8px]" @click="handlePopup('close')"
>关闭</d-button
>
</div>
</div>
<div
class="g-content-card card-box"
v-if="detail?.state === 'merged' || detail?.state === 'closed'"
>
<div class="flex items-center flex-1">
<div
class="flex justify-center items-center"
:class="[
'card-icon',
detail?.state === 'closed' ? 'card-icon-warning' : 'card-icon-success'
]"
>
<Icon
v-if="detail?.state === 'merged'"
name="gt-merge-request"
size="14px"
color="#fff"
/>
<Icon v-if="detail?.state === 'closed'" name="gt-closed-merge" size="14px" color="#fff" />
</div>
<div class="card-label font-[600]">
{{
detail?.state === 'merged'
? `Pull Request已成功合入, 合并人@${detail?.merged_by.username}`
: `当前Pull Request已关闭, 关闭人@${detail.closed_by.username}`
}}
</div>
<div class="card-labels" v-if="detail?.state === 'merged'">
{{
reviewUser.length
? `(感谢${reviewUser.map((item) => `@${item.username}`).join('、')}的贡献)`
: ''
}}
</div>
</div>
<div>
<d-button
class="mr-[8px]"
@click="handlePopup('cherry')"
v-if="isAdminOrAuthor && detail?.state === 'merged'"
>Cherry-Pick</d-button
>
<d-button
class="mr-[8px]"
@click="handlePopup('revert')"
v-if="isAdminOrAuthor && detail?.state === 'merged'"
>Revert</d-button
>
<d-button
:loading="reOpenLoading"
v-if="isAdminOrAuthor && mergeConfig.can_reopen && detail.state === 'closed'"
@click="handleReOpen"
>重新打开</d-button
>
</div>
</div>
</DataPanel>
<d-modal style="width: 420px" v-model="visible">
<template #header>
<gc-modal-header>
<span>{{ modalInfo.title }}</span>
</gc-modal-header>
</template>
<template #default>
<div v-if="modalInfo.type === 'close'">{{ modalInfo.content }}</div>
<div v-else>
<d-select v-model="mergeCallbackSetting.branch" :options="branches"></d-select>
<d-checkbox class="my-[20px]" v-model="mergeCallbackSetting.with_new_merge_request"
>是否使用新的Pull Request进行
{{ mergedType === 'cherry' ? 'Cherry Pick' : 'Revert' }}</d-checkbox
>
</div>
</template>
<template #footer>
<div class="g-modal-footer">
<d-button @click="visible = false">取消</d-button>
<d-button
@click="handleConfirm"
:color="modalInfo.type === 'close' ? 'danger' : 'primary'"
:loading="confirmLoading"
>{{ modalInfo.type == 'close' ? '关闭' : '提交' }}</d-button
>
</div>
</template>
</d-modal>
</template>
<script setup lang="ts">
defineOptions({ name: 'MergeStream' });
import { ref, reactive, computed, watchEffect, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { getRepoBranches } from '@/api/repo';
import { putApproval_review, changeMrRebase, cherryPick } from '@/api/merge/index';
import { revert, reOpen, approvalReviewers } from '@/api/merge/index';
import { mergeDetail, putMerge, mergeableState, merge, closeMerge } from '@/api/merge/index';
import { Message } from 'vue-devui/message';
import { watchOnce } from '@vueuse/core';
import { useAccountStore } from '@/stores/user';
import { getProtectedBranchInfo } from '@/api/repo';
import { escapeResData } from '@/utils';
import { repoInfoStore } from '@/stores/Repo';
const { isDeveloper, isAdmin } = repoInfoStore();
const props = defineProps<{ repoId: string; iid: string }>();
const router = useRouter();
const emits = defineEmits(['change', 'approvalChange']);
const { accountInfo } = useAccountStore();
// const isAuthor = computed(() => detail.value?.author?.id === accountInfo?.arts_id);
const isAdminOrAuthor = computed(() => {
return isAdmin || detail.value?.author?.id === accountInfo?.arts_id;
});
const haveMergeAuth = computed(() => {
// admin
if (isAdmin) return true;
// 开发者
if (targetProtectedBranchInfo.value?.developers_can_merge && isDeveloper) {
// 开发者自己
if (
mergeAble.value?.merge_request_switch.disable_merge_by_self &&
detail.value?.author?.id === accountInfo?.arts_id
) {
return false;
}
return true;
}
return false;
});
const loading = ref(true);
const approvalLoading = ref(false);
const rejectLoading = ref(false);
const rebaseLoading = ref(false);
const reOpenLoading = ref(false);
const confirmLoading = ref(false);
const editLoading = ref(false);
const visible = ref(false);
const mergedType = ref<'cherry' | 'revert' | ''>(''); // cherry or revert
let interval: any = null; // 定时器
/** 数据集合 */
const mergeAble = ref<any>();
const detail = ref<any>();
const targetProtectedBranchInfo = ref({});
const sourceProtectedBranchInfo = ref({});
const reviewUser = ref<any[]>([]);
const branches = ref<
{
name: string;
[x: string]: any;
}[]
>([]);
/** merge合并配置 */
const mergeOptions = reactive({
squash: false,
should_remove_source_branch: false,
squash_commit_message: ''
});
/** cherryPick or revert */
const mergeCallbackSetting = reactive({
branch: '',
with_new_merge_request: false
});
/** 弹窗混合 */
const modalInfo = reactive<{
title: string;
content: string;
type: 'close' | 'operate';
}>({
title: '',
content: '',
type: 'close'
});
/** 初始状态是否准备就绪 */
const enabledWIP = computed(() => {
return !!mergeAble.value?.work_in_progress_passed;
});
/**
* canMerge: non_ff_passed为true
* can_force: 是否允许强制何如
* 轮询过程中如果详情中的rebase_in_progress为false则可以则提交合并
*/
const mergeConfig = computed(() => {
if (mergeAble.value) {
return {
canMerge:
!!mergeAble.value.non_ff_passed &&
!!mergeAble.value.state &&
!!mergeAble.value.work_in_progress_passed,
can_reopen: !!mergeAble.value.merge_request_switch.can_reopen,
canForce: isAdmin && !!mergeAble.value.can_force_merge,
showRebase:
haveMergeAuth.value && !mergeAble.value.non_ff_passed && !detail.value?.merge_error
};
} else {
return {
canMerge: false,
can_reopen: false,
canForce: false,
showRebase: false
};
}
});
/** 评审相关交互
* @result state
* enum state {
* true: 通过
* reject: 拒绝
* optional: 默认状态
* }
*/
const needReview = computed(() => {
if (reviewUser.value?.length) {
const user = reviewUser.value.find((item) => item.id === accountInfo?.arts_id);
return {
showReviewButton: !!user,
showAll: user?.state === 'optional',
text: mergeAble.value?.approval_reviewers_required_passed ? '代码评审完成' : `代码评审待通过`
};
} else {
return {
showReviewButton: false,
showAll: false,
text: mergeAble.value?.approval_reviewers_required_passed
? '代码评审完成'
: `至少需要${mergeAble.value?.merge_request_switch.approval_required_reviewers_count}人通过代码评审`
};
}
});
/** 无法合并的相关提示信息 */
const warningInfo = computed(() => {
const result: string[] = [];
if (mergeAble.value) {
const {
approval_reviewers_required_passed,
branch_missing_passed,
conflict_passed,
merge_by_self_passed,
non_ff_passed,
resolve_discussion_passed,
merged_by_user_passed
} = mergeAble.value;
if (detail.value && detail.value?.merge_error) {
result.push(detail.value.merge_error);
}
if (!non_ff_passed && !detail.value.merge_error) {
result.push('变基通过后方可查询合并状态');
}
if (!approval_reviewers_required_passed) {
result.push('未通过评审');
}
if (!branch_missing_passed) {
result.push('缺少分支');
}
if (!conflict_passed) {
result.push('代码合入存在冲突');
}
if (!merge_by_self_passed) {
result.push('不允许合并自己创建的mr');
}
if (!resolve_discussion_passed) {
result.push('存在未解决的评审问题');
}
if (!merged_by_user_passed) {
result.push('用户无权限');
}
return {
errorInfo: result?.length ? result[0] : '',
errors: result
};
} else {
return {
errorInfo: '',
errors: result
};
}
});
const mergeLoading = ref(false);
/** 合入mr */
const onMerge = (force: boolean = false) => {
if (mergeConfig.value.canMerge || mergeConfig.value.canForce) {
mergeLoading.value = true;
merge({
repoId: props.repoId,
iid: props.iid,
force_merge: force,
squash: mergeOptions.squash,
should_remove_source_branch: mergeOptions.should_remove_source_branch,
squash_commit_message: mergeOptions.squash_commit_message
}).then(() => {
mergeLoading.value = false;
router.go(0);
});
}
};
/** 关闭mr */
const onCloseMerge = () => {
confirmLoading.value = true;
closeMerge({ repoId: props.repoId, iid: props.iid, state_event: 'close' })
.then(() => {
// router.back();
router.go(0);
})
.catch((e) => {})
.finally(() => {
confirmLoading.value = false;
});
};
// 轮询查看详情中的rebase_in_progress
const intervalRequest = async() => {
if (!interval) {
interval = setInterval(() => {
const params = { repoId: props.repoId, iid: props.iid, rebase_in_progress: true };
mergeDetail(params).then((res) => {
const { rebase_in_progress } = res.data;
detail.value = res.data;
if (!rebase_in_progress) {
if (interval) {
clearInterval(interval);
}
mergeableState({ repoId: props.repoId, iid: props.iid }).then((resp) => {
mergeAble.value = resp?.data;
});
rebaseLoading.value = false;
}
});
}, 3000);
}
};
/** 操作评审
* @param action_type Enum
* reject 拒绝
* complete 通过
* reset 撤销
*/
const handleComment = (type: 'complete' | 'reject' | 'reset') => {
if (type === 'reject') {
rejectLoading.value = true;
} else {
approvalLoading.value = true;
}
putApproval_review({
repoId: props.repoId,
iid: props.iid,
action_type: type
// reviewer_ids: accountInfo?.arts_id
})
.then(() => {
getMergeRequestStatus();
getReviewers();
emits('approvalChange');
approvalLoading.value = false;
rejectLoading.value = false;
})
.catch(() => {
approvalLoading.value = false;
rejectLoading.value = false;
});
};
/** 变基rebase操作 */
const handleChangeRebase = () => {
rebaseLoading.value = true;
changeMrRebase({ repoId: props.repoId, iid: props.iid })
.then(() => {
intervalRequest();
})
.catch(() => {
getMergeRequestDetail();
});
};
/** 开关是否编辑状态
* @param enable boolean
* enable true/加上[WIP] false/移除[WIP]
*/
const manipulateString = (inputString: string, shouldAddPrefix: boolean) => {
const prefix = '[WIP]';
const regex = /^\[WIP\]|\[wip\]/;
if (shouldAddPrefix) {
// Add prefix if it's not already present
if (!regex.test(inputString)) {
return `${prefix} ${inputString}`;
}
} else {
// Remove prefix if it's present
const modifiedString = inputString.replace(regex, '').trim();
return modifiedString;
}
return inputString;
};
/** 修改标题 */
const enableWip = () => {
editLoading.value = true;
const title = manipulateString(detail.value.title, mergeAble.value.work_in_progress_passed);
putMerge({ repoId: props.repoId, iid: props.iid, title })
.then(() => {
editLoading.value = false;
getMergeRequestDetail(true);
getMergeRequestStatus();
})
.catch(() => {
editLoading.value = false;
});
};
/** modal popup */
const handlePopup = (type: 'cherry' | 'revert' | 'close') => {
if (type === 'close') {
modalInfo.title = '关闭PullRequest';
modalInfo.type = 'close';
modalInfo.content = '你正在关闭当前PR, 关闭后可以在 pull requests 列表中已关闭状态中查询, 确认关闭?';
} else {
modalInfo.title = type === 'cherry' ? 'CherryPick' : 'Revert';
modalInfo.type = 'operate';
mergedType.value = type;
}
nextTick(() => {
visible.value = true;
});
};
/** modal event */
const handleConfirm = () => {
if (modalInfo.type === 'close') {
onCloseMerge();
} else {
handleMergeCallback();
}
};
/** cherryPick或者revert */
const handleMergeCallback = () => {
if (!mergeCallbackSetting.branch) {
return Message.warning('请选择分支');
}
confirmLoading.value = true;
const param = {
repoId: props.repoId,
iid: props.iid,
branch: mergeCallbackSetting.branch,
with_new_merge_request: mergeCallbackSetting.with_new_merge_request || false
};
const req = mergedType.value === 'cherry' ? cherryPick : revert;
req(param)
.then(() => {
visible.value = false;
router.back();
})
.catch((e: any) => {
confirmLoading.value = false;
});
};
/** 获取全部分支 */
const getBranches = () => {
getRepoBranches({ project_id: props.repoId }).then((res) => {
branches.value = res.data.data.content.map((item: any) => item.name);
});
};
/** 重新打开 */
const handleReOpen = () => {
reOpenLoading.value = true;
reOpen({ repoId: props.repoId, iid: props.iid, state_event: 'reopen' })
.then(() => {
reOpenLoading.value = false;
router.go(0);
})
.catch((e) => {
reOpenLoading.value = false;
Message.error(e.error_message);
});
};
/** 获取评审人 */
const getReviewers = () => {
approvalReviewers({ repoId: props.repoId, iid: props.iid }).then((res) => {
reviewUser.value = [...res.data.approval_merge_request_reviewers];
});
};
/** 获取mr状态 */
const getMergeRequestStatus = () => {
mergeableState({ repoId: props.repoId, iid: props.iid }).then((res) => {
mergeAble.value = res?.data;
});
};
/** 获取mr详情 */
const getMergeRequestDetail = (update: boolean = false) => {
const params = { view: 'basic', repoId: props.repoId, iid: props.iid };
mergeDetail(params).then((res) => {
detail.value = res?.data;
if (update) {
emits('change', res?.data?.title);
}
getProtectedBranchInfo({
project_id: detail.value?.target_project_id,
branch_name: detail.value?.target_branch
}).then((res) => {
const data = escapeResData(res);
if (data) targetProtectedBranchInfo.value = data;
});
//
getProtectedBranchInfo({
project_id: detail.value?.source_project_id,
branch_name: detail.value?.source_branch
}).then((res) => {
const data = escapeResData(res);
if (data) sourceProtectedBranchInfo.value = data;
});
});
};
watchOnce(detail, () => {
mergeOptions.should_remove_source_branch = detail.value.should_remove_source_branch;
mergeOptions.squash = detail.value.squash;
mergeOptions.squash_commit_message = detail.value.squash_commit_message || '';
});
/** 初始化 */
const init = () => {
getBranches();
getReviewers();
getMergeRequestStatus();
getMergeRequestDetail();
setTimeout(() => {
loading.value = false;
}, 1000);
};
watchEffect(() => {
if (props.repoId && props.iid) {
init();
}
});
onUnmounted(() => {
if (interval) {
clearInterval(interval);
}
});
defineExpose({
getMergeRequestStatus,
getReviewers,
getMergeRequestDetail
});
</script>
<style lang="scss" scoped>
.card-box {
@apply px-[20px] py-[14px] my-[12px] flex justify-between;
&:first-of-type {
@apply mt-0;
}
:deep(.button-content) {
font-size: 14px;
font-weight: 500;
}
}
.card-icon {
@apply mr-[18px] w-[30px] h-[30px] rounded-[50%];
}
.card-icon-success {
@apply bg-[#0EB07B];
}
.card-icon-warning {
@apply bg-[var(--color-CG600)];
}
.card-icon-error {
@apply bg-[#FF6C17];
}
.card-label {
color: var(--color-G900);
@apply text-[14px];
}
.card-labels {
color: var(--color-CG-600);
@apply text-[14px];
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<DiscussionItem
:author="author"
:canEdit="isMrAuthor || isDeveloper"
:body="temp?.description"
:repo-id="repoId"
@save-content="saveDescription"
eventIcon="gt-comment"
:loading="loading"
:targetId="0"
event-msg="评论"
@quote-reply="$emit('quote-reply', temp?.description)"
/>
</template>
<script lang="ts" setup>
defineOptions({ name: 'MrDescriptionItem' });
import { ref, inject, type Ref } from 'vue';
import type { IAuthor } from '@/api/issue/types';
import DiscussionItem from '@/views/Repo/components/DiscussionItem/index.vue';
import { putMerge } from '@/api/merge';
import { reqCatch } from '@/utils/catch';
import { repoInfoStore } from '@/stores/Repo';
const { isDeveloper } = repoInfoStore();
const repoId = inject('repoId');
const iid = inject('iid');
const isMrAuthor = inject('isMrAuthor') as Ref;
const props = defineProps<{
data: object;
user: IAuthor;
author: IAuthor;
}>();
const emit = defineEmits<{
'quote-reply': [value: string];
'update-description': [];
}>();
const loading = ref(false);
const temp = ref(props.data);
const saveDescription = async(str: string) => {
// 修改描述
if (loading.value) return;
loading.value = true;
const res = await reqCatch(putMerge, {
repoId: repoId.value,
iid: iid,
description: str
});
if (!res.error) {
temp.value = res?.data?.data;
emit('update-description');
}
};
</script>

View File

@@ -0,0 +1,58 @@
<template>
<span class="">
<d-dropdown style="width: 240px" :position="['bottom-end']" close-scope="blank" align="start">
<div class="flex items-center cursor-pointer">
<d-icon name="icon-local-parameter"></d-icon>
</div>
<template #menu>
<div class="flex flex-col gap-2 p-5">
<div class="flex justify-between items-center">
<div>显示设置</div>
<d-radio-group
class="ml-2"
direction="row"
v-model="mrChangeStore.mergeDiffOutputFormat"
size="sm"
>
<d-radio-button
v-for="item in [
{ name: '左右', value: 'side-by-side' },
{ name: '上下', value: 'line-by-line' }
]"
:key="item.name"
:value="item.value"
>{{ item.name }}</d-radio-button
>
</d-radio-group>
</div>
<div class="flex justify-between items-center mt-4">
<div>忽略空格和换行符</div>
<d-switch
class="ml-2"
size="sm"
v-model="mrChangeStore.ignore_whitespace_change"
></d-switch>
</div>
</div>
</template>
</d-dropdown>
</span>
</template>
<script setup lang="ts">
import { watch } from 'vue';
import { localStorageKeys } from '@/constant/index';
import { setStore } from '@/utils/storage';
import { useMrChangeStore } from '@/stores/merge';
const mrChangeStore = useMrChangeStore();
const saveSettingToStorage = () => {
setStore(localStorageKeys.merge_showformat_setting, {
mergeDiffOutputFormat: mrChangeStore.mergeDiffOutputFormat,
ignore_whitespace_change: mrChangeStore.ignore_whitespace_change
});
};
watch(() => mrChangeStore.mergeDiffOutputFormat, saveSettingToStorage);
watch(() => mrChangeStore.ignore_whitespace_change, saveSettingToStorage);
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div>
<div v-if="data" class="flex">
<div class="font-normal text-sm text-[#2D2D2E]">评审意见已解决</div>
<div class="ml-2 text-CG500">
{{ data[0]?.notes_count?.already_resolved_count || 0 }}/{{
data[0]?.notes_count?.need_resolved_count || 0
}}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, inject, watch } from 'vue';
import { useReq } from '@/utils/hooks/useReq';
import { statistic } from '@/api/merge/index';
const props = defineProps(['mutateTagForDissChange']);
const repoId = inject('repoId');
const iid = inject('iid');
const { data, mutate } = useReq(
statistic,
reactive({
repoId,
iids: iid,
fields: 'notes_count',
exclude_sub_mr: false
})
);
watch(() => props.mutateTagForDissChange, mutate);
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div
:class="[`text-[${types[props.type]?.color}]`, `bg-[${types[props.type]?.color}33]`]"
class="h-4 text-xs rounded-br-lg px-1 absolute flex justify-center items-center"
>
{{ types[props.type]?.name }}
</div>
</template>
<script setup lang="ts">
const props = defineProps(['type']);
const types = {
else: { name: 'M', color: '#fa9841' },
new_file: { name: 'A', color: '#3ac295' },
deleted_file: { name: 'D', color: '#f66f6a' },
renamed_file: { name: 'R', color: '#71757f' }
};
</script>

View File

@@ -0,0 +1,54 @@
<template>
<div>
<d-select
class="fordeep"
v-model="selected"
:options="options"
:multiple="true"
placeholder="全部变更类型"
@value-change="valueChange"
>
<gc-option v-for="(item, index) in options" :key="index" :value="item.value" :name="item.name">
<div class="flex items-center">
<d-checkbox class="pointer-events-none" v-model="item.selected" />
<StatusIcon :type="item.value" style="position: unset" class="ml-3" />
<span class="ml-2 text-gray-500">{{ item.name }}</span>
</div>
</gc-option>
</d-select>
</div>
</template>
<script setup lang="ts">
import StatusIcon from './StatusIcon.vue';
import { computed, ref, toValue, watch, reactive, onMounted } from 'vue';
const props = defineProps(['value']);
const emit = defineEmits(['update:value']);
const selected = ref([]);
const valueChange = () => {
//
options.value.forEach((m) => {
m.selected = selected.value.includes(m.value);
});
emit('update:value', selected.value);
};
const options = ref([
{ name: '新增', value: 'new_file' },
{ name: '修改', value: 'else' },
{ name: '删除', value: 'deleted_file' },
{ name: '重命名', value: 'renamed_file' }
]);
</script>
<style lang="scss" scoped>
.fordeep {
:deep(.devui-select__input) {
// display: none;
}
:deep(.devui-tag--default) {
color: #707a87;
}
}
</style>

View File

@@ -0,0 +1,32 @@
export const getChangeType = (diffObj) => {
if (diffObj.new_file) return 'new_file';
if (diffObj.deleted_file) return 'deleted_file';
if (diffObj.renamed_file) return 'renamed_file';
return 'else';
};
export const diffFilter = (m, selected, keyWord) => {
if (keyWord && keyWord.value && !m.file_path?.includes(keyWord.value)) return false;
if (selected.value?.length <= 0) return true;
for (const key of selected.value) {
if (key === 'new_file' && m.new_file) return true;
if (key === 'deleted_file' && m.deleted_file) return true;
if (key === 'renamed_file' && m.renamed_file) return true;
// 不属于另外3个就是 修改
if (key === 'else' && !m.new_file && !m.deleted_file && !m.renamed_file) return true;
}
return false;
};
export const wait =
(time, fn) =>
(...arg) =>
new Promise((s) => {
setTimeout(() => {
s(fn(...arg));
}, time);
});
export const prop = (name) => (obj) => Object.prototype.hasOwnProperty.call(obj, name) ? obj[name] : null;
export const formatTime = (str) => dayjs(str).format('YYYY-MM-DD HH:mm');

View File

@@ -0,0 +1,65 @@
<template>
<main class="mr-main">
<section class="header">
<!-- <d-button class="p-2">
<Icon name="gt-search" />
</d-button> -->
<RichLabel icon="gt-tag-c" @click="$router.push({ name: 'repoLabels' })"
>Labels</RichLabel
>
<RichLabel
icon="gt-milestone-c"
@click="$router.push({ name: 'repoMilestone' })"
>里程碑</RichLabel
>
<span class="header-right">
<d-button icon="add" variant="solid" color="primary" @click="create">
Pull Request
</d-button>
</span>
</section>
<MergeFilterList type="project" :repo-id="repoId" class="mt-[12px]"></MergeFilterList>
</main>
</template>
<script setup lang="ts">
defineOptions({ name: 'IssueList' });
import MergeFilterList from '@/views/Mobile/Repo/components/MergeFilterList/index.vue';
import { useRepoId } from '@/utils/hooks/useRepoId';
import { useAccountStore } from '@/stores/user';
import { emitEvent } from '@/utils/eventBus';
import { useRouter } from 'vue-router';
const router = useRouter();
const userStore = useAccountStore();
const create = () => {
if (userStore.isLogin) {
router.push({ name: 'repoMergeCreate' });
} else {
emitEvent('login');
}
};
const { repoId } = useRepoId();
</script>
<style lang="scss" scoped>
.mr-main {
@apply py-[20px] px-[16px];
}
.header {
@apply flex items-center gap-2;
.header-right {
@apply flex-grow text-right;
}
}
.scrollbar-container {
@apply mx-[-16px] mt-[20px];
}
.pin-list-box {
@apply mx-[-16px];
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<div class="blurb-header">
<div class="title">
<slot name="title">
<GLink :href="idUrl">{{ title }}</GLink>
</slot>
</div>
<div class="label" v-if="labels">
<slot name="labels">
<template v-for="item in labels" :key="item.id">
<LabelTag :color="item.color" :name="item.name"></LabelTag>
</template>
</slot>
</div>
</div>
<div class="blurb-info">
<slot name="icon">
<Icon v-if="stateIcon" :name="stateIcon" :color="stateColor"></Icon>
</slot>
<slot name="num">
<GLink :href="idUrl">{{ id }}</GLink>
</slot>
<slot name="username">
<GLink
:to="{ name: 'homepage', params: { namespace: author.username } }"
class="username"
>{{ pickNickName(author) }}
</GLink>
</slot>
<slot name="createAt">
<span v-if="createAt" class="time"
>创建于
<Time :time="createAt"></Time>
</span>
</slot>
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: 'IssueBlurb' /* issue 简介 */ });
import LabelTag from '@/components/LabelTag/index.vue';
import { pickNickName } from '@/utils';
withDefaults(
defineProps<{
id: string;
idUrl: string;
title: string;
labels: any[];
stateIcon: string;
stateColor: string;
author: any;
createAt: string;
}>(),
{}
);
</script>
<style lang="scss" scoped>
.blurb-header {
.title {
font-weight: 500;
line-height: 20px;
@apply text-G900 text-sm line-clamp-2 overflow-hidden text-ellipsis whitespace-normal break-all;
}
.label {
line-height: 20px;
@apply mt-[4px] overflow-hidden text-ellipsis;
:deep(.g-label-tag){
margin-right: 4px;
}
}
}
.blurb-info {
line-height: 16px;
@apply text-CG600 text-xs;
@apply mt-2 flex items-center gap-2 overflow-hidden text-ellipsis break-all;
display: flex;
flex-wrap: wrap;
.username {
@apply overflow-hidden text-ellipsis break-all;
}
.time {
@apply overflow-visible;
}
.cell {
@apply mr-4;
&:last-child {
margin-right: 0 !important;
@apply mr-0;
}
}
:deep(.icon) {
min-width: 16px;
}
}
</style>

View File

@@ -0,0 +1,206 @@
<template>
<section>
<div class="g-card">
<div class="table-tab-box">
<d-tabs class="table-tab" type="pills" v-model="keys.state">
<d-tab :id="item.id" :title="item.title" v-for="item in tabList" :key="item.id"></d-tab>
</d-tabs>
</div>
<data-panel :empty="!issueList[0] && !pager.loading" :card="false">
<d-table
:show-loading="pager.loading"
:data="issueList"
:show-header="false"
:row-hovered-highlight="false"
size="md"
empty="暂无数据"
class="table-box"
>
<!-- info -->
<d-column>
<template #default="{ row }">
<IssueBlurb
:id="'#' + row.iid"
:title="row?.title"
:stateIcon="issueStateOption[row?.state].icon"
:stateColor="issueStateOption[row?.state].color"
:labels="row.labels"
:idUrl="
$router.resolve({
name: 'repoIssueDetail',
params: {
namespace: row.project.path_with_namespace.split('/')[0],
repoName: row.project.path_with_namespace.split('/')[1],
serialNumber: row.iid
}
}).href
"
:author="row.author"
:createAt="row.created_at"
></IssueBlurb>
</template>
</d-column>
<template #empty>
<d-skeleton :loading="pager.loading">
<template #placeholder><TableItemSkeleton /></template>
</d-skeleton>
</template>
</d-table>
</data-panel>
</div>
<div class="table-pagination">
<d-pagination
v-model:pageSize="pager.pageSize"
v-model:pageIndex="pager.page"
:page-size-options="pageOptions.pageSizeOptions"
:total="pager.total"
:show-page-selector="false"
:can-view-total="pageOptions.canViewTotal"
:total-item-text="pageOptions.totalItemText"
:auto-hide="pageOptions.autoHide"
:max-items="pageOptions.maxItems"
/>
</div>
</section>
</template>
<script lang="ts" setup>
defineOptions({ name: 'IssueFilterList' });
import { ref } from 'vue';
import IssueBlurb from '@/views/Mobile/Repo/components/IssueBlurb/index.vue';
import TableItemSkeleton from '@/views/Mobile/Repo/components/TableItemSkeleton/index.vue';
import { fetchIssueList, myIssueList, groupIssueList } from '@/api/issue';
import { useSearch } from '@/views/Repo/hooks/useSearch';
import { issueStateOption } from '@/constant/issue';
import { reqCatch } from '@/utils/catch';
const tabList = ref([
{ count: 0, id: 'opened', title: '已开启' },
{ count: 0, id: 'closed', title: '已关闭' },
{ count: 0, id: 'all', title: '全部' }
]);
const props = defineProps<{
repoId?: string;
organizationId?: string;
type: 'organization' | 'project' | 'milestone' | 'personal'; // issue 关联主体
searchParams?: Object;
}>();
const issueList = ref([]);
const { pager, keys, pageOptions, getKeys } = useSearch({
fetch: () => {
fetchIssueListData();
},
storageKey: 'issue-h5-' + props.type,
defaultKeys: { state: 'opened' },
clientType: 'h5'
});
/**
* 项目,组织,或我的 issue 筛选
*/
const fetchIssueListData = async() => {
pager.loading = true;
let fetchApi;
if (props.type === 'personal') {
// 我的issue
fetchApi = myIssueList;
} else if (props.type === 'project') {
// 项目issue
fetchApi = fetchIssueList;
} else if (props.type === 'milestone') {
// 里程碑issue
fetchApi = fetchIssueList;
} else if (props.type === 'organization') {
// 组织issue
fetchApi = groupIssueList;
} else {
throw new Error('缺少issue 归属');
}
// 去除空参数
const params = {
...getKeys(),
project_id: props?.repoId,
group_id: props.organizationId,
page: pager.page,
per_page: pager.pageSize
};
const res = await reqCatch(fetchApi, params);
pager.loading = false;
if (!res.error) {
const resData = res.data?.data;
let { issues, content, total } = res.data.data;
issues = issues || content || [];
// 获取tab 数量
if (props.type === 'organization') {
issues = content?.issues || [];
// tab 栏 数量
for (let i = 0; i < tabList.value.length; i++) {
const element = tabList.value[i];
element.count = content[element.id];
if (keys.state === element.id) {
pager.total = element.count;
}
}
} else if (props.type === 'personal') {
// 当前状态数量
pager.total = total;
tabList.value.find((item) => item.id === params.state).count = total;
// 其他状态数量
const tabs = tabList.value.filter((item) => item.id !== keys.state).map((e) => e.id);
Promise.all(tabs.map((value) => myIssueList({ ...params, state: value, page: 1, per_page: 1 }))).then((res) => {
tabList.value.forEach((item) => {
const index = tabs.findIndex((t) => t === item.id);
if (index > -1) {
item.count = res[index].data.total;
}
});
});
} else {
for (let i = 0; i < tabList.value.length; i++) {
const element = tabList.value[i];
element.count = resData[element.id];
if (keys.state === element.id) {
pager.total = element.count;
}
}
}
issueList.value = issues;
}
pager.loading = false;
};
fetchIssueListData();
</script>
<style lang="scss" scoped>
.table-tab-box {
border-bottom: 1px solid var(--devui-line, #d7d8da);
@apply px-[20px];
}
.table-tab {
:deep(.devui-tab__content) {
display: none;
}
:deep(.devui-tabs__nav li) {
border-width: 0 !important;
border: none;
}
:deep(.devui-tabs__nav li a) {
line-height: 43px;
color: var(--color-G600, #707a87);
font-size: 14px;
line-height: 43px;
}
:deep(.devui-tabs__nav li.active a, .devui-tabs__nav li:hover:not(.disabled) a) {
@apply text-G900;
}
:deep(.devui-tabs__nav li:after) {
background: var(--color-G900, #2d2d2e);
}
}
.table-pagination {
@apply mt-20 mb-20 flex items-center justify-center;
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<section>
<div class="g-card">
<div class="table-tab-box">
<d-tabs class="table-tab" type="pills" v-model="keys.state">
<d-tab :id="item.id" :title="item.title" v-for="item in tabList" :key="item.id"></d-tab>
</d-tabs>
</div>
<data-panel :empty="!mergeList[0] && !pager.loading" :card="false">
<d-table
:show-loading="pager.loading"
:data="mergeList"
:show-header="false"
:row-hovered-highlight="false"
size="md"
empty="暂无数据"
class="table-box"
>
<!-- info -->
<d-column>
<template #default="{ row }">
<MrBlurb
:id="'#' + row.iid"
:title="row?.title"
:stateIcon="mrStateOption[row?.state].icon"
:stateColor="mrStateOption[row?.state].color"
:labels="row.labels"
:idUrl="
$router.resolve({
name: 'repoMergeDetail',
params: {
namespace: row.target_project.path_with_namespace.split('/')[0],
repoName: row.target_project.path_with_namespace.split('/')[1],
mergeId: row.iid
}
}).href
"
:author="row.author"
:createAt="row.created_at"
></MrBlurb>
</template>
</d-column>
<template #empty>
<d-skeleton :loading="pager.loading">
<template #placeholder><TableItemSkeleton /></template>
</d-skeleton>
</template>
</d-table>
</data-panel>
</div>
<div class="table-pagination">
<d-pagination
v-model:pageSize="pager.pageSize"
v-model:pageIndex="pager.page"
:page-size-options="pageOptions.pageSizeOptions"
:total="pager.total"
:show-page-selector="false"
:can-view-total="pageOptions.canViewTotal"
:total-item-text="pageOptions.totalItemText"
:auto-hide="pageOptions.autoHide"
:max-items="pageOptions.maxItems"
/>
</div>
</section>
</template>
<script lang="ts" setup>
defineOptions({ name: 'IssueFilterList' });
import { ref } from 'vue';
import MrBlurb from '@/views/Mobile/Repo/components/IssueBlurb/index.vue';
import TableItemSkeleton from '@/views/Mobile/Repo/components/TableItemSkeleton/index.vue';
import { getMergeRequests, getMergeListCount, getMyMergeRequests, getMyOrgMergeRequests } from '@/api/merge';
import { useSearch } from '@/views/Repo/hooks/useSearch';
import { mrStateOption } from '@/constant/mr';
import { reqCatch } from '@/utils/catch';
const tabList = ref([
{ count: 0, id: 'opened', title: '已开启' },
{ count: 0, id: 'closed', title: '已关闭' },
{ count: 0, id: 'all', title: '全部' }
]);
const props = defineProps<{
repoId?: string;
organizationId?: string;
type: 'organization' | 'project' | 'milestone' | 'personal'; // issue 关联主体
searchParams?: Object;
}>();
const mergeList = ref([]);
const { pager, keys, pageOptions, getKeys } = useSearch({
fetch: () => {
fetchMergeListData();
},
storageKey: 'merge-h5-' + props.type,
defaultKeys: { state: 'opened' },
clientType: 'h5'
});
/**
* 获取分页数据 和 数量
*/
const fetchMergeListData = async() => {
const params = {
...getKeys(),
repoId: props.repoId,
page: pager.page,
per_page: pager.pageSize,
group_id: props.organizationId,
view: 'basic'
};
let fetchApi;
if (props.type === 'project') {
fetchApi = getMergeRequests;
} else if (props.type === 'personal') {
fetchApi = getMyMergeRequests;
} else if (props.type === 'organization') {
fetchApi = getMyOrgMergeRequests;
} else {
fetchApi = getMergeRequests;
}
pager.loading = true;
const listRes = await reqCatch(fetchApi, params);
pager.loading = false;
const data = listRes.data.data;
if (!listRes.error) {
if (props.type === 'organization') {
const { content = [], total } = data;
const { merge_requests } = content;
mergeList.value = merge_requests;
pager.total = total;
tabList.value.forEach((item) => {
if (typeof content[item.id] === 'number') {
item.count = content[item.id];
}
});
} else if (props.type === 'project') {
let { total = 0, content = [] } = data;
total = total || 0;
content = content || [];
mergeList.value = content;
pager.total = total;
// scope 数据
const pageRes = await reqCatch(getMergeListCount, {
...params,
only_count: true
});
const tabCount = pageRes.data.data;
tabList.value.forEach((item) => {
if (typeof tabCount[item.id] === 'number') {
item.count = tabCount[item.id];
}
});
} else if (props.type === 'personal') {
// 当前状态
let { total = 0, content = [] } = data;
total = total || 0;
content = content || [];
mergeList.value = content;
pager.total = total;
// 当前状态数量
const findRes = tabList.value.find((item) => item.id === keys.state);
if (findRes) {
findRes.count = total;
}
// 其他状态数量
const tabs = tabList.value.filter((item) => item.id !== keys.state).map((e) => e.id);
Promise.all(tabs.map((value) => fetchApi({ ...params, state: value, page: 1, per_page: 1 }))).then((res) => {
tabList.value.forEach((item) => {
const index = tabs.findIndex((t) => t === item.id);
if (index > -1) {
item.count = res[index].data.total;
}
});
});
}
}
};
fetchMergeListData();
</script>
<style lang="scss" scoped>
.table-tab-box {
border-bottom: 1px solid var(--devui-line, #d7d8da);
@apply px-[20px];
}
.table-tab {
:deep(.devui-tab__content) {
display: none;
}
:deep(.devui-tabs__nav li) {
border-width: 0 !important;
border: none;
}
:deep(.devui-tabs__nav li a) {
line-height: 43px;
color: var(--color-G600, #707a87);
font-size: 14px;
line-height: 43px;
}
:deep(.devui-tabs__nav li.active a, .devui-tabs__nav li:hover:not(.disabled) a) {
@apply text-G900;
}
:deep(.devui-tabs__nav li:after) {
background: var(--color-G900, #2d2d2e);
}
}
.table-pagination {
@apply mt-20 mb-20 flex items-center justify-center;
}
</style>

View File

@@ -0,0 +1,22 @@
<template>
<div
v-for="i in 1"
:key="i"
class="bg-white py-4 px-4 flex items-center gap-4 my-[-40px]"
>
<gc-skeleton-item
variant="circle"
style="width: 16px; height: 16px; align-self: flex-start;"
></gc-skeleton-item>
<div class="flex-grow">
<gc-skeleton-item style="width: 80%;"></gc-skeleton-item>
<div class="flex gap-4 mt-2">
<gc-skeleton-item
v-for="i in 5"
:key="i"
style="width: 50px; height: 16px;"
></gc-skeleton-item>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,179 @@
<template>
<div>
<d-skeleton :loading="profileLoading" :rows="4">
<div class="g-user-info-wrapper">
<div class="g-user-info-card" :style="{width: `${width}px`}">
<div class="username">
<g-text>
{{ name }}
</g-text>
</div>
<div class="namespace">{{ namespace }}</div>
<div v-if="!hideFollowData" class="count-info">
<span>
<router-link
:to="`/user/${namespace.replace('@', '')}/fans`"
><strong>{{ fansCount }}</strong> 粉丝</router-link>
</span>
<span>
<router-link
:to="`/user/${namespace.replace('@', '')}/following`"
><strong>{{ followCount }}</strong> 关注</router-link></span>
</div>
<div class="info-list">
<div class="flex gap-1 items-stretch" v-if="tenant">
<Icon name="gt-organizations" class="w-4" /><g-text class="flex-1">{{ tenant }}</g-text>
</div>
<div class="flex gap-1 items-stretch" v-if="location">
<Icon name="gt-location" class="w-4" /><g-text class="flex-1">{{ location }}</g-text>
</div>
<GLink class="flex items-stretch" :href="`mailto:${email}`" v-if="email">
<Icon name="gt-mail" class="mr-1 w-4" /><g-text class="flex-1">{{ email }}</g-text>
</GLink>
<GLink class="flex items-stretch" :href="`https://github.com/${github}`" target="_blank" v-if="github">
<Icon name="gt-github" class="mr-1 w-4" /><g-text class="flex-1">@{{ github }}</g-text>
</GLink>
<GLink class="flex items-stretch" :href="blog" target="_blank" v-if="blog">
<Icon name="gt-connect" class="mr-1 w-4" /><g-text class="flex-1">{{ blog }}</g-text>
</GLink>
</div>
</div>
<div class="g-user-info-avatar">
<div class="text-center">
<GAvatar :src="avatarUrl" :name="name" :width="100" :height="100"></GAvatar>
</div>
<GLink v-if="isSelf" class="user-info-edit" :to="{ name: 'setting' }">
<Icon name="gt-edit" class="mr-2" /><span style="line-height: 1;">编辑个人资料</span>
</GLink>
<slot name="action"></slot>
</div>
</div>
<div class="description">
<g-text :config="{ ellipse: true, line: 4, tooltip: false, showAll: true }">
{{ description }}
</g-text>
</div>
</d-skeleton>
</div>
</template>
<script setup lang="ts">
interface User {
name: string;
namespace: string;
avatarUrl: string;
description: string;
fansCount: number;
followCount: number;
tenant?: string;
location?: string;
email?: string;
github?: string;
blog?: string;
width?: number;
isSelf?: boolean;
hideFollowData?: boolean;
profileLoading?: boolean;
}
withDefaults(defineProps<User>(), {
fansCount: 0,
followCount: 0,
width: 200,
isSelf: true,
hideFollowData: false,
profileLoading: true
});
</script>
<style lang="scss" scoped>
@import 'devui-theme/styles-var/devui-var.scss';
.g-user-info-wrapper {
display: flex;
justify-content: space-between;
width: 100%;
}
.g-user-info-card {
display: flex;
flex-direction: column;
background-color: transparent;
:deep(.devui-avatar) {
line-height: normal;
img {
// border: 4px solid $devui-global-bg;
border-radius: 50%;
object-fit: cover;
}
}
.username {
font-size: 22px;
font-weight: bold;
line-height: 32px;
color: var(--color-G900);
}
.namespace {
padding: 4px 0;
font-size: 14px;
line-height: 20px;
color: var(--color-CG600);
margin-bottom: 4px;
}
.description {
font-size: 14px;
color: var(--color-G900);
line-height: 20px;
margin-bottom: 4px;
}
.count-info {
display: flex;
justify-content: flex-start;
gap: 16px;
}
.info-list {
width: 100%;
margin: 20px 0;
display: flex;
flex-direction: column;
align-items: flex-start;
font-size: 14px;
color: var(--color-G900);
gap: 12px;
> div {
line-height: 1;
}
> a {
line-height: 1;
}
span {
line-height: 1;
}
:deep(.icon){
vertical-align: middle;
}
}
}
.user-info-edit {
width: 140px;
background-color: #ffffff;
margin: 8px 0 24px;
display: flex;
border-radius: var(--border-radius, 4px);
border: 1px solid var(--color-G300);
padding: 8px;
justify-content: center;
align-items: center;
font-size: 14px;
color: var(--black-60, #606060);
i {
margin-right: 8px;
}
&:hover {
color: var(--color-link);
:deep(.icon){
color: var(--color-link)!important;
}
}
}
</style>

View File

@@ -0,0 +1,170 @@
<template>
<user-info-mobile
class="user-info"
v-bind="finalData"
:hideFollowData="hideFollowData"
:profileLoading="profileLoading"
>
<template v-if="!finalData.isSelf && false" #action>
<div class="user-info-actions">
<d-button :loading="loading" class="user-info-button" @click="onToggleStar">
<Icon
v-if="!loading"
:name="finalData.hasFollowed ? 'gt-star-c' : 'gt-star'"
:color="finalData.hasFollowed ? 'var(--color-Y500)' : ''"
size="16px"
/>
<span class="ml-2 hover:color-text">
{{ finalData.hasFollowed ? '已关注' : '关注' }}
</span>
</d-button>
</div>
</template>
</user-info-mobile>
</template>
<script lang="ts">
export default {
name: 'user-info-wrapper'
};
</script>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue';
import { useUserInfo } from '@/views/User/hooks/useUserInfo';
import UserInfoMobile from './user-info-mobile.vue';
import { getUserProfile } from '@/api/user';
import { reqCatch } from '@/utils/catch';
import { otherAccountStore, type AccountInfo } from '@/stores/user';
import type { UserProfile } from '@/api/user/types';
import type { AxiosResponse } from 'axios';
import { useStarFollow } from '@/views/User/hooks/useStarFollow';
import { useRouter } from 'vue-router';
import { xssPurify } from '@/utils';
const router = useRouter();
// 状态仓库
const otherStore = otherAccountStore();
const emits = defineEmits<{(e: 'updateBanner', image: string): void; (e: 'updateInfo', data: any): void }>();
const profileLoading = ref(true);
// 用户信息
const userInfoData = ref({
name: '',
namespace: '',
avatarUrl: '',
description: '',
fansCount: 0,
followCount: 0,
tenant: '',
location: '',
email: '',
github: '',
blog: '',
username: '',
profile: {},
isFollow: false,
isSelf: false
});
const finalData = computed(() => {
return Object.assign({}, userInfoData.value, {
fansCount: fanCount.value,
followCount: followCount.value,
hasFollowed: hasFollowed.value
});
});
const { isSelf, userInfo, namespace } = useUserInfo();
const { followCount, fanCount, hasFollowed, loading, hideFollowData, toggleStar, getAllCounts } = useStarFollow(
{
namespace,
username: userInfo.username
},
true
);
watch(
() => hasFollowed.value,
(val, oldVal) => {
otherStore.saveFollowed(val);
}
);
watch(
() => otherStore.accountInfo.isFollow,
(val) => {
hasFollowed.value = val || false;
otherStore.saveFollowed(val);
}
);
const onToggleStar = () => {
if (loading.value) return false;
toggleStar(userInfo.username || '', namespace, !hasFollowed.value);
};
onMounted(async() => {
let userData = userInfo;
if (!isSelf) userData = {};
const username = namespace;
const res = await reqCatch(getUserProfile, { username });
if (!res.data) {
// 审核未通过跳404页
router.replace('/404');
}
const infoData = (res.data as AxiosResponse<UserProfile>).data || { avatar: '', profile: {}};
const $name = infoData.nickname || '';
userInfoData.value = {
name: $name ? xssPurify($name) : '',
namespace: infoData.username ? `@${infoData.username}` : `@${namespace}`,
avatarUrl: infoData.avatar || '',
description: infoData.profile.description || '',
fansCount: infoData.fans || 0,
followCount: infoData.concerns || 0,
tenant: infoData.profile.company || '',
location: infoData.profile.location || '',
email: !infoData.profile.email_private ? infoData.profile.show_email : '',
github: infoData.profile.github_account.replace(/(https?:\/\/)?github\.com\//, ''),
blog: infoData.profile.website || '',
username: infoData.username || namespace,
profile: infoData.profile,
isFollow: hasFollowed.value,
isSelf
};
profileLoading.value = false;
sessionStorage.setItem('curName', userInfoData.value.name);
emits('updateBanner', infoData.profile.bg_image);
emits('updateInfo', infoData);
otherStore.saveAccountInfo(userInfoData.value);
});
defineExpose({
getAllCounts
});
</script>
<style scoped lang="scss">
.user-info {
&-actions {
margin: 24px 0;
width: 100%;
}
&-button {
display: block;
width: 180px;
padding: 8px 16px;
line-height: 1 !important;
:deep(.button-content) {
display: inline-flex;
align-items: center;
line-height: 1;
}
&:active,
&:hover {
color: var(--color-text) !important;
border-color: var(--devui-line, #d7d8da);
}
}
}
</style>

View File

@@ -0,0 +1,238 @@
<template>
<div class="page-user">
<d-skeleton v-if="false" :loading="loadingStatus.bgImageLoading" :rows="4">
<header class="page-user-header">
<img v-if="banner" class="page-user-banner" :src="banner" />
<img v-else class="page-user-banner" src="@/assets/imgs/user-banner.png" />
<!--上传banner-->
<div class="page-user-header-actions" v-if="isSelf && false">
<d-upload class="page-user-header-button" accept=".png,.jpg,.gif,.jpeg"
:before-upload="beforeUpload" @file-select="onBannerUpload">
<Icon class="page-user-header-button-icon" name="gt-upload-cover" size="16px" color="#7E7E80" />
</d-upload>
</div>
<!--选择banner-->
<div class="page-user-header-actions" v-if="isSelf">
<div class="page-user-header-button">
<banner-choose mobile @update="onChooseBanner" />
</div>
</div>
</header>
</d-skeleton>
<section class="page-user-wrapper">
<div class="page-user-info">
<user-info-wrapper @update-banner="onUpdateBanner" @update-info="onUpdateInfo" />
</div>
<div class="page-user-content">
<div class="page-user-intro">
<div class="page-user-intro-md">
<md-intro mobile />
</div>
</div>
<div class="page-user-repos">
<panel :blank="false" class="overflow-hidden">
<template #header>
<div class="page-user-repos-header">
<Icon name="gt-folder-c" size="16px" />
<span class="page-user-repos-title">精选项目</span>
</div>
</template>
<template #headerRight>
<repo-select-modal v-if="isSelf" mobile @update="onUpdate" />
</template>
<DataPanel :empty="!repoList?.length" :loading="repoList?.length ? false : loading" skeleton >
<div class="page-user-repos-content" v-loading="loading">
<template v-for="(item, index) in repoList" :key="item.id">
<repo-item
:iconHandleList="item.iconHandleList"
:id="item.id"
:imgSrc="item.imgSrc"
:title="item.title"
:tag-list="item.tagList"
:desc="item.desc"
:isStar="item.isStar"
:web_url="item.web_url"
:topicNames="item.topicNames"
mobile
@handle-star="({isStar}) => item.isStar = isStar"
/>
</template>
</div>
</DataPanel>
</panel>
</div>
<div class="page-user-activities">
<activity-contributes mobile />
</div>
</div>
</section>
</div>
</template>
<script lang="ts">
export default {
name: 'UserMobile'
};
</script>
<script lang="ts" setup>
import { ref, watch, reactive } from 'vue';
import UserInfoWrapper from '@/views/Mobile/User/components/user-info-wrapper.vue';
import MdIntro from '@/views/User/components/md-intro.vue';
import ActivityContributes from '@/views/User/components/activity-contributes.vue';
import Panel from '@/components/Panel/index.vue';
import RepoItem from '@/components/RepoItem/index.vue';
import RepoSelectModal from '@/views/User/components/repo-select-modal.vue';
import BannerChoose from '@/views/User/components/banner-choose.vue';
import { useUserInfo } from '@/views/User/hooks/useUserInfo';
import { useRepoList } from '@/views/User/hooks/useRepoList';
import { Message } from 'vue-devui/message';
import { uploadFile, updateUserProfile } from '@/api/user';
import { useRoute, useRouter } from 'vue-router';
const router = useRouter();
const route = useRoute();
// 精选项目
const { namespace, isSelf } = useUserInfo();
const { repoList, loading, toggleRepoStar, init } = useRepoList({ profile: { params: { username: namespace }}, auto: true });
const banner = ref('');
const loadingStatus = reactive({ // 加载状态
profileLoading: true,
bgImageLoading: true,
eventsLoading: true,
contributorLoading: true,
releasesLoading: true
});
const onUpdateBanner = (img: string) => {
banner.value = img;
loadingStatus.profileLoading = false;
loadingStatus.bgImageLoading = false;
};
const uploadOptions = ref({});
const beforeUpload = (file: any) => {
return false;
};
const infoData = ref<any>(null);
const onUpdateInfo = (data: any) => {
infoData.value = data;
};
const onBannerUpload = async(fileInfo: any) => {
if (fileInfo.length > 0 && fileInfo[0].size > 500 * 1024) {
return Message({
type: 'warning',
message: '图片大小不超过500kb!'
});
}
const resImg: any = await uploadFile(fileInfo, false, fileInfo[0].type);
if (!resImg || resImg.includes('Error')) return false;
if (!infoData.value || !infoData.value.profile) return false;
banner.value = resImg + '?time' + new Date().getTime();
infoData.value.profile.bg_image = resImg;
updateUserProfile(infoData.value);
};
const onChooseBanner = (item: { img: string; title: string; }) => {
const resImg = item.img;
banner.value = resImg + '?time' + new Date().getTime();
infoData.value.profile.bg_image = resImg;
updateUserProfile(infoData.value);
};
const onUpdate = () => {
init();
};
watch(() => route, (val) => {
if (val.params.namespace !== namespace) {
// 如果从其他用户切换过来导致路由不刷新强制刷新页面
router.go(0);
}
}, {
deep: true
});
</script>
<style scoped lang="scss">
$g-page-user-border-color: #808080;
$g-page-user-border-color-second: #E6E6E8;
$g-page-user-border-radius: 4px;
$g-page-user-border-radius-large: 12px;
$g-page-user-bg-color: #D3D3D3;
$g-page-user-color: #000000;
$g-page-user-color-second: #2D2D2E;
$g-page-user-color-third: #7E7E80;
.page-user {
position: relative;
margin: 0 auto;
&-header {
position: relative;
&-button {
position: absolute;
bottom: 10px;
right: 10px;
line-height: 1;
&-icon {
color: $g-page-user-color;
cursor: pointer;
line-height: 1;
}
:deep(.button-content) {
line-height: 1;
}
&:hover {
opacity: 0.9;
}
}
}
&-banner {
object-fit: cover;
width: 100%;
height: 80px;
display: block;
border-top-left-radius: $g-page-user-border-radius-large;
border-top-right-radius: $g-page-user-border-radius-large;
}
&-wrapper {
display: flex;
position: relative;
z-index: 1;
flex-wrap: wrap;
}
&-info {
padding: 20px 16px 0 16px;
flex: 1;
overflow: hidden;
min-width: 260px;
}
&-content {
position: relative;
flex: 1;
padding: 20px 16px 0 16px;
}
&-repos {
&-header {
display: flex;
align-items: center;
}
&-title {
font-size: 14px;
font-weight: 500;
color: $g-page-user-color-second;
margin-left: 10px;
}
&-content {
:deep(.g-repo-item) {
border-top: none;
border-left: none;
border-right: none;
border-radius: 0;
box-shadow: none;
}
}
}
&-activities {
margin-top: 12px;
}
}
</style>