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,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 {