496 lines
14 KiB
Vue
496 lines
14 KiB
Vue
<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>
|
||
|