搜索结果列表页面开发
This commit is contained in:
@@ -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 ? '' : ` · 已关闭` }}</span>
|
||||
<span
|
||||
v-if="category.category_type === DISCUSS_FORMAT.QANDA && is_answered === 1"
|
||||
class="bottom-answered"
|
||||
> · <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"
|
||||
> · <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>
|
||||
444
src/components/Discussion/Module/Create/index.vue
Normal file
444
src/components/Discussion/Module/Create/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"> ·
|
||||
<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>
|
||||
@@ -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> ·
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
·
|
||||
<span v-if="discussDetail?.closed_date && discussDetail?.is_closed === 1">
|
||||
于 <Time :time="discussDetail?.closed_date"></Time> 关闭
|
||||
</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>
|
||||
@@ -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>
|
||||
337
src/components/Discussion/Module/Detail/index.vue
Normal file
337
src/components/Discussion/Module/Detail/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
954
src/components/Discussion/Module/List/index.vue
Normal file
954
src/components/Discussion/Module/List/index.vue
Normal 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; // 是否锁定:0:否;1:是
|
||||
is_closed?: string; // 是否关闭:0:否;1:是
|
||||
is_answered?: string; // 否已采纳答案:0:否;1:是
|
||||
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);
|
||||
}
|
||||
};
|
||||
// 监听 label,sort,filter 变更
|
||||
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>
|
||||
990
src/components/Discussion/Module/List/orgDiscussion.vue
Normal file
990
src/components/Discussion/Module/List/orgDiscussion.vue
Normal 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; // 是否锁定:0:否;1:是
|
||||
is_closed?: string; // 是否关闭:0:否;1:是
|
||||
is_answered?: string; // 否已采纳答案:0:否;1:是
|
||||
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);
|
||||
}
|
||||
};
|
||||
// 监听 label,sort,filter 变更
|
||||
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>
|
||||
244
src/components/Discussion/Module/SectionCreate/index.vue
Normal file
244
src/components/Discussion/Module/SectionCreate/index.vue
Normal 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>
|
||||
154
src/components/Discussion/Module/Select/index.vue
Normal file
154
src/components/Discussion/Module/Select/index.vue
Normal 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>
|
||||
301
src/components/Discussion/Module/TypeCreate/index.vue
Normal file
301
src/components/Discussion/Module/TypeCreate/index.vue
Normal 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>
|
||||
310
src/components/Discussion/Module/TypeManage/index.vue
Normal file
310
src/components/Discussion/Module/TypeManage/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
410
src/components/Discussion/Module/components/Sidebar/index.vue
Normal file
410
src/components/Discussion/Module/components/Sidebar/index.vue
Normal 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>
|
||||
<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>
|
||||
Reference in New Issue
Block a user