搜索结果列表页面开发
This commit is contained in:
49
src/views/Mobile/Account/BindPhone/index.vue
Normal file
49
src/views/Mobile/Account/BindPhone/index.vue
Normal 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>
|
||||
237
src/views/Mobile/Account/Login/Password/index.vue
Normal file
237
src/views/Mobile/Account/Login/Password/index.vue
Normal 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>
|
||||
249
src/views/Mobile/Account/Login/Phone/index.vue
Normal file
249
src/views/Mobile/Account/Login/Phone/index.vue
Normal 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>
|
||||
|
||||
314
src/views/Mobile/Account/Register/index.vue
Normal file
314
src/views/Mobile/Account/Register/index.vue
Normal 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>
|
||||
217
src/views/Mobile/Account/ResetPassword/index.vue
Normal file
217
src/views/Mobile/Account/ResetPassword/index.vue
Normal 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>
|
||||
304
src/views/Mobile/Org/components/ActivityList.vue
Normal file
304
src/views/Mobile/Org/components/ActivityList.vue
Normal 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>
|
||||
111
src/views/Mobile/Org/components/OrgHeaderInfo.vue
Normal file
111
src/views/Mobile/Org/components/OrgHeaderInfo.vue
Normal 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>
|
||||
217
src/views/Mobile/Org/components/ReadmeInfo.vue
Normal file
217
src/views/Mobile/Org/components/ReadmeInfo.vue
Normal 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>
|
||||
282
src/views/Mobile/Org/components/SelectedItems.vue
Normal file
282
src/views/Mobile/Org/components/SelectedItems.vue
Normal 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>
|
||||
157
src/views/Mobile/Org/index.vue
Normal file
157
src/views/Mobile/Org/index.vue
Normal 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>
|
||||
109
src/views/Mobile/Repo/Issue/Create/index.vue
Normal file
109
src/views/Mobile/Repo/Issue/Create/index.vue
Normal 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>
|
||||
65
src/views/Mobile/Repo/Issue/Detail/DiscussionItem.vue
Normal file
65
src/views/Mobile/Repo/Issue/Detail/DiscussionItem.vue
Normal 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>
|
||||
17
src/views/Mobile/Repo/Issue/Detail/EventItem.vue
Normal file
17
src/views/Mobile/Repo/Issue/Detail/EventItem.vue
Normal 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>
|
||||
50
src/views/Mobile/Repo/Issue/Detail/IssueDescription.vue
Normal file
50
src/views/Mobile/Repo/Issue/Detail/IssueDescription.vue
Normal 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>
|
||||
495
src/views/Mobile/Repo/Issue/Detail/index.vue
Normal file
495
src/views/Mobile/Repo/Issue/Detail/index.vue
Normal 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>
|
||||
|
||||
65
src/views/Mobile/Repo/Issue/Template/index.vue
Normal file
65
src/views/Mobile/Repo/Issue/Template/index.vue
Normal 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>
|
||||
56
src/views/Mobile/Repo/Issue/components/PinList.vue
Normal file
56
src/views/Mobile/Repo/Issue/components/PinList.vue
Normal 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>
|
||||
71
src/views/Mobile/Repo/Issue/index.vue
Normal file
71
src/views/Mobile/Repo/Issue/index.vue
Normal 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>
|
||||
65
src/views/Mobile/Repo/Issue/types.ts
Normal file
65
src/views/Mobile/Repo/Issue/types.ts
Normal 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
|
||||
}
|
||||
307
src/views/Mobile/Repo/Merge/Compare/index.vue
Normal file
307
src/views/Mobile/Repo/Merge/Compare/index.vue
Normal 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>
|
||||
178
src/views/Mobile/Repo/Merge/Create/index.vue
Normal file
178
src/views/Mobile/Repo/Merge/Create/index.vue
Normal 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>
|
||||
355
src/views/Mobile/Repo/Merge/Detail/index.vue
Normal file
355
src/views/Mobile/Repo/Merge/Detail/index.vue
Normal 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>
|
||||
97
src/views/Mobile/Repo/Merge/components/CompareFileDiff.vue
Normal file
97
src/views/Mobile/Repo/Merge/components/CompareFileDiff.vue
Normal 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>
|
||||
145
src/views/Mobile/Repo/Merge/components/DetailCheck.vue
Normal file
145
src/views/Mobile/Repo/Merge/components/DetailCheck.vue
Normal 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>
|
||||
41
src/views/Mobile/Repo/Merge/components/DetailCommits.vue
Normal file
41
src/views/Mobile/Repo/Merge/components/DetailCommits.vue
Normal 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>
|
||||
294
src/views/Mobile/Repo/Merge/components/DetailDiscuss.vue
Normal file
294
src/views/Mobile/Repo/Merge/components/DetailDiscuss.vue
Normal 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>
|
||||
442
src/views/Mobile/Repo/Merge/components/DetailFileDiff.vue
Normal file
442
src/views/Mobile/Repo/Merge/components/DetailFileDiff.vue
Normal 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>
|
||||
314
src/views/Mobile/Repo/Merge/components/DiffView.vue
Normal file
314
src/views/Mobile/Repo/Merge/components/DiffView.vue
Normal 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>
|
||||
177
src/views/Mobile/Repo/Merge/components/DiscussionItem.vue
Normal file
177
src/views/Mobile/Repo/Merge/components/DiscussionItem.vue
Normal 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>
|
||||
12
src/views/Mobile/Repo/Merge/components/EventItem.vue
Normal file
12
src/views/Mobile/Repo/Merge/components/EventItem.vue
Normal 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>
|
||||
149
src/views/Mobile/Repo/Merge/components/FileDiffDiscussions.vue
Normal file
149
src/views/Mobile/Repo/Merge/components/FileDiffDiscussions.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
737
src/views/Mobile/Repo/Merge/components/MergeStream.vue
Normal file
737
src/views/Mobile/Repo/Merge/components/MergeStream.vue
Normal 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>
|
||||
55
src/views/Mobile/Repo/Merge/components/MrDescriptionItem.vue
Normal file
55
src/views/Mobile/Repo/Merge/components/MrDescriptionItem.vue
Normal 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>
|
||||
58
src/views/Mobile/Repo/Merge/components/ShowFormat.vue
Normal file
58
src/views/Mobile/Repo/Merge/components/ShowFormat.vue
Normal 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>
|
||||
30
src/views/Mobile/Repo/Merge/components/Statistic.vue
Normal file
30
src/views/Mobile/Repo/Merge/components/Statistic.vue
Normal 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>
|
||||
19
src/views/Mobile/Repo/Merge/components/StatusIcon.vue
Normal file
19
src/views/Mobile/Repo/Merge/components/StatusIcon.vue
Normal 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>
|
||||
54
src/views/Mobile/Repo/Merge/components/StatusSelect.vue
Normal file
54
src/views/Mobile/Repo/Merge/components/StatusSelect.vue
Normal 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>
|
||||
32
src/views/Mobile/Repo/Merge/components/support.js
Normal file
32
src/views/Mobile/Repo/Merge/components/support.js
Normal 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');
|
||||
65
src/views/Mobile/Repo/Merge/index.vue
Normal file
65
src/views/Mobile/Repo/Merge/index.vue
Normal 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>
|
||||
99
src/views/Mobile/Repo/components/IssueBlurb/index.vue
Normal file
99
src/views/Mobile/Repo/components/IssueBlurb/index.vue
Normal 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>
|
||||
206
src/views/Mobile/Repo/components/IssueFilterList/index.vue
Normal file
206
src/views/Mobile/Repo/components/IssueFilterList/index.vue
Normal 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>
|
||||
212
src/views/Mobile/Repo/components/MergeFilterList/index.vue
Normal file
212
src/views/Mobile/Repo/components/MergeFilterList/index.vue
Normal 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>
|
||||
22
src/views/Mobile/Repo/components/TableItemSkeleton/index.vue
Normal file
22
src/views/Mobile/Repo/components/TableItemSkeleton/index.vue
Normal 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>
|
||||
179
src/views/Mobile/User/components/user-info-mobile.vue
Normal file
179
src/views/Mobile/User/components/user-info-mobile.vue
Normal 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>
|
||||
170
src/views/Mobile/User/components/user-info-wrapper.vue
Normal file
170
src/views/Mobile/User/components/user-info-wrapper.vue
Normal 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>
|
||||
238
src/views/Mobile/User/index.vue
Normal file
238
src/views/Mobile/User/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user