feat: 增加知识库新类型FAQ知识库

This commit is contained in:
2026-02-24 13:07:14 +08:00
parent e27ba8d457
commit a9060ed2b9
27 changed files with 1701 additions and 58 deletions

View File

@@ -29,6 +29,8 @@
"@easyflow/types": "workspace:*",
"@easyflow/utils": "workspace:*",
"@element-plus/icons-vue": "^2.3.2",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"@tinyflow-ai/vue": "workspace:*",
"@vueuse/core": "catalog:",
"dayjs": "catalog:",

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import {computed} from 'vue';
import { useAccess } from '@easyflow/access';
import {useAccess} from '@easyflow/access';
import { MoreFilled } from '@element-plus/icons-vue';
import {MoreFilled} from '@element-plus/icons-vue';
import {
ElAvatar,
ElButton,
@@ -13,6 +13,7 @@ import {
ElDropdownItem,
ElDropdownMenu,
ElIcon,
ElTag,
ElText,
} from 'element-plus';
@@ -31,12 +32,16 @@ export interface CardListProps {
actions?: ActionButton[];
defaultIcon: any;
data: any[];
tagField?: string;
tagMap?: Record<string, string>;
}
const props = withDefaults(defineProps<CardListProps>(), {
iconField: 'icon',
titleField: 'title',
descField: 'description',
actions: () => [],
tagField: '',
tagMap: () => ({}),
});
const { hasAccessByCodes } = useAccess();
const filterActions = computed(() => {
@@ -72,12 +77,22 @@ const hiddenActions = computed(() => {
:src="item[iconField] || defaultIcon"
:size="36"
/>
<ElText truncated size="large" class="font-medium">
{{ item[titleField] }}
</ElText>
<div class="title-row">
<ElText truncated size="large" class="font-medium">
{{ item[titleField] }}
</ElText>
<ElTag
v-if="tagField && item[tagField]"
size="small"
effect="plain"
type="info"
>
{{ tagMap[item[tagField]] || item[tagField] }}
</ElTag>
</div>
</div>
<ElText line-clamp="2" class="item-desc w-full">
{{ item[titleField] }}
{{ item[descField] }}
</ElText>
</div>
<template #footer>
@@ -198,4 +213,13 @@ const hiddenActions = computed(() => {
.item-danger {
color: var(--el-color-danger);
}
.title-row {
display: flex;
flex: 1;
gap: 8px;
align-items: center;
justify-content: space-between;
min-width: 0;
}
</style>

View File

@@ -6,6 +6,9 @@
"icon": "Icon",
"title": "Title",
"categoryId": "Category",
"collectionType": "Knowledge Type",
"collectionTypeDocument": "Document",
"collectionTypeFaq": "FAQ",
"description": "Description",
"slug": "Slug",
"vectorStoreEnable": "VectorStoreEnable",
@@ -76,6 +79,15 @@
"confirmImport": "ConfirmImport",
"cancelImport": "CancelImport"
},
"faq": {
"faqList": "FAQ List",
"question": "Question",
"answer": "Answer",
"questionPlaceholder": "Please input question",
"answerPlaceholder": "Please input answer",
"questionRequired": "Question is required",
"answerRequired": "Answer is required"
},
"searchResults": "SearchResults",
"documentPreview": "DocumentPreview",
"total": "Total",

View File

@@ -6,6 +6,9 @@
"icon": "ICON",
"title": "名称",
"categoryId": "分类",
"collectionType": "知识库类型",
"collectionTypeDocument": "文档",
"collectionTypeFaq": "FAQ",
"description": "描述",
"slug": "URL 别名",
"vectorStoreEnable": "是否启用向量数据库",
@@ -76,6 +79,15 @@
"confirmImport": "确认导入",
"cancelImport": "取消导入"
},
"faq": {
"faqList": "FAQ列表",
"question": "问题",
"answer": "答案",
"questionPlaceholder": "请输入问题",
"answerPlaceholder": "请输入答案",
"questionRequired": "问题不能为空",
"answerRequired": "答案不能为空"
},
"searchResults": "检索结果",
"documentPreview": "文档预览",
"total": "共",

View File

@@ -0,0 +1,6 @@
declare module '@wangeditor/editor-for-vue' {
import type {DefineComponent} from 'vue';
export const Editor: DefineComponent<any, any, any>;
export const Toolbar: DefineComponent<any, any, any>;
}

View File

@@ -1,19 +1,21 @@
<script setup lang="ts">
import { 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';
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
import KnowledgeSearchConfig from '#/views/ai/documentCollection/KnowledgeSearchConfig.vue';
@@ -24,6 +26,21 @@ const router = useRouter();
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 fallbackMenu = collectionType === 'FAQ' ? 'faqList' : 'documentList';
if (!menuKey) {
return fallbackMenu;
}
const validMenuSet = collectionType === 'FAQ' ? faqMenus : documentMenus;
return validMenuSet.has(menuKey) ? menuKey : fallbackMenu;
};
const getKnowledge = () => {
api
.get('/api/v1/documentCollection/detail', {
@@ -32,23 +49,36 @@ const getKnowledge = () => {
.then((res) => {
if (res.errorCode === 0) {
knowledgeInfo.value = res.data;
const initialMenu = resolveDefaultMenu(
res.data.collectionType || 'DOCUMENT',
activeMenu.value,
);
defaultSelectedMenu.value = initialMenu;
selectedCategory.value = initialMenu;
}
});
};
onMounted(() => {
if (activeMenu.value) {
defaultSelectedMenu.value = activeMenu.value;
}
getKnowledge();
});
const back = () => {
router.push({ path: '/ai/documentCollection' });
};
const categoryData = [
{ key: 'documentList', name: $t('documentCollection.documentList') },
{ key: 'knowledgeSearch', name: $t('documentCollection.knowledgeRetrieval') },
{ key: 'config', name: $t('documentCollection.config') },
];
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: 'config', name: $t('documentCollection.config') },
];
}
return [
{ key: 'documentList', name: $t('documentCollection.documentList') },
{ key: 'knowledgeSearch', name: $t('documentCollection.knowledgeRetrieval') },
{ key: 'config', name: $t('documentCollection.config') },
];
});
const headerButtons = [
{
key: 'importFile',
@@ -59,7 +89,6 @@ const headerButtons = [
},
];
const isImportFileVisible = ref(false);
const selectedCategory = ref('documentList');
const documentTableRef = ref();
const handleSearch = (searchParams: string) => {
documentTableRef.value.search(searchParams);
@@ -91,7 +120,6 @@ const viewDoc = (docId: string) => {
const backDoc = () => {
isImportFileVisible.value = false;
};
const defaultSelectedMenu = ref('documentList');
</script>
<template>
@@ -127,6 +155,7 @@ const defaultSelectedMenu = ref('documentList');
<div v-if="selectedCategory === 'documentList'" class="doc-table">
<div class="doc-header" v-if="!viewDocVisible">
<HeaderSearch
v-if="!isFaqCollection"
:buttons="headerButtons"
@search="handleSearch"
@button-click="handleButtonClick"
@@ -145,6 +174,9 @@ const defaultSelectedMenu = ref('documentList');
:default-summary-prompt="knowledgeInfo.summaryPrompt"
/>
</div>
<div v-if="selectedCategory === 'faqList'" class="doc-table">
<FaqTable :knowledge-id="knowledgeId" />
</div>
<!--知识检索-->
<div
v-if="selectedCategory === 'knowledgeSearch'"

View File

@@ -1,14 +1,5 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import type { ActionButton } from '#/components/page/CardList.vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
import { Delete, Edit, Notebook, Plus, Search } from '@element-plus/icons-vue';
import type {FormInstance} from 'element-plus';
import {
ElButton,
ElDialog,
@@ -19,17 +10,30 @@ import {
ElMessage,
ElMessageBox,
} from 'element-plus';
import { tryit } from 'radash';
import { api } from '#/api/request';
import type {ActionButton} from '#/components/page/CardList.vue';
import CardPage from '#/components/page/CardList.vue';
import {computed, onMounted, ref} from 'vue';
import {useRouter} from 'vue-router';
import {$t} from '@easyflow/locales';
import {Delete, Edit, Notebook, Plus, Search} from '@element-plus/icons-vue';
import {tryit} from 'radash';
import {api} from '#/api/request';
import defaultIcon from '#/assets/ai/knowledge/book.svg';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import CardPage from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
const router = useRouter();
const collectionTypeLabelMap = {
DOCUMENT: $t('documentCollection.collectionTypeDocument'),
FAQ: $t('documentCollection.collectionTypeFaq'),
};
interface FieldDefinition {
// 字段名称
prop: string;
@@ -310,6 +314,8 @@ function changeCategory(category: any) {
description-key="description"
:data="pageList"
:actions="actions"
tag-field="collectionType"
:tag-map="collectionTypeLabelMap"
/>
</template>
</PageData>

View File

@@ -68,6 +68,7 @@ const vecotrDatabaseList = ref<any>([
]);
const defaultEntity = {
collectionType: 'DOCUMENT',
alias: '',
deptId: '',
icon: '',
@@ -108,6 +109,9 @@ const entity = ref<any>(normalizeEntity(defaultEntity));
const btnLoading = ref(false);
const rules = ref({
collectionType: [
{ required: true, message: $t('message.required'), trigger: 'change' },
],
deptId: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
@@ -131,6 +135,16 @@ const rules = ref({
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
});
const collectionTypeList = [
{
label: $t('documentCollection.collectionTypeDocument'),
value: 'DOCUMENT',
},
{
label: $t('documentCollection.collectionTypeFaq'),
value: 'FAQ',
},
];
function openDialog(row: any = {}) {
if (row.id) {
@@ -217,6 +231,19 @@ defineExpose({
:placeholder="$t('documentCollection.placeholder.title')"
/>
</ElFormItem>
<ElFormItem
prop="collectionType"
:label="$t('documentCollection.collectionType')"
>
<ElSelect v-model="entity.collectionType">
<ElOption
v-for="item in collectionTypeList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
prop="categoryId"
:label="$t('documentCollection.categoryId')"

View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
import type {IDomEditor} from '@wangeditor/editor';
import {onBeforeUnmount, ref, shallowRef, watch} from 'vue';
import {$t} from '@easyflow/locales';
import {ElButton, ElDialog, ElForm, ElFormItem, ElInput, ElMessage} from 'element-plus';
import DOMPurify from 'dompurify';
import {Editor, Toolbar} from '@wangeditor/editor-for-vue';
import '@wangeditor/editor/dist/css/style.css';
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
data: {
type: Object as any,
default: () => ({}),
},
});
const emit = defineEmits(['submit', 'update:modelValue']);
const editorRef = shallowRef<IDomEditor | null>(null);
const form = ref<any>({
id: '',
collectionId: '',
question: '',
answerHtml: '',
orderNo: 0,
});
watch(
() => props.data,
(newData: any) => {
form.value = {
id: newData?.id || '',
collectionId: newData?.collectionId || '',
question: newData?.question || '',
answerHtml: newData?.answerHtml || '',
orderNo: newData?.orderNo ?? 0,
};
},
{ immediate: true, deep: true },
);
const toolbarConfig = {
excludeKeys: [
'uploadImage',
'insertImage',
'group-image',
'insertVideo',
'group-video',
'uploadVideo',
'todo',
'emotion',
],
};
const editorConfig = {
placeholder: $t('documentCollection.faq.answerPlaceholder'),
};
const handleEditorCreated = (editor: IDomEditor) => {
editorRef.value = editor;
};
const closeDialog = () => {
emit('update:modelValue', false);
};
const handleSubmit = () => {
if (!form.value.question?.trim()) {
ElMessage.error($t('documentCollection.faq.questionRequired'));
return;
}
const sanitizedHtml = DOMPurify.sanitize(form.value.answerHtml || '');
const pureText = sanitizedHtml.replace(/<[^>]*>/g, '').trim();
if (!pureText) {
ElMessage.error($t('documentCollection.faq.answerRequired'));
return;
}
emit('submit', {
...form.value,
question: form.value.question.trim(),
answerHtml: sanitizedHtml,
orderNo: Number(form.value.orderNo) || 0,
});
};
onBeforeUnmount(() => {
const editor = editorRef.value;
if (editor) {
editor.destroy();
}
});
</script>
<template>
<ElDialog
class="faq-edit-dialog"
:model-value="modelValue"
:title="form.id ? $t('button.edit') : $t('button.add')"
width="min(920px, 92vw)"
:close-on-click-modal="false"
@close="closeDialog"
>
<ElForm class="faq-form" label-position="top">
<ElFormItem :label="$t('documentCollection.faq.question')">
<ElInput
v-model="form.question"
:placeholder="$t('documentCollection.faq.questionPlaceholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('documentCollection.faq.answer')">
<div class="editor-wrapper">
<Toolbar
:editor="editorRef"
:default-config="toolbarConfig"
mode="default"
/>
<Editor
v-model="form.answerHtml"
:default-config="editorConfig"
mode="default"
@on-created="handleEditorCreated"
/>
</div>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton class="footer-btn" @click="closeDialog">{{ $t('button.cancel') }}</ElButton>
<ElButton class="footer-btn" type="primary" @click="handleSubmit">
{{ $t('button.save') }}
</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped>
.faq-form :deep(.el-form-item__label) {
padding-bottom: 8px;
font-weight: 600;
}
.editor-wrapper {
width: 100%;
border: 1px solid var(--el-border-color-light);
border-radius: 10px;
background: var(--el-fill-color-blank);
overflow: hidden;
transition: border-color 0.2s ease;
}
.editor-wrapper:focus-within {
border-color: var(--el-color-primary);
}
:deep(.w-e-toolbar) {
border-bottom: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-blank);
}
:deep(.w-e-text-container) {
min-height: 320px;
background: var(--el-fill-color-blank);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.footer-btn {
min-width: 88px;
}
:deep(.faq-edit-dialog .el-dialog) {
border-radius: 14px;
overflow: hidden;
}
:deep(.faq-edit-dialog .el-dialog__header) {
margin-right: 0;
padding: 18px 22px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
:deep(.faq-edit-dialog .el-dialog__body) {
padding: 18px 22px 16px;
}
:deep(.faq-edit-dialog .el-dialog__footer) {
padding: 12px 22px 18px;
border-top: 1px solid var(--el-border-color-lighter);
}
</style>

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import {ref} from 'vue';
import {$t} from '@easyflow/locales';
import {Delete, Edit, Plus} from '@element-plus/icons-vue';
import {ElButton, ElMessage, ElMessageBox, ElTable, ElTableColumn,} from 'element-plus';
import {api} from '#/api/request';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PageData from '#/components/page/PageData.vue';
import FaqEditDialog from './FaqEditDialog.vue';
const props = defineProps({
knowledgeId: {
type: String,
required: true,
},
});
const pageDataRef = ref();
const dialogVisible = ref(false);
const editData = ref<any>({});
const queryParams = ref({
collectionId: props.knowledgeId,
});
const headerButtons = [
{
key: 'add',
text: $t('button.add'),
icon: Plus,
type: 'primary',
},
];
const reloadList = () => {
pageDataRef.value.setQuery(queryParams.value);
};
const handleSearch = (keyword: string) => {
pageDataRef.value.setQuery({
...queryParams.value,
question: keyword,
});
};
const openAddDialog = () => {
editData.value = {
collectionId: props.knowledgeId,
answerHtml: '',
question: '',
};
dialogVisible.value = true;
};
const handleButtonClick = (event: any) => {
if (event.key === 'add') {
openAddDialog();
}
};
const openEditDialog = (row: any) => {
editData.value = {
id: row.id,
collectionId: row.collectionId,
question: row.question,
answerHtml: row.answerHtml,
orderNo: row.orderNo,
};
dialogVisible.value = true;
};
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'));
dialogVisible.value = false;
reloadList();
} else {
ElMessage.error(res.message);
}
};
const removeFaq = (row: any) => {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
}).then(() => {
api.post('/api/v1/faqItem/remove', { id: row.id }).then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.deleteOkMessage'));
reloadList();
} else {
ElMessage.error(res.message);
}
});
});
};
</script>
<template>
<div class="faq-table-wrapper">
<div class="faq-header">
<HeaderSearch
:buttons="headerButtons"
@search="handleSearch"
@button-click="handleButtonClick"
/>
</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"
/>
<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>
<FaqEditDialog
v-model="dialogVisible"
:data="editData"
@submit="saveFaq"
/>
</div>
</template>
<style scoped>
.faq-table-wrapper {
width: 100%;
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
padding: 14px 16px 8px;
background: var(--el-fill-color-blank);
}
.faq-header {
margin-bottom: 12px;
}
:deep(.el-table) {
border-radius: 10px;
overflow: hidden;
}
:deep(.el-table th.el-table__cell) {
background: var(--el-fill-color-light);
}
:deep(.el-table td.el-table__cell) {
padding-top: 14px;
padding-bottom: 14px;
}
</style>