Files
Situation-Awareness-Platfor…/src/views/Mobile/Repo/Issue/Detail/index.vue

496 lines
14 KiB
Vue
Raw Normal View History

2025-03-12 18:41:20 +08:00
<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>