feat(ai): add three-level FAQ category management
- add FAQ category table/sql migration and initialize ddl updates - add category service/controller with validation, default category rules, and sorting - support faq item category binding and category-based filtering (include descendants) - redesign FAQ page with category tree actions and UI polish
This commit is contained in:
@@ -81,6 +81,28 @@
|
||||
},
|
||||
"faq": {
|
||||
"faqList": "FAQ List",
|
||||
"allFaq": "All FAQ",
|
||||
"category": "Category",
|
||||
"categoryPlaceholder": "Select a category (empty means default category)",
|
||||
"categoryTree": "Category Tree",
|
||||
"categoryPath": "Category Path",
|
||||
"categoryName": "Category Name",
|
||||
"categoryNamePlaceholder": "Please input category name",
|
||||
"parentCategory": "Parent Category",
|
||||
"parentCategoryPlaceholder": "Please select parent category",
|
||||
"rootCategory": "Root Category",
|
||||
"sortNo": "Sort No",
|
||||
"addCategory": "Add Category",
|
||||
"addSiblingCategory": "Add Sibling Category",
|
||||
"addChildCategory": "Add Child Category",
|
||||
"moveUp": "Move Up",
|
||||
"moveDown": "Move Down",
|
||||
"promote": "Promote",
|
||||
"demote": "Demote",
|
||||
"editCategory": "Edit Category",
|
||||
"maxLevelTip": "Maximum 3 levels are supported",
|
||||
"defaultCategoryChildForbidden": "Default category cannot have child categories",
|
||||
"defaultCategoryDeleteForbidden": "Default category cannot be deleted",
|
||||
"question": "Question",
|
||||
"answer": "Answer",
|
||||
"questionPlaceholder": "Please input question",
|
||||
|
||||
@@ -81,6 +81,28 @@
|
||||
},
|
||||
"faq": {
|
||||
"faqList": "FAQ列表",
|
||||
"allFaq": "全部FAQ",
|
||||
"category": "分类",
|
||||
"categoryPlaceholder": "请选择分类(不选则自动归入默认分类)",
|
||||
"categoryTree": "分类树",
|
||||
"categoryPath": "分类路径",
|
||||
"categoryName": "分类名称",
|
||||
"categoryNamePlaceholder": "请输入分类名称",
|
||||
"parentCategory": "父分类",
|
||||
"parentCategoryPlaceholder": "请选择父分类",
|
||||
"rootCategory": "根分类",
|
||||
"sortNo": "排序",
|
||||
"addCategory": "新增分类",
|
||||
"addSiblingCategory": "新增同级分类",
|
||||
"addChildCategory": "新增子分类",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"promote": "升级",
|
||||
"demote": "降级",
|
||||
"editCategory": "编辑分类",
|
||||
"maxLevelTip": "最多支持三级分类",
|
||||
"defaultCategoryChildForbidden": "默认分类不允许创建子分类",
|
||||
"defaultCategoryDeleteForbidden": "默认分类不允许删除",
|
||||
"question": "问题",
|
||||
"answer": "答案",
|
||||
"questionPlaceholder": "请输入问题",
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, ref} from 'vue';
|
||||
import {useRoute, useRouter} from 'vue-router';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import {$t} from '@easyflow/locales';
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import {ArrowLeft, Plus} from '@element-plus/icons-vue';
|
||||
import {ElIcon, ElImage} from 'element-plus';
|
||||
import { ArrowLeft, Plus } from '@element-plus/icons-vue';
|
||||
import { ElIcon, ElImage } from 'element-plus';
|
||||
|
||||
import {api} from '#/api/request';
|
||||
import { api } from '#/api/request';
|
||||
import bookIcon from '#/assets/ai/knowledge/book.svg';
|
||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import PageSide from '#/components/page/PageSide.vue';
|
||||
import ChunkDocumentTable from '#/views/ai/documentCollection/ChunkDocumentTable.vue';
|
||||
import DocumentCollectionDataConfig
|
||||
from '#/views/ai/documentCollection/DocumentCollectionDataConfig.vue';
|
||||
import DocumentCollectionDataConfig from '#/views/ai/documentCollection/DocumentCollectionDataConfig.vue';
|
||||
import DocumentTable from '#/views/ai/documentCollection/DocumentTable.vue';
|
||||
import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
|
||||
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
||||
@@ -27,11 +25,10 @@ const knowledgeId = ref<string>((route.query.id as string) || '');
|
||||
const activeMenu = ref<string>((route.query.activeMenu as string) || '');
|
||||
const knowledgeInfo = ref<any>({});
|
||||
const selectedCategory = ref('');
|
||||
const defaultSelectedMenu = ref('');
|
||||
|
||||
const resolveDefaultMenu = (collectionType: string, menuKey: string) => {
|
||||
const faqMenus = new Set(['faqList', 'knowledgeSearch', 'config']);
|
||||
const documentMenus = new Set(['documentList', 'knowledgeSearch', 'config']);
|
||||
const faqMenus = new Set(['config', 'faqList', 'knowledgeSearch']);
|
||||
const documentMenus = new Set(['config', 'documentList', 'knowledgeSearch']);
|
||||
const fallbackMenu = collectionType === 'FAQ' ? 'faqList' : 'documentList';
|
||||
|
||||
if (!menuKey) {
|
||||
@@ -53,7 +50,6 @@ const getKnowledge = () => {
|
||||
res.data.collectionType || 'DOCUMENT',
|
||||
activeMenu.value,
|
||||
);
|
||||
defaultSelectedMenu.value = initialMenu;
|
||||
selectedCategory.value = initialMenu;
|
||||
}
|
||||
});
|
||||
@@ -64,18 +60,26 @@ onMounted(() => {
|
||||
const back = () => {
|
||||
router.push({ path: '/ai/documentCollection' });
|
||||
};
|
||||
const isFaqCollection = computed(() => knowledgeInfo.value.collectionType === 'FAQ');
|
||||
const isFaqCollection = computed(
|
||||
() => knowledgeInfo.value.collectionType === 'FAQ',
|
||||
);
|
||||
const categoryData = computed(() => {
|
||||
if (isFaqCollection.value) {
|
||||
return [
|
||||
{ key: 'faqList', name: $t('documentCollection.faq.faqList') },
|
||||
{ key: 'knowledgeSearch', name: $t('documentCollection.knowledgeRetrieval') },
|
||||
{
|
||||
key: 'knowledgeSearch',
|
||||
name: $t('documentCollection.knowledgeRetrieval'),
|
||||
},
|
||||
{ key: 'config', name: $t('documentCollection.config') },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ key: 'documentList', name: $t('documentCollection.documentList') },
|
||||
{ key: 'knowledgeSearch', name: $t('documentCollection.knowledgeRetrieval') },
|
||||
{
|
||||
key: 'knowledgeSearch',
|
||||
name: $t('documentCollection.knowledgeRetrieval'),
|
||||
},
|
||||
{ key: 'config', name: $t('documentCollection.config') },
|
||||
];
|
||||
});
|
||||
@@ -106,8 +110,8 @@ const handleButtonClick = (event: any) => {
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleCategoryClick = (category: any) => {
|
||||
selectedCategory.value = category.key;
|
||||
const handleCategoryClick = (menuKey: string) => {
|
||||
selectedCategory.value = menuKey;
|
||||
viewDocVisible.value = false;
|
||||
};
|
||||
const viewDocVisible = ref(false);
|
||||
@@ -138,17 +142,19 @@ const backDoc = () => {
|
||||
{{ knowledgeInfo.description || '' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-top-menu">
|
||||
<button
|
||||
v-for="item in categoryData"
|
||||
:key="item.key"
|
||||
class="doc-menu-item"
|
||||
:class="{ active: selectedCategory === item.key }"
|
||||
@click="handleCategoryClick(item.key)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-content">
|
||||
<div>
|
||||
<PageSide
|
||||
label-key="name"
|
||||
value-key="key"
|
||||
:menus="categoryData"
|
||||
:default-selected="defaultSelectedMenu"
|
||||
@change="handleCategoryClick"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="doc-table-content menu-container border border-[var(--el-border-color)]"
|
||||
>
|
||||
@@ -230,11 +236,53 @@ const backDoc = () => {
|
||||
}
|
||||
.doc-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
.doc-top-menu {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
.doc-menu-item {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
padding: 7px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
color 0.2s,
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
.doc-menu-item:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.doc-menu-item.active {
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
box-shadow: 0 2px 10px rgb(64 158 255 / 16%);
|
||||
font-weight: 600;
|
||||
}
|
||||
.doc-menu-item:focus-visible {
|
||||
outline: 2px solid var(--el-color-primary-light-5);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.doc-table {
|
||||
background-color: var(--el-bg-color);
|
||||
@@ -260,6 +308,7 @@ const backDoc = () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 160px;
|
||||
}
|
||||
.title {
|
||||
font-weight: 500;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElTreeSelect,
|
||||
} from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
data: {
|
||||
type: Object as any,
|
||||
default: () => ({}),
|
||||
},
|
||||
parentOptions: {
|
||||
type: Array as any,
|
||||
default: () => [],
|
||||
},
|
||||
disableParent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'submit']);
|
||||
|
||||
const formRef = ref();
|
||||
const form = ref<any>({
|
||||
id: '',
|
||||
collectionId: '',
|
||||
categoryName: '',
|
||||
parentId: '0',
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(newData: any) => {
|
||||
form.value = {
|
||||
id: newData?.id || '',
|
||||
collectionId: newData?.collectionId || '',
|
||||
categoryName: newData?.categoryName || '',
|
||||
parentId:
|
||||
newData?.parentId === undefined || newData?.parentId === null
|
||||
? '0'
|
||||
: String(newData.parentId),
|
||||
};
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
const rules = computed(() => ({
|
||||
categoryName: [
|
||||
{
|
||||
required: true,
|
||||
message: $t('message.required'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
parentId: [
|
||||
{
|
||||
required: true,
|
||||
message: $t('message.required'),
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const closeDialog = () => {
|
||||
emit('update:modelValue', false);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
formRef.value?.validate((valid: boolean) => {
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
emit('submit', {
|
||||
...form.value,
|
||||
categoryName: form.value.categoryName.trim(),
|
||||
parentId: String(form.value.parentId || '0'),
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="modelValue"
|
||||
:title="title"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
@close="closeDialog"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<ElFormItem
|
||||
:label="$t('documentCollection.faq.categoryName')"
|
||||
prop="categoryName"
|
||||
>
|
||||
<ElInput
|
||||
v-model="form.categoryName"
|
||||
:placeholder="$t('documentCollection.faq.categoryNamePlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
:label="$t('documentCollection.faq.parentCategory')"
|
||||
prop="parentId"
|
||||
>
|
||||
<ElTreeSelect
|
||||
v-model="form.parentId"
|
||||
:disabled="disableParent"
|
||||
check-strictly
|
||||
clearable
|
||||
:data="parentOptions"
|
||||
node-key="id"
|
||||
:props="{ label: 'categoryName', children: 'children' }"
|
||||
:placeholder="$t('documentCollection.faq.parentCategoryPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
<ElButton @click="closeDialog">{{ $t('button.cancel') }}</ElButton>
|
||||
<ElButton type="primary" @click="handleSubmit">
|
||||
{{ $t('button.save') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
@@ -1,13 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type {IDomEditor} from '@wangeditor/editor';
|
||||
import type { IDomEditor } from '@wangeditor/editor';
|
||||
|
||||
import {onBeforeUnmount, ref, shallowRef, watch} from 'vue';
|
||||
import { nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
|
||||
|
||||
import {$t} from '@easyflow/locales';
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import {ElButton, ElDialog, ElForm, ElFormItem, ElInput, ElMessage} from 'element-plus';
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
|
||||
import DOMPurify from 'dompurify';
|
||||
import {Editor, Toolbar} from '@wangeditor/editor-for-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElTreeSelect,
|
||||
} from 'element-plus';
|
||||
|
||||
import '@wangeditor/editor/dist/css/style.css';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -19,6 +28,10 @@ const props = defineProps({
|
||||
type: Object as any,
|
||||
default: () => ({}),
|
||||
},
|
||||
categoryOptions: {
|
||||
type: Array as any,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'update:modelValue']);
|
||||
@@ -27,6 +40,7 @@ const editorRef = shallowRef<IDomEditor | null>(null);
|
||||
const form = ref<any>({
|
||||
id: '',
|
||||
collectionId: '',
|
||||
categoryId: '',
|
||||
question: '',
|
||||
answerHtml: '',
|
||||
orderNo: 0,
|
||||
@@ -38,6 +52,10 @@ watch(
|
||||
form.value = {
|
||||
id: newData?.id || '',
|
||||
collectionId: newData?.collectionId || '',
|
||||
categoryId:
|
||||
newData?.categoryId === undefined || newData?.categoryId === null
|
||||
? ''
|
||||
: String(newData.categoryId),
|
||||
question: newData?.question || '',
|
||||
answerHtml: newData?.answerHtml || '',
|
||||
orderNo: newData?.orderNo ?? 0,
|
||||
@@ -66,6 +84,30 @@ const handleEditorCreated = (editor: IDomEditor) => {
|
||||
editorRef.value = editor;
|
||||
};
|
||||
|
||||
const focusAnswerEditor = () => {
|
||||
const editor = editorRef.value as any;
|
||||
if (editor && typeof editor.focus === 'function') {
|
||||
try {
|
||||
editor.focus(true);
|
||||
} catch {
|
||||
editor.focus();
|
||||
}
|
||||
}
|
||||
nextTick(() => {
|
||||
const editableEl = document.querySelector(
|
||||
'.faq-edit-dialog .w-e-text-container [contenteditable="true"]',
|
||||
) as HTMLElement | null;
|
||||
editableEl?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const handleQuestionKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Tab' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
focusAnswerEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
emit('update:modelValue', false);
|
||||
};
|
||||
@@ -76,7 +118,7 @@ const handleSubmit = () => {
|
||||
return;
|
||||
}
|
||||
const sanitizedHtml = DOMPurify.sanitize(form.value.answerHtml || '');
|
||||
const pureText = sanitizedHtml.replace(/<[^>]*>/g, '').trim();
|
||||
const pureText = sanitizedHtml.replaceAll(/<[^>]*>/g, '').trim();
|
||||
if (!pureText) {
|
||||
ElMessage.error($t('documentCollection.faq.answerRequired'));
|
||||
return;
|
||||
@@ -86,6 +128,7 @@ const handleSubmit = () => {
|
||||
question: form.value.question.trim(),
|
||||
answerHtml: sanitizedHtml,
|
||||
orderNo: Number(form.value.orderNo) || 0,
|
||||
categoryId: form.value.categoryId || null,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -107,11 +150,24 @@ onBeforeUnmount(() => {
|
||||
@close="closeDialog"
|
||||
>
|
||||
<ElForm class="faq-form" label-position="top">
|
||||
<ElFormItem :label="$t('documentCollection.faq.category')">
|
||||
<ElTreeSelect
|
||||
v-model="form.categoryId"
|
||||
check-strictly
|
||||
clearable
|
||||
:data="categoryOptions"
|
||||
node-key="id"
|
||||
:props="{ label: 'categoryName', children: 'children' }"
|
||||
:placeholder="$t('documentCollection.faq.categoryPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('documentCollection.faq.question')">
|
||||
<ElInput
|
||||
v-model="form.question"
|
||||
:placeholder="$t('documentCollection.faq.questionPlaceholder')"
|
||||
@keydown="handleQuestionKeydown"
|
||||
/>
|
||||
<div class="field-tip">Tab 可快速跳转到答案编辑区域</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('documentCollection.faq.answer')">
|
||||
<div class="editor-wrapper">
|
||||
@@ -131,7 +187,9 @@ onBeforeUnmount(() => {
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton class="footer-btn" @click="closeDialog">{{ $t('button.cancel') }}</ElButton>
|
||||
<ElButton class="footer-btn" @click="closeDialog">
|
||||
{{ $t('button.cancel') }}
|
||||
</ElButton>
|
||||
<ElButton class="footer-btn" type="primary" @click="handleSubmit">
|
||||
{{ $t('button.save') }}
|
||||
</ElButton>
|
||||
@@ -146,27 +204,69 @@ onBeforeUnmount(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.faq-form :deep(.el-form-item) {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.faq-form :deep(.el-input__wrapper),
|
||||
.faq-form :deep(.el-select__wrapper) {
|
||||
border-radius: 10px;
|
||||
min-height: 42px;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.faq-form :deep(.el-input__wrapper.is-focus),
|
||||
.faq-form :deep(.el-select__wrapper.is-focused) {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||
}
|
||||
|
||||
.field-tip {
|
||||
margin-top: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
width: 100%;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
background: var(--el-fill-color-blank);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease;
|
||||
box-shadow: 0 8px 24px rgb(15 23 42 / 4%);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.editor-wrapper:focus-within {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 3px rgb(64 158 255 / 12%);
|
||||
}
|
||||
|
||||
:deep(.w-e-toolbar) {
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
background: var(--el-fill-color-blank);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container) {
|
||||
min-height: 320px;
|
||||
height: 340px;
|
||||
min-height: 340px;
|
||||
background: var(--el-fill-color-blank);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-scroll) {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container [data-slate-editor]) {
|
||||
min-height: 100%;
|
||||
padding: 12px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container [data-slate-editor] p) {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import {$t} from '@easyflow/locales';
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import {Delete, Edit, Plus} from '@element-plus/icons-vue';
|
||||
import {ElButton, ElMessage, ElMessageBox, ElTable, ElTableColumn,} from 'element-plus';
|
||||
import {
|
||||
Bottom,
|
||||
CirclePlus,
|
||||
Delete,
|
||||
Download,
|
||||
Edit,
|
||||
FolderAdd,
|
||||
MoreFilled,
|
||||
Plus,
|
||||
Top,
|
||||
Upload,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTree,
|
||||
} from 'element-plus';
|
||||
|
||||
import {api} from '#/api/request';
|
||||
import { api } from '#/api/request';
|
||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
|
||||
import FaqCategoryDialog from './FaqCategoryDialog.vue';
|
||||
import FaqEditDialog from './FaqEditDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -21,10 +43,21 @@ const props = defineProps({
|
||||
|
||||
const pageDataRef = ref();
|
||||
const dialogVisible = ref(false);
|
||||
const categoryDialogVisible = ref(false);
|
||||
const editData = ref<any>({});
|
||||
const queryParams = ref({
|
||||
const categoryEditData = ref<any>({});
|
||||
const categoryDialogTitle = ref('');
|
||||
const categoryDialogDisableParent = ref(false);
|
||||
const selectedCategoryId = ref<string>('all');
|
||||
const searchKeyword = ref('');
|
||||
const categoryTree = ref<any[]>([]);
|
||||
const categoryParentOptions = ref<any[]>([]);
|
||||
const categoryActionLoading = ref(false);
|
||||
|
||||
const baseQueryParams = ref({
|
||||
collectionId: props.knowledgeId,
|
||||
});
|
||||
|
||||
const headerButtons = [
|
||||
{
|
||||
key: 'add',
|
||||
@@ -34,20 +67,83 @@ const headerButtons = [
|
||||
},
|
||||
];
|
||||
|
||||
const reloadList = () => {
|
||||
pageDataRef.value.setQuery(queryParams.value);
|
||||
const treeData = computed(() => [
|
||||
{
|
||||
id: 'all',
|
||||
categoryName: $t('documentCollection.faq.allFaq'),
|
||||
isVirtual: true,
|
||||
levelNo: 0,
|
||||
children: categoryTree.value,
|
||||
},
|
||||
]);
|
||||
|
||||
const refreshList = () => {
|
||||
const query: Record<string, any> = {};
|
||||
if (searchKeyword.value.trim()) {
|
||||
query.question = searchKeyword.value.trim();
|
||||
}
|
||||
if (selectedCategoryId.value !== 'all') {
|
||||
query.categoryId = selectedCategoryId.value;
|
||||
}
|
||||
pageDataRef.value?.setQuery(query);
|
||||
};
|
||||
|
||||
const reloadCategoryTree = async () => {
|
||||
const res = await api.get('/api/v1/faqCategory/list', {
|
||||
params: {
|
||||
collectionId: props.knowledgeId,
|
||||
asTree: true,
|
||||
},
|
||||
});
|
||||
if (res.errorCode === 0) {
|
||||
categoryTree.value = normalizeCategoryTree(res.data || []);
|
||||
|
||||
if (
|
||||
selectedCategoryId.value !== 'all' &&
|
||||
!hasCategoryId(categoryTree.value, selectedCategoryId.value)
|
||||
) {
|
||||
selectedCategoryId.value = 'all';
|
||||
}
|
||||
refreshList();
|
||||
} else {
|
||||
ElMessage.error(res.message || $t('message.getDataError'));
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeCategoryTree = (nodes: any[]): any[] => {
|
||||
return (nodes || []).map((node) => ({
|
||||
...node,
|
||||
id: String(node.id),
|
||||
parentId:
|
||||
node.parentId === undefined || node.parentId === null
|
||||
? '0'
|
||||
: String(node.parentId),
|
||||
children: normalizeCategoryTree(node.children || []),
|
||||
}));
|
||||
};
|
||||
|
||||
const hasCategoryId = (nodes: any[], id: string): boolean => {
|
||||
for (const node of nodes || []) {
|
||||
if (String(node.id) === id) {
|
||||
return true;
|
||||
}
|
||||
if (node.children?.length && hasCategoryId(node.children, id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleSearch = (keyword: string) => {
|
||||
pageDataRef.value.setQuery({
|
||||
...queryParams.value,
|
||||
question: keyword,
|
||||
});
|
||||
searchKeyword.value = keyword || '';
|
||||
refreshList();
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
editData.value = {
|
||||
collectionId: props.knowledgeId,
|
||||
categoryId:
|
||||
selectedCategoryId.value === 'all' ? null : selectedCategoryId.value,
|
||||
answerHtml: '',
|
||||
question: '',
|
||||
};
|
||||
@@ -64,6 +160,10 @@ const openEditDialog = (row: any) => {
|
||||
editData.value = {
|
||||
id: row.id,
|
||||
collectionId: row.collectionId,
|
||||
categoryId:
|
||||
row.categoryId === undefined || row.categoryId === null
|
||||
? ''
|
||||
: String(row.categoryId),
|
||||
question: row.question,
|
||||
answerHtml: row.answerHtml,
|
||||
orderNo: row.orderNo,
|
||||
@@ -75,9 +175,11 @@ const saveFaq = async (payload: any) => {
|
||||
const url = payload.id ? '/api/v1/faqItem/update' : '/api/v1/faqItem/save';
|
||||
const res = await api.post(url, payload);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(payload.id ? $t('message.updateOkMessage') : $t('message.saveOkMessage'));
|
||||
ElMessage.success(
|
||||
payload.id ? $t('message.updateOkMessage') : $t('message.saveOkMessage'),
|
||||
);
|
||||
dialogVisible.value = false;
|
||||
reloadList();
|
||||
refreshList();
|
||||
} else {
|
||||
ElMessage.error(res.message);
|
||||
}
|
||||
@@ -92,79 +194,739 @@ const removeFaq = (row: any) => {
|
||||
api.post('/api/v1/faqItem/remove', { id: row.id }).then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
reloadList();
|
||||
refreshList();
|
||||
} else {
|
||||
ElMessage.error(res.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleCategoryClick = (data: any) => {
|
||||
selectedCategoryId.value = String(data.id);
|
||||
refreshList();
|
||||
};
|
||||
|
||||
const openAddRootCategory = () => {
|
||||
categoryDialogTitle.value = $t('documentCollection.faq.addCategory');
|
||||
categoryDialogDisableParent.value = false;
|
||||
categoryEditData.value = {
|
||||
collectionId: props.knowledgeId,
|
||||
parentId: '0',
|
||||
categoryName: '',
|
||||
};
|
||||
categoryParentOptions.value = buildParentOptions();
|
||||
categoryDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openAddSiblingCategory = (node: any) => {
|
||||
categoryDialogTitle.value = $t('documentCollection.faq.addSiblingCategory');
|
||||
categoryDialogDisableParent.value = false;
|
||||
categoryEditData.value = {
|
||||
collectionId: props.knowledgeId,
|
||||
parentId:
|
||||
node.parentId === undefined || node.parentId === null
|
||||
? '0'
|
||||
: String(node.parentId),
|
||||
categoryName: '',
|
||||
};
|
||||
categoryParentOptions.value = buildParentOptions();
|
||||
categoryDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openAddChildCategory = (node: any) => {
|
||||
if (node.isDefault) {
|
||||
ElMessage.warning(
|
||||
$t('documentCollection.faq.defaultCategoryChildForbidden'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (Number(node.levelNo) >= 3) {
|
||||
ElMessage.warning($t('documentCollection.faq.maxLevelTip'));
|
||||
return;
|
||||
}
|
||||
categoryDialogTitle.value = $t('documentCollection.faq.addChildCategory');
|
||||
categoryDialogDisableParent.value = false;
|
||||
categoryEditData.value = {
|
||||
collectionId: props.knowledgeId,
|
||||
parentId: String(node.id),
|
||||
categoryName: '',
|
||||
};
|
||||
categoryParentOptions.value = buildParentOptions();
|
||||
categoryDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEditCategory = (node: any) => {
|
||||
categoryDialogTitle.value = $t('documentCollection.faq.editCategory');
|
||||
categoryDialogDisableParent.value = !!node.isDefault;
|
||||
categoryEditData.value = {
|
||||
id: node.id,
|
||||
collectionId: node.collectionId,
|
||||
parentId:
|
||||
node.parentId === undefined || node.parentId === null
|
||||
? '0'
|
||||
: String(node.parentId),
|
||||
categoryName: node.categoryName,
|
||||
};
|
||||
categoryParentOptions.value = buildParentOptions(String(node.id));
|
||||
categoryDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const removeCategory = (node: any) => {
|
||||
if (node.isDefault) {
|
||||
ElMessage.warning(
|
||||
$t('documentCollection.faq.defaultCategoryDeleteForbidden'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
const res = await api.post('/api/v1/faqCategory/remove', { id: node.id });
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
if (selectedCategoryId.value === String(node.id)) {
|
||||
selectedCategoryId.value = 'all';
|
||||
}
|
||||
await reloadCategoryTree();
|
||||
} else {
|
||||
ElMessage.error(res.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toLevel = (value: any, fallback = 1) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
|
||||
const findNodeById = (
|
||||
targetId: string,
|
||||
nodes: any[] = categoryTree.value,
|
||||
): any => {
|
||||
for (const node of nodes || []) {
|
||||
if (String(node.id) === targetId) {
|
||||
return node;
|
||||
}
|
||||
if (node.children?.length) {
|
||||
const found = findNodeById(targetId, node.children);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const findNodeContext = (
|
||||
targetId: string,
|
||||
nodes: any[] = categoryTree.value,
|
||||
parent: any = null,
|
||||
): any => {
|
||||
for (let i = 0; i < (nodes || []).length; i += 1) {
|
||||
const node = nodes[i];
|
||||
if (String(node.id) === targetId) {
|
||||
return {
|
||||
node,
|
||||
parent,
|
||||
siblings: nodes,
|
||||
index: i,
|
||||
};
|
||||
}
|
||||
if (node.children?.length) {
|
||||
const found = findNodeContext(targetId, node.children, node);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const findSubtreeMaxLevel = (node: any): number => {
|
||||
const current = toLevel(node?.levelNo, 1);
|
||||
let maxLevel = current;
|
||||
for (const child of node?.children || []) {
|
||||
maxLevel = Math.max(maxLevel, findSubtreeMaxLevel(child));
|
||||
}
|
||||
return maxLevel;
|
||||
};
|
||||
|
||||
const canMoveUnderParent = (node: any, newParentLevel: number): boolean => {
|
||||
const currentLevel = toLevel(node?.levelNo, 1);
|
||||
const subtreeMaxLevel = findSubtreeMaxLevel(node);
|
||||
const targetLevel = newParentLevel + 1;
|
||||
const delta = targetLevel - currentLevel;
|
||||
return subtreeMaxLevel + delta <= 3;
|
||||
};
|
||||
|
||||
const getChildrenByParentId = (parentId: string): any[] => {
|
||||
if (parentId === '0') {
|
||||
return categoryTree.value || [];
|
||||
}
|
||||
const parent = findNodeById(parentId);
|
||||
return parent?.children || [];
|
||||
};
|
||||
|
||||
const updateCategoryRequest = async (payload: Record<string, any>) => {
|
||||
const res = await api.post('/api/v1/faqCategory/update', payload);
|
||||
if (res.errorCode !== 0) {
|
||||
throw new Error(res.message || $t('message.getDataError'));
|
||||
}
|
||||
};
|
||||
|
||||
const persistSiblingOrder = async (siblings: any[]) => {
|
||||
for (const [i, item] of siblings.entries()) {
|
||||
await updateCategoryRequest({
|
||||
id: item.id,
|
||||
collectionId: item.collectionId,
|
||||
parentId:
|
||||
item.parentId === undefined || item.parentId === null
|
||||
? '0'
|
||||
: String(item.parentId),
|
||||
categoryName: item.categoryName,
|
||||
sortNo: i,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const canMoveUp = (node: any): boolean => {
|
||||
const ctx = findNodeContext(String(node.id));
|
||||
if (!ctx || ctx.index <= 0) {
|
||||
return false;
|
||||
}
|
||||
const previousSibling = ctx.siblings[ctx.index - 1];
|
||||
return !previousSibling?.isDefault;
|
||||
};
|
||||
|
||||
const canMoveDown = (node: any): boolean => {
|
||||
const ctx = findNodeContext(String(node.id));
|
||||
return !!ctx && ctx.index < ctx.siblings.length - 1;
|
||||
};
|
||||
|
||||
const canPromote = (node: any): boolean => {
|
||||
const ctx = findNodeContext(String(node.id));
|
||||
if (!ctx || !ctx.parent) {
|
||||
return false;
|
||||
}
|
||||
const parentParentId =
|
||||
ctx.parent.parentId === undefined || ctx.parent.parentId === null
|
||||
? '0'
|
||||
: String(ctx.parent.parentId);
|
||||
if (parentParentId === String(ctx.parent.id)) {
|
||||
return false;
|
||||
}
|
||||
const newParentLevel =
|
||||
parentParentId === '0'
|
||||
? 0
|
||||
: toLevel(findNodeById(parentParentId)?.levelNo, 1);
|
||||
return canMoveUnderParent(node, newParentLevel);
|
||||
};
|
||||
|
||||
const canDemote = (node: any): boolean => {
|
||||
const ctx = findNodeContext(String(node.id));
|
||||
if (!ctx || ctx.index <= 0) {
|
||||
return false;
|
||||
}
|
||||
const previousSibling = ctx.siblings[ctx.index - 1];
|
||||
if (previousSibling?.isDefault) {
|
||||
return false;
|
||||
}
|
||||
return canMoveUnderParent(node, toLevel(previousSibling?.levelNo, 1));
|
||||
};
|
||||
|
||||
const moveCategoryUp = async (node: any) => {
|
||||
if (categoryActionLoading.value || !canMoveUp(node)) {
|
||||
return;
|
||||
}
|
||||
const ctx = findNodeContext(String(node.id));
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
const reordered = [...ctx.siblings];
|
||||
[reordered[ctx.index - 1], reordered[ctx.index]] = [
|
||||
reordered[ctx.index],
|
||||
reordered[ctx.index - 1],
|
||||
];
|
||||
categoryActionLoading.value = true;
|
||||
try {
|
||||
await persistSiblingOrder(reordered);
|
||||
ElMessage.success($t('message.updateOkMessage'));
|
||||
await reloadCategoryTree();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || $t('message.getDataError'));
|
||||
} finally {
|
||||
categoryActionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const moveCategoryDown = async (node: any) => {
|
||||
if (categoryActionLoading.value || !canMoveDown(node)) {
|
||||
return;
|
||||
}
|
||||
const ctx = findNodeContext(String(node.id));
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
const reordered = [...ctx.siblings];
|
||||
[reordered[ctx.index], reordered[ctx.index + 1]] = [
|
||||
reordered[ctx.index + 1],
|
||||
reordered[ctx.index],
|
||||
];
|
||||
categoryActionLoading.value = true;
|
||||
try {
|
||||
await persistSiblingOrder(reordered);
|
||||
ElMessage.success($t('message.updateOkMessage'));
|
||||
await reloadCategoryTree();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || $t('message.getDataError'));
|
||||
} finally {
|
||||
categoryActionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const promoteCategory = async (node: any) => {
|
||||
if (categoryActionLoading.value || !canPromote(node)) {
|
||||
return;
|
||||
}
|
||||
const ctx = findNodeContext(String(node.id));
|
||||
if (!ctx || !ctx.parent) {
|
||||
return;
|
||||
}
|
||||
const newParentId =
|
||||
ctx.parent.parentId === undefined || ctx.parent.parentId === null
|
||||
? '0'
|
||||
: String(ctx.parent.parentId);
|
||||
const newSiblings = getChildrenByParentId(newParentId);
|
||||
const oldSiblingsWithoutNode = ctx.siblings.filter(
|
||||
(_: any, index: number) => index !== ctx.index,
|
||||
);
|
||||
categoryActionLoading.value = true;
|
||||
try {
|
||||
await updateCategoryRequest({
|
||||
id: node.id,
|
||||
collectionId: node.collectionId,
|
||||
parentId: newParentId,
|
||||
categoryName: node.categoryName,
|
||||
sortNo: newSiblings.length,
|
||||
});
|
||||
await persistSiblingOrder(oldSiblingsWithoutNode);
|
||||
ElMessage.success($t('message.updateOkMessage'));
|
||||
await reloadCategoryTree();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || $t('message.getDataError'));
|
||||
} finally {
|
||||
categoryActionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const demoteCategory = async (node: any) => {
|
||||
if (categoryActionLoading.value || !canDemote(node)) {
|
||||
return;
|
||||
}
|
||||
const ctx = findNodeContext(String(node.id));
|
||||
if (!ctx || ctx.index <= 0) {
|
||||
return;
|
||||
}
|
||||
const previousSibling = ctx.siblings[ctx.index - 1];
|
||||
if (previousSibling?.isDefault) {
|
||||
ElMessage.warning(
|
||||
$t('documentCollection.faq.defaultCategoryChildForbidden'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const childCategories = previousSibling.children || [];
|
||||
const oldSiblingsWithoutNode = ctx.siblings.filter(
|
||||
(_: any, index: number) => index !== ctx.index,
|
||||
);
|
||||
categoryActionLoading.value = true;
|
||||
try {
|
||||
await updateCategoryRequest({
|
||||
id: node.id,
|
||||
collectionId: node.collectionId,
|
||||
parentId: String(previousSibling.id),
|
||||
categoryName: node.categoryName,
|
||||
sortNo: childCategories.length,
|
||||
});
|
||||
await persistSiblingOrder(oldSiblingsWithoutNode);
|
||||
ElMessage.success($t('message.updateOkMessage'));
|
||||
await reloadCategoryTree();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || $t('message.getDataError'));
|
||||
} finally {
|
||||
categoryActionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveCategory = async (payload: any) => {
|
||||
const url = payload.id
|
||||
? '/api/v1/faqCategory/update'
|
||||
: '/api/v1/faqCategory/save';
|
||||
const res = await api.post(url, payload);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(
|
||||
payload.id ? $t('message.updateOkMessage') : $t('message.saveOkMessage'),
|
||||
);
|
||||
categoryDialogVisible.value = false;
|
||||
await reloadCategoryTree();
|
||||
} else {
|
||||
ElMessage.error(res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const buildParentOptions = (excludeRootId?: string) => {
|
||||
const excludedIds = new Set<string>();
|
||||
if (excludeRootId) {
|
||||
collectDescendantIds(categoryTree.value, excludeRootId, excludedIds);
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: '0',
|
||||
categoryName: $t('documentCollection.faq.rootCategory'),
|
||||
children: filterTree(categoryTree.value, excludedIds),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const collectDescendantIds = (
|
||||
nodes: any[],
|
||||
rootId: string,
|
||||
output: Set<string>,
|
||||
) => {
|
||||
for (const node of nodes || []) {
|
||||
const currentId = String(node.id);
|
||||
if (currentId === rootId) {
|
||||
collectNodeIds(node, output);
|
||||
return true;
|
||||
}
|
||||
if (node.children?.length) {
|
||||
const found = collectDescendantIds(node.children, rootId, output);
|
||||
if (found) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const collectNodeIds = (node: any, output: Set<string>) => {
|
||||
output.add(String(node.id));
|
||||
for (const child of node.children || []) {
|
||||
collectNodeIds(child, output);
|
||||
}
|
||||
};
|
||||
|
||||
const filterTree = (nodes: any[], excludedIds: Set<string>): any[] => {
|
||||
return (nodes || [])
|
||||
.filter((node) => !excludedIds.has(String(node.id)))
|
||||
.map((node) => ({
|
||||
...node,
|
||||
children: filterTree(node.children || [], excludedIds),
|
||||
}));
|
||||
};
|
||||
|
||||
const categoryTreeOptions = computed(() => categoryTree.value || []);
|
||||
|
||||
onMounted(() => {
|
||||
reloadCategoryTree();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="faq-table-wrapper">
|
||||
<div class="faq-header">
|
||||
<HeaderSearch
|
||||
:buttons="headerButtons"
|
||||
@search="handleSearch"
|
||||
@button-click="handleButtonClick"
|
||||
/>
|
||||
</div>
|
||||
<div class="faq-layout">
|
||||
<div class="faq-category-pane">
|
||||
<div class="faq-category-header">
|
||||
<span>{{ $t('documentCollection.faq.categoryTree') }}</span>
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@click="openAddRootCategory"
|
||||
>
|
||||
{{ $t('button.add') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElTree
|
||||
class="faq-category-tree"
|
||||
:data="treeData"
|
||||
node-key="id"
|
||||
default-expand-all
|
||||
:expand-on-click-node="false"
|
||||
:current-node-key="selectedCategoryId"
|
||||
:props="{ label: 'categoryName', children: 'children' }"
|
||||
@node-click="handleCategoryClick"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div
|
||||
class="faq-category-node"
|
||||
:class="{ 'is-all-node': data.isVirtual }"
|
||||
>
|
||||
<span class="faq-category-node-label">{{
|
||||
data.categoryName
|
||||
}}</span>
|
||||
<div
|
||||
v-if="!data.isVirtual && !data.isDefault"
|
||||
class="faq-category-node-actions"
|
||||
@click.stop
|
||||
>
|
||||
<ElDropdown trigger="click">
|
||||
<ElButton link :icon="MoreFilled" />
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
:icon="Top"
|
||||
:disabled="!canMoveUp(data)"
|
||||
@click="moveCategoryUp(data)"
|
||||
>
|
||||
{{ $t('documentCollection.faq.moveUp') }}
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
:icon="Bottom"
|
||||
:disabled="!canMoveDown(data)"
|
||||
@click="moveCategoryDown(data)"
|
||||
>
|
||||
{{ $t('documentCollection.faq.moveDown') }}
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
:icon="Upload"
|
||||
:disabled="!canPromote(data)"
|
||||
@click="promoteCategory(data)"
|
||||
>
|
||||
{{ $t('documentCollection.faq.promote') }}
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
:icon="Download"
|
||||
:disabled="!canDemote(data)"
|
||||
@click="demoteCategory(data)"
|
||||
>
|
||||
{{ $t('documentCollection.faq.demote') }}
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
:icon="CirclePlus"
|
||||
@click="openAddSiblingCategory(data)"
|
||||
>
|
||||
{{ $t('documentCollection.faq.addSiblingCategory') }}
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
:icon="FolderAdd"
|
||||
:disabled="
|
||||
Number(data.levelNo) >= 3 || !!data.isDefault
|
||||
"
|
||||
@click="openAddChildCategory(data)"
|
||||
>
|
||||
{{ $t('documentCollection.faq.addChildCategory') }}
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem :icon="Edit" @click="openEditCategory(data)">
|
||||
{{ $t('button.edit') }}
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
:icon="Delete"
|
||||
:disabled="!!data.isDefault"
|
||||
@click="removeCategory(data)"
|
||||
>
|
||||
{{ $t('button.delete') }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
</div>
|
||||
|
||||
<PageData
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/faqItem/page"
|
||||
:page-size="10"
|
||||
:extra-query-params="queryParams"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable :data="pageList" size="large">
|
||||
<ElTableColumn
|
||||
prop="question"
|
||||
:label="$t('documentCollection.faq.question')"
|
||||
min-width="220"
|
||||
<div class="faq-content-pane">
|
||||
<div class="faq-header">
|
||||
<HeaderSearch
|
||||
:buttons="headerButtons"
|
||||
@search="handleSearch"
|
||||
@button-click="handleButtonClick"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="answerText"
|
||||
:label="$t('documentCollection.faq.answer')"
|
||||
min-width="260"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn :label="$t('common.handle')" width="170" align="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="primary" :icon="Edit" @click="openEditDialog(row)">
|
||||
{{ $t('button.edit') }}
|
||||
</ElButton>
|
||||
<ElButton link type="danger" :icon="Delete" @click="removeFaq(row)">
|
||||
{{ $t('button.delete') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</template>
|
||||
</PageData>
|
||||
</div>
|
||||
|
||||
<PageData
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/faqItem/page"
|
||||
:page-size="10"
|
||||
:extra-query-params="baseQueryParams"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable :data="pageList" size="large">
|
||||
<ElTableColumn
|
||||
prop="categoryPath"
|
||||
:label="$t('documentCollection.faq.categoryPath')"
|
||||
min-width="220"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="question"
|
||||
:label="$t('documentCollection.faq.question')"
|
||||
min-width="220"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="answerText"
|
||||
:label="$t('documentCollection.faq.answer')"
|
||||
min-width="260"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn
|
||||
:label="$t('common.handle')"
|
||||
width="170"
|
||||
align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
:icon="Edit"
|
||||
@click="openEditDialog(row)"
|
||||
>
|
||||
{{ $t('button.edit') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="removeFaq(row)"
|
||||
>
|
||||
{{ $t('button.delete') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</template>
|
||||
</PageData>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FaqEditDialog
|
||||
v-model="dialogVisible"
|
||||
:data="editData"
|
||||
:category-options="categoryTreeOptions"
|
||||
@submit="saveFaq"
|
||||
/>
|
||||
|
||||
<FaqCategoryDialog
|
||||
v-model="categoryDialogVisible"
|
||||
:title="categoryDialogTitle"
|
||||
:data="categoryEditData"
|
||||
:disable-parent="categoryDialogDisableParent"
|
||||
:parent-options="categoryParentOptions"
|
||||
@submit="saveCategory"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.faq-table-wrapper {
|
||||
width: 100%;
|
||||
height: calc(100vh - 220px);
|
||||
}
|
||||
|
||||
.faq-layout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.faq-category-pane {
|
||||
width: 236px;
|
||||
min-width: 236px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
background: var(--el-fill-color-blank);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.faq-category-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.faq-category-tree {
|
||||
padding: 6px;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.faq-category-node {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.faq-category-node.is-all-node .faq-category-node-label {
|
||||
padding-left: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.faq-category-node-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.faq-category-node-actions {
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.faq-content-pane {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.faq-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(
|
||||
.faq-category-tree > .el-tree-node > .el-tree-node__content > .el-tree-node__expand-icon
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.faq-category-tree .el-tree-node__content) {
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
:deep(.faq-category-tree .el-tree-node__content:hover) {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
:deep(.faq-category-tree .el-tree-node__content:hover .faq-category-node-actions) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:deep(.el-tree-node.is-current > .el-tree-node__content) {
|
||||
background-color: hsl(var(--primary) / 15%);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
:deep(.el-tree-node.is-current > .el-tree-node__content .faq-category-node-actions) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user