feat(公共): 富文本组件评论Mention功能扩展

This commit is contained in:
xinxin.wu 2023-12-28 15:48:44 +08:00 committed by 刘瑞斌
parent e56239cb5a
commit 15b744be34
11 changed files with 292 additions and 13 deletions

View File

@ -55,6 +55,22 @@ export default defineConfig({
find: 'vue', find: 'vue',
replacement: 'vue/dist/vue.esm-bundler.js', // compile template replacement: 'vue/dist/vue.esm-bundler.js', // compile template
}, },
{
find: '@tiptap/pm/state',
replacement: '@halo-dev/richtext-editor',
},
{
find: '@tiptap/pm/model',
replacement: '@halo-dev/richtext-editor',
},
{
find: '@tiptap/core',
replacement: '@halo-dev/richtext-editor',
},
{
find: '@tiptap/pm/view',
replacement: '@halo-dev/richtext-editor',
},
], ],
extensions: ['.ts', '.js'], extensions: ['.ts', '.js'],
}, },

View File

@ -40,7 +40,10 @@
"@arco-design/web-vue": "^2.53.3", "@arco-design/web-vue": "^2.53.3",
"@arco-themes/vue-ms-theme-default": "^0.0.30", "@arco-themes/vue-ms-theme-default": "^0.0.30",
"@form-create/arco-design": "^3.1.23", "@form-create/arco-design": "^3.1.23",
"@halo-dev/richtext-editor": "0.0.0-alpha.32", "@halo-dev/richtext-editor": "0.0.0-alpha.33",
"@tiptap/extension-mention": "^2.1.13",
"@tiptap/suggestion": "^2.1.13",
"@tiptap/vue-3": "^2.1.13",
"@types/color": "^3.0.4", "@types/color": "^3.0.4",
"@vueuse/core": "^10.4.1", "@vueuse/core": "^10.4.1",
"ace-builds": "^1.24.2", "ace-builds": "^1.24.2",
@ -60,6 +63,7 @@
"query-string": "^8.1.0", "query-string": "^8.1.0",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"sortablejs": "^1.15.0", "sortablejs": "^1.15.0",
"tippy.js": "^6.3.7",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-dompurify-html": "^4.1.4", "vue-dompurify-html": "^4.1.4",
"vue-draggable-plus": "^0.2.7", "vue-draggable-plus": "^0.2.7",

View File

@ -31,6 +31,7 @@ import {
GetFileIsUpdateUrl, GetFileIsUpdateUrl,
GetRecycleCaseListUrl, GetRecycleCaseListUrl,
GetRecycleCaseModulesCountUrl, GetRecycleCaseModulesCountUrl,
GetReviewerListUrl,
GetSearchCustomFieldsUrl, GetSearchCustomFieldsUrl,
getTransferTreeUrl, getTransferTreeUrl,
GetTrashCaseModuleTreeUrl, GetTrashCaseModuleTreeUrl,
@ -64,6 +65,7 @@ import type {
UpdateModule, UpdateModule,
} from '@/models/caseManagement/featureCase'; } from '@/models/caseManagement/featureCase';
import type { CommonList, MoveModules, TableQueryParams } from '@/models/common'; import type { CommonList, MoveModules, TableQueryParams } from '@/models/common';
import type { UserListItem } from '@/models/setting/user';
// 获取模块树 // 获取模块树
export function getCaseModuleTree(projectId: string) { export function getCaseModuleTree(projectId: string) {
return MSR.get<ModulesTreeType[]>({ url: `${GetCaseModuleTreeUrl}/${projectId}` }); return MSR.get<ModulesTreeType[]>({ url: `${GetCaseModuleTreeUrl}/${projectId}` });
@ -250,17 +252,17 @@ export function getCommentList(caseId: string) {
} }
// 创建评论 // 创建评论
export function CreateCommentList(data: CreateOrUpdate) { export function createCommentList(data: CreateOrUpdate) {
return MSR.post({ url: CreateCommentItemUrl, data }); return MSR.post({ url: CreateCommentItemUrl, data });
} }
// 创建评论 // 创建评论
export function UpdateCommentList(data: CreateOrUpdate) { export function updateCommentList(data: CreateOrUpdate) {
return MSR.post({ url: UpdateCommentItemUrl, data }); return MSR.post({ url: UpdateCommentItemUrl, data });
} }
// 删除评论 // 删除评论
export function DeleteCommentList(commentId: string) { export function deleteCommentList(commentId: string) {
return MSR.post({ url: `${DeleteCommentItemUrl}/${commentId}` }); return MSR.post({ url: `${DeleteCommentItemUrl}/${commentId}` });
} }
@ -269,4 +271,9 @@ export function getDetailCaseReviewPage(data: TableQueryParams) {
return MSR.post<CommonList<CaseManagementTable>>({ url: GetDetailCaseReviewUrl, data }); return MSR.post<CommonList<CaseManagementTable>>({ url: GetDetailCaseReviewUrl, data });
} }
// 获取评审人列表
export function getReviewerList(projectId: string, keyword: string) {
return MSR.get<UserListItem[]>({ url: `${GetReviewerListUrl}/${projectId}`, params: { keyword } });
}
export default {}; export default {};

View File

@ -100,5 +100,7 @@ export const UpdateCommentItemUrl = '/functional/case/comment/update';
export const DeleteCommentItemUrl = '/functional/case/comment/delete'; export const DeleteCommentItemUrl = '/functional/case/comment/delete';
// 获取详情用例评审 // 获取详情用例评审
export const GetDetailCaseReviewUrl = '/functional/case/review/page'; export const GetDetailCaseReviewUrl = '/functional/case/review/page';
// 获取有权限的评审人
export const GetReviewerListUrl = '/case/review/user-option';
export default {}; export default {};

View File

@ -15,6 +15,7 @@
import useLocale from '@/locale/useLocale'; import useLocale from '@/locale/useLocale';
import '@halo-dev/richtext-editor/dist/style.css'; import '@halo-dev/richtext-editor/dist/style.css';
import suggestion from './extensions/mention/suggestion';
import { import {
ExtensionAudio, ExtensionAudio,
ExtensionBlockquote, ExtensionBlockquote,
@ -58,6 +59,7 @@
RichTextEditor, RichTextEditor,
useEditor, useEditor,
} from '@halo-dev/richtext-editor'; } from '@halo-dev/richtext-editor';
import Mention from '@tiptap/extension-mention';
const props = defineProps<{ const props = defineProps<{
modelValue: string; modelValue: string;
@ -79,6 +81,7 @@
class: 'dropcursor', class: 'dropcursor',
color: 'skyblue', color: 'skyblue',
}), }),
ExtensionCommands,
ExtensionGapcursor, ExtensionGapcursor,
ExtensionHardBreak, ExtensionHardBreak,
ExtensionHeading, ExtensionHeading,
@ -113,7 +116,6 @@
ExtensionHighlight, ExtensionHighlight,
ExtensionVideo, ExtensionVideo,
ExtensionAudio, ExtensionAudio,
ExtensionCommands,
ExtensionCodeBlock.configure({ ExtensionCodeBlock.configure({
lowlight, lowlight,
}), }),
@ -126,6 +128,12 @@
ExtensionColumn, ExtensionColumn,
ExtensionNodeSelected, ExtensionNodeSelected,
ExtensionTrailingNode, ExtensionTrailingNode,
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion,
}),
], ],
onUpdate: () => { onUpdate: () => {
content.value = `${editor.value?.getHTML()}`; content.value = `${editor.value?.getHTML()}`;
@ -167,4 +175,26 @@
} }
} }
} }
:deep(.editor-header) {
svg {
color: var(--color-text-3) !important;
}
}
//
:deep(.editor-header + div > div) {
&::-webkit-scrollbar {
width: 6px !important;
height: 4px !important;
}
&::-webkit-scrollbar-thumb {
border-radius: 8px !important;
background: var(--color-text-input-border) !important;
}
&::-webkit-scrollbar-thumb:hover {
background: #a1a7b0 !important;
}
&&::-webkit-scrollbar-track {
@apply bg-white !important;
}
}
</style> </style>

View File

@ -0,0 +1,110 @@
<template>
<div class="items">
<template v-if="items.length">
<button
v-for="(item, index) in items"
:key="index"
class="item one-line-text"
:class="{ 'is-selected': index === selectedIndex }"
@click="selectItem(index)"
>
{{ item.name }}
</button>
</template>
<div v-else class="item"> No result </div>
</div>
</template>
<script lang="ts" setup>
import type { UserListItem } from '@/models/setting/user';
const props = defineProps<{
items: UserListItem[];
command: (item: UserListItem) => void;
}>();
const selectedIndex = ref(0);
watch(
() => props.items,
() => {
selectedIndex.value = 0;
}
);
function handleKeyUp() {
selectedIndex.value = (selectedIndex.value + props.items.length - 1) % props.items.length;
}
function handleKeyDown() {
selectedIndex.value = (selectedIndex.value + 1) % props.items.length;
}
function handleSelectItem(index: number) {
const item = props.items[index];
if (item) {
props.command({ id: `${item.name}(${item.email})` } as any);
}
}
function handleKeyEnter() {
handleSelectItem(selectedIndex.value);
}
function onKeyDown({ event }: { event: KeyboardEvent }) {
if (event.key === 'ArrowUp' || (event.key === 'k' && event.ctrlKey)) {
handleKeyUp();
return true;
}
if (event.key === 'ArrowDown' || (event.key === 'j' && event.ctrlKey)) {
handleKeyDown();
return true;
}
if (event.key === 'Enter') {
handleKeyEnter();
return true;
}
return false;
}
function selectItem(index: any) {
const item = props.items[index];
if (item) {
props.command({ id: `${item.name}(${item.email})` } as any);
}
}
defineExpose({
onKeyDown,
});
</script>
<style lang="scss">
.items {
position: relative;
overflow: hidden;
padding: 8px;
border-radius: 4px;
color: block;
box-shadow: 0 0 0 1px rgba(0 0 0 / 5%), 0 10px 20px rgba(0 0 0 / 10%);
@apply bg-white;
}
.item {
display: block;
margin: 0;
padding: 4px 8px;
width: 100%;
border-radius: 4px;
text-align: left;
background: transparent;
&.is-selected {
background: var(--color-bg-3);
}
&:hover {
background: var(--color-bg-3);
}
}
</style>

View File

@ -0,0 +1,88 @@
import MentionList from './MentionList.vue';
import { getReviewerList } from '@/api/modules/case-management/featureCase';
import useAppStore from '@/store/modules/app';
import type { UserListItem } from '@/models/setting/user';
import { VueRenderer } from '@halo-dev/richtext-editor';
import type { Instance } from 'tippy.js';
import tippy from 'tippy.js';
const appStore = useAppStore();
const projectMember = ref<UserListItem[]>([]);
async function getMembersToolBar(query: string) {
const params = {
projectId: appStore.currentProjectId,
keyword: query,
};
try {
projectMember.value = await getReviewerList(params.projectId, params.keyword);
} catch (error) {
console.log(error);
}
}
export default {
items: async ({ query }: any) => {
await getMembersToolBar(query);
return projectMember.value.filter((item: UserListItem) => item.name.toLowerCase().startsWith(query.toLowerCase()));
},
render: () => {
let component: VueRenderer;
let popup: Instance[];
return {
onStart: (props: Record<string, any>) => {
debugger;
component = new VueRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
});
},
onUpdate(props: Record<string, any>) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props: Record<string, any>) {
if (props.event.key === 'Escape') {
popup[0].hide();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
popup[0].destroy();
component.destroy();
},
};
},
};

View File

@ -79,4 +79,5 @@ export default {
'common.new': 'New', 'common.new': 'New',
'common.newSuccess': 'Added successfully', 'common.newSuccess': 'Added successfully',
'common.publish': 'Publish', 'common.publish': 'Publish',
'common.publishSuccessfully': 'Published successfully',
}; };

View File

@ -81,4 +81,5 @@ export default {
'common.new': '新增', 'common.new': '新增',
'common.newSuccess': '新增成功', 'common.newSuccess': '新增成功',
'common.publish': '发布', 'common.publish': '发布',
'common.publishSuccessfully': '发布成功',
}; };

View File

@ -209,7 +209,7 @@
import TabTestPlan from './tabContent/tabTestPlan.vue'; import TabTestPlan from './tabContent/tabTestPlan.vue';
import { import {
CreateCommentList, createCommentList,
deleteCaseRequest, deleteCaseRequest,
followerCaseRequest, followerCaseRequest,
getCaseDetail, getCaseDetail,
@ -479,16 +479,23 @@
const content = ref(''); const content = ref('');
async function publishHandler(currentContent: string) { async function publishHandler(currentContent: string) {
const regex = /data-id="([^"]*)"/g;
const matchesNotifier = currentContent.match(regex);
let notifiers = '';
if (matchesNotifier?.length) {
notifiers = matchesNotifier.map((match) => match.replace('data-id="', '').replace('"', '')).join(';');
}
try { try {
const params = { const params = {
caseId: detailInfo.value.id, caseId: detailInfo.value.id,
notifier: '', notifier: notifiers,
replyUser: '', replyUser: '',
parentId: '', parentId: '',
content: currentContent, content: currentContent,
event: 'COMMENT', // (: COMMENT; @: AT; /@: REPLAY;) event: notifiers ? 'AT' : 'COMMENT', // (: COMMENT; @: AT; /@: REPLAY;)
}; };
await CreateCommentList(params); await createCommentList(params);
Message.success(t('common.publishSuccessfully'));
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }

View File

@ -9,7 +9,15 @@
</a-radio-group> </a-radio-group>
</div> </div>
</div> </div>
<!-- <MsComment :comment-list="commentList" /> --> <div>
<!-- TODO -->
<!-- <MsComment
:current-user-id="currentUserId"
:comment-list="commentList"
@update-or-add="handleUpdateOrAdd"
@delete="handleDelete"
/> -->
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -34,9 +42,6 @@
const commentList = ref<CommentItem[]>([]); const commentList = ref<CommentItem[]>([]);
//
function publishHandler() {}
// //
async function initCommentList() { async function initCommentList() {
try { try {
@ -46,6 +51,14 @@
} }
} }
const currentUserId = ref('');
//
function handleUpdateOrAdd() {}
//
function handleDelete() {}
onBeforeMount(() => { onBeforeMount(() => {
initCommentList(); initCommentList();
}); });