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>
|
||
|
|
|