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:
2026-02-25 16:53:31 +08:00
parent 3b6ed8a49a
commit 9600d0855e
19 changed files with 2224 additions and 99 deletions

View File

@@ -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;