- 收敛 easyflow-ui-admin 的 lint、格式和类型问题 - 修正 demo 页面与管理端前端构建失败点 - 验证 pnpm lint 与 pnpm build 均已通过
1200 lines
31 KiB
Vue
1200 lines
31 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue';
|
|
|
|
import { $t } from '@easyflow/locales';
|
|
import { downloadFileFromBlob } from '@easyflow/utils';
|
|
|
|
import {
|
|
Bottom,
|
|
CirclePlus,
|
|
Delete,
|
|
Download,
|
|
Edit,
|
|
FolderAdd,
|
|
MoreFilled,
|
|
Plus,
|
|
RefreshRight,
|
|
Search,
|
|
Top,
|
|
Upload,
|
|
} from '@element-plus/icons-vue';
|
|
import {
|
|
ElButton,
|
|
ElDropdown,
|
|
ElDropdownItem,
|
|
ElDropdownMenu,
|
|
ElInput,
|
|
ElMessage,
|
|
ElMessageBox,
|
|
ElTable,
|
|
ElTableColumn,
|
|
ElTree,
|
|
} from 'element-plus';
|
|
|
|
import { api } from '#/api/request';
|
|
import PageData from '#/components/page/PageData.vue';
|
|
|
|
import FaqCategoryDialog from './FaqCategoryDialog.vue';
|
|
import FaqEditDialog from './FaqEditDialog.vue';
|
|
import FaqImportDialog from './FaqImportDialog.vue';
|
|
|
|
const props = defineProps({
|
|
knowledgeId: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
manageable: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
});
|
|
|
|
const pageDataRef = ref();
|
|
const dialogVisible = ref(false);
|
|
const categoryDialogVisible = ref(false);
|
|
const editData = ref<any>({});
|
|
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 importDialogVisible = ref(false);
|
|
const templateDownloadLoading = ref(false);
|
|
const exportLoading = ref(false);
|
|
|
|
const baseQueryParams = ref({
|
|
collectionId: props.knowledgeId,
|
|
});
|
|
|
|
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 = () => {
|
|
searchKeyword.value = searchKeyword.value.trim();
|
|
refreshList();
|
|
};
|
|
|
|
const handleResetSearch = () => {
|
|
searchKeyword.value = '';
|
|
refreshList();
|
|
};
|
|
|
|
const openAddDialog = () => {
|
|
if (!props.manageable) {
|
|
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
|
return;
|
|
}
|
|
editData.value = {
|
|
collectionId: props.knowledgeId,
|
|
categoryId:
|
|
selectedCategoryId.value === 'all' ? null : selectedCategoryId.value,
|
|
answerHtml: '',
|
|
question: '',
|
|
};
|
|
dialogVisible.value = true;
|
|
};
|
|
|
|
const downloadImportTemplate = async () => {
|
|
if (templateDownloadLoading.value) {
|
|
return;
|
|
}
|
|
templateDownloadLoading.value = true;
|
|
try {
|
|
const blob = await api.download(
|
|
`/api/v1/faqItem/downloadImportTemplate?collectionId=${props.knowledgeId}`,
|
|
);
|
|
downloadFileFromBlob({
|
|
fileName: 'faq_import_template.xlsx',
|
|
source: blob,
|
|
});
|
|
} finally {
|
|
templateDownloadLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const exportFaqExcel = async () => {
|
|
if (exportLoading.value) {
|
|
return;
|
|
}
|
|
exportLoading.value = true;
|
|
try {
|
|
const blob = await api.download(
|
|
`/api/v1/faqItem/exportExcel?collectionId=${props.knowledgeId}`,
|
|
);
|
|
downloadFileFromBlob({
|
|
fileName: 'faq_export.xlsx',
|
|
source: blob,
|
|
});
|
|
} finally {
|
|
exportLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const handleImportSuccess = async () => {
|
|
await reloadCategoryTree();
|
|
refreshList();
|
|
};
|
|
|
|
const handleMoreActionCommand = (command: string) => {
|
|
if (command === 'import') {
|
|
if (!props.manageable) {
|
|
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
|
return;
|
|
}
|
|
importDialogVisible.value = true;
|
|
return;
|
|
}
|
|
if (command === 'downloadTemplate') {
|
|
downloadImportTemplate();
|
|
return;
|
|
}
|
|
if (command === 'export') {
|
|
exportFaqExcel();
|
|
}
|
|
};
|
|
|
|
const openEditDialog = (row: any) => {
|
|
if (!props.manageable) {
|
|
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
|
return;
|
|
}
|
|
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,
|
|
};
|
|
dialogVisible.value = true;
|
|
};
|
|
|
|
const saveFaq = async (payload: any) => {
|
|
if (!props.manageable) {
|
|
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
|
return;
|
|
}
|
|
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;
|
|
refreshList();
|
|
} else {
|
|
ElMessage.error(res.message);
|
|
}
|
|
};
|
|
|
|
const removeFaq = (row: any) => {
|
|
if (!props.manageable) {
|
|
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
|
return;
|
|
}
|
|
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'));
|
|
refreshList();
|
|
} else {
|
|
ElMessage.error(res.message);
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
const handleCategoryClick = (data: any) => {
|
|
selectedCategoryId.value = String(data.id);
|
|
refreshList();
|
|
};
|
|
|
|
const openAddRootCategory = () => {
|
|
if (!props.manageable) {
|
|
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
|
return;
|
|
}
|
|
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) => {
|
|
if (!props.manageable) {
|
|
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
|
return;
|
|
}
|
|
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 (!props.manageable) {
|
|
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
|
return;
|
|
}
|
|
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) => {
|
|
if (!props.manageable) {
|
|
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
|
return;
|
|
}
|
|
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 (!props.manageable) {
|
|
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
|
return;
|
|
}
|
|
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) => {
|
|
if (!props.manageable) {
|
|
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
|
return;
|
|
}
|
|
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-layout">
|
|
<div class="faq-category-pane">
|
|
<div class="faq-category-header">
|
|
<span>{{ $t('documentCollection.faq.categoryTree') }}</span>
|
|
<ElButton
|
|
v-if="props.manageable"
|
|
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="props.manageable && !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>
|
|
|
|
<div class="faq-content-pane">
|
|
<div class="faq-header">
|
|
<div v-if="!props.manageable" class="faq-readonly-tip">
|
|
{{ $t('documentCollection.managePermissionHint') }}
|
|
</div>
|
|
<div class="faq-toolbar">
|
|
<div class="faq-search-actions">
|
|
<ElInput
|
|
v-model="searchKeyword"
|
|
clearable
|
|
class="faq-search-input"
|
|
:placeholder="$t('common.searchPlaceholder')"
|
|
@keyup.enter="handleSearch"
|
|
/>
|
|
<ElButton type="primary" :icon="Search" @click="handleSearch">
|
|
{{ $t('button.query') }}
|
|
</ElButton>
|
|
<ElButton :icon="RefreshRight" @click="handleResetSearch">
|
|
{{ $t('button.reset') }}
|
|
</ElButton>
|
|
</div>
|
|
|
|
<div class="faq-primary-actions">
|
|
<ElButton
|
|
v-if="props.manageable"
|
|
type="primary"
|
|
:icon="Plus"
|
|
@click="openAddDialog"
|
|
>
|
|
{{ $t('button.add') }}
|
|
</ElButton>
|
|
<ElDropdown trigger="click" @command="handleMoreActionCommand">
|
|
<ElButton :icon="MoreFilled">
|
|
{{ $t('documentCollection.faq.import.moreActions') }}
|
|
</ElButton>
|
|
<template #dropdown>
|
|
<ElDropdownMenu>
|
|
<ElDropdownItem
|
|
v-if="props.manageable"
|
|
command="import"
|
|
:icon="Upload"
|
|
>
|
|
{{ $t('button.import') }}
|
|
</ElDropdownItem>
|
|
<ElDropdownItem
|
|
command="downloadTemplate"
|
|
:icon="Download"
|
|
:disabled="templateDownloadLoading"
|
|
>
|
|
{{ $t('documentCollection.faq.import.downloadTemplate') }}
|
|
</ElDropdownItem>
|
|
<ElDropdownItem
|
|
command="export"
|
|
:icon="Download"
|
|
:disabled="exportLoading"
|
|
>
|
|
{{ $t('button.export') }}
|
|
</ElDropdownItem>
|
|
</ElDropdownMenu>
|
|
</template>
|
|
</ElDropdown>
|
|
</div>
|
|
</div>
|
|
</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
|
|
v-if="props.manageable"
|
|
: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"
|
|
/>
|
|
|
|
<FaqImportDialog
|
|
v-model="importDialogVisible"
|
|
:knowledge-id="knowledgeId"
|
|
@success="handleImportSuccess"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.faq-table-wrapper {
|
|
width: 100%;
|
|
height: calc(100vh - 220px);
|
|
}
|
|
|
|
.faq-layout {
|
|
display: flex;
|
|
gap: 12px;
|
|
height: 100%;
|
|
}
|
|
|
|
.faq-category-pane {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 236px;
|
|
min-width: 236px;
|
|
overflow: hidden;
|
|
background: var(--el-fill-color-blank);
|
|
border: 1px solid var(--el-border-color-lighter);
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.faq-category-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 10px;
|
|
font-weight: 600;
|
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
|
}
|
|
|
|
.faq-category-tree {
|
|
flex: 1;
|
|
padding: 6px;
|
|
overflow: auto;
|
|
}
|
|
|
|
.faq-category-node {
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
width: 100%;
|
|
min-width: 0;
|
|
}
|
|
|
|
.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 {
|
|
flex: 1;
|
|
padding: 14px 16px 8px;
|
|
overflow: auto;
|
|
background: var(--el-fill-color-blank);
|
|
border: 1px solid var(--el-border-color-lighter);
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.faq-header {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.faq-readonly-tip {
|
|
margin-bottom: 10px;
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
color: var(--el-text-color-secondary);
|
|
}
|
|
|
|
.faq-toolbar {
|
|
display: flex;
|
|
gap: 14px;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 2px 0;
|
|
}
|
|
|
|
.faq-search-actions {
|
|
display: flex;
|
|
flex: 1;
|
|
gap: 10px;
|
|
align-items: center;
|
|
min-width: 260px;
|
|
}
|
|
|
|
.faq-search-input {
|
|
width: min(460px, 100%);
|
|
}
|
|
|
|
.faq-primary-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
:deep(.faq-toolbar .el-button) {
|
|
height: 38px;
|
|
padding: 0 16px;
|
|
font-weight: 500;
|
|
border-radius: 10px;
|
|
transition:
|
|
border-color 0.2s ease,
|
|
background-color 0.2s ease,
|
|
color 0.2s ease;
|
|
}
|
|
|
|
:deep(.faq-toolbar .el-button:not(.el-button--primary):hover) {
|
|
color: hsl(var(--primary));
|
|
background: hsl(var(--primary) / 7%);
|
|
border-color: hsl(var(--primary) / 45%);
|
|
}
|
|
|
|
:deep(.faq-search-input .el-input__wrapper) {
|
|
padding-right: 12px;
|
|
padding-left: 12px;
|
|
border-radius: 10px;
|
|
box-shadow: 0 0 0 1px var(--el-border-color) inset;
|
|
}
|
|
|
|
:deep(.faq-search-input .el-input__wrapper:hover) {
|
|
box-shadow: 0 0 0 1px hsl(var(--primary) / 30%) inset;
|
|
}
|
|
|
|
:deep(.faq-search-input .el-input__wrapper.is-focus) {
|
|
box-shadow: 0 0 0 1px hsl(var(--primary) / 60%) inset;
|
|
}
|
|
|
|
: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;
|
|
padding-right: 4px;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
: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) {
|
|
color: hsl(var(--primary));
|
|
background-color: hsl(var(--primary) / 15%);
|
|
}
|
|
|
|
:deep(
|
|
.el-tree-node.is-current > .el-tree-node__content .faq-category-node-actions
|
|
) {
|
|
opacity: 1;
|
|
}
|
|
|
|
:deep(.el-table) {
|
|
overflow: hidden;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
: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;
|
|
}
|
|
|
|
@media (max-width: 1360px) {
|
|
.faq-toolbar {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.faq-search-actions,
|
|
.faq-primary-actions {
|
|
width: 100%;
|
|
}
|
|
|
|
.faq-primary-actions {
|
|
justify-content: flex-start;
|
|
}
|
|
}
|
|
</style>
|