Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqEditDialog.vue
陈子默 31a755a8bc feat: 收口知识库分享链路
- 新增 shareKey 单参数 URL 分享页与失效页

- 新增知识库分享后端鉴权、审计与迁移脚本

- 在访问令牌中增加知识库分享授权入口
2026-04-13 14:44:31 +08:00

358 lines
8.7 KiB
Vue

<script setup lang="ts">
import type { IDomEditor } from '@wangeditor/editor';
import { nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
import { $t } from '@easyflow/locales';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import DOMPurify from 'dompurify';
import {
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElTreeSelect,
} from 'element-plus';
import { api } from '#/api/request';
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
import '@wangeditor/editor/dist/css/style.css';
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
data: {
type: Object as any,
default: () => ({}),
},
categoryOptions: {
type: Array as any,
default: () => [],
},
requestClient: {
type: Object as any,
default: () => api,
},
endpointPrefix: {
type: String,
default: '',
},
});
const emit = defineEmits(['submit', 'update:modelValue']);
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
const ALLOWED_IMAGE_TYPES = new Set([
'image/gif',
'image/jpeg',
'image/png',
'image/webp',
]);
const editorRef = shallowRef<IDomEditor | null>(null);
const form = ref<any>({
id: '',
collectionId: '',
categoryId: '',
question: '',
answerHtml: '',
orderNo: 0,
});
watch(
() => props.data,
(newData: any) => {
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,
};
},
{ immediate: true, deep: true },
);
const toolbarConfig = {
excludeKeys: ['insertVideo', 'group-video', 'uploadVideo', 'todo', 'emotion'],
};
const editorConfig: any = {
placeholder: $t('documentCollection.faq.answerPlaceholder'),
MENU_CONF: {
uploadImage: {
customUpload: async (
file: File,
insertFn: (url: string, alt?: string, href?: string) => void,
) => {
if (!form.value.collectionId) {
ElMessage.error($t('documentCollection.faq.collectionRequired'));
return;
}
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
ElMessage.error($t('documentCollection.faq.imageTypeInvalid'));
return;
}
if (file.size > MAX_IMAGE_SIZE) {
ElMessage.error($t('documentCollection.faq.imageSizeExceeded'));
return;
}
try {
const res = await props.requestClient.upload(
buildKnowledgePath(
props.endpointPrefix,
'/api/v1/faqItem/uploadImage',
),
{ collectionId: form.value.collectionId, file },
{},
);
const imageUrl = res?.data?.path;
if (!imageUrl) {
ElMessage.error($t('documentCollection.faq.imageUploadFailed'));
return;
}
insertFn(imageUrl, file.name, imageUrl);
} catch (error) {
console.error('FAQ image upload failed:', error);
ElMessage.error($t('documentCollection.faq.imageUploadFailed'));
}
},
},
},
};
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: Event | KeyboardEvent) => {
if (
event instanceof KeyboardEvent &&
event.key === 'Tab' &&
!event.shiftKey
) {
event.preventDefault();
focusAnswerEditor();
}
};
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 || '', {
ADD_TAGS: ['img'],
ADD_ATTR: ['src', 'alt', 'title', 'width', 'height'],
ALLOWED_URI_REGEXP: /^https?:\/\//i,
});
const doc = new DOMParser().parseFromString(sanitizedHtml, 'text/html');
const pureText = (doc.body?.textContent || '').trim();
const hasImage = doc.querySelector('img[src]') !== null;
if (!pureText && !hasImage) {
ElMessage.error($t('documentCollection.faq.answerRequired'));
return;
}
emit('submit', {
...form.value,
question: form.value.question.trim(),
answerHtml: sanitizedHtml,
orderNo: Number(form.value.orderNo) || 0,
categoryId: form.value.categoryId || null,
});
};
onBeforeUnmount(() => {
const editor = editorRef.value;
if (editor) {
editor.destroy();
}
});
</script>
<template>
<EasyFlowFormModal
class="faq-edit-dialog"
:open="modelValue"
:title="form.id ? $t('button.edit') : $t('button.add')"
width="min(920px, 92vw)"
:confirm-text="$t('button.save')"
@update:open="emit('update:modelValue', $event)"
@cancel="closeDialog"
@confirm="handleSubmit"
>
<ElForm
class="faq-form easyflow-modal-form easyflow-modal-form--compact"
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">
<Toolbar
:editor="editorRef"
:default-config="toolbarConfig"
mode="default"
/>
<Editor
v-model="form.answerHtml"
:default-config="editorConfig"
mode="default"
@on-created="handleEditorCreated"
/>
</div>
</ElFormItem>
</ElForm>
</EasyFlowFormModal>
</template>
<style scoped>
.faq-form :deep(.el-form-item__label) {
padding-bottom: 8px;
font-weight: 600;
}
.faq-form :deep(.el-form-item) {
margin-bottom: 18px;
}
.faq-form :deep(.el-input__wrapper),
.faq-form :deep(.el-select__wrapper) {
min-height: 42px;
border-radius: 10px;
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;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
.editor-wrapper {
width: 100%;
overflow: hidden;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
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) {
padding: 8px 10px;
background: var(--el-fill-color-blank);
border-bottom: 1px solid var(--el-border-color-light);
}
:deep(.w-e-text-container) {
height: 340px;
min-height: 340px;
cursor: text;
background: var(--el-fill-color-blank);
}
: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 {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.footer-btn {
min-width: 88px;
}
:deep(.faq-edit-dialog .el-dialog) {
overflow: hidden;
border-radius: 14px;
}
:deep(.faq-edit-dialog .el-dialog__header) {
padding: 18px 22px;
margin-right: 0;
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>