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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user