搜索结果列表页面开发

This commit is contained in:
付民康
2025-03-12 18:41:20 +08:00
commit 7cebe8fc00
739 changed files with 88149 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { discussionListItemType } from '@/api/discussion/types';
import { DISCUSS_FORMAT } from '@/constant/discuss';
import Time from '@/components/Time/index.vue';
import { baseURL } from '@/utils/request';
defineOptions({
name: 'DashboardDiscussionListItem'
});
interface additionType {}
type Iprops = discussionListItemType & additionType;
withDefaults(defineProps<Iprops>(), {});
</script>
<template>
<div class="root">
<div class="top">
<GLink
class="top-title"
:href="`${baseURL}/api/v1/discuss/detail/${source_id}/${source_type}/${serial_number}`"
>{{ title }}</GLink
>
</div>
<div class="bottom">
<span class="bottom-icon">
{{ category?.category_icon }}
</span>
<span v-if="created_date"
><Time :time="created_date"></Time>创建的{{ category?.category_name }}</span
>
<span>{{ is_closed === 0 ? '' : `&nbsp;·&nbsp;已关闭` }}</span>
<span
v-if="category.category_type === DISCUSS_FORMAT.QANDA && is_answered === 1"
class="bottom-answered"
>&nbsp;·&nbsp;<Icon name="gt-closed-issue" color="#0EB07B" size="14px"></Icon
><span class="ml-1">回答已采纳</span>
</span>
<span
v-if="category.category_type === DISCUSS_FORMAT.VOTE && is_closed === 1"
class="info-content_voted"
>&nbsp;·&nbsp;<Icon name="gt-skip-issue" color="#7E7E80" size="14px"></Icon
><span class="ml-1">投票已结束</span></span
>
<span class="bottom-data">
<Icon name="gt-comment" size="14px" class="mr-1" color="#7e7e80"></Icon>
<span class="bottom-total">{{ comment_total || '0' }}</span>
</span>
</div>
</div>
</template>
<style scoped lang="scss">
@import 'devui-theme/styles-var/devui-var.scss';
.root {
padding: 16px 20px;
border-bottom: 1px solid var(--color-border-light);
}
.top {
font-size: 16px;
line-height: 30px;
color: var(--color-font);
font-weight: 500;
overflow: hidden;
&-title {
font-weight: 500;
cursor: pointer;
}
}
.bottom {
font-size: 12px;
font-weight: 400;
color: #7e7e80;
line-height: 20px;
&-icon {
margin-right: 4px;
}
&-data {
margin-left: 16px;
}
}
.top-title:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,444 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, reactive, watch, computed, nextTick } from 'vue';
import { useRouter, useRoute, onBeforeRouteLeave } from 'vue-router';
import DiscussionTypeItem from '../components/DiscussionTypeItem.vue';
import PollForm from '../components/DiscussionPollForm.vue';
import Sidebar from '../components/Sidebar/index.vue';
import MdEditor from '@/components/MdEditor/index.vue';
import type { discussionTypeOrSection } from '@/api/discussion/types';
import { discussSave, discussRepoMembers, discussOrgMembers, typeDetail } from '@/api/discussion';
import { DISCUSS_FORMAT } from '@/constant/discuss';
import { Message } from 'vue-devui/message';
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
import { orgInfoStore } from '@/stores/Org';
import { repoInfoStore } from '@/stores/Repo';
defineOptions({
name: 'DiscussionCreate'
});
const props = withDefaults(
defineProps<{
sourceType: 1 | 2; // 组织1项目2
orgNamespace?: string; // 组织namespace
}>(),
{
sourceType: 1
}
);
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const typeId = route.params ? (route.params.discussionTypeId as string) : '';
// 获取当前用户信息 & 是否登录
const { isLogin = false, userInfo = {}} = useDiscussGetUserInfo();
if (!isLogin) router.push('/404');
// 确认讨论是否开启
const {
id: source_id,
discussOpen,
getDiscussionStatus,
project_id
} = useDiscussionOpen(props.sourceType, props.orgNamespace);
// 获取当前用户项目/组织权限
const access_level =
props.sourceType === 1 ? orgInfoStore().access_level : repoInfoStore().access_level;
const currentType = ref<discussionTypeOrSection>({
id: typeId,
icon: '',
title: '',
categoryType: 0,
isGroup: false
});
const fetchTypeDetail = async() => {
const res = await typeDetail({ id: typeId });
if (!res.error) {
const resData = res?.data?.data;
const { category_icon, category_name, category_type, category_desc } = resData;
// 非管理员,禁止创建公告
if (category_type === DISCUSS_FORMAT.ANNOUNCE) {
if (access_level < 50) router.replace({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSelect` });
};
currentType.value = {
id: typeId,
icon: category_icon,
title: category_name,
categoryType: category_type,
isGroup: false,
desc: category_desc,
answerAcceptEnable: category_type === DISCUSS_FORMAT.QANDA
};
}
};
const initialData = async() => {
await getDiscussionStatus();
if (source_id.value && discussOpen.value === '1') {
await fetchTypeDetail();
// 提取暂存数据
if (localStorage.getItem(localStoreKey.value)) {
const { main, poll } = JSON.parse(localStorage.getItem(localStoreKey.value) as string);
formData.value = main;
pollData.value = poll;
}
} else {
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
router.replace('/404');
}
};
initialData();
const fetchMemberList = () => {};
const goToTypeSelect = () => {
onReset();
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSelect` });
};
interface formDataType {
title: string;
md_content: string;
}
const formData = ref({ title: '', md_content: '' } as formDataType);
const formRef = ref(null);
const formRules = {
title: [
{ required: true, message: '讨论标题不能为空', trigger: 'blur' },
{ min: 2, max: 50, message: '讨论标题长度限制为2-50', trigger: 'blur' }
],
md_content: [{ required: true, message: '讨论内容不能为空', trigger: 'change' }]
};
const mdRules = reactive({
linkify: {
fuzzyLink: false
}
});
interface pollOptionType {
id: string;
value: string;
}
interface pollInfoType {
title: string;
options: pollOptionType[];
}
const pollData = ref<pollInfoType>({ title: '', options: [{ id: '', value: '' }] });
const pollDataError = reactive({
title: false,
options: false
});
const onPollOptionsChange = (val: pollOptionType[]) => {
pollData.value.options = val;
};
// 校验投票内容
const validatePollData = () => {
pollDataError.title = !pollData.value.title.trim();
pollDataError.options = pollData.value.options.reduce((p, c) => (p + (c.value ? 1 : 0)), 0) < 2;
return !pollDataError.title && !pollDataError.options;
};
interface submitDataType {
source_id: string;
source_type: number;
category_id: string;
title: string;
content?: string;
md_content: string;
label?: string[];
commentAts?: string[];
question?: string;
options?: string[];
}
const submitCreate = async(data: any) => {
const res = await discussSave(data);
if (!res.error) {
Message.success('已新建');
onReset();
// 跳转到详情页
router.push({
name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionDetail`,
params: {
serialNumber: res?.data?.data
}
});
}
};
const handleSubmit = async() => {
if (loading.value) return;
loading.value = true;
const submitData: submitDataType = {
...formData.value,
source_id: source_id.value,
source_type: props.sourceType,
category_id: typeId,
label: relatedLabels.value
};
if (currentType.value.categoryType === DISCUSS_FORMAT.VOTE) {
if (validatePollData()) {
submitData.question = pollData.value.title;
submitData.options = pollData.value.options.map((item) => item.value);
await submitCreate(submitData);
}
} else {
await submitCreate(submitData);
}
loading.value = false;
};
const onSubmit = () => {
if (formRef.value) {
formRef.value.validate((isValid: boolean) => {
if (isValid) {
handleSubmit();
}
});
}
};
// 能否创建
const canCreate = ref(false);
watch(
[() => formData.value, () => pollData.value],
() => {
if (formData.value.title) {
formData.value.title = formData.value.title.trim();
}
nextTick(() => {
if (currentType.value.categoryType === DISCUSS_FORMAT.VOTE) {
// 投票
formRef.value &&
formRef.value.validate((isValid: boolean) => {
const pollValidate = validatePollData();
canCreate.value = isValid && pollValidate;
});
} else {
formRef.value &&
formRef.value.validate((isValid: boolean) => {
canCreate.value = isValid;
});
}
});
},
{ deep: true }
);
// 重置
const onReset = () => {
enableStore.value = false; // 不存储表单数据
localStorage.removeItem(localStoreKey.value);
};
const onCancel = () => {
onReset();
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSelect` });
};
// Label 关联
const relatedLabels = ref<string[]>();
const getLabels = (val: string[]) => {
relatedLabels.value = val;
};
// 表单信息暂存
const enableStore = ref(true);
const localStoreKey = computed(() => {
return `discussion:${userInfo.id}/${source_id.value}/${typeId}`;
});
const storeFormData = () => {
// 检测是否需要暂存
if (
enableStore.value &&
(formData.value.title.trim() || formData.value.md_content.trim() || pollData.value.title.trim())
) {
const storeData = {
main: { ...formData.value },
poll: { ...pollData.value }
};
localStorage.setItem(localStoreKey.value, JSON.stringify(storeData));
}
};
onBeforeRouteLeave((to, from, next) => {
storeFormData();
next();
});
onMounted(() => {
window.addEventListener('beforeunload', storeFormData);
});
onUnmounted(() => {
window.removeEventListener('beforeunload', storeFormData);
});
</script>
<template>
<div class="discussion-create mt-6" v-loading="loading">
<div class="mb-6">
<d-breadcrumb>
<gc-breadcrumb-item
:to="{ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSelect` }"
><span class="title">讨论类型选择</span></gc-breadcrumb-item
>
<gc-breadcrumb-item><span class="cur-title">新建讨论</span></gc-breadcrumb-item>
</d-breadcrumb>
</div>
<div class="flex gap-8 main-content">
<Card class="discussion-create-container min-w-0 flex-1">
<!-- 当前内容分类信息 -->
<DiscussionTypeItem class="discussion-create-head" :info="currentType">
<template #icon>
<span class="emoji-icon">{{ currentType.icon }}</span>
</template>
<template #tools>
<d-button @click="goToTypeSelect">重新选择讨论类型</d-button>
</template>
</DiscussionTypeItem>
<div class="py-4 px-20 -mb-30">
<d-form
ref="formRef"
layout="vertical"
:data="formData"
:pop-postion="['right']"
:rules="formRules"
>
<d-form-item field="title" label="" :show-feedback="false">
<d-input
style="width: 100%"
v-model="formData.title"
placeholder="请输入讨论标题"
maxLength="50"
minLength="2"
/>
</d-form-item>
<d-form-item field="md_content" label="" :show-feedback="false">
<MdEditor v-model="formData.md_content" :project-id="project_id"></MdEditor>
</d-form-item>
</d-form>
</div>
<!-- 投票表单 -->
<div class="p-20" v-if="currentType?.categoryType === DISCUSS_FORMAT.VOTE">
<PollForm
v-model:title="pollData.title"
:title-empty="pollDataError.title"
:valid-options="pollDataError.options"
@poll-options="onPollOptionsChange"
/>
</div>
<!-- 按钮 -->
<div class="flex justify-end gap-2 px-5 py-5 mt-3 discussion-create-footer">
<d-button @click="onCancel">取消</d-button>
<d-button variant="solid" color="primary" @click="onSubmit" :disabled="!canCreate" :loading="loading"
>新建讨论</d-button
>
</div>
</Card>
<!-- 侧边栏 -->
<div class="wrapper-right" v-if="access_level >= 30">
<Sidebar
v-if="source_id"
:access_level="access_level"
:source-id="source_id"
:is-detail="false"
:source-type="sourceType"
@create-discuss="getLabels"
></Sidebar>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@import 'devui-theme/styles-var/devui-var.scss';
.discussion-create {
&-container {
padding: 0;
}
.title {
font-size: 14px;
font-weight: 400;
color: #9a9b9c;
line-height: 20px;
}
.cur-title {
font-size: 14px;
font-weight: 500;
color: #2d2d2e;
line-height: 20px;
}
&-head {
padding: 16px 20px !important;
}
.create-btn {
padding: 8px 16px;
border-radius: 4px;
background-color: #333;
color: #fff;
}
&-footer {
border-top: 1px solid var(--color-border-light);
}
:deep(.devui-form__control-info) {
margin-top: 4px;
}
:deep(.devui-form__label--vertical) {
display: none;
}
:deep(.devui-form__item--vertical) {
margin-bottom: 16px;
}
}
.emoji-icon {
font-size: 18px;
}
// 投票讨论
// sidebar
.header {
display: flex;
justify-content: space-between;
.header-select {
margin-top: 7px;
width: 120px;
}
.setting {
margin-top: 7px;
cursor: pointer;
font-size: 16px;
}
}
.wrapper-right {
width: 260px;
}
@media screen and (max-width: 576px){
.discussion-create {
padding:20px;
}
.main-content{
flex-direction: column;
.discussion-create-container{
width:100%;
}
.wrapper-right{
width:100%;
}
}
}
</style>

View File

@@ -0,0 +1,266 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import type { userInfoType, commentType } from '@/api/discussion/types';
import { useClipboard } from '@vueuse/core';
import { Message } from 'vue-devui/message';
import { getDiscussionOriginalData } from '@/api/discussion';
import LikeBtn from '@/components/Discussion/Module/components/DiscussionLikeBtn.vue';
import MdEditor from '@/components/MdEditor/index.vue';
import MdRender from '@/components/MdRender/index.vue';
import DiscussionContentToolbar from '../../ContentToolbar/index.vue';
import { GModal } from '@/components/Setting/index';
import { replyUpdate, replyDelete } from '@/api/discussion';
defineOptions({
name: 'DiscussionReplyItem'
});
const props = defineProps<{
replyDetail: commentType;
userInfo: userInfoType; // 当前用户信息
access_level?: number; // 权限
projectId?:string;
memberList?: any[]; // 可@用户列表
hintConfig?: any; // 提示配置
}>();
const emit = defineEmits(['updateReply', 'deleteReply', 'quoteReply']);
const loading = ref(false);
const editing = ref(false);
const created_avatar = ref('');
const renderReplyContent = computed(() => {
return props.replyDetail.md_content as string;
});
// 表单
const formData = ref({ md_content: '' });
const formRef = ref(null);
const formRules = {
md_content: [{ required: true, message: '回复内容不能为空', trigger: 'change' }]
};
const mdRules = ref({ linkify: { fuzzyLink: false }});
// 提交数据
const onSubmit = async() => {
if (formRef.value) {
formRef.value.validate(async(isValid:boolean) => {
if (isValid) {
const submitData = {
id: props.replyDetail.id,
md_content: formData.value?.md_content
};
await onReplyUpdate(submitData);
}
});
}
};
interface submitDataType {
id: string;
md_content: string;
}
// 更新回复
const onReplyUpdate = async(submitData:submitDataType) => {
loading.value = true;
const res = await replyUpdate(submitData);
if (!res.error) {
emit('updateReply', res.data.data);
editing.value = false;
}
loading.value = false;
};
// 删除回复
const replyDeleteVisible = ref(false);
const deleteLoading = ref(false);
const handleDelete = () => {
document.body.click();
replyDeleteVisible.value = true;
};
const onReplyDelete = async() => {
deleteLoading.value = true;
const res = await replyDelete({ id: props.replyDetail.id });
if (!res.error) {
emit('deleteReply');
}
deleteLoading.value = false;
replyDeleteVisible.value = false;
};
const getOriginalComment = async(id: any) => {
const res = await getDiscussionOriginalData(id);
if (!res.error) {
return res?.data?.data?.content;
}
return '';
};
const handleEdit = async () => {
editing.value = true;
const originalContent = await getOriginalComment(props.replyDetail.id );
const mdContent = originalContent || (props.replyDetail?.md_content as string)
formData.value.md_content = mdContent;
}
// 复制链接
const { copy } = useClipboard({ source: 'text', legacy: true });
const copyLink = () => {
copy(`${location.origin}${location.pathname}#discussion-reply-${props?.replyDetail?.id}`);
document.body.click();
Message.success('已复制链接');
};
// 引用回复
const quoteReply = () => {
document.body.click();
emit('quoteReply', props.replyDetail.md_content);
};
// 取消,清空表单
const onCancel = () => {
loading.value = false;
editing.value = false;
};
// watch(editing, (newVal) => {
// if (!newVal) {
// // 表单值重置
// formData.value.md_content = props.replyDetail?.md_content;
// }
// });
// 初始化值
watch(
() => props.replyDetail,
() => {
formData.value.md_content = props.replyDetail?.md_content;
created_avatar.value = props.replyDetail?.created_by_user_photo || '';
},
{ deep: true, immediate: true }
);
</script>
<template>
<div class="reply-container" :id="replyDetail?.id?`discussion-reply-${replyDetail.id}`:undefined">
<!-- 展示区 -->
<div class="content-show" v-show="!editing">
<!-- bar -->
<DiscussionContentToolbar
:created_avatar="created_avatar"
:created_by_user_name="replyDetail?.created_by_user_name"
type="reply"
:showDropDown="!!userInfo?.id"
:created_date="replyDetail?.created_date"
>
<template #edit>
<Icon
name="gt-edit"
size="16px"
class="mr-1 cursor-pointer"
color="inherit"
@click="handleEdit"
v-if="userInfo.id === replyDetail?.created_by"
></Icon>
</template>
<template #option>
<!-- <gc-option class="comment-menu-option" @click="copyLink">复制链接</gc-option> -->
<gc-option @click="quoteReply">引用回复</gc-option>
<gc-option
@click="handleDelete"
v-if="userInfo.id === replyDetail?.created_by || access_level === 50"
>删除回复</gc-option
>
</template>
</DiscussionContentToolbar>
<!-- md-render -->
<div class="content-md">
<MdRender v-model="renderReplyContent"></MdRender>
</div>
<!-- 点赞区 -->
<div class="content-like" v-if="replyDetail?.id">
<LikeBtn
:target-type="3"
:likeTotal="replyDetail?.like_total"
:target-id="replyDetail.id"
:is-like="replyDetail.is_like"
:is-login="!!userInfo?.id"
></LikeBtn>
</div>
</div>
<!-- 编辑区 -->
<div class="content-edit" v-if="editing" v-loading="loading">
<d-form
ref="formRef"
layout="vertical"
:data="formData"
:pop-postion="['right']"
:rules="formRules"
>
<d-form-item field="md_content" label="" :show-feedback="false">
<MdEditor v-model="formData.md_content" :hint-config="hintConfig" :project-id="projectId" :options="{ autofocus: true }" border></MdEditor>
</d-form-item>
</d-form>
<div class="mt-4 flex justify-end gap-4">
<d-button @click="onCancel">取消</d-button>
<d-button color="primary" variant="solid" @click="onSubmit">更新回复</d-button>
</div>
</div>
<GModal v-model="replyDeleteVisible" showWarnIcon @confirm="onReplyDelete" confirmColor="danger" title="删除回复">
<p>确定要删除此回复</p>
</GModal>
</div>
</template>
<style scoped lang="scss">
.reply-container {
background: #fbfbfb;
width: calc(100% + 40px);
position: relative;
left: -20px;
padding: 16px 20px;
border: 1px solid #f5f5f5;
:deep(.devui-form__label--vertical) {
display: none;
}
}
.reply-container:first-of-type {
margin-top: 16px;
}
.content-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
height: 28px;
line-height: 1;
font-size: 14px;
font-weight: 400;
&__left,
&__right {
display: flex;
align-items: center;
}
&__role {
padding: 2px 8px;
font-size: 12px;
border-radius: 12px;
border: 1px solid #e4e9f0;
line-height: 16px;
}
&__creator {
color: #2d2d2e;
line-height: 24px;
}
}
.content-like {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,676 @@
<script setup lang="ts">
import { ref, watch, computed, nextTick, reactive, watchEffect, onMounted, inject } from 'vue';
import type { discussDetailType, userInfoType, commentType } from '@/api/discussion/types';
import { useClipboard } from '@vueuse/core';
import { Message } from 'vue-devui/message';
import MdEditor from '@/components/MdEditor/index.vue';
import MdRender from '@/components/MdRender/index.vue';
import LikeBtn from '@/components/Discussion/Module/components/DiscussionLikeBtn.vue';
import ReplyItem from './components/ReplyItem.vue';
import DiscussionContentToolbar from '../ContentToolbar/index.vue';
import { GModal } from '@/components/Setting/index';
import { DISCUSS_FORMAT } from '@/constant/discuss';
import {
commentUpdate,
commentDelete,
commentRemark,
commentUnremark,
replyList,
replySave,
getDiscussionOriginalData,
} from '@/api/discussion';
import { useReport } from '@/utils/hooks/useReport';
import { useAccountStore } from '@/stores/user';
import { formatQuoteReply } from '@/utils';
defineOptions({
name: 'DiscussionCommentItem'
});
const props = withDefaults(defineProps<{
discussDetail?: discussDetailType | undefined;
commentDetail?: commentType | undefined;
userInfo: userInfoType; // 当前用户信息
access_level?: number; // 权限
projectId?:string;
markedComment?: commentType; // 已采纳的答案
memberList?: any[]; // 可@用户列表
mode?: string; // 评论模式
}>(), {
access_level: 0
});
const emit = defineEmits(['updateComment', 'updateDiscuss', 'refreshDiscuss']);
const isCover = computed(() => props.mode === 'cover');
const showCover = ref(true);
const loading = ref(false);
const editing = ref(false);
const userStore = useAccountStore();
const created_avatar = ref('');
const hasMarkedCommentStyle = ref({});
const renderCommentContent = computed(() => {
return props?.commentDetail?.md_content as string;
});
// 表单
const formData = ref({ md_content: '' });
const formRef = ref(null);
const formRules = {
md_content: [{ required: true, message: '评论内容不能为空', trigger: 'change' }]
};
const mdRules = ref({ linkify: { fuzzyLink: false }});
const commentDeleteVisible = ref(false);
const getGroupMembers = inject('getGroupMembers') as () => Promise<void>;
const hintConfig = {
'@': getGroupMembers,
};
// 提交更新评论
const onSubmit = () => {
if (formRef.value) {
formRef.value.validate((isValid:boolean) => {
if (isValid) {
const submitData = {
id: props.commentDetail?.id as string,
md_content: formData.value?.md_content
};
onCommentUpdate(submitData);
}
});
}
};
interface submitDataType {
id:string;
md_content:string;
}
// 更新评论
const onCommentUpdate = async(submitData:submitDataType) => {
if (loading.value) return;
loading.value = true;
const res = await commentUpdate(submitData);
if (!res.error) {
emit('updateComment');
editing.value = false;
}
loading.value = false;
};
// 删除评论
const deleteLoading = ref(false);
const handleDelete = () => {
document.body.click();
commentDeleteVisible.value = true;
};
const onCommentDelete = async() => {
deleteLoading.value = true;
const res = await commentDelete({ id: props.commentDetail?.id as string });
if (!res.error) {
// 删除评论,需要更新讨论所有内容 - 包括侧边栏数据
emit('refreshDiscuss');
}
deleteLoading.value = false;
commentDeleteVisible.value = false;
};
// 取消,清空表单
const onCancel = () => {
loading.value = false;
editing.value = false;
};
// 采纳回答
const onRemarkAnswer = async() => {
const res = await commentRemark({ id: props.commentDetail?.id as string });
if (!res.error) {
emit('updateDiscuss');
}
};
// 取消采纳回答
const onUnremarkAnswer = async() => {
const res = await commentUnremark({ id: props.commentDetail?.id as string });
if (!res.error) {
emit('updateDiscuss');
}
};
// 复制链接
const { copy } = useClipboard({ source: 'text', legacy: true });
const copyLink = () => {
copy(`${location.origin}${location.pathname}#discussion-comment-${props?.commentDetail?.id}`);
document.body.click();
Message.success('已复制链接');
};
// 评论-引用回复
const refReplyMd = ref();
const focusReplyMdEditor = async() => {
await nextTick();
const lastLineNumber = refReplyMd.value.instance.lastLine();
refReplyMd.value.instance.setCursor(lastLineNumber, 0);
refReplyMd.value.instance.scrollIntoView(null, refReplyMd.value.instance.getScrollInfo().clientHeight - 10);
refReplyMd.value.instance.focus();
};
const getOriginalComment = async(id: any) => {
const res = await getDiscussionOriginalData(id);
if (!res.error) {
return res?.data?.data?.content;
}
return '';
};
const handleEdit = async () => {
editing.value = true;
const originalContent = await getOriginalComment(props.commentDetail?.id );
const mdContent = originalContent || (props.commentDetail?.md_content as string)
formData.value.md_content = mdContent;
}
const quoteReply = async () => {
document.body.click();
showReplyEditor.value = true;
const originalContent = await getOriginalComment(props.commentDetail?.id );
const mdContent = originalContent || (props.commentDetail?.md_content as string)
replyFormData.value.md_content = formatQuoteReply(mdContent);
focusReplyMdEditor();
};
// 回复-引用回复
const replyQuoteReply = async (content = '', id) => {
document.body.click();
showReplyEditor.value = true;
const originalContent = await getOriginalComment(id)
const mdContent = originalContent || (props.commentDetail?.md_content as string)
replyFormData.value.md_content = formatQuoteReply(mdContent);
focusReplyMdEditor();
};
// 获取回复列表
const replyListData = ref<commentType[]>([]);
const addReplyList = ref<commentType[]>([]);// 暂存已添加的数据 (在分页获取同样数据后移除重复数据)
const replyListLength = ref(0);
const replyPager = reactive({
page: 1,
pages: 0,
pageSize: 10,
total: props.commentDetail.reply_total || 0,
loading
});
const showReplyEditor = ref(false);
const fetchReplyList = async() => {
// 锚点定位过来展示一条评论作为封面
if(isCover.value && showCover.value) {
replyListData.value = props.commentDetail?.replyCover ? [props.commentDetail?.replyCover] : [];
replyListLength.value = 1;
replyPager.total = props.commentDetail?.reply_total;
return
};
if (props.commentDetail.reply_total < 1 || replyPager.loading) return; // 一级评论已知有无二级评论,避免多余请求
replyPager.loading = true;
const res = await replyList({ parent_id: props.commentDetail?.id as string, page: replyPager.page, size: replyPager.pageSize });
replyPager.loading = false;
if (!res.error) {
const resData = res?.data?.data;
replyListData.value = resData.records;
replyListLength.value = resData.total;
replyPager.total = resData.total;
replyPager.pages = resData.pages;
}
};
// 回复表单
const replyLoading = ref(false);
const replyFormRef = ref(null);
const replyFormData = ref({ md_content: '' });
const replyFormRules = {
md_content: [{ required: true, message: '回复内容不能为空', trigger: 'change' }]
};
// 确认提交回复
const onReplySubmit = () => {
if (replyFormRef.value) {
replyFormRef.value.validate((isValid:boolean) => {
if (isValid) {
const submitData = {
parent_id: props.commentDetail?.id as string,
md_content: replyFormData.value?.md_content
};
onReplySave(submitData);
}
});
}
};
interface replySubmitDataType {
parent_id:string;
md_content:string;
}
// 调用新增回复接口
const onReplySave = async(submitData:replySubmitDataType) => {
if (replyLoading.value) return;
replyLoading.value = true;
const res = await replySave(submitData);
if (!res.error) {
emit('refreshDiscuss', { total: addReplyList.value.length + replyPager.total + 1 }); // 同步总回复数量
showReplyEditor.value = false;
replyFormData.value.md_content = '';
useReport('comment', {
event_id: 'comment',
source_type: 'disscussion',
source_name: props.discussDetail?.title,
source_id: props.discussDetail?.created_by,
repo_author_id: props.discussDetail?.created_by_user_name,
comment_type: 'replay'
});
const replyData = res.data.data;
replyData.created_by_user_name = userStore.accountInfo.username;
replyData.created_by_user_photo = userStore.accountInfo.avatar;
addReplyList.value.push(replyData); // 添加暂存的回复数据
}
replyLoading.value = false;
};
const updateReply = ($event, item) => {
const { md_content } = $event;
item.md_content = md_content;
};
// 删除添加的回复
const handleDeleteReply = (item, type) => {
if (type === 'added') {
// 后添加的
const index = addReplyList.value.findIndex((e) => e.id === item.id);
if (index > -1) {
addReplyList.value.splice(index, 1);
}
} else {
// 已添加的
const index = replyListData.value.findIndex((e) => e.id === item.id);
if (index > -1) {
replyListData.value.splice(index, 1);
replyPager.total--;
}
}
emit('refreshDiscuss', { total: replyPager.total + addReplyList.value.length });
};
watchEffect(() => {
// 清除副作用 移除已添加的重复数据
if (replyListData.value.length && addReplyList.value.length) {
const list = replyListData.value.slice(0);
list.forEach((item) => {
const index = addReplyList.value.findIndex((e) => e.id === item.id);
if (index > -1) {
addReplyList.value.splice(index, 1);
}
});
}
});
onMounted(() => {
if (props.commentDetail?.serial_number === 1 && !isCover.value) { // 快速获取第一条,其他 二级评论 延迟在元素暴露时获取
fetchReplyList();
}
});
// 取消新增回复,清空表单
const onReplyCancel = () => {
replyLoading.value = false;
showReplyEditor.value = false;
};
const loadComment = async() => {
showCover.value = false;
fetchReplyList();
};
const loadNextComment = async() => {
replyPager.page++;
if (replyPager.loading) return;
replyPager.loading = true;
const res = await replyList({ parent_id: props.commentDetail?.id as string, page: replyPager.page, size: replyPager.pageSize });
replyPager.loading = false;
if (res.error) {
replyPager.page--;
} else {
const resData = res?.data?.data;
replyListData.value = replyListData.value.slice(0).concat(resData.records);
replyListLength.value = resData.total;
replyPager.total = resData.total;
}
};
// watch(showReplyEditor, (newVal) => {
// if (!newVal) {
// // 表单值重置
// replyFormData.value.md_content = '';
// }
// });
// 初始化值
watch(
() => props.commentDetail,
() => {
formData.value.md_content = props.commentDetail?.md_content as string;
created_avatar.value = props.commentDetail?.created_by_user_photo || '';
},
{ deep: true, immediate: true }
);
// watch(editing, (newVal) => {
// if (!newVal) {
// // 表单值重置
// formData.value.md_content = props.commentDetail?.md_content as string;
// }
// });
watch(
() => props.markedComment,
() => {
hasMarkedCommentStyle.value =
(props.markedComment?.id === props.commentDetail?.id && props.discussDetail?.category?.category_type === DISCUSS_FORMAT.QANDA)
? {
borderWidth: '1px',
borderColor: '#26b688'
}
: {
borderWidth: '0px',
borderColor: 'transparent'
};
},
{ deep: true, immediate: true }
);
const answerStatus = computed(() => {
const origin = { showAnswerBar: false, isAnswered: false, isRemarked: false };
if (
props.discussDetail?.category?.category_type === DISCUSS_FORMAT.QANDA &&
(props.userInfo?.id === props.discussDetail.created_by || props.access_level >= 30)
) {
origin.showAnswerBar = true;
if (props.discussDetail.is_answered === 1) {
if (props.commentDetail?.is_remark === 0) {
origin.showAnswerBar = false;
}
origin.isAnswered = true;
}
if (props.commentDetail?.is_remark === 1) {
origin.isRemarked = true;
}
}
return origin;
});
const currentRole = computed(() => {
switch (props.access_level) {
case 10:
return '浏览者';
case 30:
return '开发者';
case 50:
return '管理员';
default:
return '无权限';
}
});
// 回复 trigger input 是否启用
const replyCommentEnable = computed(() => {
if (!props.userInfo?.id || (props.discussDetail?.is_lock === 1 && props.access_level < 10)) {
return false;
} else return true;
});
</script>
<template>
<Card simple class="container" :id="commentDetail?.id?`discussion-comment-${commentDetail.id}`:undefined" :style="hasMarkedCommentStyle">
<!-- 展示区 -->
<div class="content-show" v-show="!editing" v-element-exposure="{trigger: fetchReplyList}">
<!-- bar -->
<DiscussionContentToolbar
:created_avatar="created_avatar"
:created_by_user_name="commentDetail?.created_by_user_name"
type="comment"
:showDropDown="!!userInfo?.id && !!commentDetail?.md_content"
:created_date="commentDetail?.created_date"
>
<template #edit>
<Icon
name="gt-edit"
size="16px"
class="mr-1 cursor-pointer"
color="inherit"
@click="handleEdit"
v-if="userInfo?.id === commentDetail?.created_by && commentDetail?.md_content"
></Icon>
</template>
<template #option>
<!-- <gc-option class="comment-menu-option" @click="copyLink">复制链接</gc-option> -->
<gc-option class="comment-menu-option" @click="quoteReply">引用回复</gc-option>
<gc-option
class="comment-menu-option"
@click="handleDelete"
v-if="userInfo?.id === commentDetail?.created_by || access_level === 50"
>删除评论</gc-option
>
</template>
</DiscussionContentToolbar>
<!-- md-render -->
<div class="content-md" v-if="commentDetail?.md_content">
<MdRender v-model="renderCommentContent"></MdRender>
</div>
<!-- 点赞区 -->
<div class="content-like" v-if="commentDetail?.id">
<LikeBtn
:target-type="2"
:likeTotal="commentDetail?.like_total"
:target-id="commentDetail.id"
:is-like="commentDetail.is_like"
:is-login="!!userInfo?.id"
></LikeBtn>
<p v-if="commentDetail.reply_total && commentDetail.reply_total > 0">{{ commentDetail.reply_total }}条回复</p>
</div>
<!-- 回复展示区 -->
<div v-if="replyListData.length > 0">
<ReplyItem
v-for="item in replyListData"
:key="item.id"
:access_level="access_level"
:user-info="userInfo"
:reply-detail="item"
:projectId="props.projectId"
@update-reply="updateReply($event, item)"
@delete-reply="handleDeleteReply(item, undefined)"
@quote-reply="(evt) =>{ replyQuoteReply(evt, item.id) }"
:hint-config="hintConfig"
></ReplyItem>
</div>
<div
class="leading-[26px] py-2 px-[20px] mx-[-20px] bg-[#fbfbfb]"
v-if="showCover && replyPager.total > replyListData.length"
>
<d-button variant="text" :loading="replyPager.loading" @click="loadComment">点击查看更多</d-button>
</div>
<div
class="leading-[26px] py-2 px-[20px] mx-[-20px] bg-[#fbfbfb]"
v-if="!showCover && replyPager.total > replyListData.length && replyListData.length && replyPager.page < replyPager.pages"
>
<d-button variant="text" :loading="replyPager.loading" @click="loadNextComment">点击查看更多</d-button>
</div>
<!-- 添加的回复列表 -->
<div v-if="addReplyList.length > 0">
<ReplyItem
v-for="item in addReplyList"
:key="item.id"
:access_level="access_level"
:user-info="userInfo"
:reply-detail="item"
:projectId="props.projectId"
@update-reply="updateReply($event, item)"
@delete-reply="handleDeleteReply(item, 'added')"
@quote-reply="(evt) =>{ replyQuoteReply(evt, item.id) }"
class="first-of-type:!mt-0"
:hint-config="hintConfig"
>
</ReplyItem>
</div>
<!-- 采纳答案 & 回复评论区 -->
<div class="content-answer" v-if="!showReplyEditor">
<d-input
placeholder="回复评论"
class="flex-1 input-hover"
@focus="replyCommentEnable && (showReplyEditor = true)"
:disabled="!replyCommentEnable"
:title="replyCommentEnable?undefined:'请先登录'"
/>
<span v-show="answerStatus.showAnswerBar">
<d-button
v-if="!answerStatus.isAnswered"
@click="onRemarkAnswer"
icon="right-o"
class="remark-answer-btn"
>采纳该回答</d-button
>
<d-button
v-else
icon="forbid"
@click="onUnremarkAnswer"
class="unremark-answer-btn"
>取消采纳回答</d-button
>
</span>
</div>
<div
class="content-reply-editor"
v-else
v-loading="replyLoading"
>
<d-form
ref="replyFormRef"
layout="vertical"
:data="replyFormData"
:pop-postion="['right']"
:rules="replyFormRules"
>
<d-form-item field="md_content" label="" :show-feedback="false">
<MdEditor ref="refReplyMd" v-model="replyFormData.md_content" :hint-config="hintConfig" :project-id="projectId" :options="{ autofocus: true }" border-light></MdEditor>
</d-form-item>
</d-form>
<div class="mt-4 flex justify-end gap-2">
<d-button @click="onReplyCancel">取消</d-button>
<d-button variant="solid" @click="onReplySubmit" color="primary" :disabled="!replyFormData.md_content">回复</d-button>
</div>
</div>
</div>
<!-- 编辑区 -->
<div class="content-edit" v-if="editing" v-loading="loading">
<d-form
ref="formRef"
layout="vertical"
:data="formData"
:pop-postion="['right']"
:rules="formRules"
>
<d-form-item field="md_content" label="" :show-feedback="false">
<MdEditor v-model="formData.md_content" :hint-config="hintConfig" :project-id="projectId" :options="{ autofocus: true }" border-light />
</d-form-item>
</d-form>
<div class="mt-4 flex justify-end gap-2">
<d-button @click="onCancel">取消</d-button>
<d-button variant="solid" color="primary" @click="onSubmit" :disabled="!formData.md_content">更新评论</d-button>
</div>
</div>
<GModal v-model="commentDeleteVisible" showWarnIcon @confirm="onCommentDelete" confirmColor="danger" title="删除评论">
<p>确定要删除此评论?</p>
</GModal>
</Card>
</template>
<style scoped lang="scss">
.container {
margin-top: 16px;
:deep(.devui-form__label--vertical) {
display: none;
}
}
.container.g-content-card {
padding: 16px 20px;
}
.content-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
height: 28px;
line-height: 1;
font-size: 14px;
font-weight: 400;
&__left,
&__right {
display: flex;
align-items: center;
}
&__role {
padding: 2px 8px;
font-size: 12px;
border-radius: 12px;
border: 1px solid #e4e9f0;
line-height: 16px;
}
&__creator {
color: #2d2d2e;
line-height: 24px;
}
}
.content-like {
margin-top: 16px;
display: flex;
justify-content: space-between;
align-items: center;
& > p {
font-size: 12px;
font-weight: 400;
color: #9fa7b3;
line-height: 16px;
}
}
.content-answer {
margin-top: 16px;
display: flex;
justify-content: space-between;
gap: 16px;
.remark-answer-btn {
background: rgba(14, 176, 123, 0.1);
border: 1px solid #0eb07b;
color: #0eb07b;
:deep(.devui-button__icon-fix) {
color: #0eb07b;
}
}
.unremark-answer-btn {
background: #fae5ec;
border: 1px solid #e05e86;
color: #e05e86;
:deep(.devui-button__icon-fix) {
color: #e05e86;
}
}
}
.content-reply-editor {
margin-top: 24px;
}
</style>

View File

@@ -0,0 +1,99 @@
<!-- 展示投票结果投票标题投票各选项票数-->
<script setup lang="ts">
import { computed } from 'vue';
import CustomTag from '@/components/CustomTag/index.vue';
defineOptions({
name: 'DiscussionContentToolbar'
});
const props = withDefaults(defineProps<{
created_avatar: string; // 创建人头像
created_by_user_name: string; // 创建人username
type: 'discussion' | 'comment' | 'reply';
showDropDown: boolean; // 是否登录
created_date: string; // 创建日期
isAnswerDisplay:boolean; // 是否为答案展示
}>(), {
created_avatar: '',
created_by_user_name: '',
type: 'comment',
showDropDown: false,
created_data: '',
isAnswerDisplay: false
});
const typeText = computed(() => {
switch (props.type) {
case 'discussion':
return '创建讨论:';
case 'comment':
return '评论:';
case 'reply':
return '回复:';
default:
return '评论:';
}
});
</script>
<template>
<div class="content-toolbar">
<div class="content-toolbar__left space-x-2">
<span><GAvatar :src="created_avatar" :name="created_by_user_name" :width="20" :height="20"></GAvatar></span>
<GLink
style="color: inherit"
:to="{ name: 'homepage', params: { namespace: created_by_user_name } }"
target="_blank"
>{{ created_by_user_name }}</GLink
>
<!-- TODO: 接口缺失 先去除 role 展示 -->
<!-- <span class="content-toolbar__role">开发者</span> -->
<span class="content-toolbar__creator">{{ typeText }}</span>
</div>
<div class="content-toolbar__right space-x-5">
<CustomTag v-if="isAnswerDisplay" title="回答已采纳" icon="right-o" bg-color="#e6f7f1" color="#0EB07B" icon-color="#0EB07B"></CustomTag>
<slot name="edit">
</slot>
<d-dropdown style="width: 100px" align="start" v-if="showDropDown">
<Icon name="gt-more-operate" size="16px" class="cursor-pointer"></Icon>
<template #menu>
<!-- slot 传入自定义 option -->
<slot name="option"></slot>
</template>
</d-dropdown>
<span v-if="created_date"><Time :time="created_date"></Time></span>
</div>
</div>
</template>
<style scoped lang="scss">
.content-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
height: 28px;
line-height: 1;
font-size: 14px;
font-weight: 400;
&__left,
&__right {
display: flex;
align-items: center;
}
&__role {
padding: 2px 8px;
font-size: 12px;
border-radius: 12px;
border: 1px solid #e4e9f0;
line-height: 16px;
}
&__creator {
color: #2d2d2e;
line-height: 24px;
}
}
</style>

View File

@@ -0,0 +1,125 @@
<!-- 已采纳答案展示答案内容渲染点击查看完整回答 -->
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import type { commentType, userInfoType } from '@/api/discussion/types';
import DiscussionContentToolbar from '../../../ContentToolbar/index.vue';
import MdRender from '@/components/MdRender/index.vue';
defineOptions({
name: 'DiscussionAnswerDisplay'
});
const props = defineProps<{
markedComment: commentType;
userInfo: userInfoType; // 当前用户信息
}>();
const created_avatar = ref('');
const renderCommentContent = computed(() => {
return props.markedComment.md_content as string;
});
const mdRules = ref({ linkify: { fuzzyLink: false }});
// 跳转到锚点
const goHashId = async(hash:string) => {
const aNode = document.createElement('a');
aNode.href = hash;
aNode.click();
const height = document.querySelector('.g-header')?.clientHeight || 141;
window.scrollBy(0, -height);
};
watch(
() => props.markedComment,
() => {
created_avatar.value = props.markedComment.created_by_user_photo || '';
},
{ deep: true, immediate: true }
);
</script>
<template>
<div class="container">
<DiscussionContentToolbar
:created_avatar="created_avatar"
:created_by_user_name="markedComment?.created_by_user_name"
type="comment"
:showDropDown="false"
:created_date="markedComment?.created_date"
isAnswerDisplay
>
</DiscussionContentToolbar>
<div class="content-markdown">
<MdRender v-model="renderCommentContent"></MdRender>
</div>
<!-- link -->
<div class="answer-link">
<span @click="goHashId(`#discussion-comment-${markedComment.id}`)" class="cursor-pointer">点击查看完整回答</span><d-icon name="icon-run" color="#0EB07B" :rotate="90" size="12px"></d-icon>
</div>
</div>
</template>
<style scoped lang="scss">
.container {
margin-top: 20px;
padding:16px 20px 40px 20px;
background: linear-gradient(180deg,#e6f7f1,#f0faf6);
position:relative;
width:calc(100% + 40px);
left: -20px;
border-radius: 0 0 4px 4px;
}
.content-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
height: 28px;
line-height: 1;
font-size: 14px;
font-weight: 400;
&__left,
&__right {
display: flex;
align-items: center;
}
&__role {
padding: 2px 8px;
font-size: 12px;
border-radius: 12px;
border: 1px solid #e4e9f0;
line-height: 16px;
}
&__creator {
color: #2d2d2e;
line-height: 24px;
}
}
.content-markdown {
max-height: 100px;
overflow: hidden;
}
.answer-link{
height:20px;
position:absolute;
margin-bottom: 10px;
bottom:0;
left:0;
// background: #ffffff;
width:100%;
display:flex;
justify-content: center;
align-items: center;
a{
font-size: 14px;
font-weight: 500;
color: #0EB07B;
line-height: 16px;
margin-right: 4px;
}
}
</style>

View File

@@ -0,0 +1,209 @@
<!-- 投票结果展示 && 投票表单界面 -->
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import type {
discussDetailType,
userInfoType,
quesitonOptionType
} from '@/api/discussion/types';
import { Message } from 'vue-devui/message';
import PollResult from '../PollResult/index.vue';
import { discussVote } from '@/api/discussion';
import CustomTag from '@/components/CustomTag/index.vue';
defineOptions({
name: 'DiscussionPollDisplay'
});
interface voteFormType {
title: string;
options: quesitonOptionType[];
}
const props = defineProps<{
discussDetail: discussDetailType | undefined; // 讨论详情
userInfo: userInfoType; // 当前用户信息
access_level: number; // 当前用户项目权限
}>();
const showResult = ref(true); // 默认展示投票结果
const choosedOptionId = ref('');
const emit = defineEmits(['updatePollContent']);
const voteForm = ref<voteFormType>({ title: '', options: [] });
watch(
() => props.discussDetail,
() => {
voteForm.value.title = props.discussDetail?.question?.question || '';
voteForm.value.options = props.discussDetail?.options as quesitonOptionType[];
// 初始化:显示表单还是显示结果
// 登录 & 未投票 & 未锁定 || 锁定了,但是是项目成员 → 显示投票表单
if (props.userInfo.id && !props.discussDetail?.is_vote && (props.discussDetail?.is_lock !== 1 || (props.discussDetail?.is_lock === 1 && props.access_level && props.access_level >= 30))) {
showResult.value = false;
} else showResult.value = true;
},
{ deep: true, immediate: true }
);
const voteClosed = computed(() => {
if (props.discussDetail?.is_closed === 1 || (props.discussDetail?.is_lock === 1 && props.access_level < 10)) {
return true; // 投票关闭
} else return false;
});
// 投票按钮 disable 控制
const voteBtnDisabled = computed(() => {
// 讨论关闭后,普通讨论允许用户评论、投票讨论不允许用户投票、修改投票
if (props.discussDetail?.is_closed === 1) {
return true;
} else if (props.discussDetail?.is_lock === 1) {
// 锁定后,讨论不允许非成员用户发表评论、投票
if (!props.access_level || props.access_level < 30) {
return true;
} else return showResult.value;
} else return showResult.value;
});
// 投票表单 - 投票结果切换
const toggleResult = () => {
showResult.value = !showResult.value;
};
// 是否可以点击切换到投票表单
const enableToggleResult = computed(() => {
if (props.discussDetail?.is_closed === 1 || (props.discussDetail?.is_lock === 1 && props.access_level < 10)) return false;
else return true;
});
const handleVote = async() => {
if (choosedOptionId.value) {
const res = await discussVote({
discuss_id: props.discussDetail?.id as string,
option_id: choosedOptionId.value
});
if (!res.error) {
Message.success('已投票');
emit('updatePollContent');
}
} else {
Message.warning('请选择一个投票项');
return;
}
};
</script>
<template>
<div class="container">
<div class="poll-form" v-if="!showResult">
<p class="poll-title">{{ voteForm?.title }}</p>
<div class="poll-container">
<d-radio
v-for="item in voteForm.options"
v-model="choosedOptionId"
:key="item.id"
:value="item.id"
class="mb-2"
>
{{ item.vote_option }}
</d-radio>
</div>
</div>
<div v-else class="poll-result">
<p class="poll-title">{{ voteForm?.title }}</p>
<PollResult :vote-form="discussDetail?.options" :option_id="(discussDetail?.option_id as string)"></PollResult>
</div>
<div class="line"></div>
<div class="poll-footer">
<div class="poll-footer__left">
<CustomTag
v-if="voteClosed"
title="投票关闭"
color="#707A87"
bg-color="rgba(159, 167, 179,0.1)"
></CustomTag>
<CustomTag
v-else
title="投票进行中"
color="#0EB07B"
bg-color="rgba(14, 176, 123,0.1)"
></CustomTag>
<span class="poll-footer__total"
>{{ discussDetail?.question?.vote_total }}人已投票</span
>
<span v-if="userInfo.id">&nbsp;·&nbsp;
<d-button variant="text" v-if="showResult" @click="toggleResult" class="toggle-result" :disabled="!enableToggleResult">隐藏投票结果</d-button>
<d-button v-else @click="toggleResult" class="toggle-result underline" variant="text">查看投票结果</d-button>
</span>
</div>
<div class="poll-footer__right">
<!-- <d-button :disable="voteBtnDisabled" @click="handleVote">{{
discussDetail?.is_vote ? '已投' : '投票'
}}</d-button> -->
<d-button :disabled="voteBtnDisabled" @click="handleVote" variant="solid" color="primary" v-if="userInfo.id">投票</d-button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.poll-form {
padding: 12px 20px;
background: linear-gradient(146deg, #e2eaff 0%, #fdf4f6 35%, #fef9fa 66%, #ebf2ff 100%);
border-radius: 4px;
:deep(.devui-radio__wrapper) {
background-color: #fff;
padding: 6px 12px;
border-radius: 4px;
height: 32px;
}
:deep(.devui-radio--md) {
flex: 1;
}
}
.poll-title {
font-size: 18px;
font-weight: 500;
color: #252d3b;
line-height: 26px;
margin-bottom: 10px;
}
.line {
margin-top: 14px;
position: relative;
left: -20px;
width: calc(100% + 40px);
height: 1px;
background: #f0f1f2;
}
.poll-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
font-size: 12px;
font-weight: 400;
color: #707a87;
line-height: 16px;
&__left {
display:flex;
align-items: center;
.toggle-result{
height:16px;
font-size: 12px;
color:#707a87;
}
}
&__total{
margin-left: 12px;
}
}
.poll-result {
padding: 12px 20px;
background: linear-gradient(146deg, #e2eaff 0%, #fdf4f6 35%, #fef9fa 66%, #ebf2ff 100%);
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,90 @@
<!-- 展示投票结果投票标题投票各选项票数-->
<script setup lang="ts">
import { computed } from 'vue';
import type { quesitonOptionType } from '@/api/discussion/types';
defineOptions({
name: 'DiscussionPollResult'
});
const props = defineProps<{
voteForm: quesitonOptionType[];
option_id: string;
}>();
const dynamicStyle = (item: quesitonOptionType) => {
return {
width: `${item.vote_total_percent * 100}%`
};
};
const currentOptionId = computed(() => props.option_id);
</script>
<template>
<div>
<div v-for="item in props.voteForm" :key="item.id" class="poll-result-container">
<div class="title">
<d-radio disabled v-model="currentOptionId" :value="item.id">{{
item.vote_option
}}</d-radio>
</div>
<div class="item-container">
<div class="item-chart">
<div class="item-inner" :style="dynamicStyle(item)"></div>
</div>
<div class="item-data">
<span>{{ item.vote_total }}</span>&nbsp;·&nbsp;
<span>{{ `${item.vote_total_percent * 100}%` }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.poll-result-container {
padding: 12px;
background: #ffffff;
border-radius: 4px;
margin-bottom: 12px;
&:last-of-type {
margin-bottom: 0;
}
:deep(.devui-radio.disabled .devui-radio__label) {
font-size: 14px;
font-weight: 400;
color: #2d2d2e;
line-height: 20px;
}
:deep(.devui-radio.active .devui-radio__material-inner.disabled) {
fill: #adb0b8;
}
}
.item-container {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
margin-top: 12px;
}
.item-chart {
flex: 1;
height: 4px;
background: #f0f1f2;
border-radius: 4px;
}
.item-inner {
background: #2865e0;
border-radius: 2px;
height: 4px;
}
.item-data {
flex: 0 1 80px;
text-align: right;
font-size: 12px;
font-weight: 400;
color: #505a69;
line-height: 16px;
}
</style>

View File

@@ -0,0 +1,382 @@
<script setup lang="ts">
import { ref, watch, reactive, computed, inject } from 'vue';
import type { discussDetailType, userInfoType, commentType } from '@/api/discussion/types';
import isEqual from 'lodash/isEqual';
import { useClipboard } from '@vueuse/core';
import { Message } from 'vue-devui/message';
import LikeBtn from '@/components/Discussion/Module/components/DiscussionLikeBtn.vue';
import PollForm from '@/components/Discussion/Module/components/DiscussionPollForm.vue';
import PollDisplay from './components/PollDisplay/index.vue';
import AnswerDisplay from './components/AnswerDisplay/index.vue';
import MdEditor from '@/components/MdEditor/index.vue';
import MdRender from '@/components/MdRender/index.vue';
import DiscussionContentToolbar from '../ContentToolbar/index.vue';
import { GModal } from '@/components/Setting/index';
import { DISCUSS_FORMAT } from '@/constant/discuss';
defineOptions({
name: 'DiscussionDetailContent'
});
const props = defineProps<{
discussDetail: discussDetailType | undefined; // 讨论详情
userInfo: userInfoType; // 当前用户信息
access_level:number; // 当前用户权限
projectId?:string;
markedComment?: commentType; // 已采纳的答案
memberList?: any[]; // 可@用户列表
}>();
const emit = defineEmits(['editContent', 'quoteReply']);
const loading = ref(false);
const editing = ref(false);
const created_avatar = ref('');
const hasMarkedCommentStyle = ref({});
const renderDetailContent = computed(() => {
return props.discussDetail?.md_content as string;
});
const getGroupMembers = inject('getGroupMembers') as () => Promise<void>;
const hintConfig = {
'@': getGroupMembers,
};
// 表单
const formData = ref({ md_content: '' });
const formRef = ref(null);
const formRules = {
md_content: [{ required: true, message: '讨论内容不能为空', trigger: 'change' }]
};
const mdRules = ref({ linkify: { fuzzyLink: false }});
// 投票表单
interface pollOptionType {
id: string;
value: string;
}
interface pollInfoType {
title: string;
options: pollOptionType[];
}
const pollData = reactive<pollInfoType>({ title: '', options: [{ id: '', value: '' }] });
const pollDataError = reactive({
title: false,
options: false
});
const newPollOptions = ref(); // 记录修改后的投票选项
const originPollData = ref({ title: '', options: [''] }); // 原始投票数据,用作是否修改投票比对
// 监听投票内容更新
const onPollOptionsChange = (val: pollOptionType[]) => {
newPollOptions.value = val.map((item) => item.value);
};
// 校验投票内容
const validatePollData = () => {
pollDataError.title = !pollData.title.trim();
pollDataError.options = pollData.options.reduce((p, c) => (p + (c.value ? 1 : 0)), 0) < 2;
return !pollDataError.title && !pollDataError.options;
};
// 弹窗确认
const pollChangeModalVisible = ref(false);
// 能否更新
const canUpdate = ref(false);
watch([formData.value, pollData], () => {
if (props.discussDetail?.category?.category_type === DISCUSS_FORMAT.VOTE) {
// 投票
formRef.value && formRef.value.validate((isValid:boolean) => {
const pollValidate = validatePollData();
canUpdate.value = isValid && pollValidate;
});
} else {
formRef.value && formRef.value.validate((isValid:boolean) => {
canUpdate.value = isValid;
});
}
}, { deep: true });
// 提交数据
const onSubmit = () => {
if (formRef.value) {
formRef.value.validate((isValid:boolean) => {
if (isValid) {
const submitData = {
id: props.discussDetail?.id,
title: props.discussDetail?.title,
category_id: props.discussDetail?.category_id,
is_edit: props.discussDetail?.is_edit,
md_content: formData.value?.md_content
};
if (props.discussDetail?.category?.category_type === DISCUSS_FORMAT.VOTE) {
// 标题 或者 投票选项有没有更新
if (
originPollData.value.title !== pollData.title ||
!isEqual(originPollData.value.options, newPollOptions.value)
) {
// 弹窗确认
pollChangeModalVisible.value = true;
} else {
emit('editContent', submitData);
editing.value = false;
}
} else {
emit('editContent', submitData);
editing.value = false;
}
}
});
}
};
const onPollChangeSumbit = () => {
// 确定更新投票
const submitData = {
id: props.discussDetail?.id,
title: props.discussDetail?.title,
category_id: props.discussDetail?.category_id,
is_edit: true,
md_content: formData.value?.md_content,
question: pollData.title,
options: newPollOptions.value
};
if (validatePollData()) {
emit('editContent', submitData);
pollChangeModalVisible.value = false;
editing.value = false;
}
};
// 取消,清空表单
const onCancel = () => {
loading.value = false;
editing.value = false;
};
const onVoteUpdate = () => {
emit('editContent');
};
// 复制链接
const { copy } = useClipboard({ source: 'text', legacy: true });
const copyLink = () => {
copy(`${location.origin}${location.pathname}#discussion-${props?.discussDetail?.id}`);
document.body.click();
Message.success('已复制链接');
};
// 引用回复
const quoteReply = () => {
document.body.click();
emit('quoteReply', props.discussDetail?.md_content);
};
// 根据 props 初始化部分值
watch(
() => props.discussDetail,
() => {
created_avatar.value = props.discussDetail?.created_by_user_photo || '';
formData.value.md_content = props.discussDetail?.md_content || '';
pollData.title = props.discussDetail?.question?.question || '';
pollData.options =
props.discussDetail?.options && props.discussDetail.options.length > 0
? props.discussDetail.options.map((item) => {
return {
id: item.id,
value: item.vote_option
};
})
: [];
originPollData.value.title = props.discussDetail?.question?.question || '';
originPollData.value.options =
props.discussDetail?.options && props.discussDetail.options.length > 0
? props.discussDetail.options.map((item) => item.vote_option)
: [];
newPollOptions.value = [...originPollData.value.options];
},
{ deep: true, immediate: true }
);
watch(
() => props.markedComment,
() => {
hasMarkedCommentStyle.value = (props.markedComment?.id && (props.discussDetail?.category?.category_type === DISCUSS_FORMAT.QANDA))
? {
borderWidth: '1px',
borderColor: '#26b688',
paddingBottom: '0'
}
: {
borderWidth: '0px',
borderColor: 'transparent',
paddingBottom: '16px'
};
},
{ deep: true }
);
</script>
<template>
<Card simple class="container" :style="hasMarkedCommentStyle" :id="discussDetail?.id?`discussion-${discussDetail.id}`:undefined">
<!-- 展示区 -->
<div class="content-show" v-show="!editing">
<!-- bar -->
<DiscussionContentToolbar
:created_avatar="created_avatar"
:created_by_user_name="discussDetail?.created_by_user_name"
type="discussion"
:showDropDown="!!userInfo?.id"
:created_date="discussDetail?.created_date"
>
<template #edit>
<Icon
name="gt-edit"
size="16px"
class="mr-1 cursor-pointer"
color="inherit"
@click="editing = true"
v-if="userInfo.id === discussDetail?.created_by || access_level > 30"
></Icon>
</template>
<template #option>
<!-- <gc-option class="comment-menu-option" @click="copyLink">复制链接</gc-option> -->
<gc-option class="comment-menu-option" @click="quoteReply">引用回复</gc-option>
</template>
</DiscussionContentToolbar>
<!-- md-render -->
<div class="content-md">
<MdRender v-model="renderDetailContent"></MdRender>
</div>
<!-- 点赞区 -->
<div class="content-like" v-if="discussDetail?.id">
<LikeBtn
:target-type="1"
:likeTotal="discussDetail?.like_total"
:target-id="discussDetail?.id"
:is-like="discussDetail?.is_like"
:is-login="!!userInfo?.id"
></LikeBtn>
<p v-if="discussDetail.comment_total > 0">{{ discussDetail.comment_total }}条评论</p>
</div>
<!-- 投票 -->
<div
v-if="discussDetail?.category?.category_type === DISCUSS_FORMAT.VOTE"
class="poll-container"
>
<PollDisplay
:discuss-detail="discussDetail"
:user-info="userInfo"
:access_level="access_level"
@update-poll-content="onVoteUpdate"
></PollDisplay>
</div>
<!-- 采纳回答的展示 -->
<div
v-if="
discussDetail?.category?.category_type === DISCUSS_FORMAT.QANDA &&
discussDetail.is_answered === 1
"
>
<AnswerDisplay
v-if="markedComment?.id"
:marked-comment="markedComment"
:user-info="userInfo"
></AnswerDisplay>
</div>
</div>
<!-- 编辑区 -->
<div class="content-edit" v-if="editing" v-loading="loading">
<d-form
ref="formRef"
layout="vertical"
:data="formData"
:pop-postion="['right']"
:rules="formRules"
>
<d-form-item field="md_content" label="" :show-feedback="false">
<MdEditor v-model="formData.md_content" :hint-config="hintConfig" :project-id="projectId" :options="{ autofocus: true }" border-light></MdEditor>
</d-form-item>
</d-form>
<!-- 投票表单 -->
<div class="mt-6" v-if="discussDetail?.category?.category_type === DISCUSS_FORMAT.VOTE">
<PollForm
@poll-options="onPollOptionsChange"
:default-value="pollData.options"
v-model:title="pollData.title"
:title-empty="pollDataError.title"
:valid-options="pollDataError.options"
></PollForm>
</div>
<div
class="mt-4 flex justify-end gap-2"
:style="{ marginBottom: markedComment?.id ? '16px' : '' }"
>
<d-button @click="onCancel">取消</d-button>
<d-button color="primary" variant="solid" @click="onSubmit" :disabled="!canUpdate">更新讨论</d-button>
</div>
</div>
<GModal v-model="pollChangeModalVisible" showWarnIcon @confirm="onPollChangeSumbit" title="更改投票内容">
<p>更改投票内容会清空已有的投票数据,已投票的人也需要重新投票,确定要更改吗?</p>
</GModal>
</Card>
</template>
<style scoped lang="scss">
.container {
&.g-content-card {
padding: 16px 20px;
}
:deep(.devui-form__label--vertical) {
display: none;
}
}
.content-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
height: 28px;
line-height: 1;
font-size: 14px;
font-weight: 400;
&__left,
&__right {
display: flex;
align-items: center;
}
&__role {
padding: 2px 8px;
font-size: 12px;
border-radius: 12px;
border: 1px solid #e4e9f0;
line-height: 16px;
}
&__creator {
color: #2d2d2e;
line-height: 24px;
}
}
.content-like {
margin-top: 16px;
display: flex;
justify-content: space-between;
align-items: center;
& > p {
font-size: 12px;
font-weight: 400;
color: #9fa7b3;
line-height: 16px;
}
}
.poll-container {
margin-top: 24px;
}
</style>

View File

@@ -0,0 +1,256 @@
<script setup lang="ts">
import { ref, watch, computed, nextTick, onMounted, onUnmounted } from 'vue';
import Time from '@/components/Time/index.vue';
import type { discussDetailType, userInfoType } from '@/api/discussion/types';
import { Message } from 'vue-devui/message';
import { DISCUSS_FORMAT } from '@/constant/discuss';
defineOptions({
name: 'DiscussionDetailHead'
});
const props = defineProps<{
discussDetail: discussDetailType | undefined; // 讨论详情
userInfo: userInfoType; // 当前用户信息
}>();
const emit = defineEmits(['editTitle']);
const titleInput = ref(null);
const editing = ref(false);
const editError = ref(false);
const title = ref('');
const onChangeTitle = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
handleEdit();
} else if (e.key === 'Escape') {
handleEscape();
}
};
const onBlurTitle = (e: FocusEvent) => {
if (title.value !== props.discussDetail?.title) {
handleEdit();
} else {
editing.value = false;
}
};
const handleEdit = () => {
if (editing.value) {
if (title.value.trim()) {
if (title.value.trim().length > 50 || title.value.trim().length < 2) {
Message.warning('标题长度范围为2-50');
editError.value = true;
return;
}
if (title.value !== props.discussDetail?.title) {
emit('editTitle', {
title: title.value.trim(),
id: props.discussDetail?.id,
md_content: props.discussDetail?.md_content,
category_id: props.discussDetail?.category_id
});
}
editing.value = false;
editError.value = false;
} else {
Message.warning('标题不能为空');
editError.value = true;
}
} else {
editing.value = false;
}
};
const handleEscape = () => {
title.value = props.discussDetail?.title as string;
editing.value = false;
};
const handleKeydownEvent = (event:KeyboardEvent) => {
if (event.code === 'Escape') {
handleEscape();
}
};
const canEdit = computed(() => {
if ((props.userInfo.id === props.discussDetail?.created_by) && props.discussDetail?.is_closed === 0) return true;
else return false;
});
const onEdit = () => {
if (canEdit.value) {
editing.value = true;
nextTick(() => {
title.value && titleInput.value && titleInput.value.focus();
});
}
};
watch(
() => props.discussDetail,
() => {
title.value = props.discussDetail?.title as string;
},
{ deep: true, immediate: true }
);
// 判断当前
const currentStatus = computed(() => {
if (props.discussDetail?.is_closed === 0) {
return {
status: 'open',
bgColor: '#FAE5EC',
color: '#E05E86',
icon: 'gt-issue',
text: '已开启'
};
} else {
// 是否为问答类型
if (props.discussDetail?.category?.category_type === DISCUSS_FORMAT.QANDA) {
if (props.discussDetail.is_answered === 1) {
// 已回答
return {
status: 'closed_answered',
bgColor: 'rgba(14, 176, 123,0.1)',
color: '#0EB07B',
icon: 'gt-closed-issue',
text: '完成关闭'
};
}
}
return {
status: 'closed',
bgColor: 'rgba(159, 167, 179,0.1)',
color: '#9FA7B3',
icon: 'gt-skip-issue',
text: '已关闭'
};
}
});
onMounted(() => {
document.addEventListener('keydown', handleKeydownEvent);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydownEvent);
editing.value = false;
});
</script>
<template>
<div class="container">
<div class="left">
<div class="status" :style="{ backgroundColor: currentStatus.bgColor }">
<div class="left-top">
<Icon :name="currentStatus.icon" :color="currentStatus.color" size="16px"></Icon>
<span :style="{ color: currentStatus.color }">{{ currentStatus.text }}</span>
</div>
<div class="left-bottom">#{{ discussDetail?.serial_number }}</div>
</div>
</div>
<div class="right">
<div class="right-top">
<span v-show="discussDetail?.is_lock === 1"><Icon name="gt-lock" size="16px"></Icon></span>
<d-input
ref="titleInput"
v-if="editing"
v-model="title"
:error="editError"
autofocus
:style="{maxWidth: `calc(100% - ${314 - (discussDetail?.is_lock === 1 ? 0 : 24)}px)`}"
@keydown="onChangeTitle"
@blur="onBlurTitle"
maxLength="50"
minLength="2"
/>
<h4 v-else :class="['top-title',canEdit?'can-edit':'']" @click="onEdit">{{ discussDetail?.title }}</h4>
</div>
<div class="right-bottom bottom flex content-center mt-3">
<span class="bottom-chat-icon">
<Icon name="gt-comment" size="16px" class="mr-1" color="#707a87"></Icon>
</span>
<GLink style="color:inherit" :to="{ name: 'homepage', params: { namespace: discussDetail?.created_by_user_name }}" target="_blank">
{{ discussDetail?.created_by_user_name }}
</GLink>
&nbsp;·&nbsp;
<span v-if="discussDetail?.closed_date && discussDetail?.is_closed === 1">
&nbsp;<Time :time="discussDetail?.closed_date"></Time>&nbsp;关闭
</span>
<span v-if="discussDetail?.created_date && discussDetail?.is_closed === 0">
创建于 <Time :time="discussDetail?.created_date"></Time>
</span>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.container {
display: flex;
gap: 12px;
}
.left {
font-size: 14px;
font-weight: 400;
color: #2d2d2e;
line-height: 20px;
.status {
padding: 10px 12px;
border-radius: 6px;
width: 100px;
height:100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
.left-top {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
}
}
.right {
flex: 1;
.top-title {
word-break: break-all;
font-size: 22px;
font-weight: 500;
color: #2d2d2e;
line-height: 32px;
&.can-edit{
cursor: pointer;
position:relative;
&:hover::after{
content:url('/src/assets/imgs/icon/icon-pen.svg');
position:absolute;
right:-20px;
}
}
}
.bottom {
line-height: 16px;
font-size: 12px;
font-weight: 400;
margin-bottom: 4px;
color: #707a87;
&-chat-icon {
display: flex;
align-items: center;
}
}
}
.right-top{
display:flex;
align-items: center;
gap:8px;
}
</style>

View File

@@ -0,0 +1,214 @@
<script setup lang="ts" name="NewComment">
import { ref, computed, nextTick, inject } from 'vue';
import type { userInfoType, discussDetailType } from '@/api/discussion/types';
import MdEditor from '@/components/MdEditor/index.vue';
import { Message } from 'vue-devui/message';
import { emitEvent } from '@/utils/eventBus';
import { discussClose, commentSave } from '@/api/discussion';
import { useReport } from '@/utils/hooks/useReport';
import { discussDetailRecentActiveUsers } from '@/api/discussion';
defineOptions({
name: 'DiscussionNewComment'
});
const props = defineProps<{
discussDetail: discussDetailType | undefined;
access_level: number;
userInfo: userInfoType;
projectId?:string;
quotedReply?: string;
clearQuotedReply?: Function;
}>();
const emit = defineEmits(['newComment']);
const formData = ref({
md_content: ''
});
const replyStat = ref(false);
const formRef = ref();
const formRules = {
md_content: [{ required: true, message: '讨论内容不能为空', trigger: 'change' }]
};
const mdRules = ref({ linkify: { fuzzyLink: false }});
const loading = ref(false);
const getGroupMembers = inject('getGroupMembers') as () => Promise<void>;
const hintConfig = {
'@': getGroupMembers,
};
const checkLogin = () => {
if (props.userInfo?.id) {
return;
} else {
emitEvent('logout', { Authorization: true, triggerType: '评论' });
}
};
const onSubmit = async(withState = false) => {
loading.value = true;
if (!props.userInfo.id) {
Message.warning('请先登录');
loading.value = false;
return undefined;
}
if (formData.value.md_content.trim()) {
try {
const isValid = await formRef.value.validate();
if (isValid) {
const submitData = {
discuss_id: props.discussDetail?.id as string,
md_content: formData.value.md_content
};
const res = await onCommentSave(submitData, withState);
loading.value = false;
return res;
}
} catch (error) {
loading.value = false;
console.error(error);
}
} else {
Message.warning('评论内容不能为空');
loading.value = false;
}
return undefined;
};
interface submitDataType {
discuss_id:string;
md_content:string;
}
const onCommentSave = async(submitData: submitDataType, withState = false) => {
const res = await commentSave(submitData);
if (!res.error) {
if (!withState) {
Message.success({ type: 'success', message: '已创建评论' });
emit('newComment');
}
formData.value.md_content = '';
useReport('comment', {
event_id: 'comment',
source_type: 'disscussion',
source_name: props.discussDetail?.title,
source_id: props.discussDetail?.created_by,
repo_author_id: props.discussDetail?.created_by_user_name,
comment_type: replyStat.value ? 'replay' : 'comment'
});
}
return res;
};
// 关闭讨论 & 重新打开讨论
const confirmCloseOrReopen = async() => {
const res = await discussClose({
id: props.discussDetail?.id as string,
state: props.discussDetail?.is_closed === 0 ? 1 : 0
});
if (!res.error) {
props.discussDetail?.is_closed === 0
? Message.success('讨论已关闭')
: Message.success('讨论已重新打开');
emit('newComment');
}
};
const handleCloseOrReopen = async() => {
const { is_closed } = props.discussDetail;
loading.value = true;
if (is_closed === 1) { // 关闭中
await confirmCloseOrReopen();
} else if (is_closed === 0) {
// 开启中
if (formData.value.md_content.trim()) {
// 填写内容,先提交再变更状态
const submitRes = await onSubmit(true);
if (submitRes?.data?.data) {
await confirmCloseOrReopen();
}
} else {
await confirmCloseOrReopen();
}
}
loading.value = false;
};
const closeEnabled = computed(() => {
if ((props.userInfo.id && props.discussDetail?.created_by === props.userInfo.id) || props.access_level >= 30) {
return true;
} else return false;
});
const newCommentDisabled = computed(() => {
if (!props.userInfo?.id || (props.discussDetail?.is_lock === 1 && props.access_level < 10)) {
return true;
} else return false;
});
const refMd = ref();
defineExpose({
setMdContent(value: string) {
formData.value.md_content = value;
},
async focusMdEditor() {
await nextTick();
const lastLineNumber = refMd.value.instance.lastLine();
refMd.value.instance.setCursor(lastLineNumber, 0);
refMd.value.instance.focus();
},
setReply(blo:boolean = true) {
replyStat.value = blo;
}
});
</script>
<template>
<Card class="mt-4 new-comment" :title="newCommentDisabled?'请先登录':undefined" @click="checkLogin">
<d-form
ref="formRef"
layout="vertical"
:data="formData"
:pop-postion="['right']"
:rules="formRules"
v-loading="loading"
:disabled="newCommentDisabled"
>
<d-form-item field="md_content" label="" :show-feedback="false" :class="{'newcomment-disabled':newCommentDisabled}">
<MdEditor ref="refMd" v-model="formData.md_content" :hint-config='hintConfig' :project-id="projectId"></MdEditor>
</d-form-item>
<div class="mt-4 flex justify-end gap-2">
<d-button
@click="handleCloseOrReopen"
v-if="discussDetail?.is_closed === 1 && closeEnabled"
:loading="loading"
>重新打开讨论</d-button
>
<d-button v-if="discussDetail?.is_closed === 0 && closeEnabled" @click="handleCloseOrReopen" :loading="loading">关闭讨论</d-button>
<d-button @click="onSubmit(false)" :loading="loading" variant="solid" color="primary" :disabled="newCommentDisabled || formData.md_content.length<1">发表评论</d-button>
</div>
</d-form>
</Card>
</template>
<style scoped lang="scss">
.new-comment{
:deep(.devui-form__label--vertical) {
display: none;
}
}
.newcomment-disabled {
cursor:not-allowed;
&::after {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
}
</style>

View File

@@ -0,0 +1,337 @@
<script setup lang="ts">
import { ref, provide, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
discussDetail,
discussUpdate,
discussDetailRelatedUsers,
commentList,
getDiscussionDetail
} from '@/api/discussion';
import { getRepoMember } from '@/api/repo';
import type {
discussDetailType,
commonPaginationType,
commentType
} from '@/api/discussion/types';
import isEmpty from 'lodash/isEmpty';
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
import { formatQuoteReply } from '@/utils';
import { orgInfoStore } from '@/stores/Org';
import { repoInfoStore } from '@/stores/Repo';
import { useGlobalInfoStore } from '@/stores/Global';
import { Message } from 'vue-devui/message';
import { discussDetailRecentActiveUsers } from '@/api/discussion';
import DetailHead from './components/DetailHead/index.vue';
import DetailContent from './components/DetailContent/index.vue';
import CommentItem from './components/CommentItem/index.vue';
import NewComment from './components/NewComment/index.vue';
import Sidebar from '../components/Sidebar/index.vue';
import { useRepoId } from '@/utils/hooks/useRepoId';
defineOptions({
name: 'DiscussionDetail'
});
const props = withDefaults(defineProps<{
sourceType: 1|2; // 组织1项目2
orgNamespace?: string; // 组织namespace
}>(), {
sourceType: 1
});
const router = useRouter();
const route = useRoute();
const serialNumber = route.params.serialNumber as string;
const { repoId } = useRepoId();
// 获取当前用户信息 & 是否登录
const { userInfo = {}} = useDiscussGetUserInfo();
// 确认讨论是否开启
const { id: source_id, discussOpen, getDiscussionStatus, project_id } = useDiscussionOpen(props.sourceType, props.orgNamespace);
const { setIsNotFound } = useGlobalInfoStore();
// 获取当前用户项目/组织权限
const access_level = props.sourceType === 1 ? orgInfoStore().access_level : repoInfoStore().access_level;
// 获取讨论详情
const loading = ref(false);
const discussDetailData = ref<discussDetailType>();
const fetchDetail = async() => {
if (loading.value) return;
loading.value = true;
const detailRes = await discussDetail({
source_id: source_id.value,
source_type: props.sourceType,
serial_number: serialNumber
});
if (!detailRes.error) {
const resData = detailRes?.data?.data;
discussDetailData.value = resData;
// 获取评论列表
await fetchCommentList();
} else {
// 审核未通过,内容区域 404
setIsNotFound(true);
}
loading.value = false;
};
const getGroupMembers = async () => {
const recentRes = await discussDetailRecentActiveUsers({ source_id: discussDetailData.value?.id as string });
const repoRes = await getRepoMember({ repoId: repoId.value, page: 1, per_page: 999 });
const projectMembers = repoRes.data?.content || [];
const recentMembers = recentRes.data?.data || [];
const members = [...recentMembers, ...projectMembers].reduce((acc, cur) => {
const target = acc.find((item) => item.username === cur.username);
if (!target) {
acc.push(cur);
} else {
('access_level' in cur) && (target.access_level = cur.access_level);
}
return acc;
}, [])
.map((item)=>({
...item,
itemText: item.username,
insertText: ` @${item.username}`,
})) || [];
return members;
};
provide('getGroupMembers', getGroupMembers);
// 获取评论列表
const commentsLoading = ref(false);
const commentQuery = ref<commonPaginationType>({ page: 1, size: 100 });
const commentTotal = ref(0);
const comments = ref<commentType[]>([]);
const markedComment = ref<commentType>({ id: '', md_content: '' });
const fetchCommentList = async() => {
if (commentsLoading.value) return;
commentsLoading.value = true;
const commentRes = await commentList({
discuss_id: discussDetailData.value?.id as string,
...commentQuery.value
});
if (!commentRes.error) {
const resData = commentRes?.data?.data;
commentTotal.value = resData.totol;
comments.value = resData.records;
// 查下有没有被标记为答案的
if (resData.records && !isEmpty(resData.records)) {
const $markedComment = resData.records.find((item: commentType) => item.is_remark === 1);
if ($markedComment) {
markedComment.value = $markedComment;
} else {
markedComment.value = { id: '', md_content: '' };
}
}
}
commentsLoading.value = false;
};
// 获取更多评论 - 每次多加载100条评论
const fetchMoreComments = () => {
commentQuery.value = {
...commentQuery.value,
size: commentQuery.value.size + 100
};
};
const anchorComment = ref([])
let anchorId = ref<string>('');
const showAnchorItem = computed(()=>{
if(!anchorId.value) return false;
return !comments.value.some(item=> item.id === anchorId.value );
})
const getAnchorCommentInfo = async () => {
const commentId = route.hash.split('?')?.find( i=> i.indexOf('#') > -1)?.slice(1);
if(commentId) {
const res = await getDiscussionDetail(commentId);
const {parent, ...replyCover} = res?.data?.data || {};
if(!parent) {
anchorId.value = replyCover.id;
anchorComment.value = [{...replyCover, replyCover: null}];
} else {
anchorId.value = parent.id;
anchorComment.value = [{ ...parent, replyCover }];
}
}
}
const initialData = async() => {
getAnchorCommentInfo();
await getDiscussionStatus();
if (source_id.value && discussOpen.value === '1') {
await fetchDetail();
} else {
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
router.replace('/404');
}
};
initialData();
// 已登录用户获取可 @用户 列表
const fetchMemberList = (id = '') => {};
// 回到讨论列表
const goToList = () => {
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}Discussion` });
};
// 引用回复
const NewCommentEditor = ref();
const onQuoteReply = (content: string) => {
NewCommentEditor.value.setMdContent(formatQuoteReply(content));
NewCommentEditor.value.setReply();
const newComment = document && document.querySelector(`#newComment-${discussDetailData.value?.id}`);
setTimeout(() => {
newComment && newComment.scrollIntoView({ behavior: 'instant', block: 'end' });
NewCommentEditor.value.focusMdEditor();
}, 100);
};
// 讨论更新
const onDetailUpdate = async(val?: discussDetailType) => {
if (val) {
const res = await discussUpdate({
...val
});
if (!res.error) {
// 刷新详情
fetchDetail();
}
} else {
fetchDetail();
}
};
// 刷新讨论数据获取,包括参与者
const detailSidebar = ref(null);
const onRefresh = async($event, item, type = '') => {
await fetchDetail();
detailSidebar.value && detailSidebar.value.fetchRecentActiveUsers();
if ($event) {
item.reply_total = $event?.total || 0;
}
};
</script>
<template>
<div class="root my-6" v-if="discussDetailData?.id">
<DetailHead
:discuss-detail="discussDetailData"
:user-info="userInfo"
@edit-title="onDetailUpdate"
></DetailHead>
<div class="wrapper">
<div class="wrapper-left">
<d-skeleton :loading="commentsLoading">
<div>
<DetailContent
:discuss-detail="discussDetailData"
:user-info="userInfo"
:access_level="access_level"
:projectId="sourceType===1?'':project_id"
:marked-comment="markedComment"
@edit-content="onDetailUpdate"
@quote-reply="onQuoteReply"
></DetailContent>
</div>
</d-skeleton>
<div v-if="anchorComment.length > 0 && discussDetailData?.id && showAnchorItem">
<CommentItem
v-for="item in anchorComment"
:key="item.id"
:discuss-detail="discussDetailData"
:access_level="access_level"
:comment-detail="item"
:user-info="userInfo"
:projectId="sourceType===1?'':project_id"
:marked-comment="markedComment"
@update-comment="fetchCommentList"
@update-discuss="fetchDetail"
@refresh-discuss="onRefresh($event,item)"
mode="cover"
></CommentItem>
</div>
<div v-if="comments.length > 0 && discussDetailData?.id && !commentsLoading">
<CommentItem
v-for="item in comments"
:key="item.id"
:discuss-detail="discussDetailData"
:access_level="access_level"
:comment-detail="item"
:user-info="userInfo"
:projectId="sourceType===1?'':project_id"
:marked-comment="markedComment"
@update-comment="fetchCommentList"
@update-discuss="fetchDetail"
@refresh-discuss="onRefresh($event,item)"
></CommentItem>
</div>
<div :id="`newComment-${discussDetailData?.id}`">
<NewComment
:discuss-detail="discussDetailData"
:user-info="userInfo"
:access_level="access_level"
:projectId="sourceType===1?'':project_id"
@new-comment="onRefresh(undefined, undefined, 'reload')"
ref="NewCommentEditor"
></NewComment>
</div>
</div>
<div class="wrapper-right">
<d-skeleton :loading="loading">
<Sidebar
v-if="source_id && !loading"
ref="detailSidebar"
:discuss-detail="discussDetailData"
:access_level="access_level"
:user-info="userInfo"
:source-id="source_id"
:source-type="sourceType"
:is-detail="true"
@update-discuss="fetchDetail"
></Sidebar>
</d-skeleton>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.root {
scroll-behavior: smooth;
}
.wrapper {
margin-top: 24px;
display: flex;
gap: 32px;
justify-content: space-between;
&-left {
min-width: 0;
flex: 1;
}
&-right {
width: 260px;
}
}
@media screen and (max-width: 576px){
.root{
padding:20px;
}
.wrapper{
flex-direction: column;
&-left,&-right{
width:100%;
}
}
}
</style>

View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import { watch, ref, computed } from 'vue';
import LikeBtn from '@/components/Discussion/Module/components/DiscussionLikeBtn.vue';
import Time from '@/components/Time/index.vue';
import type { discussionListItemType, commonDictType } from '@/api/discussion/types';
import { DISCUSS_FORMAT } from '@/constant/discuss';
import LabelTag from '@/components/LabelTag/index.vue';
defineOptions({
name: 'DiscussionListItem'
});
interface additionType {
label_dict?: commonDictType[];
isLogin?: boolean;
categoryQuery?:boolean;
sourceType:1|2;
}
type Iprops = discussionListItemType & additionType;
const props = withDefaults(defineProps<Iprops>(), { sourceType: 1 });
// label 翻译
const dictTranslate = (value: string, dict: commonDictType[]) => {
return dict.find((item) => item.value === value);
};
const labels = ref<string[]>([]);
watch(
() => props.label,
() => {
if (props.label) {
labels.value = props.label;
} else {
labels.value = [];
}
},
{ immediate: true }
);
// 置顶图标是否展示
const showPinStatus = computed(() => {
if (props.categoryQuery) {
return props.is_category_pin === 1;
} else {
return props.is_pin === 1;
}
});
</script>
<template>
<div class="root">
<div class="info">
<div class="info-left">
<LikeBtn
:likeTotal="like_total"
:isLogin="isLogin"
:targetId="id"
:targetType="1"
:isLike="is_like"
/>
</div>
<div class="info-right">
<div class="info-icon" v-if="category">
{{ category.category_icon }}
</div>
<div class="info-content">
<div class="info-content__top">
<GLink class="info-content__title ellipsis" :to="{ name: `${sourceType===1?'org':'repo'}DiscussionDetail`, params: { serialNumber: serial_number }}">
{{ title }}
</GLink>
<div v-if="labels.length && label_dict.length" class="flex-center">
<LabelTag
v-for="item in labels"
:key="item"
:name="dictTranslate(item, label_dict).label"
:color="dictTranslate(item, label_dict).color"
></LabelTag>
</div>
</div>
<div class="info-content__bottom">
<GLink class="info-content__name" :to="{ name: 'homepage', params: { namespace: created_by_user_name }}" target="_blank">{{ created_by_user_name }}</GLink>
<span v-if="created_date && category"><Time :time="created_date"></Time>创建的{{ category.category_name }}</span>
<span v-if="is_closed === 1"><span class="px-1">·</span>已关闭</span>
<span v-if="category?.category_type === DISCUSS_FORMAT.QANDA && is_answered === 1" class="info-content_answered">
<span class="px-1">·</span>
<Icon name="gt-closed-issue" color="#0EB07B" size="14px"></Icon><span class="ml-1">回答已采纳</span>
</span>
<span v-if="category?.category_type === DISCUSS_FORMAT.VOTE && is_closed === 1" class="info-content_voted">
<span class="px-1">·</span>
<Icon name="gt-skip-issue" color="#7E7E80" size="14px"></Icon><span class="ml-1">投票已结束</span>
</span>
<span class="statistics-total ml-4">
<Icon name="gt-comment" class="mr-1" color="inherit" size="14px" />
<span>{{ comment_total }}</span>
</span>
</div>
</div>
</div>
</div>
<!-- <div class="flex items-center" v-if="showPinStatus">
<Icon name="gt-to-top"></Icon>
</div> -->
<div class="flex items-center"></div>
<div class="bg-CG300 flex items-end px-5 absolute pt-[32px] top-[-22px] left-[-30px] -rotate-45" v-if="showPinStatus"><span class="text-CG500 text-xs">置顶</span></div>
</div>
</template>
<style scoped lang="scss">
@import 'devui-theme/styles-var/devui-var.scss';
.root {
padding: 20px;
border-bottom: 1px solid var(--color-border-light);
display: flex;
justify-content: space-between;
gap:16px;
position: relative;
overflow: hidden;
}
.root:first-of-type {
border-top-left-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
}
.root:last-of-type {
border-bottom: none;
}
.info {
flex:1;
}
.info,
.info-right {
display: flex;
gap: 24px;
align-items: center;
min-width: 0;
}
.info-icon {
width: 36px;
height: 36px;
padding: 8px 6px 8px 8px;
border-radius: 4px;
display:flex;
justify-content: center;
align-items: center;
}
.info-content{
min-width: 0;
}
.info-content__top {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 4px;
flex-wrap: wrap;
.info-content__title {
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 22px;
cursor: pointer;
}
.g-label-tag {
margin-right:8px;
}
}
.info-content__bottom {
color: var(--color-light);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
display:flex;
align-items: center;
.info-content__name {
margin-right: 16px;
}
.info-content_answered{
color:var(--color-success);
display: flex;
align-items: center;
}
.info-content_voted{
display:flex;
align-items: center;
}
}
.statistics-total {
font-size: 12px;
font-weight: 400;
color: var(--color-light);
line-height: 16px;
display:flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,954 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import ListItem from './components/DiscussListItem.vue';
import { discussList, getAllSectionAndTypes, repoLabelList, getAnswerRank } from '@/api/discussion';
import type { sectionItemType, commonDictType } from '@/api/discussion/types';
import isArray from 'lodash/isArray';
import isEmpty from 'lodash/isEmpty';
import { filterEmptyObj } from '@/utils';
import { Message } from 'vue-devui/message';
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
import { orgInfoStore } from '@/stores/Org';
import { repoInfoStore } from '@/stores/Repo';
import { emitEvent } from '@/utils/eventBus';
defineOptions({
name: 'DiscussionList'
});
const props = withDefaults(
defineProps<{
sourceType: 1 | 2; // 组织1项目2
orgNamespace?: string; // 组织namespace
}>(),
{
sourceType: 1
}
);
const router = useRouter();
const route = useRoute();
const loading = ref(true);
// 获取当前用户信息 & 是否登录
const { isLogin = false, userInfo = {}} = useDiscussGetUserInfo();
// 确认讨论是否开启
const {
id: source_id,
discussOpen,
getDiscussionStatus
} = useDiscussionOpen(props.sourceType, props.orgNamespace);
// 获取当前用户项目/组织权限
const access_level =
props.sourceType === 1 ? orgInfoStore().access_level : repoInfoStore().access_level;
// 获取所有内容分类&组别
interface discussionTypeMenuItem {
key: string;
icon: string;
label: string;
isGroup: boolean;
category_list?: {
key: string;
icon: string;
label: string;
isGroup: boolean;
};
}
const typeAndSectionList = ref<discussionTypeMenuItem[]>([]);
const getTypeAndSection = async() => {
// 获取所有内容分类&组别
const resData = await getAllSectionAndTypes({
source_id: source_id.value,
source_type: props.sourceType
});
const $typeAndSectionList = [{ key: 'allType', label: '全部分类', icon: '📁', isGroup: false }];
if (!resData.error) {
const data: sectionItemType = resData?.data?.data;
const { section, unSection } = data;
if (unSection && unSection.length > 0) {
for (const each of unSection) {
$typeAndSectionList.push({
key: each.id,
icon: each.category_icon,
label: each.category_name,
isGroup: false
});
}
}
if (section && section.length > 0) {
for (const each of section) {
if (isArray(each.category_list) && !isEmpty(each.category_list)) {
const sectionInfo = {
key: each.id,
icon: each.section_icon,
label: each.section_name,
isGroup: true,
category_list: each.category_list.map((item) => ({
key: item.id,
icon: item.category_icon,
label: item.category_name,
isGroup: false
}))
};
$typeAndSectionList.push(sectionInfo);
}
}
}
}
typeAndSectionList.value = $typeAndSectionList;
};
// 获取社区热心榜
interface answerRankType {
user_id: string;
user_name: string;
user_photo: string;
total: string;
}
const answerRankList = ref<answerRankType[]>([]);
const getAnswerRankList = async() => {
// 讨论-社区热心榜
const res = await getAnswerRank({ source_id: source_id.value, source_type: props.sourceType });
if (!res.error) {
const resData = res?.data?.data;
if (!isEmpty(resData)) {
answerRankList.value = resData;
}
}
};
// 获取Labels
const labelOptions = ref<commonDictType[]>([]);
const getLabels = async() => {
if (props.sourceType === 1) {
// const res = await orgLabelList({ project_id: source_id.value });
// if (!res.error) {
// const resData = res?.data?.data;
// if (!isEmpty(resData.content)) {
// labelOptions.value = resData.content.map((item: any) => ({
// label: item.name,
// value: item.id.toString(),
// color: item.color
// }));
// }
// }
} else {
const res = await repoLabelList({ project_id: source_id.value });
if (!res.error) {
const resData = res?.data?.data;
if (!isEmpty(resData.content)) {
labelOptions.value = resData.content.map((item: any) => ({
label: item.name,
value: item.id.toString(),
color: item.color
}));
}
}
}
};
// 获取讨论列表 query
interface queryType {
page: number;
size: number;
source_id: string;
source_type: number;
enable_query?: boolean;
initial_query?: boolean; // 初次获取数据
title?: string;
category_id?: string;
is_lock?: string; // 是否锁定01
is_closed?: string; // 是否关闭01
is_answered?: string; // 否已采纳答案01
label?: string;
sort?: string; // 1:按创建时间倒序(默认)2按创建时间正序3按评论数量倒序4按评论数量正序
}
const query = ref<queryType>({
page: 1,
size: 10,
source_id: '',
source_type: props.sourceType,
enable_query: false,
initial_query: true,
category_id: 'allType'
});
// 分类菜单点击
const handleTypeChange = (e: { type: 'select'; key: string; el: HTMLElement; e: PointerEvent }) => {
query.value.page = 1;
if (!searchStore.value.title) {
query.value.title = '';
}
handleQuery('category_id', e.key);
};
// selector 值
const searchStore = ref({ title: '', label_id: '', sort: '', filter: '', initial_query: true });
// 监听 title 变更
const handleTitleChange = (e: KeyboardEvent) => {
// 回车触发
if (e.key === 'Enter') {
query.value.page = 1;
handleQuery('title', searchStore.value.title);
}
};
// 监听 labelsortfilter 变更
watch(
() => searchStore.value.label_id,
(newVal, oldVal) => {
if (newVal !== oldVal) {
query.value.page = 1;
!searchStore.value.initial_query && handleQuery('label_id', newVal);
}
}
);
watch(
() => searchStore.value.sort,
(newVal, oldVal) => {
if (newVal !== oldVal) {
query.value.page = 1;
!searchStore.value.initial_query && handleQuery('sort', newVal);
}
}
);
watch(
() => searchStore.value.filter,
(newVal, oldVal) => {
if (newVal !== oldVal) {
delete query.value.is_lock;
delete query.value.is_answered;
delete query.value.is_closed;
let transferFilter = { label: '', value: '' };
switch (newVal) {
case '1': {
transferFilter = {
label: 'is_lock',
value: '1'
};
break;
}
case '2': {
transferFilter = {
label: 'is_lock',
value: '0'
};
break;
}
case '3': {
transferFilter = {
label: 'is_closed',
value: '1'
};
break;
}
case '4': {
transferFilter = {
label: 'is_closed',
value: '0'
};
break;
}
case '5': {
transferFilter = {
label: 'is_answered',
value: '1'
};
break;
}
case '6': {
transferFilter = {
label: 'is_answered',
value: '0'
};
break;
}
default: {
transferFilter = {
label: 'clear_select',
value: '1'
};
break;
}
}
query.value.page = 1;
!searchStore.value.initial_query && handleQuery(transferFilter.label, transferFilter.value);
}
}
);
// 字典
const sortOptions = [
{ value: '1', label: '创建时间倒序' },
{ value: '2', label: '创建时间正序' },
{ value: '3', label: '评论数量倒序' },
{ value: '4', label: '评论数量正序' }
];
const filterOptions = [
{ value: '1', label: '已锁定' },
{ value: '2', label: '未锁定' },
{ value: '3', label: '已关闭' },
{ value: '4', label: '未关闭' },
{ value: '5', label: '已采纳回答' },
{ value: '6', label: '未采纳回答' }
];
const total = ref(0); // 讨论总条数
const discussionList = ref(); // 讨论列表数据
// 路由进入时,获取初始 query 参数
interface initialSeachParamType {
category_id: string;
label_id: string;
sort: string;
title: string;
is_lock: string;
is_closed: string;
is_answered: string;
}
const fetchInitial = async() => {
const initialQuery = route.query;
const paramDict = [
'category_id',
'label_id',
'sort',
'title',
'is_lock',
'is_closed',
'is_answered'
];
const filterdSearchParams = {} as initialSeachParamType;
if (Object.keys(initialQuery).length) {
for (const key of Object.keys(initialQuery)) {
if (paramDict.includes(key)) {
filterdSearchParams[key] = initialQuery[key];
}
}
query.value = {
...query.value,
...filterEmptyObj(filterdSearchParams),
enable_query: true
};
// searchStore 更新
// 筛选方式确定后,后端修改下逻辑,不在前端做对应
if (filterdSearchParams.title) searchStore.value.title = filterdSearchParams.title;
if (filterdSearchParams.sort) searchStore.value.sort = filterdSearchParams.sort;
if (filterdSearchParams.label_id) searchStore.value.label_id = filterdSearchParams.label_id;
if (filterdSearchParams.is_lock) {
if (filterdSearchParams.is_lock === '0') {
searchStore.value.filter = '2';
} else {
searchStore.value.filter = '1';
}
}
if (filterdSearchParams.is_closed) {
if (filterdSearchParams.is_closed === '0') {
searchStore.value.filter = '4';
} else {
searchStore.value.filter = '3';
}
}
if (filterdSearchParams.is_answered) {
if (filterdSearchParams.is_answered === '0') {
searchStore.value.filter = '6';
} else {
searchStore.value.filter = '5';
}
}
} else {
query.value = {
...query.value,
enable_query: true
};
}
await fetchData(query.value);
query.value.initial_query = false;
searchStore.value.initial_query = false;
};
// 更新地址栏
const updateURI = (key = '', val = '', resetFilter = false) => {
const currentQuery = { ...route.query };
if (resetFilter) {
delete currentQuery.is_answered;
delete currentQuery.is_lock;
delete currentQuery.is_closed;
}
if (key) {
currentQuery[key] = val;
}
const queryParams = filterEmptyObj({ ...currentQuery });
router.push({ query: queryParams });
};
const clearTitle = () => {
searchStore.value.title = '';
query.value.page = 1;
handleQuery('title', '');
};
// 更新 query
const handleQuery = (mode = '', value = '') => {
switch (mode) {
case 'title': {
query.value = filterEmptyObj({
...query.value,
title: value,
enable_query: true
}) as queryType;
// updateURI('title', value, false);
break;
}
case 'category_id': {
if (value === 'allType') {
const temp = { ...query.value, category_id: 'allType', enable_query: true } as queryType;
// delete temp.title;
query.value = filterEmptyObj({ ...temp }) as queryType;
// updateURI('category_id', '', false);
} else {
query.value = { ...query.value, [mode]: value, enable_query: true };
// updateURI(mode, value, false);
}
break;
}
case 'is_lock': {
const temp = { ...query.value };
delete temp.is_closed;
delete temp.is_lock;
delete temp.is_answered;
query.value = { ...temp, [mode]: value, enable_query: true };
// updateURI(mode, value, true);
break;
}
case 'is_closed': {
const temp = { ...query.value };
delete temp.is_closed;
delete temp.is_lock;
delete temp.is_answered;
query.value = { ...temp, [mode]: value, enable_query: true };
// updateURI(mode, value, true);
break;
}
case 'is_answered': {
const temp = { ...query.value };
delete temp.is_closed;
delete temp.is_lock;
delete temp.is_answered;
query.value = { ...temp, [mode]: value, enable_query: true };
// updateURI(mode, value, true);
break;
}
case 'clear_select': {
const temp = { ...query.value };
delete temp.is_closed;
delete temp.is_lock;
delete temp.is_answered;
query.value = filterEmptyObj({ ...temp, enable_query: true }) as queryType;
// updateURI('', '', true);
break;
}
default: {
query.value = filterEmptyObj({
...query.value,
[mode]: value,
enable_query: true
}) as queryType;
// updateURI(mode, value, false);
break;
}
}
};
// 清空
const resetQuery = () => {
query.value = {
page: 1,
size: 10,
source_id: '',
source_type: props.sourceType,
enable_query: true,
initial_query: false,
category_id: 'allType'
};
searchStore.value = {
title: '',
label_id: '',
sort: '',
filter: '',
initial_query: false
};
router.push({ query: {}});
};
// 获取讨论列表
const fetchData = async(currentQuery: queryType) => {
loading.value = true;
const submitQuery = { ...currentQuery };
if (submitQuery.category_id === 'allType' || !submitQuery.category_id) {
delete submitQuery.category_id;
}
delete submitQuery.enable_query;
delete submitQuery.initial_query;
const res = await discussList({
...submitQuery,
source_id: source_id.value
});
if (!res.error) {
const resData = res?.data?.data;
total.value = resData.total;
discussionList.value = resData.records;
}
loading.value = false;
// enable_query 置回 false
query.value = { ...query.value, enable_query: false };
};
// 监听 query 变化, 获取讨论列表
watch(
query,
(newVal) => {
if (newVal.enable_query && !newVal.initial_query) {
//
document.body.click(); // TODO: 华为pagination bug ,待修复
fetchData(newVal);
}
},
{ deep: true }
);
// 跳转新建
const goCreate = () => {
if (isLogin) {
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSelect` });
} else {
emitEvent('logout', true);
}
};
// 跳转讨论分类管理
const goSet = () => {
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
};
const init = async() => {
await getDiscussionStatus();
// 获取讨论数据
if (source_id.value && discussOpen.value === '1') {
await getTypeAndSection();
await fetchInitial();
getLabels();
getAnswerRankList();
} else {
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
router.replace('/404');
}
};
init();
</script>
<template>
<div class="disscussion-layout-wrapper flex g-page-layout mt-[24px]" v-if="discussOpen === '1'">
<!-- 左侧边栏 -->
<div class="left-menu">
<div class="flex justify-between items-center content-center">
<p class="secondary-title">分类</p>
<Icon v-if="access_level >= 50" :operable="true" name="gt-setting" size="16px" class="cursor-pointer" @click="goSet" />
</div>
<div class="mt-3">
<d-menu mode="vertical" :default-select-keys="[query.category_id]" class="type-menu"
@select="handleTypeChange($event)" width="100%">
<template v-for="item in typeAndSectionList" :key="item.key">
<d-menu-item class="disscuttion-menu-item" v-if="!item.isGroup" :key="item.key">
<span>{{ `${item.icon} ${item.label}` }}</span>
</d-menu-item>
<d-sub-menu v-else :title="`${item.icon} ${item.label}`">
<d-menu-item v-for="each in item.category_list" :key="each.key">
{{ `${each.icon} ${each.label}` }}
</d-menu-item>
</d-sub-menu>
</template>
</d-menu>
</div>
<!-- 社区热心榜 -->
<div class="answer-rank">
<div class="answer-rank-title">
<p class="secondary-title">社区热心榜<span class="tertiary-title">近30天</span></p>
</div>
<div class="answer-rank-list" v-if="!isEmpty(answerRankList)">
<template v-for="item in answerRankList" :key="item.user_id">
<div class="answer-rank-item">
<div class="answer-rank-item__left">
<GAvatar :src="item.user_photo" :width="32" :height="32" :name="item.user_name">
</GAvatar>
<GLink class="answer-rank-name ellipsis" :to="{ name: 'homepage', params: { namespace: item.user_name } }"
target="_blank">
{{ item.user_name }}
</GLink>
</div>
<div class="answer-rank-item__right">
<Icon name="gt-closed-issue" class="mr-2" color="var(--color-success)" />
{{ item.total }}
</div>
</div>
</template>
</div>
<div class="answer-rank-empty" v-else>
暂无数据
</div>
</div>
</div>
<!-- 讨论列表展示 -->
<div class="right-list mb-[20px]">
<!-- query -->
<div class="search">
<div class="search-query">
<div class="search-query__title">
<d-input v-model="searchStore.title" placeholder="搜索讨论" prefix="search" clearable
@keydown="handleTitleChange" @clear="clearTitle"></d-input>
</div>
<div class="search-query__selector flex">
<d-select v-model="searchStore.label_id" placeholder="Label" allow-clear v-if="sourceType === 2">
<gc-option v-for="item in labelOptions" :key="item.value" :value="item.value" :name="`Label:${item.label}`">
<div class="label-option">
<span :style="{ backgroundColor: item.color }" class="label-option__color"></span>
<span :title="item.label" class="label-option__label">{{ item.label }}</span>
</div>
</gc-option>
</d-select>
<d-select v-model="searchStore.filter" placeholder="状态" allow-clear>
<gc-option v-for="item in filterOptions" :key="item.value" :value="item.value"
:name="item.label"></gc-option>
</d-select>
<d-select v-model="searchStore.sort" placeholder="排序" allow-clear>
<gc-option v-for="item in sortOptions" :key="item.value" :value="item.value"
:name="item.label"></gc-option></d-select>
</div>
<!-- <d-button icon="icon-refresh" class="search-query__selector" @click="resetQuery">重置</d-button> -->
<d-button @click="goCreate" variant="solid" color="primary">
<Icon name="gt-add" color="white" /> 新讨论
</d-button>
</div>
</div>
<DataPanel skeleton :loading="loading" :empty="!discussionList?.length" animation class="discussion-list g-card">
<div class="list-wrapper">
<ListItem v-for="item in discussionList" :key="item.id" v-bind="item" :label_dict="labelOptions"
:is-login="isLogin" :categoryQuery="!!query?.category_id && query.category_id !== 'allType'"
:source-type="sourceType" />
</div>
</DataPanel>
<!-- 分页 -->
<d-pagination size="md" :total="total" :page-size-options="[10, 20, 50]" v-model:pageSize="query.size"
v-model:pageIndex="query.page" :max-items="5" :can-change-page-size="true" :can-view-total="true"
total-item-text="总计" auto-hide @page-index-change="query.enable_query = true"
@page-size-change="query.enable_query = true" class="flex-center mt-20" />
</div>
</div>
</template>
<style lang="scss" scoped>
@import 'devui-theme/styles-var/devui-var.scss';
.container {
margin-top: 24px;
flex-direction: row;
padding: 0;
:deep(.devui-submenu-title-content) {
margin: 0;
}
}
.disscussion-layout-wrapper {
:deep(.devui-menu-vertical) {
border-right: 0;
background: none !important;
}
}
.disscuttion-menu-item {
&:hover {
background: $devui-list-item-hover-bg;
border-radius: var(--border-radius);
}
}
.left-menu {
width: 300px;
margin-right: 32px;
overflow-y: scroll;
max-height: calc(100vh - 280px);
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.secondary-title {
font-size: 16px;
color: var(--color-font);
line-height: 20px;
font-weight: 500;
}
.tertiary-title {
height: 16px;
font-size: 12px;
font-weight: 500;
color: #707a87;
line-height: 16px;
margin-left: 8px;
}
// 热心榜
.answer-rank {
margin-top: 25px;
.answer-rank-title {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 12px;
}
.line {
border-bottom: 1px solid $devui-line;
margin-bottom: 16px;
}
&-item {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
&__left {
display: flex;
gap: 8px;
align-items: center;
overflow: hidden;
}
&__right {
color: $devui-success;
display: flex;
align-items: center;
}
}
&-name {
height: 16px;
min-width: 0;
flex: 1;
font-size: 14px;
font-weight: 500;
color: #707a87;
line-height: 16px;
}
// 空数据效果
&-empty {
font-size: 14px;
font-weight: 500;
color: #707a87;
line-height: 18px;
margin-left: 12px;
}
}
}
.right-list {
flex: 1;
min-width: 0;
}
.label-option {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 8px;
min-width: 0;
&__color {
flex: 0 0 14px;
width: 14px;
height: 14px;
border-radius: 50%;
}
&__label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.search {
margin-bottom: 8px;
&-query {
display: flex;
gap: 8px;
justify-content: space-between;
&__title {
width: 0;
flex: 1;
}
&__selector {
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
:deep(.devui-select) {
width: 150px;
}
}
}
}
.discussion-list {
display: flex;
flex-direction: column;
justify-content: space-between;
}
@media screen and (max-width: 576px) {
.container {
flex-direction: column;
padding: 0 20px;
}
.left-menu {
width: 100%;
}
.right-list {
width: 100%;
}
.search-query {
flex-wrap: wrap;
&__title {
flex: 0 0 100%;
}
&__selector {
flex: 0 0 100%;
:deep(.devui-select) {
width: 0;
flex: 1;
}
}
}
}
</style>
<style lang="scss">
.type-menu {
.devui-menu {
&-item {
height: 32px;
line-height: 32px;
background-color: $devui-global-bg;
&-vertical-wrapper {
&:not(:first-of-type) {
margin-top: 4px !important;
}
&>.devui-menu-item {
padding-left: 12px !important;
}
&.layer_1 {
&>.devui-menu-item {
padding-left: 12px !important;
height: 32px;
}
&+.layer_1 {
margin-top: 4px !important;
}
}
&.layer_2 {
&>.devui-menu-item {
padding-left: 34px !important;
height: 32px;
}
&+.layer_2 {
margin-top: 4px !important;
}
}
}
&-select {
background: var(--color-CG300) !important;
&::after {
content: '';
width: 0;
}
}
}
}
.devui-submenu.layer_2 {
margin-top: 4px !important;
}
.devui-submenu-title {
background-color: $devui-global-bg !important;
padding-left: 12px !important;
height: 32px !important;
line-height: 32px !important;
padding-right: 0 !important;
&>i {
opacity: 0.3;
}
}
}
.search {
&-query {
&__selector {
/** :deep */
.devui-select {
&__selection {
border: none;
border-radius: 0;
}
&:first-of-type {
.devui-select__selection {
border-top-left-radius: var(--border-radius);
border-bottom-left-radius: var(--border-radius);
}
}
&:last-of-type {
.devui-select__selection {
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
}
}
&+.devui-select {
position: relative;
&:before {
content: '';
width: 1px;
height: 18px;
background-color: #e6e7e8;
display: inline-block;
position: absolute;
z-index: 2;
top: 6px;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,990 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import ListItem from './components/DiscussListItem.vue';
import { discussList, getAllSectionAndTypes, repoLabelList, getAnswerRank } from '@/api/discussion';
import type { sectionItemType, commonDictType } from '@/api/discussion/types';
import isArray from 'lodash/isArray';
import isEmpty from 'lodash/isEmpty';
import { filterEmptyObj } from '@/utils';
import { Message } from 'vue-devui/message';
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
import { orgInfoStore } from '@/stores/Org';
import { repoInfoStore } from '@/stores/Repo';
import { emitEvent } from '@/utils/eventBus';
import cloneDeep from 'lodash/cloneDeep';
defineOptions({
name: 'DiscussionList'
});
const props = withDefaults(
defineProps<{
sourceType: 1 | 2; // 组织1项目2
orgNamespace?: string; // 组织namespace
}>(),
{
sourceType: 1
}
);
const router = useRouter();
const route = useRoute();
const loading = ref(true);
const orgStore = orgInfoStore();
// 获取当前用户信息 & 是否登录
const { isLogin = false, userInfo = {}} = useDiscussGetUserInfo();
// 确认讨论是否开启
const {
id: source_id,
discussOpen,
getDiscussionStatus
} = useDiscussionOpen(props.sourceType, props.orgNamespace);
// 获取当前用户项目/组织权限
const access_level =
props.sourceType === 1 ? orgStore.access_level : repoInfoStore().access_level;
// 获取所有内容分类&组别
interface discussionTypeMenuItem {
key: string;
icon: string;
label: string;
isGroup: boolean;
category_list?: discussionTypeMenuItem[];
}
const discussionTypeList = ref<discussionTypeMenuItem[]>([]);
const topicOptions = ref<discussionTypeMenuItem[]>([]);
const topicActive = ref('');
const subcatalogList = ref<discussionTypeMenuItem[]>([]);
const getTypeAndSection = async() => {
// 获取所有内容分类&组别
const resData = await getAllSectionAndTypes({
source_id: source_id.value,
source_type: props.sourceType
});
const catalog = [{ key: '', label: '全部组别', icon: '📁', isGroup: false }];
const subCatalog = [{ key: '', label: '全部分类', icon: '', isGroup: false }];
if (!resData.error) {
const data: sectionItemType = resData?.data?.data;
const { section, unSection } = data;
if (unSection && unSection.length > 0) {
for (const each of unSection) {
subCatalog.push({
key: each.id,
icon: each.category_icon,
label: each.category_name,
isGroup: false
});
}
}
if (section && section.length > 0) {
for (const each of section) {
if (isArray(each.category_list) && !isEmpty(each.category_list)) {
const sectionInfo = {
key: each.id,
icon: each.section_icon,
label: each.section_name,
isGroup: true,
category_list: each.category_list.map((item) => ({
key: item.id,
icon: item.category_icon,
label: item.category_name,
isGroup: false
}))
};
sectionInfo.category_list.forEach((sub:discussionTypeMenuItem) => subCatalog.push(sub));
catalog.push(sectionInfo);
}
}
}
}
discussionTypeList.value = subCatalog;
topicOptions.value = catalog;
handleTopic({ value: catalog[0].key });// 默认第一个大的主题
};
// 获取社区热心榜
interface answerRankType {
user_id: string;
user_name: string;
user_photo: string;
total: string;
}
const answerRankList = ref<answerRankType[]>([]);
const getAnswerRankList = async() => {
// 讨论-社区热心榜
const res = await getAnswerRank({ source_id: source_id.value, source_type: props.sourceType });
if (!res.error) {
const resData = res?.data?.data;
if (!isEmpty(resData)) {
answerRankList.value = resData;
}
}
};
// 获取Labels
const labelOptions = ref<commonDictType[]>([]);
const getLabels = async() => {
if (props.sourceType === 1) {
// const res = await orgLabelList({ project_id: source_id.value });
// if (!res.error) {
// const resData = res?.data?.data;
// if (!isEmpty(resData.content)) {
// labelOptions.value = resData.content.map((item: any) => ({
// label: item.name,
// value: item.id.toString(),
// color: item.color
// }));
// }
// }
} else {
const res = await repoLabelList({ project_id: source_id.value });
if (!res.error) {
const resData = res?.data?.data;
if (!isEmpty(resData.content)) {
labelOptions.value = resData.content.map((item: any) => ({
label: item.name,
value: item.id.toString(),
color: item.color
}));
}
}
}
};
// 获取讨论列表 query
interface queryType {
page: number;
size: number;
source_id: string;
source_type: number;
enable_query?: boolean;
initial_query?: boolean; // 初次获取数据
title?: string;
category_id?: string;
is_lock?: string; // 是否锁定01
is_closed?: string; // 是否关闭01
is_answered?: string; // 否已采纳答案01
label?: string;
sort?: string; // 1:按创建时间倒序(默认)2按创建时间正序3按评论数量倒序4按评论数量正序
}
const query = ref<queryType>({
page: 1,
size: 10,
source_id: '',
source_type: props.sourceType,
enable_query: false,
initial_query: true,
category_id: ''
});
const communityList = [
{ label: '技术文章', name: 'article', path: '/organization/$namespace/article', devpress: true },
{ label: '最新活动', name: 'activity', path: '/organization/$namespace/activity', devpress: true },
{ label: '精品专栏', name: 'column', path: '/organization/$namespace/column', devpress: true },
{ label: '热门讨论', name: 'orgDiscussion' }
];
const communityType = ref('');
const handleTopic = (item:any) => {
const arr = cloneDeep(item.value === '' ? discussionTypeList.value : ((topicOptions.value.find((v) => v.key === item.value)?.category_list || [])));
subcatalogList.value = arr;
topicActive.value = item.value;
handleTypeChange({ value: arr[0].key });
};
// 分类菜单点击
const handleTypeChange = (item:any) => {
query.value.page = 1;
query.value.category_id = item.value;
fetchData(query.value);
};
// selector 值
const searchStore = ref({ title: '', label_id: '', sort: '', filter: '', initial_query: true });
// 监听 title 变更
const handleTitleChange = (e: KeyboardEvent) => {
// 回车触发
if (e.key === 'Enter') {
query.value.page = 1;
handleQuery('title', searchStore.value.title);
}
};
// 监听 labelsortfilter 变更
watch(
() => searchStore.value.label_id,
(newVal, oldVal) => {
if (newVal !== oldVal) {
query.value.page = 1;
!searchStore.value.initial_query && handleQuery('label_id', newVal);
}
}
);
watch(
() => searchStore.value.sort,
(newVal, oldVal) => {
if (newVal !== oldVal) {
query.value.page = 1;
!searchStore.value.initial_query && handleQuery('sort', newVal);
}
}
);
watch(
() => searchStore.value.filter,
(newVal, oldVal) => {
if (newVal !== oldVal) {
delete query.value.is_lock;
delete query.value.is_answered;
delete query.value.is_closed;
let transferFilter = { label: '', value: '' };
switch (newVal) {
case '1': {
transferFilter = {
label: 'is_lock',
value: '1'
};
break;
}
case '2': {
transferFilter = {
label: 'is_lock',
value: '0'
};
break;
}
case '3': {
transferFilter = {
label: 'is_closed',
value: '1'
};
break;
}
case '4': {
transferFilter = {
label: 'is_closed',
value: '0'
};
break;
}
case '5': {
transferFilter = {
label: 'is_answered',
value: '1'
};
break;
}
case '6': {
transferFilter = {
label: 'is_answered',
value: '0'
};
break;
}
default: {
transferFilter = {
label: 'clear_select',
value: '1'
};
break;
}
}
query.value.page = 1;
!searchStore.value.initial_query && handleQuery(transferFilter.label, transferFilter.value);
}
}
);
// 字典
const sortOptions = [
{ value: '1', label: '创建时间倒序' },
{ value: '2', label: '创建时间正序' },
{ value: '3', label: '评论数量倒序' },
{ value: '4', label: '评论数量正序' }
];
const filterOptions = [
{ value: '1', label: '已锁定' },
{ value: '2', label: '未锁定' },
{ value: '3', label: '已关闭' },
{ value: '4', label: '未关闭' },
{ value: '5', label: '已采纳回答' },
{ value: '6', label: '未采纳回答' }
];
const total = ref(0); // 讨论总条数
const discussionList = ref(); // 讨论列表数据
// 路由进入时,获取初始 query 参数
interface initialSeachParamType {
category_id: string;
label_id: string;
sort: string;
title: string;
is_lock: string;
is_closed: string;
is_answered: string;
}
const fetchInitial = async() => {
const initialQuery = route.query;
const paramDict = [
'category_id',
'label_id',
'sort',
'title',
'is_lock',
'is_closed',
'is_answered'
];
const filterdSearchParams = {} as initialSeachParamType;
if (Object.keys(initialQuery).length) {
for (const key of Object.keys(initialQuery)) {
if (paramDict.includes(key)) {
filterdSearchParams[key] = initialQuery[key];
}
}
query.value = {
...query.value,
...filterEmptyObj(filterdSearchParams),
enable_query: true
};
// searchStore 更新
// 筛选方式确定后,后端修改下逻辑,不在前端做对应
if (filterdSearchParams.title) searchStore.value.title = filterdSearchParams.title;
if (filterdSearchParams.sort) searchStore.value.sort = filterdSearchParams.sort;
if (filterdSearchParams.label_id) searchStore.value.label_id = filterdSearchParams.label_id;
if (filterdSearchParams.is_lock) {
if (filterdSearchParams.is_lock === '0') {
searchStore.value.filter = '2';
} else {
searchStore.value.filter = '1';
}
}
if (filterdSearchParams.is_closed) {
if (filterdSearchParams.is_closed === '0') {
searchStore.value.filter = '4';
} else {
searchStore.value.filter = '3';
}
}
if (filterdSearchParams.is_answered) {
if (filterdSearchParams.is_answered === '0') {
searchStore.value.filter = '6';
} else {
searchStore.value.filter = '5';
}
}
} else {
query.value = {
...query.value,
enable_query: true
};
}
await fetchData(query.value);
query.value.initial_query = false;
searchStore.value.initial_query = false;
};
// 更新地址栏
const updateURI = (key = '', val = '', resetFilter = false) => {
const currentQuery = { ...route.query };
if (resetFilter) {
delete currentQuery.is_answered;
delete currentQuery.is_lock;
delete currentQuery.is_closed;
}
if (key) {
currentQuery[key] = val;
}
const queryParams = filterEmptyObj({ ...currentQuery });
router.push({ query: queryParams });
};
// 更新 query
const handleQuery = (mode = '', value = '') => {
switch (mode) {
case 'title': {
query.value = filterEmptyObj({
...query.value,
title: value,
enable_query: true
}) as queryType;
// updateURI('title', value, false);
break;
}
case 'is_lock': {
const temp = { ...query.value };
delete temp.is_closed;
delete temp.is_lock;
delete temp.is_answered;
query.value = { ...temp, [mode]: value, enable_query: true };
// updateURI(mode, value, true);
break;
}
case 'is_closed': {
const temp = { ...query.value };
delete temp.is_closed;
delete temp.is_lock;
delete temp.is_answered;
query.value = { ...temp, [mode]: value, enable_query: true };
// updateURI(mode, value, true);
break;
}
case 'is_answered': {
const temp = { ...query.value };
delete temp.is_closed;
delete temp.is_lock;
delete temp.is_answered;
query.value = { ...temp, [mode]: value, enable_query: true };
// updateURI(mode, value, true);
break;
}
case 'clear_select': {
const temp = { ...query.value };
delete temp.is_closed;
delete temp.is_lock;
delete temp.is_answered;
query.value = filterEmptyObj({ ...temp, enable_query: true }) as queryType;
// updateURI('', '', true);
break;
}
default: {
query.value = filterEmptyObj({
...query.value,
[mode]: value,
enable_query: true
}) as queryType;
// updateURI(mode, value, false);
break;
}
}
};
// 清空
const resetQuery = () => {
query.value = {
page: 1,
size: 10,
source_id: '',
source_type: props.sourceType,
enable_query: true,
initial_query: false,
category_id: ''
};
searchStore.value = {
title: '',
label_id: '',
sort: '',
filter: '',
initial_query: false
};
router.push({ query: {}});
};
// 获取讨论列表
const fetchData = async(currentQuery: queryType) => {
loading.value = true;
const submitQuery = { ...currentQuery };
if (submitQuery.category_id === '' || !submitQuery.category_id) {
delete submitQuery.category_id;
}
delete submitQuery.enable_query;
delete submitQuery.initial_query;
const res = await discussList({
...submitQuery,
source_id: source_id.value
});
if (!res.error) {
const resData = res?.data?.data;
total.value = resData.total;
discussionList.value = resData.records;
}
loading.value = false;
// enable_query 置回 false
query.value = { ...query.value, enable_query: false };
};
// 监听 query 变化, 获取讨论列表
watch(
query,
(newVal) => {
if (newVal.enable_query && !newVal.initial_query) {
//
document.body.click(); // TODO: 华为pagination bug ,待修复
fetchData(newVal);
}
},
{ deep: true }
);
// 跳转新建
const goCreate = () => {
if (isLogin) {
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSelect` });
} else {
emitEvent('logout', true);
}
};
const nsId = computed(() => orgStore.communityInfo?.ns_id);
// 跳转讨论分类管理
const goSet = () => {
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
};
const handleCommunity = (item:any) => {
if (item.devpress) {
const path = item.path.replace('$namespace', props.orgNamespace);
window.open(path, '_self');
} else router.push({ name: item.name });
};
const init = async() => {
const query = route;
if (query.name) communityType.value = query.name;
await getDiscussionStatus();
// 获取讨论数据
if (source_id.value && discussOpen.value === '1') {
await getTypeAndSection();
await fetchInitial();
getLabels();
getAnswerRankList();
} else {
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
router.replace('/404');
}
};
init();
</script>
<template>
<div class="flex container" v-if="discussOpen === '1'">
<Card style="padding:0;">
<div class="discussion">
<div class="tabs flex items-center justify-between">
<div class="tabs-nav flex items-center">
<div v-for="comty in communityList" :key="comty.name" class="tabs-nav-option" :class="{active:comty.name === communityType}" @click="handleCommunity(comty)"><span>{{comty.label}}</span></div>
</div>
<div class="right-search pr-3">
<div v-if="communityType==='orgDiscussion'">
<div class="search">
<div class="search-query">
<div class="search-query__title">
<d-input
v-model="searchStore.title"
placeholder="搜索讨论"
prefix="search"
@keydown="handleTitleChange"
></d-input>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="communityType==='orgDiscussion'">
<div class="filter-bar flex items-center justify-between bg-CG100 px-2 py-1">
<div class="flex items-center">
<d-select v-if="topicOptions.length>1" v-model="topicActive" class="w-[120px]" placeholder="主题" @value-change="handleTopic">
<gc-option
v-for="item in topicOptions"
:key="item.key"
:value="item.key"
:name="item.label"
></gc-option>
</d-select>
<d-select v-model="query.category_id" class="w-[180px]" placeholder="分类" @value-change="handleTypeChange">
<gc-option
v-for="item in subcatalogList"
:key="item.key"
:value="item.key"
:name="item.label"
></gc-option>
</d-select>
<d-select
v-model="searchStore.label_id"
placeholder="Label"
allow-clear
class="w-[200px]"
v-if="sourceType === 2"
>
<gc-option
v-for="item in labelOptions"
:key="item.value"
:value="item.value"
:name="`Label:${item.label}`"
>
<div class="label-option">
<span :style="{ backgroundColor: item.color }" class="label-option__color"></span>
<span :title="item.label" class="label-option__label">{{ item.label }}</span>
</div>
</gc-option>
</d-select>
<d-select
v-model="searchStore.filter"
placeholder="状态"
class="w-[120px]"
allow-clear
>
<gc-option
v-for="item in filterOptions"
:key="item.value"
:value="item.value"
:name="item.label"
></gc-option>
</d-select>
<d-select
v-model="searchStore.sort"
placeholder="排序"
class="w-[150px]"
allow-clear
>
<gc-option
v-for="item in sortOptions"
:key="item.value"
:value="item.value"
:name="item.label"
></gc-option
></d-select>
</div>
<div class="flex items-center gap-2">
<div v-if="access_level >= 50" class="discussion-type text-[14px] cursor-pointer px-1 text-CG600 whitespace-nowrap" @click="goSet"><span>新建类别</span></div>
<div class="text-CG600 cursor-pointer whitespace-nowrap" @click="resetQuery"><span>清空筛选</span></div>
<d-button @click="goCreate" class="bg-[#fff] whitespace-nowrap"><Icon name="gt-add"/> 新讨论</d-button>
</div>
</div>
<DataPanel
skeleton
:loading="loading"
:empty="!discussionList?.length"
animation
:card="false"
class="discussion-list"
>
<div class="list-wrapper">
<ListItem
v-for="item in discussionList"
:key="item.id"
v-bind="item"
:label_dict="labelOptions"
:is-login="isLogin"
:categoryQuery="query?.category_id!==''"
:source-type="sourceType"
/>
</div>
</DataPanel>
</div>
</div>
</Card>
<!-- 分页 -->
<d-pagination
size="md"
class="px-[20px] py-[20px] flex justify-center"
:total="total"
:page-size-options="[10, 20, 50]"
v-model:pageSize="query.size"
v-model:pageIndex="query.page"
:max-items="5"
:can-change-page-size="true"
:can-view-total="true"
total-item-text="总计"
auto-hide
@page-index-change="query.enable_query = true"
@page-size-change="query.enable_query = true"
/>
</div>
</template>
<style lang="scss" scoped>
@import 'devui-theme/styles-var/devui-var.scss';
.container {
flex-direction: column;
padding:0;
.discussion{
.tabs{
height: 60px;
border-bottom: 1px solid var(--color-G300);
.tabs-nav{
.tabs-nav-option{
line-height: 58px;
color: var(--color-CG600);
cursor: pointer;
padding: 0 4px;
margin: 0 12px;
&::after{
content: "";
display: block;
margin: auto;
height: 2px;
width: 0;
background: transparent;
//transition: width var(--devui-animation-duration-slow, .3s) var(--devui-animation-ease-in-out, cubic-bezier(.5, .05, .5, .95)),background-color var(--devui-animation-duration-slow, .3s) var(--devui-animation-ease-in-out, cubic-bezier(.5, .05, .5, .95));
}
&.active{color:var(--color-G900);}
&.active::after{
content: "";
display: block;
margin: auto;
height: 2px;
width: 100%;
background: var(--color-G900);
//transition: width var(--devui-animation-duration-slow, .3s) var(--devui-animation-ease-in-out, cubic-bezier(.5, .05, .5, .95)),background-color var(--devui-animation-duration-slow, .3s) var(--devui-animation-ease-in-out, cubic-bezier(.5, .05, .5, .95));
}
&:hover::after{
content: "";
display: block;
margin: auto;
height: 2px;
width: 100%;
background: var(--color-G900);
//transition: width var(--devui-animation-duration-slow, .3s) var(--devui-animation-ease-in-out, cubic-bezier(.5, .05, .5, .95)),background-color var(--devui-animation-duration-slow, .3s) var(--devui-animation-ease-in-out, cubic-bezier(.5, .05, .5, .95));
}
}
}
}
}
}
.filter-bar{
.devui-select{
position: relative;
&:not(:first-child)::after{
content:'';
position: absolute;
top:6px;
left:0;
height: 18px;
width: 0;
border-left: 1px solid var(--color-G300);
}
:deep(.devui-select__selection){
background: transparent;
border: none;
}
}
}
.left-menu {
width: 300px;
margin-right: 32px;
overflow-y: scroll;
max-height: calc(100vh - 280px);
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.secondary-title {
font-size: 16px;
color: var(--color-font);
line-height: 20px;
font-weight: 500;
}
.tertiary-title{
height: 16px;
font-size: 12px;
font-weight: 500;
color: #707a87;
line-height: 16px;
margin-left: 8px;
}
// 热心榜
.answer-rank {
margin-top: 25px;
.answer-rank-title {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 12px;
}
.line {
border-bottom: 1px solid $devui-line;
margin-bottom: 16px;
}
&-item {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
&__left {
display: flex;
gap: 8px;
align-items: center;
overflow: hidden;
}
&__right {
color: $devui-success;
display: flex;
align-items: center;
}
}
&-name {
height: 16px;
min-width: 0;
flex: 1;
font-size: 14px;
font-weight: 500;
color: #707a87;
line-height: 16px;
}
// 空数据效果
&-empty {
font-size: 14px;
font-weight: 500;
color: #707a87;
line-height: 18px;
margin-left: 12px;
}
}
}
.label-option {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 8px;
min-width: 0;
&__color {
flex: 0 0 14px;
width: 14px;
height: 14px;
border-radius: 50%;
}
&__label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.search {
&-query {
display: flex;
gap: 8px;
justify-content: space-between;
&__selector {
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
:deep(.devui-select){
width:150px;
}
}
}
}
.discussion-list {
display: flex;
flex-direction: column;
justify-content: space-between;
}
@media screen and (max-width: 576px) {
.container {
flex-direction: column;
padding:0 20px;
}
.left-menu{
width:100%;
}
.search-query{
flex-wrap: wrap;
&__title{
flex:0 0 100%;
}
&__selector {
flex:0 0 100%;
:deep(.devui-select){
width:0;
flex:1;
}
}
}
}
</style>
<style lang="scss">
.type-menu {
.devui-menu {
&-item {
height: 32px;
line-height: 32px;
background-color: $devui-global-bg;
&-vertical-wrapper {
&:not(:first-of-type) {
margin-top: 4px !important;
}
& > .devui-menu-item {
padding-left: 12px !important;
}
&.layer_1 {
& > .devui-menu-item {
padding-left: 12px !important;
height: 32px;
}
& + .layer_1 {
margin-top: 4px !important;
}
}
&.layer_2 {
& > .devui-menu-item {
padding-left: 34px !important;
height: 32px;
}
& + .layer_2 {
margin-top: 4px !important;
}
}
}
&-select {
background: var(--color-CG300) !important;
&::after {
content: '';
width: 0;
}
}
}
}
.devui-submenu.layer_2 {
margin-top: 4px !important;
}
.devui-submenu-title {
background-color: $devui-global-bg !important;
padding-left: 12px !important;
height: 32px !important;
line-height: 32px !important;
padding-right: 0 !important;
& > i {
opacity: 0.3;
}
}
}
.search {
&-query {
&__selector {
/** :deep */
.devui-select {
&__selection {
border: none;
border-radius: 0;
}
&:first-of-type {
.devui-select__selection {
border-top-left-radius: var(--border-radius);
border-bottom-left-radius: var(--border-radius);
}
}
&:last-of-type {
.devui-select__selection {
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
}
}
& + .devui-select {
position: relative;
&:before {
content: '';
width: 1px;
height: 18px;
background-color: #e6e7e8;
display: inline-block;
position: absolute;
z-index: 2;
top: 6px;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,244 @@
<script setup lang="ts">
import EmojiTitle from '@/components/Discussion/Module/components/DiscussionEmojiTitle.vue';
import { reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import debounce from 'lodash/debounce';
import isArray from 'lodash/isArray';
import isEmpty from 'lodash/isEmpty';
import { getAllTypes, sectionDetail, sectionSave, sectionUpdate } from '@/api/discussion';
import type { categoryType } from '@/api/discussion/types';
import { debounceTime } from '@/constant/discuss';
import { Message } from 'vue-devui/message';
import { CheckboxGroup } from 'vue-devui/checkbox';
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
import { orgInfoStore } from '@/stores/Org';
import { repoInfoStore } from '@/stores/Repo';
defineOptions({
name: 'DiscussionSectionForm'
});
const props = withDefaults(defineProps<{
sourceType: 1|2; // 组织1项目2
orgNamespace?: string; // 组织namespace
}>(), {
sourceType: 1
});
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const sectionId = route.params.sectionId as string | undefined;
const isEditMode = !!sectionId;
// 获取当前用户信息 & 是否登录
const { isLogin = false, userInfo = {}} = useDiscussGetUserInfo();
if (!isLogin) router.push('/404');
// 确认讨论是否开启
const { id: source_id, discussOpen, getDiscussionStatus } = useDiscussionOpen(props.sourceType, props.orgNamespace);
type TypeCheckbox = { name: string; value: unknown };
const typeList = ref([] as TypeCheckbox[]);
// 获取所有 type
const getDiscussionTypeList = async() => {
loading.value = true;
const res = await getAllTypes({ id: source_id.value, source_type: props.sourceType });
if (!res.error) {
const resData = res?.data?.data;
typeList.value =
isArray(resData) && !isEmpty(resData)
? resData.map((item: categoryType) => ({
name: item.category_name,
value: item.id
}))
: [];
if (isEditMode) {
await getSectionDetail();
}
}
loading.value = false;
};
const group = reactive({
icon: '🔥',
name: ''
});
const typeSelectedList = ref([] as TypeCheckbox[]);
/**
* 编辑组别中,获取当前组别的信息,并填充到 `group` 中
*/
const getSectionDetail = async() => {
const res = await sectionDetail({ id: sectionId });
if (!res.error) {
const resData = res?.data?.data;
group.icon = resData.section_icon;
group.name = resData.section_name;
typeSelectedList.value = resData.category_list.map((each: categoryType) => ({
name: each.category_name,
value: each.id
}));
}
};
const intialData = async() => {
await getDiscussionStatus();
if (source_id.value && discussOpen.value === '1') {
// 获取当前用户项目/组织权限
const access_level = props.sourceType === 1 ? orgInfoStore().access_level : repoInfoStore().access_level;
if (access_level < 50) {
router.replace({
name: `${props.sourceType === 1 ? 'org' : 'repo'}Discussion`
});
}
await getDiscussionTypeList();
} else {
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
router.replace('/404');
}
};
intialData();
const onCreate = debounce(async() => {
if (!group.name.trim()) {
Message.warning('分组组别名称不能为空');
return;
} else if (group.name.length > 16) {
Message.warning('名称过长');
return;
}
loading.value = true;
const submitData = {
section_name: group.name.trim(),
section_icon: group.icon,
category_id_list: !isEmpty(typeSelectedList.value)
? typeSelectedList.value.map((each) => each.value)
: [],
source_id: source_id.value,
source_type: props.sourceType
};
const res = await sectionSave(submitData);
if (!res.error) {
Message.success('已新建');
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
}
loading.value = false;
}, debounceTime);
const onUpdate = debounce(async() => {
if (!group.name.trim()) {
Message.warning('分组组别名称不能为空');
return;
} else if (group.name.length > 16) {
Message.warning('名称过长');
return;
}
loading.value = true;
const submitData = {
id: sectionId,
section_name: group.name.trim(),
section_icon: group.icon,
category_id_list: !isEmpty(typeSelectedList.value)
? typeSelectedList.value.map((each) => each.value)
: [],
source_id: source_id.value,
source_type: props.sourceType
};
const res = await sectionUpdate(submitData);
if (!res.error) {
Message.success('已更新');
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
}
loading.value = false;
}, debounceTime);
const onCancel = async() => {
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
};
</script>
<template>
<div class="discussion-section mt-6">
<d-breadcrumb>
<gc-breadcrumb-item :to="{ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` }"
><span class="title">讨论分类管理</span></gc-breadcrumb-item
>
<gc-breadcrumb-item v-if="!isEditMode"
><span class="cur-title">新建组别</span></gc-breadcrumb-item
>
<gc-breadcrumb-item v-else><span class="cur-title">编辑组别</span></gc-breadcrumb-item>
</d-breadcrumb>
<Card class="mt-6">
<h3 class="form-label">分类组别名称</h3>
<div class="section-name">
<EmojiTitle
v-model:icon="group.icon"
v-model:title="group.name"
placeholder="请输入分类组别名称"
/>
</div>
<h3 class="mt-6 form-label">包含的分类</h3>
<p class="mt-4 mb-2 form-tip">注意每个内容分类只能归属于一个唯一的组别</p>
<CheckboxGroup
v-model="typeSelectedList"
:options="typeList"
direction="row"
/>
<div class="flex gap-2 mt-40">
<d-button @click="onCancel">取消</d-button>
<d-button :loading="loading" v-if="!isEditMode" variant="solid" color="primary" @click="onCreate" :disabled="group.name.length<1 || group.name.length>16">创建</d-button>
<d-button v-else :loading="loading" variant="solid" color="primary" @click="onUpdate" :disabled="group.name.length<1 || group.name.length>16">更新</d-button>
</div>
</Card>
</div>
</template>
<style scoped lang="scss">
@import 'devui-theme/styles-var/devui-var.scss';
$g-commit-border-radius: 0.25rem;
.discussion-section {
.section-name {
margin-top: 10px;
}
.discussion-create-form {
margin-top: 24px;
}
.title {
font-size: 14px;
font-weight: 400;
color: #9a9b9c;
line-height: 20px;
}
.cur-title {
font-size: 14px;
font-weight: 500;
color: #2d2d2e;
line-height: 20px;
}
.form-label {
font-size: 16px;
font-weight: 500;
color: #2d2d2e;
line-height: 24px;
}
.form-tip {
font-size: 14px;
font-weight: 400;
color: #7e7e80;
line-height: 20px;
}
.create-btn {
padding: 8px 16px;
border-radius: 4px;
background-color: #333;
color: #fff;
}
}
</style>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import DiscussionTypeItem from '../components/DiscussionTypeItem.vue';
import { ref } from 'vue';
import type {
discussionTypeOrSection,
categoryType
} from '@/api/discussion/types';
import { useRouter } from 'vue-router';
import { getAllTypes } from '@/api/discussion';
import { DISCUSS_FORMAT } from '@/constant/discuss';
import isArray from 'lodash/isArray';
import isEmpty from 'lodash/isEmpty';
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
import { orgInfoStore } from '@/stores/Org';
import { repoInfoStore } from '@/stores/Repo';
import { Message } from 'vue-devui/message';
defineOptions({
name: 'DiscussionSelectType'
});
const props = withDefaults(defineProps<{
sourceType: 1|2; // 组织1项目2
orgNamespace?: string; // 组织namespace
}>(), {
sourceType: 1
});
const router = useRouter();
// 获取当前用户信息 & 是否登录
const { isLogin = false, userInfo = {}} = useDiscussGetUserInfo();
if (!isLogin) router.push('/404');
// 确认讨论是否开启
const { id: source_id, discussOpen, getDiscussionStatus } = useDiscussionOpen(props.sourceType, props.orgNamespace);
// 获取当前用户项目/组织权限
const access_level = props.sourceType === 1 ? orgInfoStore().access_level : repoInfoStore().access_level;
const loading = ref(false);
const typeList = ref([] as discussionTypeOrSection[]);
// 获取所有 type
const getDiscussionTypeList = async() => {
loading.value = true;
const res = await getAllTypes({ id: source_id.value, source_type: props.sourceType });
if (!res.error) {
const resData = res?.data?.data;
const tempData = access_level > 30 ? resData : resData.filter((item:categoryType) => item.category_type !== DISCUSS_FORMAT.ANNOUNCE);
// 赋值
typeList.value =
isArray(resData) && !isEmpty(resData)
? tempData.map((item: categoryType) => ({
id: item.id,
icon: item.category_icon,
title: item.category_name,
desc: item.category_desc,
answerAcceptEnable: item.category_type === 2,
isGroup: false
}))
: [];
}
loading.value = false;
};
const initialData = async() => {
await getDiscussionStatus();
if (source_id.value && discussOpen.value === '1') {
await getDiscussionTypeList();
} else {
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
router.replace('/404');
}
};
initialData();
const onDiscussionCreate = (item: discussionTypeOrSection) => {
router.push({
name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionCreate`,
params: {
discussionTypeId: item.id
}
});
};
const goToTypeManage = () => {
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
};
</script>
<template>
<div class="discussion-select space-y-20 mt-6">
<d-skeleton :loading="loading">
<header>
<p class="discussion-select__title">请选择你要创建的讨论类型</p>
<d-button v-if="access_level>30" variant="solid" color="primary" @click="goToTypeManage">
讨论内容分类设置
</d-button>
</header>
<Card simple>
<DiscussionTypeItem v-for="item in typeList" :key="item.id" :info="item">
<!-- 替换为 emoji -->
<template #icon>
<span class="emoji-icon">{{ item.icon }}</span>
</template>
<template #tools>
<d-button class="create-btn" size="md" @click="onDiscussionCreate(item)"
>发起讨论</d-button
>
</template>
</DiscussionTypeItem>
</Card>
</d-skeleton>
</div>
</template>
<style scoped lang="scss">
@import 'devui-theme/styles-var/devui-var.scss';
.discussion-select {
& > header {
display: flex;
justify-content: space-between;
align-items: center;
}
&__title {
font-size: 18px;
font-weight: 500;
color: #2d2d2e;
line-height: 32px;
}
:deep(.discussion-type-item) {
border-top: 0;
border-radius: 0;
border-bottom: 1px solid var(--color-border-light);
box-shadow: none;
&:first-of-type {
border-top-left-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
}
&:last-of-type {
border-bottom-left-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
border-bottom: none;
}
}
}
.create-btn {
padding: 6px 20px;
border-radius: var(--border-radius);
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,301 @@
<script setup lang="ts">
import { ref } from 'vue';
import { debounceTime, defaultGroup } from '@/constant/discuss';
import type { categoryType, sectionType } from '@/api/discussion/types';
import { DISCUSS_FORMAT } from '@/constant/discuss';
import debounce from 'lodash/debounce';
import isArray from 'lodash/isArray';
import isEmpty from 'lodash/isEmpty';
import EmojiTitle from '@/components/Discussion/Module/components/DiscussionEmojiTitle.vue';
import { useRoute, useRouter } from 'vue-router';
import { getAllSections, typeDetail, typeSave, typeUpdate } from '@/api/discussion';
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
import { orgInfoStore } from '@/stores/Org';
import { repoInfoStore } from '@/stores/Repo';
import { Message } from 'vue-devui/message';
defineOptions({
name: 'DiscussionTypeForm'
});
const props = withDefaults(defineProps<{
sourceType: 1|2; // 组织1项目2
orgNamespace?: string; // 组织namespace
}>(), {
sourceType: 1
});
const route = useRoute();
const router = useRouter();
const typeId = route.params.typeId as string | undefined;
const loading = ref(false);
const isEditMode = !!typeId;
// 获取当前用户信息 & 是否登录
const { isLogin = false, userInfo = {}} = useDiscussGetUserInfo();
if (!isLogin) router.push('/404');
// 确认讨论是否开启
const { id: source_id, discussOpen, getDiscussionStatus } = useDiscussionOpen(props.sourceType, props.orgNamespace);
type SectionRadio = { label: string; value: string };
const sectionList = ref([] as SectionRadio[]);
// 获取所有 section
const getDiscussionSectionList = async() => {
loading.value = true;
const res = await getAllSections({ source_id: source_id.value, source_type: props.sourceType });
if (!res.error) {
const resData = res?.data?.data;
let allSections = [
{
label: defaultGroup.label,
value: defaultGroup.value
}
];
if (isArray(resData) && !isEmpty(resData)) {
allSections = [...allSections].concat(
resData.map((item: sectionType) => ({
label: item.section_name,
value: item.id
}))
);
}
sectionList.value = allSections;
if (isEditMode) {
await getTypeDetail();
}
}
loading.value = false;
};
const typeData = ref<categoryType>({
id: '',
category_icon: '🔥',
category_name: '',
category_type: DISCUSS_FORMAT.OPEN,
section_id: defaultGroup.value,
category_desc: ''
});
const discussFormatList = ref([
{
value: DISCUSS_FORMAT.OPEN,
label: '开放讨论形式',
disabled: false
},
{
value: DISCUSS_FORMAT.QANDA,
label: '问答形式',
disabled: false
},
{
value: DISCUSS_FORMAT.ANNOUNCE,
label: '公告形式',
disabled: false
},
{
value: DISCUSS_FORMAT.VOTE,
label: '投票形式',
disabled: false
}
]);
// 获取内容分类详情
const getTypeDetail = async() => {
const res = await typeDetail({ id: typeId });
if (!res.error) {
const resData = res?.data?.data;
const { category_icon, category_name, category_type, section_id, category_desc } = resData;
typeData.value = {
...typeData.value,
category_icon,
category_name,
category_type,
section_id: section_id || defaultGroup.value,
category_desc
};
if (category_type === DISCUSS_FORMAT.VOTE) {
discussFormatList.value = discussFormatList.value.map((item) => ({
...item,
disabled: item.value !== DISCUSS_FORMAT.VOTE
}));
} else {
discussFormatList.value = discussFormatList.value.map((item) => ({
...item,
disabled: item.value === DISCUSS_FORMAT.VOTE
}));
}
}
};
const initialData = async() => {
await getDiscussionStatus();
if (source_id.value && discussOpen.value === '1') {
// 获取当前用户项目/组织权限
const access_level = props.sourceType === 1 ? orgInfoStore().access_level : repoInfoStore().access_level;
if (access_level < 50) {
router.replace({
name: `${props.sourceType === 1 ? 'org' : 'repo'}Discussion`
});
}
await getDiscussionSectionList();
} else {
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
router.replace('/404');
}
};
initialData();
const onCreate = debounce(async() => {
if (!typeData.value.category_name.trim()) {
Message.warning('内容分类名称不能为空');
return;
} else if (typeData.value.category_name.length > 16) {
Message.warning('名称过长');
return;
}
loading.value = true;
const { category_name, category_icon, category_desc, section_id, category_type } = typeData.value;
const submitData = {
category_name: category_name.trim(),
category_icon,
category_desc,
section_id: section_id === 'default' ? '' : section_id,
category_type,
source_id: source_id.value,
source_type: props.sourceType
};
const res = await typeSave(submitData);
if (!res.error) {
Message.success('已新建');
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
}
loading.value = false;
}, debounceTime);
const onUpdate = debounce(async() => {
if (!typeData.value.category_name.trim()) {
Message.warning('内容分类名称不能为空');
return;
} else if (typeData.value.category_name.length > 16) {
Message.warning('名称过长');
return;
}
loading.value = true;
const { category_name, category_icon, category_desc, section_id, category_type } = typeData.value;
const submitData = {
id: typeId,
category_name: category_name.trim(),
category_icon,
category_desc,
section_id: section_id === 'default' ? '' : section_id,
category_type
};
const res = await typeUpdate(submitData);
if (!res.error) {
Message.success('已更新');
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
}
loading.value = false;
}, debounceTime);
const onCancel = async() => {
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` });
};
</script>
<template>
<div class="discussion-type mt-6">
<d-breadcrumb>
<gc-breadcrumb-item :to="{ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionType` }">
<span class="title">讨论分类管理</span>
</gc-breadcrumb-item>
<gc-breadcrumb-item>
<span v-if="!isEditMode" class="cur-title">新建内容分类</span>
<span v-else class="cur-title">编辑内容分类</span>
</gc-breadcrumb-item>
</d-breadcrumb>
<Card class="mt-6">
<h3 class="text-base font-bold">内容分类名称</h3>
<p class="mt-4 form-tip">最多支持20个内容分类</p>
<div class="type-name">
<EmojiTitle
v-model:icon="typeData.category_icon"
v-model:title="typeData.category_name"
/>
</div>
<h3 class="mt-6 text-base font-bold">分类简介</h3>
<div class="type-name">
<d-textarea
v-model="typeData.category_desc"
placeholder="请输入内容分类简介"
rows="2"
show-count
maxlength="2000"
/>
</div>
<h3 class="mt-6 text-base font-bold">讨论形式</h3>
<div class="mt-10 form-tip">
<p class="mb-10">请从下方选择适合当前内容分类的讨论形式其中</p>
<ul class="list-disc list-inside">
<li class="mb-1">
开放讨论形式不需要对问题给出明确的答案适合分享技巧和窍门或只是简单的聊天讨论
</li>
<li class="mb-1">问答形式针对问题进行讨论对讨论的问题可以采纳出最佳答案</li>
<li class="mb-1">
公告形式只有管理员可以在这些类别中发布新的讨论但任何人都可以发表评论和回复
</li>
<li class="mb-1">投票形式支持投票的方式来收集了解社区用户的兴趣或偏好</li>
</ul>
</div>
<d-radio-group class="mt-10" direction="row" v-model="typeData.category_type">
<d-radio v-for="item in discussFormatList" :key="item.value" :value="item.value" :disabled="item.disabled">
<span class="leading-6">{{item.label}}</span>
</d-radio>
</d-radio-group>
<h3 class="mt-6 mb-2 font-bold text-base">分类组别设置</h3>
<d-radio-group class="mt-10" direction="row" v-model="typeData.section_id">
<d-radio v-for="item in sectionList" :key="item.value" :value="item.value">
<span class="leading-6">{{item.label}}</span>
</d-radio>
</d-radio-group>
<div class="flex gap-2 mt-40">
<d-button @click="onCancel">取消</d-button>
<d-button :loading="loading" variant="solid" color="primary" v-if="!route.params.typeId" @click="onCreate" :disabled="typeData.category_name.length<1 || typeData.category_name.length>16">创建</d-button>
<d-button v-else :loading="loading" variant="solid" color="primary" @click="onUpdate" :disabled="typeData.category_name.length<1 || typeData.category_name.length>16">更新</d-button>
</div>
</Card>
</div>
</template>
<style scoped lang="scss">
@import 'devui-theme/styles-var/devui-var.scss';
.discussion-type {
.type-name {
margin-top: 10px;
}
.discussion-create-form {
margin-top: 24px;
}
.title {
font-size: 14px;
font-weight: 400;
color: #9a9b9c;
line-height: 20px;
}
.cur-title {
font-size: 14px;
line-height: 20px;
}
.form-tip {
font-size: 14px;
font-weight: 400;
color: var(--color-light);
line-height: 20px;
}
}
</style>

View File

@@ -0,0 +1,310 @@
<script setup lang="ts">
import DiscussionTypeItem from '../components/DiscussionTypeItem.vue';
import { ref } from 'vue';
import { getAllSectionAndTypes, typeDelete, sectionDelete } from '@/api/discussion';
import type {
discussionTypeOrSection,
sectionItemType
} from '@/api/discussion/types';
import { useRouter } from 'vue-router';
import { Message } from 'vue-devui/message';
import { DISCUSS_FORMAT } from '@/constant/discuss';
import { useDiscussGetUserInfo, useDiscussionOpen } from '@/api/discussion/hook';
import { orgInfoStore } from '@/stores/Org';
import { repoInfoStore } from '@/stores/Repo';
import MoreList from '@/components/MoreList/index.vue';
import { GModal } from '@/components/Setting/index';
import { TransAssetsUrl } from '@/utils/asset';
defineOptions({
name: 'DiscussionTypeManage'
});
const props = withDefaults(defineProps<{
sourceType: 1|2; // 组织1项目2
orgNamespace?: string; // 组织namespace
}>(), {
sourceType: 1
});
const router = useRouter();
const loading = ref(false);
// 获取当前用户信息 & 是否登录
const { isLogin = false, userInfo = {}} = useDiscussGetUserInfo();
if (!isLogin) router.push('/404');
// 确认讨论是否开启
const { id: source_id, discussOpen, getDiscussionStatus } = useDiscussionOpen(props.sourceType, props.orgNamespace);
const typeAndSectionList = ref<discussionTypeOrSection[]>([]);
const allTypes = ref<discussionTypeOrSection[]>();
const getTypeAndSection = async() => {
// 获取所有内容分类&组别
const resData = await getAllSectionAndTypes({ source_id: source_id.value, source_type: props.sourceType });
const $typeAndSectionList = [];
if (!resData.error) {
const data: sectionItemType = resData?.data?.data;
const { section, unSection } = data;
if (unSection && unSection.length > 0) {
for (const each of unSection) {
$typeAndSectionList.push({
id: each.id,
icon: each.category_icon,
title: each.category_name,
desc: each.category_desc,
answerAcceptEnable: each.category_type === DISCUSS_FORMAT.QANDA,
isGroup: false,
categoryType: each.category_type
});
}
}
if (section && section.length > 0) {
for (const each of section) {
const sectionInfo = {
id: each.id,
icon: each.section_icon,
title: each.section_name,
isGroup: true
};
$typeAndSectionList.push(sectionInfo);
const { category_list } = each;
if (category_list && category_list.length > 0) {
for (const item of category_list) {
$typeAndSectionList.push({
id: item.id,
icon: item.category_icon,
title: item.category_name,
desc: item.category_desc,
answerAcceptEnable: item.category_type === DISCUSS_FORMAT.QANDA,
isGroup: false,
categoryType: item.category_type
});
}
}
}
}
}
typeAndSectionList.value = $typeAndSectionList;
allTypes.value = $typeAndSectionList.filter((item: discussionTypeOrSection) => !item.isGroup);
};
const initialData = async() => {
loading.value = true;
await getDiscussionStatus();
if (source_id.value && discussOpen.value === '1') {
// 获取当前用户项目/组织权限
const access_level = props.sourceType === 1 ? orgInfoStore().access_level : repoInfoStore().access_level;
if (access_level < 50) {
router.replace({
name: `${props.sourceType === 1 ? 'org' : 'repo'}Discussion`
});
}
await getTypeAndSection();
} else {
Message.warning(`${props.sourceType === 1 ? '组织' : '项目'}讨论未开启`);
router.replace('/404');
}
loading.value = false;
};
initialData();
const onTypeCreate = () => {
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionTypeCreate` });
};
const onSectionCreate = () => {
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSectionCreate` });
};
const onEdit = (item: discussionTypeOrSection) => {
if (item.isGroup) {
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionSectionEdit`, params: { sectionId: item.id }});
return;
}
router.push({ name: `${props.sourceType === 1 ? 'org' : 'repo'}DiscussionTypeEdit`, params: { typeId: item.id }});
};
// 删除
const deleteInfo = ref<any>({
id: '',
visible: false,
isGroup: false,
title: '',
categoryType: 0,
transfer_id: '', // type删除后原type下的讨论转移到哪个type
transferOptions: [],
text: '删除',
btnLoading: false
});
const onDelete = (item: discussionTypeOrSection) => {
if (!item.isGroup) {
if (allTypes.value && allTypes.value.filter(e => e.categoryType !== DISCUSS_FORMAT.VOTE).length <= 1 && item.categoryType !== DISCUSS_FORMAT.VOTE) {
Message.warning('至少保留一个非投票分类');
return;
} else {
if (item.categoryType === DISCUSS_FORMAT.VOTE) {
// 投票可迁移为其他类型,但投票相关数据会清空,给出弹窗提示
deleteInfo.value.transferOptions =
allTypes.value && allTypes.value.filter((each) => item.id !== each.id);
} else {
// 其他类型不可迁移为投票类型
deleteInfo.value.transferOptions =
allTypes.value && allTypes.value.filter((each) => item.id !== each.id && each.categoryType !== DISCUSS_FORMAT.VOTE);
}
}
}
deleteInfo.value = {
...deleteInfo.value,
id: item.id,
visible: true,
isGroup: item.isGroup,
title: item.title,
categoryType: item.categoryType,
transfer_id: item.isGroup ? '' : deleteInfo.value.transferOptions[0].id,
text: item.isGroup ? '删除' : '删除及转移'
};
};
// 确认删除
const onDeleteConfirm = async() => {
deleteInfo.value.btnLoading = true;
const deleteFn = deleteInfo.value.isGroup ? sectionDelete : typeDelete;
const deleteData = {
id: deleteInfo.value.id,
...(!deleteInfo.value.isGroup && { transfer_id: deleteInfo.value.transfer_id })
};
const resData = await deleteFn(deleteData);
if (!resData.error) {
Message.success('已删除');
}
deleteInfo.value.btnLoading = false;
deleteInfo.value.visible = false;
getTypeAndSection();
};
const IconSource = (import.meta as any).glob('@/assets/imgs/icon/*svg', { eager: true });
const IconSettig = TransAssetsUrl(IconSource, 'icon-setting');
const IconDelete = TransAssetsUrl(IconSource, 'icon-delete');
const moreOpts = [
{
svg: IconSettig,
icon: 'icon-delete',
text: '编辑',
handle: onEdit
},
{
svg: IconDelete,
icon: 'icon-delete',
text: '删除',
handle: onDelete
}];
</script>
<template>
<div class="discussion-type-manage space-y-20 my-6">
<header class="space-y-24">
<div class="header">
<d-breadcrumb>
<gc-breadcrumb-item :to="{ name: `${props.sourceType === 1 ? 'org' : 'repo'}Discussion` }"
><span class="title">讨论列表</span></gc-breadcrumb-item
>
<gc-breadcrumb-item><span class="cur-title">讨论分类管理</span></gc-breadcrumb-item>
</d-breadcrumb>
<div class="space-x-3">
<d-button variant="solid" type="primary" @click="onTypeCreate">
<Icon name="gt-add" color="white" />新分类
</d-button>
<d-button variant="solid" type="primary" class="ml-2" @click="onSectionCreate">
<Icon name="gt-add" color="white" />新组别
</d-button>
</div>
</div>
<p class="text-light mt-6">
组别是多个相似内容类型的组合组别中包含讨论的内容类型以及每个类型中的讨论内容
</p>
</header>
<Card simple>
<DataPanel :loading="loading" skeleton :empty="!typeAndSectionList.length">
<section>
<DiscussionTypeItem v-for="item in typeAndSectionList" :key="item.id" :info="item">
<template #icon>
<span class="emoji-icon">{{ item.icon }}</span>
</template>
<template #tools>
<MoreList :moreOpts="moreOpts" :item="item"></MoreList>
</template>
</DiscussionTypeItem>
</section>
</DataPanel>
</Card>
<GModal v-model="deleteInfo.visible" showWarnIcon @confirm="onDeleteConfirm" confirmColor="danger" :title="`确定删除${ deleteInfo.isGroup?'分组':'分类'} ${deleteInfo.title} 吗?`">
<div v-if="deleteInfo.isGroup">删除该组别后该组别下原有的内容分类将变为无组别状态</div>
<div v-else>
<p class="mb-2">如果该分类下已创建了讨论你想把它们转移到哪个内容分类下</p>
<d-select class="mb-2" v-model="deleteInfo.transfer_id" :position="['bottom-end', 'top-end']">
<gc-option
v-for="item in deleteInfo.transferOptions"
:key="item.id"
:value="item.id"
:name="item.title"
></gc-option>
</d-select>
<div v-if="deleteInfo.categoryType === DISCUSS_FORMAT.VOTE" class="text-red-400">
<p class="mb-1">警告</p>
<p class>如果投票分类被转移到一个非投票分类已有的投票结果会被删除</p>
</div>
</div>
</GModal>
</div>
</template>
<style scoped lang="scss">
@import 'devui-theme/styles-var/devui-var.scss';
.discussion-type-manage{
:deep(.g-custom-tag) {
background-color: unset;
color: $devui-aide-text;
cursor: pointer;
font-size: 16px;
}
:deep(.g-custom-tag:hover) {
color: $devui-text;
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--color-border);
padding-bottom: 24px;
:deep(.setting-title) {
font-size: 14px;
}
}
.title {
font-size: 14px;
font-weight: 400;
color: #9a9b9c;
line-height: 20px;
}
.cur-title {
font-size: 14px;
font-weight: 500;
color: #2d2d2e;
line-height: 20px;
}
.space-x-2 > *:not(:last-child) {
margin-right: 0.55rem;
}
.emoji-icon {
font-size: 18px;
}
</style>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
import { watch, ref } from 'vue';
defineOptions({
name: 'DiscussionEmojiTitle'
});
const props = withDefaults(defineProps<{
icon: string;
title: string;
placeholder?: string;
}>(), {
icon: '🔥',
title: '',
placeholder: '请输入内容分类名称'
});
defineEmits(['update:icon', 'update:title']);
const typeIcons = [
'🔥',
'📣',
'💬',
'🛠️',
'🚧',
'💡',
'🗳️',
'🙏',
'🙌',
'🔴',
'🚀',
'👍',
'🎉',
'✨',
'☀️',
'🌳',
'🌈',
'🌷',
'👀',
'📘',
'📖',
'👋',
'💯',
'📅',
'💎'
];
const titleError = ref(false);
const validateTitle = () => {
if (props.title) {
titleError.value = false;
} else {
titleError.value = true;
}
};
watch(() => props.title, () => {
validateTitle();
});
</script>
<template>
<div class="emoji-title-wrapper flex gap-2">
<d-dropdown :position="['bottom-start']" align="start">
<d-button class="cursor-pointer" style="width:32px">{{ icon || '🔥' }}</d-button>
<template #menu>
<ul class="type-icons">
<li
class="type-icon"
v-for="icon in typeIcons"
:key="icon"
@click="$emit('update:icon', icon)"
>
{{ icon }}
</li>
</ul>
</template>
</d-dropdown>
<d-input
:model-value="title"
class="max-w-[400px]"
@update:model-value="$emit('update:title', $event)"
@blur="validateTitle"
:placeholder="placeholder || '请输入内容分类名称'"
:error="titleError"
maxLength="16"
minLength="1"
>
<template #suffix>
<span>{{title?.length || 0}}/16</span>
</template>
</d-input>
</div>
<p class="form-tip" v-if="titleError">名称不能为空</p>
</template>
<style scoped lang="scss">
@import 'devui-theme/styles-var/devui-var.scss';
.type-icons {
width: 210px;
display: flex;
flex-wrap: wrap;
.type-icon {
display: flex;
width: 32px;
height: 32px;
justify-content: center;
align-items: center;
border: 1px solid $devui-line;
border-radius: 4px;
margin: 5px;
cursor: pointer;
}
}
.form-tip{
margin-top: 4px;
margin-left: calc(36px + 0.5rem);
font-weight: 400;
font-size: var(--devui-font-size, 12px);
color: var(--devui-danger, #f66f6a);
line-height: 20px;
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import debounce from 'lodash/debounce';
import { discussUnLike, discussLike } from '@/api/discussion';
import { emitEvent } from '@/utils/eventBus';
defineOptions({
name: 'DiscussionLikeBtn'
});
interface Props {
isLogin?: boolean; // 是否登录
targetId: string;
targetType: number;
likeTotal: number;
isLike: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isLogin: true,
targetId: '',
targetType: 1,
likeTotal: 0,
isLike: false
});
const basicColor = {
border: '#E4E9F0',
color: '#7E7E80',
bg: '#FFFFFF'
};
const activeColor = {
border: '#9FA7B3',
color: '#252D3B',
bg: '#F0F2F7'
};
const dynamicStyle = computed(() => {
return likeStatus.value
? {
color: activeColor.color,
borderColor: activeColor.border,
backgroundColor: activeColor.bg
}
: {
color: basicColor.color,
borderColor: basicColor.border,
backgroundColor: basicColor.bg
};
});
const likeStatus = ref(props.isLike);
const total = ref(props.likeTotal);
const handleClick = debounce(() => {
if (props.isLogin) {
const data = { target_id: props.targetId, target_type: props.targetType };
likeStatus.value = !likeStatus.value;
likeStatus.value ? discussLike(data) : discussUnLike(data);
} else {
// 跳登录
emitEvent('logout', true);
}
}, 300);
</script>
<template>
<button class="like-btn" :style="dynamicStyle" @click="handleClick">
<d-icon name="arrow-up" :color="dynamicStyle.color" class="like-icon" size="12px"></d-icon>
<span :class="['like-count', likeStatus ? 'update' : 'down']">
<span>{{ isLike ? total - 1 : total }}</span>
<span>{{ isLike ? total : total + 1 }}</span>
</span>
</button>
</template>
<style scoped lang="scss">
.like-btn {
padding: 4px 8px;
height: 24px;
border-width: 1px;
border-style: solid;
border-radius: 12px;
min-width: 48px;
position: relative;
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
line-height: 16px;
font-size: 12px;
overflow: hidden;
transition: all 0.5s;
}
.like-icon {
position: relative;
}
.like-count {
position: relative;
transition: all 0.5s ease-in-out;
display: flex;
flex-direction: column;
top: 50%;
&.down {
transform: perspective(1px) translateY(0%);
}
&.update {
transform: perspective(1px) translateY(-50%);
}
}
</style>

View File

@@ -0,0 +1,175 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import Sortable from 'sortablejs';
import isArray from 'lodash/isArray';
import isEmpty from 'lodash/isEmpty';
defineOptions({
name: 'DiscussionPollForm'
});
const randomName = () => {
const num = Math.random() * (9999 - 1000) + 1000;
const time = new Date().getTime();
return `${time}_${Math.ceil(num)}`;
};
interface optionType {
id: string | number;
value: string;
}
const props = defineProps<{
defaultValue?: optionType[];
title?: string;
titleEmpty?: boolean;
validOptions?: boolean;
}>();
const emit = defineEmits(['pollOptions', 'update:title']);
let sortableEl: any = null;
const list = ref<optionType[]>();
// defaultValue 不满足需求时,初始化两个选项
if (!props.defaultValue || !props.defaultValue[0]) {
const temp = [
{ id: randomName(), value: '' },
{ id: randomName(), value: '' }
];
list.value = temp.slice(0);
} else {
list.value = props.defaultValue.slice(0);
}
const handleDelete = (item: optionType) => {
const $list = isArray(list.value) && !isEmpty(list.value) ? list.value.slice(0) : [];
$list.splice(
$list.findIndex((e: optionType) => e.id === item.id),
1
);
list.value = $list;
};
const handleAddOption = () => {
if (isArray(list.value) && !isEmpty(list.value)) {
list.value.push({ id: randomName(), value: '' });
}
};
onMounted(() => {
const el = document.getElementById('poll-options');
sortableEl = new Sortable(el, {
group: 'poll-options',
handle: '.handle',
filter: '.ignore-item',
draggable: '.poll-options-item',
onMove(evt: Sortable.MoveEvent) {
const { dragged, related } = evt;
/* 没有填写的 禁拖 */
if (!dragged?.querySelector('input').value) return true;
if (related?.querySelector('input').value) {
/* 相邻没有填写的 禁拖 */
return true;
} else {
return false;
}
},
onSort(evt: Sortable.SortableEvent) {
// 保存排序数据
if (list.value && isArray(list.value) && !isEmpty(list.value)) {
const $list = [...list.value];
const sep = $list.splice(evt.oldIndex, 1);
$list.splice(evt.newIndex, 0, sep[0]);
list.value = $list;
}
}
});
});
onUnmounted(() => {
sortableEl?.destroy();
});
watch(
list,
(newList, oldList) => {
emit('pollOptions', newList);
},
{ deep: true }
);
</script>
<template>
<p class="text-base font-bold mb-4">投票问题</p>
<d-input
:model-value="props.title"
@update:model-value="$emit('update:title', $event)"
:error="titleEmpty"
placeholder="请输入你要发起的投票问题(必填)"
maxLength="64"
></d-input>
<p v-if="titleEmpty" class="poll-validate-title">投票问题不能为空</p>
<p class="text-base font-bold mb-4 mt-6">投票选项</p>
<p class="poll-tip mb-2">
注意请提供至少两个投票选项如果编辑已投票的选项系统将清空之前已投票的历史数据
</p>
<p v-if="validOptions" class="poll-validate-options">至少提供两个不为空的投票选项</p>
<div id="poll-options">
<div
v-for="(item, index) in list"
:key="item.id"
class="poll-options-item flex justify-between mt-2 mb-2 gap-1"
>
<d-button
:class="[item.value ? '' : 'ignore-item', 'handle']"
:style="{ cursor: item.value ? 'move' : 'not-allowed' }"
icon="drag"
></d-button>
<d-input
:placeholder="index < 2 ? `选项${index + 1}(必填)` : '请设置选项值…'"
name="qtitle"
v-model="item.value"
maxLength="64"
></d-input>
<d-button
variant="text"
@click="handleDelete(item)"
:style="{ visibility: index < 2 ? 'hidden' : undefined }"
>
<Icon name="gt-delete" color="var(--color-lighter)" />
</d-button>
</div>
<div class="poll-add-option" @click="handleAddOption">
<d-icon name="add" class="mr-1" size="16px"></d-icon><span>增加选项</span>
</div>
</div>
</template>
<style scoped lang="scss">
.poll-tip {
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
color: #7e7e80;
}
.poll-add-option {
height: 32px;
border-radius: var(--border-radius);
border: 1px dashed var(--color-border-light);
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
width: calc(100% - 58px);
margin-left: 36px;
margin-top: 8px;
}
.poll-validate-title,
.poll-validate-options {
margin-top: 4px;
display: inline-block;
min-height: 20px;
line-height: 1.5;
font-size: var(--devui-font-size, 12px);
color: var(--devui-danger, #f66f6a);
}
</style>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import type { discussionTypeOrSection } from '@/api/discussion/types';
defineOptions({
name: 'DiscussionTypeItem'
});
defineProps<{
info: discussionTypeOrSection;
}>();
</script>
<template>
<div class="discussion-type-item" :class="{ 'group clearfix': info.isGroup }">
<div class="middle space-y-2">
<div class="title space-x-2">
<slot name="icon"><d-icon :name="info.icon"></d-icon></slot>
<span class="ellipsis font-bold">{{ info.title }}</span>
<span v-if="info.answerAcceptEnable" class="answer-tag">可采纳回答</span>
</div>
<p class="ellipsis text-sm text-light" v-if="info.desc">{{ info.desc }}</p>
</div>
<div class="right">
<slot name="tools"></slot>
</div>
</div>
</template>
<style scoped lang="scss">
@import 'devui-theme/styles-var/devui-var.scss';
.discussion-type-item {
display: flex;
gap: 8px;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border-light);
&:first-of-type {
border-top: none;
}
&.group {
gap: 0;
}
}
.left {
flex-grow: 0;
flex-shrink: 0;
align-self: flex-start;
width: 2.25rem;
height: 2.25rem;
padding: 0.5rem;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid $devui-line;
border-radius: var(--border-radius);
margin-right: 1rem;
}
.group .left {
border: 0;
font-size: 1.25rem;
}
.middle {
flex-grow: 1;
flex-shrink: 1;
min-width:0;
}
.title {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 500;
color: #2D2D2E;
min-width:0;
}
.right {
flex-grow: 0;
flex-shrink: 0;
}
.answer-tag {
display:inline-block;
padding:4px 8px;
color:#0EB07B;
background-color: rgba(14, 176, 123,0.1);
font-size: 12px;
font-weight: 400;
color: #0EB07B;
line-height: 16px;
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,84 @@
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import FilterDropDown from '@/components/FilterDropDown/index.vue';
import type { IOption } from '@/components/FilterDropDown/types';
defineOptions({
name: 'DiscussionFilterLabel'
});
const props = withDefaults(defineProps<{
optionList:IOption[];
selectedList:IOption[],
submitIng?:boolean
}>(), {
optionList: () => [],
selectedList: () => []
});
const emit = defineEmits<{
'on-option-click': [item: IOption | null]
}>();
const loading = ref(false);
const key = ref('');
// 转化了一下 selectList加上 isLabel 属性
const _selectedList = computed<IOption[]>(() => {
if (props.selectedList[0]) {
return props?.selectedList?.map(item => {
return {
...item,
isLabel: true
};
});
} else {
return [];
}
});
const _optionList = ref<any[]>();
watch(key, (newVal) => {
if (newVal.trim()) {
const filteredList = props.optionList.filter((item) => !!(item.label && item.label.match(newVal.trim())));
_optionList.value = filteredList.map(item => {
return {
...item,
isLabel: true
};
});
} else {
_optionList.value = props.optionList.map(item => {
return {
...item,
isLabel: true
};
});
}
}, { immediate: true });
const onOptionClick = (option:IOption | null) => {
emit('on-option-click', option);
};
</script>
<template>
<FilterDropDown
v-model="key"
title="关联 Label"
:optionList="_optionList"
:selectedList="_selectedList"
:submitIng="submitIng"
placeholder="搜索 Label…"
emptyText="未设置 Label"
:show-empty-option="false"
hidden-tab
:optionClickScope="false"
:loading="loading"
@on-option-click="onOptionClick"
>
</FilterDropDown>
</template>

View File

@@ -0,0 +1,47 @@
<script lang="ts" setup>
import { ref } from 'vue';
import FilterDropDown from '@/components/FilterDropDown/index.vue';
import type { IOption } from '@/components/FilterDropDown/types';
defineOptions({
name: 'DiscussionFilterType'
});
withDefaults(defineProps<{
optionList:IOption[];
selectedList:IOption[],
submitIng?:boolean
}>(), {
optionList: () => [],
selectedList: () => []
});
const emit = defineEmits<{
'on-option-click': [item: IOption | null]
}>();
const loading = ref(false);
const onOptionClick = (option:IOption | null) => {
emit('on-option-click', option);
};
</script>
<template>
<FilterDropDown
hiddenInpout
title="更换讨论分类"
:optionList="optionList"
:selectedList="selectedList"
:submitIng="submitIng"
placeholder="搜索讨论分类"
emptyText="未设置讨论分类"
hidden-tab
optionClickScope
:loading="loading"
@on-option-click="onOptionClick"
>
</FilterDropDown>
</template>

View File

@@ -0,0 +1,410 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { Message } from 'vue-devui/message';
import { DISCUSS_FORMAT } from '@/constant/discuss';
import type {
discussDetailType,
categoryType,
userInfoType
} from '@/api/discussion/types';
import {
discussDetailRecentActiveUsers,
getAllTypes,
orgLabelList,
repoLabelList,
discussChangeRelatedLabels,
discussChangeType,
discussPin,
disussTypePin,
disussionLock,
discussDelete
} from '@/api/discussion';
import debounce from 'lodash/debounce';
import AsideSetSkeleton from '@/views/Repo/components/AsideSetSkeleton.vue';
import DiscussFilterLabel from './components/DiscussFilterLabel.vue';
import DiscussFilterType from './components/DiscussFilterType.vue';
import LabelTag from '@/components/LabelTag/index.vue';
import { useRouter } from 'vue-router';
import type { IOption } from '@/components/FilterDropDown/types';
import { GModal } from '@/components/Setting/index';
defineOptions({
name: 'DiscussionSidebar'
});
// label接口数据结构
interface labelType {
color: string;
description: string;
id: number;
name: string;
text_color: string;
}
const props = withDefaults(defineProps<{
sourceType: 1 | 2; // 组织1项目2
discussDetail?: discussDetailType | undefined; // 讨论详情
access_level?: number; // 用户权限
userInfo?: userInfoType; // 用户信息
sourceId: string; // 项目|组织id
isDetail?: boolean; // 是否在详情页使用
}>(), {
sourceType: 1,
access_level: 0,
sourceId: '',
isDetail: false
});
const emit = defineEmits(['updateDiscuss', 'createDiscuss']);
const loading = ref(false);
const router = useRouter();
// 获取当前用户修改分类-权限
const typeHandleAccess = computed(() => {
if (props.access_level > 30) {
return 'admin';
} else if (props.access_level === 30) {
return 'developer';
} else return '';
});
// 获取当前讨论类型-全信息
const checkedType = ref<IOption[]>([]);
const allTypes = ref<IOption[]>([]);
// 获取所有内容分类
const fetchAllTypes = async () => {
checkedType.value = [{
value: props.discussDetail?.category?.id as string,
name: props.discussDetail?.category?.category_name as string,
label: props.discussDetail?.category?.category_name
}];
const res = await getAllTypes({ id: props.sourceId, source_type: props.sourceType });
if (!res.error) {
const resData = res?.data?.data;
let temp = null;
if (props.discussDetail?.category?.category_type === DISCUSS_FORMAT.VOTE) {
temp = resData.filter((e: categoryType) => e.category_type === DISCUSS_FORMAT.VOTE);
} else {
temp = typeHandleAccess.value === 'admin' ? resData.filter((e: categoryType) => e.category_type !== DISCUSS_FORMAT.VOTE) : resData.filter((e: categoryType) => e.category_type !== DISCUSS_FORMAT.VOTE && e.category_type !== DISCUSS_FORMAT.ANNOUNCE);
}
allTypes.value = temp.map((item: categoryType) => ({
value: item.id,
name: item.category_name,
label: item.category_name
}));
}
};
const onTypeOptionClick = async (data: IOption) => {
if (loading.value) return;
loading.value = true;
const res = await discussChangeType({ id: props.discussDetail?.id, category_id: data.value });
if (!res.error) {
checkedType.value = [data];
emit('updateDiscuss');
}
loading.value = false;
};
// 获取讨论里最近活跃用户
interface recentActiveUserType {
id: string;
photo: string;
username: string;
nickname: string;
}
const recentActiveUsers = ref<recentActiveUserType[]>([]);
const fetchRecentActiveUsers = async () => {
const res = await discussDetailRecentActiveUsers({ source_id: props.discussDetail?.id as string });
if (!res.error) {
const data = res?.data?.data;
recentActiveUsers.value = data.map((item: recentActiveUserType) => ({
...item
}));
}
};
defineExpose({
fetchRecentActiveUsers
});
// 获取已选中的标签-全信息
const checkedLabels = ref<IOption[]>([]);
const sourceLabels = ref<IOption[]>([]);
// 已登录用户获取标签列表
const fetchLabelList = async () => {
if (props.sourceType === 1) {
// const res = await orgLabelList({ project_id: props.sourceId });
// if (!res.error) {
// const resData = res?.data?.data?.content;
// if (resData.length > 0) {
// sourceLabels.value = resData.map((item:labelType) => (
// {
// value: item.id.toString(),
// name: item.name,
// label: item.name,
// color: item.color
// }
// ));
// }
// }
// TODO: 组织目前没label
return;
} else {
const res = await repoLabelList({ project_id: props.sourceId });
if (!res.error) {
const resData = res?.data?.data?.content;
if (resData.length > 0) {
sourceLabels.value = resData.map((item: labelType) => (
{
value: item.id.toString(),
name: item.name,
label: item.name,
color: item.color
}
));
}
}
}
};
// 获取 checkedLabels 初始值
const initialCheckedLabels = () => {
if (sourceLabels.value?.length > 0) {
if (props.isDetail) {
// 详情初始化label数据
if (props.discussDetail?.label && props.discussDetail.label?.length > 0) {
const $checkedLabels = sourceLabels.value.filter(e => props.discussDetail?.label?.includes(e.value as string));
checkedLabels.value = $checkedLabels;
} else {
checkedLabels.value = [];
}
} else {
// 新建页
checkedLabels.value = [];
}
} else {
checkedLabels.value = [];
}
};
const initialData = async () => {
if (props.discussDetail?.id) {
await fetchAllTypes();
fetchRecentActiveUsers();
}
await fetchLabelList();
initialCheckedLabels();
};
initialData();
const onLabelOptionClick = async (data: IOption) => {
if (!props.isDetail) {
// 新建讨论页
let temp = checkedLabels.value.length > 0 ? checkedLabels.value : [];
const index = temp?.findIndex((e) => e.value === data?.value);
if (!data) {
// 无数据 点击了未设置,清空
temp = [];
} else if (index > -1) {
// 删除了该 label
temp.splice(index, 1);
} else {
// 新增了一个 label
temp.push(data);
}
checkedLabels.value = temp;
emit('createDiscuss', checkedLabels.value.length > 0 ? checkedLabels.value.map(item => item.value) : []);
} else {
// 详情页
if (loading.value) return;
let tempIds = checkedLabels.value.map(item => item.value) || [];
const index = tempIds?.findIndex((e) => e === data?.value);
if (!data) {
// 无数据 点击了未设置,清空
tempIds = [];
} else if (index > -1) {
// 删除了该 label
tempIds.splice(index, 1);
} else {
// 新增了一个 label
tempIds.push(data.value as string);
}
loading.value = true;
checkedLabels.value = tempIds.length > 0 ? sourceLabels.value.filter(e => tempIds.includes(e.value as string)) : [];
await discussChangeRelatedLabels({ discuss_id: props.discussDetail?.id as string, label: tempIds as string[] });
loading.value = false;
}
};
const showHandleBtns = computed(() => {
if (props.isDetail && (props.access_level > 30 || (props.userInfo?.id && props.discussDetail?.created_by === props.userInfo.id))) { return true; } else return false;
});
const lockModalVisible = ref(false);
const lockLoading = ref(false);
const debounceHandleLock = debounce(() => handleLock(), 600);
const handleLock = async () => {
const res = await disussionLock({
id: props.discussDetail?.id,
is_lock: props.discussDetail?.is_lock === 1 ? 0 : 1
});
if (!res.error) {
Message.success(props.discussDetail?.is_lock === 1 ? '已解锁' : '已锁定');
emit('updateDiscuss');
lockModalVisible.value = false;
}
};
const deleteModalVisible = ref(false);
const deleteLoading = ref(false);
const handleDelete = async () => {
const res = await discussDelete({ id: props.discussDetail?.id as string });
if (!res.error) {
deleteModalVisible.value = false;
Message.success('已删除讨论');
router.push({
name: `${props.sourceType === 1 ? 'org' : 'repo'}Discussion`
});
}
};
const handlePin = async () => {
const res = await discussPin({
id: props.discussDetail?.id as string,
is_pin: props.discussDetail?.is_pin === 1 ? 0 : 1
});
if (!res.error) {
Message.success(props.discussDetail?.is_pin === 0 ? '已置顶' : '已取消置顶');
emit('updateDiscuss');
}
};
const handleTypePin = async () => {
const res = await disussTypePin({
id: props.discussDetail?.id as string,
is_pin: props.discussDetail?.is_category_pin === 1 ? 0 : 1
});
if (!res.error) {
Message.success(props.discussDetail?.is_category_pin === 0 ? '已分类置顶' : '已取消分类置顶');
emit('updateDiscuss');
}
};
</script>
<template>
<div class="container">
<div v-if="isDetail" class="sidebar-type mb-8">
<AsideSetSkeleton title="讨论类型" :visibleDivider="false" :visible-set="!!typeHandleAccess">
<p class="sidber-subtitle" v-if="checkedType?.length > 0">
{{ checkedType[0]?.name }}
</p>
<template #menu>
<DiscussFilterType @on-option-click="onTypeOptionClick" :option-list="allTypes" :selected-list="checkedType"
:showEmptyOption="false" :submit-ing="loading">
</DiscussFilterType>
</template>
</AsideSetSkeleton>
</div>
<!-- TODO: 组织有 label 移除v-if判断 -->
<div v-if="sourceType === 2" class="sidebar-label mb-4">
<AsideSetSkeleton title="Label" :visibleDivider="false" :visible-set="access_level >= 30">
<span class="sidebar-subtitle" v-if="checkedLabels.length === 0">暂未设置 Label</span>&nbsp;
<div v-else class="flex flex-wrap gap-2">
<LabelTag v-for="item in checkedLabels" :key="item.value" :color="item.color" :name="item.label" class="mr-1">
</LabelTag>
</div>
<template #menu>
<DiscussFilterLabel @on-option-click="onLabelOptionClick" :option-list="sourceLabels"
:selected-list="checkedLabels" :submit-ing="loading"></DiscussFilterLabel>
</template>
</AsideSetSkeleton>
</div>
<div class="sidebar-participant mb-8" v-if="isDetail && recentActiveUsers.length > 0">
<p class="mb-2">参与者</p>
<div class="flex flex-wrap">
<GLink v-for="item in recentActiveUsers" :key="item.id"
:to="{ name: 'homepage', params: { namespace: item.username } }" target="_blank" class="mr-1 participant-link">
<d-tooltip :content="item.username">
<GAvatar :src="item.photo" :name="item.username" class="participant-avatar" :width="32" :height="32">
</GAvatar>
</d-tooltip>
</GLink>
</div>
</div>
<div class="sidebar-handle-btns" v-if="showHandleBtns">
<!-- <d-button variant="text">订阅讨论动态</d-button>
<d-button variant="text">取消订阅讨论动态</d-button> -->
<div class="mt-2 sidebar-subtitle" v-if="access_level > 30">
<d-button variant="text" v-if="discussDetail?.is_lock === 0" @click="lockModalVisible = true">
<Icon name="gt-lock" size="14px" class="mr-1" style="color:inherit"></Icon>锁定讨论
</d-button>
<d-button variant="text" v-else @click="handleLock">
<Icon name="gt-openlock" size="14px" class="mr-1" style="color:inherit"></Icon>解锁讨论
</d-button>
</div>
<div class="mt-2 sidebar-subtitle" v-if="access_level > 30">
<d-button variant="text" @click="handlePin" v-if="discussDetail?.is_pin === 0">
<Icon name="gt-to-top" size="16px" class="mr-1" style="color:inherit"></Icon>置顶讨论
</d-button>
<d-button variant="text" @click="handlePin" v-else>
<Icon name="gt-to-bottom" size="16px" class="mr-1" style="color:inherit"></Icon>取消置顶
</d-button>
</div>
<div class="mt-2 sidebar-subtitle" v-if="access_level > 30">
<d-button variant="text" @click="handleTypePin" v-if="discussDetail?.is_category_pin === 0">
<Icon name="gt-to-top" size="16px" class="mr-1" style="color:inherit"></Icon>置顶至当前分类
</d-button>
<d-button variant="text" v-else @click="handleTypePin">
<Icon name="gt-to-bottom" size="16px" class="mr-1" style="color:inherit"></Icon>取消当前分类置顶
</d-button>
</div>
<div class="mt-2 sidebar-subtitle">
<d-button variant="text" @click="deleteModalVisible = true">
<Icon name="gt-delete" size="16px" class="mr-1" style="color:inherit"></Icon>删除讨论
</d-button>
</div>
</div>
<GModal v-model="lockModalVisible" showWarnIcon @confirm="handleLock"
:title="discussDetail?.is_lock === 1 ? '解锁讨论' : '锁定讨论'">
<p v-if="discussDetail?.is_lock === 1">确定解锁讨论?</p>
<p v-else>讨论锁定后,不允许非成员用户发表评论及投票。</p>
</GModal>
<GModal v-model="deleteModalVisible" showWarnIcon @confirm="handleDelete" confirmColor="danger" title="删除讨论">
<p>你确定要删除该讨论吗?</p>
</GModal>
</div>
</template>
<style scoped lang="scss">
.sidber-title {
font-size: 14px;
font-weight: 500;
color: #2D2D2E;
line-height: 20px;
}
.sidebar-subtitle {
font-size: 14px;
font-weight: 400;
color: #707A87;
line-height: 20px;
:deep(.button-content) {
font-size: 14px;
font-weight: 400;
color: #707A87;
line-height: 20px;
}
}
.sidebar-handle-btns {
border-top: 1px solid var(--color-border);
padding-top: 16px;
}
</style>