Files
school-developer/src/components/AiEditor/index.vue
吴红兵 1f645dad3e init
2025-12-02 10:37:49 +08:00

337 lines
8.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div :style="containerStyle" @submit.prevent class="rounded-lg border shadow-sm bg-card text-card-foreground">
<aurora-editor
v-model="localModelValue"
:extensions="mergedExtensions"
:hideToolbar="hideToolbar"
:hideMenubar="hideMenubar"
:disabled="disabled"
:maxHeight="maxHeight"
:minHeight="minHeight"
:output="output"
:placeholder="placeholder"
>
</aurora-editor>
</div>
</template>
<script setup lang="ts" name="aiEditorComponent">
import { computed, toRefs } from 'vue';
import { fetchEventSource } from '@microsoft/fetch-event-source';
import request from '/@/utils/request';
import { Session } from '/@/utils/storage';
import {
Bold,
BulletList,
Italic,
BaseKit,
Underline,
Strike,
LineHeight,
Image,
History,
Heading,
CodeBlock,
FontSize,
Highlight,
Table,
Clear,
Blockquote,
Link,
Color,
Video,
OrderedList,
HorizontalRule,
Fullscreen,
TaskList,
MoreMark,
FormatPainter,
SlashCommand,
Indent,
locale,
ImportWord,
Columns,
TextAlign,
ImageUpload,
VideoUpload,
FontFamily,
FindAndReplace,
Code,
AI,
Preview,
Printer,
AuroraEditor,
} from 'aurora-editor';
import 'aurora-editor/style.css';
import other from '/@/utils/other';
import { useThemeConfig } from '/@/stores/themeConfig';
import { storeToRefs } from 'pinia';
// 获取主题配置
const storesThemeConfig = useThemeConfig();
const { themeConfig } = storeToRefs(storesThemeConfig);
// 获取布局配置信息
const getThemeConfig = computed(() => {
return themeConfig.value;
});
// 设置编辑器语言为简体中文
locale.setLang('zhHans');
// 定义组件 Props
const props = withDefaults(
defineProps<{
modelValue?: string; // 编辑器内容,用于 v-model
width?: string | number; // 编辑器容器宽度
maxHeight?: number; // 编辑器最大高度
minHeight?: number; // 编辑器最小高度
hideToolbar?: boolean; // 是否隐藏工具栏
hideMenubar?: boolean; // 是否隐藏菜单栏
disabled?: boolean; // 是否禁用编辑器
customExtensions?: any[]; // 自定义扩展
placeholder?: string; // 编辑器占位符文本
characterLimit?: number; // 编辑器字符数限制
enableImage?: boolean; // 是否启用图片功能
enableVideo?: boolean; // 是否启用视频功能
output?: string; // 是否启用输出功能
}>(),
{
modelValue: '',
width: '100%', // 默认宽度
maxHeight: 800, // 默认最大高度
minHeight: 800, // 默认最小高度
hideToolbar: false, // 默认不隐藏工具栏
hideMenubar: true, // 默认不隐藏菜单栏
disabled: false, // 默认不禁用
customExtensions: () => [], // 默认无自定义扩展
placeholder: '', // 默认无占位符
characterLimit: 50000, // 默认字符限制
enableImage: false, // 默认禁用图片功能
enableVideo: false, // 默认禁用视频功能
output: 'html', // 默认输出 HTML
}
);
// 提取props中的属性以直接在模板中使用避免使用props.xxx语法
const { hideToolbar, hideMenubar, disabled, maxHeight, minHeight, output } = toRefs(props);
// 使用计算属性创建一个双向绑定的本地变量避免直接修改props
const localModelValue = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
// 定义emit
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
// 计算编辑器容器样式
const containerStyle = computed(() => ({
width: typeof props.width === 'number' ? `${props.width}px` : props.width, // 处理数字和字符串类型的宽度
}));
// 定义默认的编辑器扩展
const defaultExtensions = computed(() => {
// Base configurations reused in both modes
const baseKitConfig = {
placeholder: {
showOnlyCurrent: true,
placeholder: props.placeholder,
},
characterCount: {
limit: props.characterLimit,
},
};
const aiConfig = {
completions: AICompletions,
shortcuts: [
{
label: '自定义选项',
children: [
{
label: '重写文案',
prompt:
'Rewrite this content with no spelling mistakes, proper grammar, and with more descriptive language, using best writing practices without losing the original meaning.',
},
],
},
],
};
// Core extensions used in both modes (except Fullscreen which is conditional)
const coreExtensions = [BaseKit.configure(baseKitConfig), AI.configure(aiConfig)];
// Base list for the full editor experience
let fullExtensions: any[] = [
...coreExtensions,
History,
Columns,
FormatPainter,
Clear,
Heading.configure({ spacer: true }),
FontSize,
FontFamily,
Bold,
Italic,
Underline,
Strike,
MoreMark,
Color.configure({ spacer: true }),
Highlight,
BulletList,
OrderedList,
TextAlign.configure({ types: ['heading', 'paragraph', 'image'], spacer: true }),
Indent,
LineHeight,
TaskList.configure({
spacer: true,
taskItem: {
nested: true,
},
}),
Link,
Blockquote,
SlashCommand,
HorizontalRule,
CodeBlock,
Table,
Code,
ImportWord.configure({
upload: handleFileUpload,
}),
FindAndReplace.configure({ spacer: true }),
Printer.configure({ pageTitle: getThemeConfig.value.globalTitle }),
Preview,
Fullscreen, // Fullscreen is also part of the full set
];
// Conditionally add image extensions
if (props.enableImage) {
fullExtensions.push(
Image, // 图片
ImageUpload.configure({
// 图片上传配置
upload: (file: File) => {
return new Promise<string>((resolve) => {
setTimeout(() => {
const fileUrl = URL.createObjectURL(file);
resolve(fileUrl);
}, 1000);
});
},
})
);
}
// Conditionally add video extensions
if (props.enableVideo) {
fullExtensions.push(
Video, // 视频
VideoUpload.configure({
// 视频上传配置
upload: handleFileUpload,
})
);
}
return fullExtensions;
});
// 合并默认扩展和自定义扩展
const mergedExtensions = computed(() => {
return [...defaultExtensions.value, ...props.customExtensions];
});
// 统一文件上传处理 (视频、Word导入等)
async function handleFileUpload(files: File[]) {
// 生成预览 URL
const uploads = files.map((file) => ({
src: URL.createObjectURL(file),
alt: file.name,
}));
// 模拟上传延迟
await new Promise((resolve) => setTimeout(resolve, 1000));
// 返回上传结果 (实际应返回服务器 URL)
return Promise.resolve(uploads);
}
// AI 补全处理函数 (使用 Server-Sent Events)
async function AICompletions(history: Array<{ role: string; content: string }> = [], signal?: AbortSignal): Promise<ReadableStream> {
try {
// 构建后端 API 地址
const backendApiUrl = `${request.defaults.baseURL}${other.adaptationUrl('/knowledge/completions/text')}`;
// 获取认证信息
const token = Session.getToken();
const tenantId = Session.getTenant();
// 准备发送到后端的消息 (假设后端处理系统提示)
const messagesToSend = history;
// 创建 ReadableStream 用于流式传输
let streamController: ReadableStreamDefaultController<any>;
const stream = new ReadableStream({
start(controller) {
streamController = controller; // 保存控制器以推送数据
},
cancel() {
// 处理流取消的逻辑 (例如,清理资源)
},
});
// 使用 fetchEventSource 发起 SSE 请求
fetchEventSource(backendApiUrl, {
method: 'POST', // 请求方法
headers: {
// 请求头
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`, // 认证 Token
'TENANT-ID': tenantId, // 租户 ID
},
body: JSON.stringify(messagesToSend), // 请求体 (发送的消息)
signal, // 用于中止请求的 AbortSignal
openWhenHidden: true, // 保持连接即使页面不可见
onmessage(event) {
const { message } = JSON.parse(event.data);
// 接收到消息时的回调
// 检查是否是结束标记
if (message === '[DONE]') {
streamController.close(); // 关闭流
return;
}
try {
// 获取消息内容
if (message) {
streamController.enqueue({
choices: [{ delta: { content: message } }], // 模拟 OpenAI 流格式
});
}
} catch (error) {
// 移除 console.error 以修复 linter 错误
streamController.error(error); // 将错误推送到流中
}
},
onclose() {
// 连接关闭时的回调
streamController.close(); // 关闭流
},
onerror(error) {
// 发生错误时的回调
streamController.error(error); // 将错误推送到流中
},
});
// 返回创建的 ReadableStream
return stream;
} catch (error) {
// 创建一个包含错误信息的流
return new ReadableStream({
start(controller) {
controller.error(error);
},
});
}
}
</script>