五、编辑器引入与操作方法
目前开源的编辑器框架有很多,这里主要介绍一款自定义能力强大的富文本编辑器Tiptap。
1、Tiptap介绍
Tiptap是一个基于 ProseMirror 构建的富文本编辑器,它是一个灵活、可扩展的富文本编辑器,同时适用于 Vue.js 和 React。Tiptap 的核心思路是通过插件系统提供丰富的功能,使得开发者可以根据需求定制编辑器的功能和样式。
官网地址:https://tiptap.dev/
github地址:https://github.com/ueberdosis/tiptap
文档地址:https://tiptap.dev/docs/editor/installation/vue3
Tiptap 的主要有5大部分组成:
(1)、Core:Tiptap 的核心模块,负责处理编辑器的基本功能,如文本输入、选择、撤销和重做等。
(2)、Extensions:扩展模块,提供丰富的编辑功能,如加粗、斜体、列表、链接等。开发者可以根据需求选择需要的功能,并通过插件系统轻松地添加到编辑器中。
(3)、Commands:命令模块,用于执行编辑操作,如插入、删除、修改等。开发者可以通过命令 API 对编辑器进行操作,实现自定义的功能。
(4)、Schema:定义编辑器的文档结构,包括节点、标记和规则。通过自定义 Schema,可以实现特定的文档结构和约束。
(5)、Vue/React components:Tiptap 提供了 Vue 和 React 的组件,使得编辑器可以轻松地集成到这两个框架中。
系统架构图如下所示:
Tiptap 作为主要的入口,连接了 Core、Extensions、Commands、Schema 和 Vue/React components。Extensions 又包括了多个功能模块,如 Bold、Italic、List 和 Link。这样的架构使得 Tiptap 可以根据需求灵活地扩展功能和样式。
2、Tiptap安装与配置
在vscode终端执行如下pnpm安装指令安装Tiptap以及一些插件:
npm i tiptap @tiptap/starter-kit @tiptap/vue-3 -force
npm i @tiptap/extension-highlight @tiptap/extension-task-item @tiptap/extension-task-list -force
npm i @tiptap/extension-code-block-lowlight highlight.js @tiptap/extension-bullet-list @tiptap/extension-ordered-list -force
npm i @tiptap/extension-list-item lowlight @tiptap/extension-placeholder -force
npm i @tiptap/extension-list-item -force
npm i @tiptap/extension-placeholder -force
npm i lowlight -force
下面也有一些插件,非必须:
pnpm install @tiptap/extension-text-align @tiptap/extension-underline @tiptap/extension-subscript @tiptap/extension-superscript @tiptap/extension-character-count
npm install @tiptap/extension-table
npm install @tiptap/extension-table-header -force
npm install @tiptap/extension-table-row -force
npm install @tiptap/extension-image -force
npm install @tiptap/extension-table-cell -force
安装完成后重新启动项目,在之前创建Edit文件夹下的index.vue中编写下面的测试代码:
<template>
<editor-content :editor="editor" />
</template>
<script lang="ts">
import { defineComponent, onMounted, onBeforeUnmount, ref } from 'vue';
import { Editor, EditorContent } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
export default defineComponent({
components: {
EditorContent,
},
setup() {
// 使用ref创建可变的响应式引用
const editor = ref<Editor | null>(null);
// 在组件挂载后初始化Editor
onMounted(() => {
editor.value = new Editor({
content: '<p>欢迎使用Tiptap!🎉</p>',
extensions: [StarterKit],
});
});
// 在组件卸载前销毁Editor实例
onBeforeUnmount(() => {
editor.value?.destroy();
});
// 返回editor供模板使用
return { editor };
},
});
</script>
打开网站,查看效果,如果出现如下界面代表配置完成了。
3、编辑器搭建
3.1 、框架搭建
对于整个页面,是需要进行框架搭建的,暂不深入探讨具体布局细节。参照众多现行在线编辑器的用户交互模式,编辑区域通常位于界面中央,作为内容创作的核心地带,而辅助性功能如目录概览、个性化设置等,则巧妙地分布在界面的左侧与右侧,旨在提升用户的操作便捷性与效率。本教程将采纳这一成熟布局,优化用户交互体验:左侧配备一系列创新辅助工具,便于用户快速调用;顶部则部署基础编辑工具栏,集成了必备的编辑功能,确保用户能够轻松驾驭;右侧设计为详尽的大纲视图,帮助用户直观把握文档结构;而在底部,加入字数统计功能。这种布局比较通用,不要限制你的想象力,可以自行发挥,遵循简洁、易用原则即可。
先初始化一下页面的样式,打开style.css
修改#app的样式如下:
#app {
margin: 0;
padding: 0;
position: relative;
width:100%;
height: 100%;
display: grid;
}
打开Edit文件夹下的index.vue,进行整体框架的布局,这里使用grid布局,不同模块用不同颜色进行表示。
修改代码如下:
<template>
<div class="EditMain">
<div class="lefttools"></div>
<div class="editor">
<editor-content :editor="editor" />
</div>
<div class="righttools"></div>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, onBeforeUnmount, ref } from 'vue';
import { Editor, EditorContent } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
export default defineComponent({
components: {
EditorContent,
},
setup() {
// 使用ref创建可变的响应式引用
const editor = ref<Editor | null>(null);
// 在组件挂载后初始化Editor
onMounted(() => {
editor.value = new Editor({
content: '<p>欢迎使用Tiptap!🎉</p>',
extensions: [StarterKit],
});
});
// 在组件卸载前销毁Editor实例
onBeforeUnmount(() => {
editor.value?.destroy();
});
// 返回editor供模板使用
return { editor };
},
});
</script>
<style>
.EditMain{
position: relative;
width:100vw;
height: 100vh;
background-color: aquamarine;
display: grid;
grid-template-columns: 20% 60% 20%;
}
.lefttools{
background-color: rgb(111 118 177 / 60%);
height: 100%;
width: 100%;
}
.righttools{
background-color: rgb(206 226 117);
height: 100%;
width: 100%;
}
.editor{
background-color:#f8f1e9;
}
</style>
初始化布局如下所示,颜色部分为了区分,后续去掉,中间部分为编辑器部分。
3.2 、编辑器部分
打开Edit文件夹下的index.vue,进行编辑器框架的布局,先引入列表、代码等模块,后续用得到,修改文件内容如下所示:
<template>
<div class="EditMain">
<div class="lefttools"></div>
<div class="editor">
<div class="editorcard">
<div class="toptools"></div>
<div class="editcont">
<EditorContent
style="padding: 8px; overflow-y: auto;"
:editor="editor"
/>
</div>
<div class="bottomcount"></div>
</div>
</div>
<div class="righttools"></div>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, onBeforeUnmount, ref,watch } from 'vue';
import { Editor, EditorContent, useEditor, BubbleMenu } from '@tiptap/vue-3';
import { storeToRefs } from 'pinia'
import Underline from '@tiptap/extension-underline'
// 列表
import ListItem from '@tiptap/extension-list-item'
import OrderedList from '@tiptap/extension-ordered-list'
import BulletList from '@tiptap/extension-bullet-list'
// 代码
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import css from 'highlight.js/lib/languages/css'
import js from 'highlight.js/lib/languages/javascript'
import ts from 'highlight.js/lib/languages/typescript'
import html from 'highlight.js/lib/languages/xml'
import { common, createLowlight } from 'lowlight'
const lowlight = createLowlight()
lowlight.register({ html, ts, css, js })
// 字数统计
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import { UndoRound, MoreHorizOutlined } from '@vicons/material'
import TaskItem from '@tiptap/extension-task-item'
import TaskList from '@tiptap/extension-task-list'
// 使用Pinia
import { useEditorStore } from '@/stores'
import EditorMenu from './EditorMenu/index.vue'
export default defineComponent({
components: {
EditorContent,
},
setup() {
// 使用ref创建可变的响应式引用
// 编辑器初始化
const editor = useEditor({
content: ``,
extensions: [
StarterKit,
TaskList,
TaskItem,
Placeholder.configure({
placeholder: '开始输入文本 …'
}),
OrderedList,
BulletList,
ListItem,
],
injectCSS: false
})
// 在组件挂载后初始化Editor
onMounted(() => {
});
// 在组件卸载前销毁Editor实例
onBeforeUnmount(() => {
editor.value?.destroy();
});
// 返回editor供模板使用
return { editor };
},
});
</script>
<style>
.EditMain{
position: relative;
width:100vw;
height: 100vh;
display: grid;
grid-template-columns: 20% 60% 20%;
}
.lefttools{
background-color: rgb(111 118 177 / 60%);
height: 100%;
width: 100%;
}
.righttools{
background-color: rgb(206 226 117);
height: 100%;
width: 100%;
}
.editor{
}
.editorcard{
position: relative;
width:95%;
height: 95%;
left: 2.5%;
top:2.5%;
display: grid;
grid-template-rows: 5% 92% 3%;
border: 1px solid #4f5c5765;
}
.editorcard .editor{
position: relative;
width:100%;
height: 100%;
left: 0;
top:0;
display: grid;
grid-template-rows: 10% 90%;
}
.editorcard .editor{
position: relative;
width:100%;
height: 100%;
left: 0;
top:0;
display: grid;
grid-template-rows: 10% 90%;
}
.toptools{
background-color: rgba(207, 220, 245, 0.199);
border-bottom: 1px dashed #9ca19f65;
}
.bottomcount{
background-color: rgba(207, 220, 245, 0.199);
border-top: 1px dashed #9ca19f65;
height: 100%;
width: 100%;
display: grid;
grid-template-columns: 100%;
grid-template-rows: 100%;
justify-items: center;
align-items: center;
}
.editcont{
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
<style lang="scss">
b {
font-weight: bold;
}
.ProseMirror {
overflow-y: scroll;
}
.ProseMirror p {
margin: 0;
}
.ProseMirror:focus {
outline: none;
}
.tiptap p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.tiptap {
> * + * {
margin-top: 0.75em;
}
ul {
padding: 0 2rem;
list-style: square;
}
ol {
padding: 0 2rem;
list-style: decimal;
}
table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
margin: 0;
overflow: hidden;
td,
th {
min-width: 1em;
border: 2px solid #ced4da;
padding: 3px 5px;
vertical-align: top;
box-sizing: border-box;
position: relative;
> * {
margin-bottom: 0;
}
}
th {
font-weight: bold;
text-align: left;
background-color: #f1f3f5;
}
.selectedCell:after {
z-index: 2;
position: absolute;
content: '';
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(200, 200, 255, 0.4);
pointer-events: none;
}
.column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
background-color: #adf;
pointer-events: none;
}
p {
margin: 0;
}
}
pre {
background: #0d0d0d;
color: #fff;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
code {
color: inherit;
padding: 0;
background: none;
font-size: 0.8rem;
}
.hljs-comment,
.hljs-quote {
color: #616161;
}
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #f98181;
}
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #fbbc88;
}
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #b9f18d;
}
.hljs-title,
.hljs-section {
color: #faf594;
}
.hljs-keyword,
.hljs-selector-tag {
color: #70cff8;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
}
}
.tableWrapper {
overflow-x: auto;
}
.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
</style>
搭建框架的效果如下所示:
接下来,完成编辑器的一些功能搭建。
4、基本工具栏
在顶部加一个功能菜单,先设置每一个功能的基本样式,在src/views/Edit文件夹下(后面的都在这创建)创建MenuItem/index.vue文件,编写代码如下:
<template>
<button
class="menu-item"
:class="{ 'is-active': props.isActive ? props.isActive() : null }"
@click="props.action"
:title="props.title"
>
<svg class="remix">
<use :xlink:href="`${remixiconUrl}#ri-${props.icon}`" />
</svg>
</button>
</template>
<script setup lang="ts">
import remixiconUrl from 'remixicon/fonts/remixicon.symbol.svg'
const props = defineProps<{
icon: string
title: string
action: Function
isActive?: Function
}>()
</script>
<style lang="scss">
.menu-item {
background: transparent;
border: none;
border-radius: 0.4rem;
color: #333;
cursor: pointer;
height: 1.75rem;
padding: 0.25rem;
margin-right: 0.25rem;
width: 1.75rem;
svg {
fill: currentColor;
height: 100%;
width: 100%;
}
&.is-active,
&:hover {
background-color: #d6d6d6;
}
}
</style>
在src文件夹下创建EditorMenu/index.vue文件,引入MenuItem/index.vue,编写代码如下:
<template>
<div>
<template v-for="(item, index) in items">
<div
class="divider"
v-if="item.type === 'divider'"
:key="`divider${index}`"
/>
<MenuItem
v-else
:key="index"
v-bind="item"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { Editor } from '@tiptap/vue-3'
import MenuItem from '@/views/Edit/MenuItem/index.vue'
const props = defineProps<{ editor: Editor }>()
const items = [
{
icon: 'bold',
title: 'Bold',
action: () => props.editor?.chain().focus().toggleBold().run(),
isActive: () => props.editor?.isActive('bold')
},
{
icon: 'italic',
title: 'Italic',
action: () => props.editor?.chain().focus().toggleItalic().run(),
isActive: () => props.editor?.isActive('italic')
},
{
icon: 'strikethrough',
title: 'Strike',
action: () => props.editor?.chain().focus().toggleStrike().run(),
isActive: () => props.editor?.isActive('strike')
},
{
icon: 'code-view',
title: 'Code',
action: () => props.editor?.chain().focus().toggleCode().run(),
isActive: () => props.editor?.isActive('code')
},
{
icon: 'mark-pen-line',
title: 'Highlight',
action: () => props.editor?.chain().focus().toggleHighlight().run(),
isActive: () => props.editor?.isActive('highlight')
},
{
type: 'divider'
},
{
icon: 'h-1',
title: 'Heading 1',
action: () =>
props.editor?.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => props.editor?.isActive('heading', { level: 1 })
},
{
icon: 'h-2',
title: 'Heading 2',
action: () =>
props.editor?.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => props.editor?.isActive('heading', { level: 2 })
},
{
icon: 'h-3',
title: 'Heading 3',
action: () =>
props.editor?.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => props.editor?.isActive('heading', { level: 3 })
},
{
icon: 'h-4',
title: 'Heading 4',
action: () =>
props.editor?.chain().focus().toggleHeading({ level: 4 }).run(),
isActive: () => props.editor?.isActive('heading', { level: 4})
},
{
icon: 'paragraph',
title: 'Paragraph',
action: () => props.editor?.chain().focus().setParagraph().run(),
isActive: () => props.editor?.isActive('paragraph')
},
{
icon: 'list-unordered',
title: 'Bullet List',
action: () => props.editor?.chain().focus().toggleBulletList().run(),
isActive: () => props.editor?.isActive('bulletList')
},
{
icon: 'list-ordered',
title: 'Ordered List',
action: () => props.editor?.chain().focus().toggleOrderedList().run(),
isActive: () => props.editor?.isActive('orderedList')
},
{
icon: 'list-check-2',
title: 'Task List',
action: () => props.editor?.chain().focus().toggleTaskList().run(),
isActive: () => props.editor?.isActive('taskList')
},
{
icon: 'code-box-line',
title: 'Code Block',
action: () => props.editor?.chain().focus().toggleCodeBlock().run(),
isActive: () => props.editor?.isActive('codeBlock')
},
{
type: 'divider'
},
{
icon: 'double-quotes-l',
title: 'Blockquote',
action: () => props.editor?.chain().focus().toggleBlockquote().run(),
isActive: () => props.editor?.isActive('blockquote')
},
{
icon: 'separator',
title: 'Horizontal Rule',
action: () => props.editor?.chain().focus().setHorizontalRule().run()
},
{
type: 'divider'
},
{
icon: 'text-wrap',
title: 'Hard Break',
action: () => props.editor?.chain().focus().setHardBreak().run()
},
{
icon: 'format-clear',
title: 'Clear Format',
action: () =>
props.editor?.chain().focus().clearNodes().unsetAllMarks().run()
},
{
type: 'divider'
},
{
icon: 'arrow-go-back-line',
title: 'Undo',
action: () => props.editor?.chain().focus().undo().run()
},
{
icon: 'arrow-go-forward-line',
title: 'Redo',
action: () => props.editor?.chain().focus().redo().run()
}
]
</script>
<style lang="scss">
.divider {
background-color: rgba(#fff, 0.25);
height: 1.25rem;
margin-left: 0.5rem;
margin-right: 0.75rem;
width: 1px;
display: inline-block;
}
</style>
最后修改Edit/index.vue,引入EditorMenu/index.vue,修改代码如下。
<template>
<div class="EditMain">
<div class="lefttools"></div>
<div class="editor">
<div class="editorcard">
<div class="toptools">
<EditorMenu :editor="editor" />
</div>
<div class="editcont">
<EditorContent
style="padding: 8px; overflow-y: auto;"
:editor="editor"
/>
</div>
<div class="bottomcount">
字数统计:
{{ editor?.storage.characterCount.characters() }}
</div>
</div>
</div>
<div class="righttools"></div>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, onBeforeUnmount, ref,watch } from 'vue';
import { Editor, EditorContent, useEditor, BubbleMenu } from '@tiptap/vue-3';
import { storeToRefs } from 'pinia'
import Underline from '@tiptap/extension-underline'
// 列表
import ListItem from '@tiptap/extension-list-item'
import OrderedList from '@tiptap/extension-ordered-list'
import BulletList from '@tiptap/extension-bullet-list'
// 代码
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import css from 'highlight.js/lib/languages/css'
import js from 'highlight.js/lib/languages/javascript'
import ts from 'highlight.js/lib/languages/typescript'
import html from 'highlight.js/lib/languages/xml'
import { common, createLowlight } from 'lowlight'
const lowlight = createLowlight()
lowlight.register({ html, ts, css, js })
// 字数统计
import CharacterCount from '@tiptap/extension-character-count'
import Heading from '@tiptap/extension-heading'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import { UndoRound, MoreHorizOutlined } from '@vicons/material'
import TaskItem from '@tiptap/extension-task-item'
import TaskList from '@tiptap/extension-task-list'
// 使用Pinia
import { useEditorStore } from '@/stores'
import EditorMenu from './EditorMenu/index.vue'
export default defineComponent({
components: {
EditorContent,
EditorMenu,
},
setup() {
// 使用ref创建可变的响应式引用
// 编辑器初始化
const editor = useEditor({
content: ``,
extensions: [
StarterKit,
TaskList,
TaskItem,
Placeholder.configure({
placeholder: '开始输入文本 …'
}),
OrderedList,
BulletList,
ListItem,
CharacterCount.configure({
limit: 10000
})
],
injectCSS: false,
})
onMounted(() => {
});
// 在组件卸载前销毁Editor实例
onBeforeUnmount(() => {
editor.value?.destroy();
});
// 返回editor供模板使用
return { editor };
},
});
</script>
<style>
.EditMain{
position: relative;
width:100vw;
height: 100vh;
display: grid;
grid-template-columns: 20% 60% 20%;
}
.lefttools{
background-color: rgb(111 118 177 / 60%);
height: 100%;
width: 100%;
}
.righttools{
background-color: rgb(206 226 117);
height: 100%;
width: 100%;
}
.editor{
}
.editorcard{
position: relative;
width:95%;
height: 95%;
left: 2.5%;
top:2.5%;
display: grid;
grid-template-rows: 5% 92% 3%;
border: 1px solid #4f5c5765;
}
.editorcard .editor{
position: relative;
width:100%;
height: 100%;
left: 0;
top:0;
display: grid;
grid-template-rows: 10% 90%;
}
.editorcard .editor{
position: relative;
width:100%;
height: 100%;
left: 0;
top:0;
display: grid;
grid-template-rows: 10% 90%;
}
.toptools{
background-color: rgba(207, 220, 245, 0.199);
border-bottom: 1px dashed #9ca19f65;
}
.bottomcount{
background-color: rgba(207, 220, 245, 0.199);
border-top: 1px dashed #9ca19f65;
height: 100%;
width: 100%;
display: grid;
grid-template-columns: 100%;
grid-template-rows: 100%;
justify-items: center;
align-items: center;
}
.editcont{
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
<style lang="scss">
b {
font-weight: bold;
}
.ProseMirror {
overflow-y: scroll;
}
.ProseMirror p {
margin: 0;
}
.ProseMirror:focus {
outline: none;
}
.tiptap p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.tiptap {
> * + * {
margin-top: 0.75em;
}
ul {
padding: 0 2rem;
list-style: square;
}
ol {
padding: 0 2rem;
list-style: decimal;
}
table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
margin: 0;
overflow: hidden;
td,
th {
min-width: 1em;
border: 2px solid #ced4da;
padding: 3px 5px;
vertical-align: top;
box-sizing: border-box;
position: relative;
> * {
margin-bottom: 0;
}
}
th {
font-weight: bold;
text-align: left;
background-color: #f1f3f5;
}
.selectedCell:after {
z-index: 2;
position: absolute;
content: '';
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(200, 200, 255, 0.4);
pointer-events: none;
}
.column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
background-color: #adf;
pointer-events: none;
}
p {
margin: 0;
}
}
pre {
background: #0d0d0d;
color: #fff;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
code {
color: inherit;
padding: 0;
background: none;
font-size: 0.8rem;
}
.hljs-comment,
.hljs-quote {
color: #616161;
}
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #f98181;
}
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #fbbc88;
}
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #b9f18d;
}
.hljs-title,
.hljs-section {
color: #faf594;
}
.hljs-keyword,
.hljs-selector-tag {
color: #70cff8;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
}
}
.tableWrapper {
overflow-x: auto;
}
.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
</style>
重新打开页面,不出意外的话加上了顶部的工具栏,效果如下所示:
5、字数统计
在之前预留的bottomcount中加入代码进行字数的统计,代码如下。
<div class="bottomcount">
字数统计:
{{ editor?.storage.characterCount.characters() }}
</div>
效果如下,在编辑器底部可以看到字数统计信息。
6、大纲
编写一个识别标题的的函数,每当富文本编辑器创建或者状态发生改变时,都会进行调用这个函数,从而实现大纲的效果,以下代码都添加到Edit/index.vue中,函数如下:
// 加载headings
const loadHeadings = () => {
const headings = [] as any[]
if (!editor.value) return
const transaction = editor.value.state.tr
if (!transaction) return
editor.value?.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
console.log(pos, node)
const start = pos
const end = pos + node.content.size
// const end = pos + node
const id = `heading-${headings.length + 1}`
if (node.attrs.id !== id) {
transaction?.setNodeMarkup(pos, undefined, {
...node.attrs,
id
})
}
headings.push({
level: node.attrs.level,
text: node.textContent,
start,
end,
id
})
}
})
transaction?.setMeta('addToHistory', false)
transaction?.setMeta('preventUpdate', true)
editor.value?.view.dispatch(transaction)
editorStore.setHeadings(headings)
}
heading的信息会在其他地方被使用,可以使用pinia存储在editStore里。下面代码添加到src/router/index.ts中。
import { defineStore} from 'pinia'
import { h, ref, type Component } from 'vue'
export const mainStore = defineStore('main',{
state:()=>{
return {
helloPinia:'你好 Pinia!'
}
},
getters:{},
actions:{}
})
export const useEditorStore = defineStore('editor', () => {
const headings = ref()
const activeHeading = ref()
const editorInstance = ref()
const setHeadings = (data) => {
headings.value = data
}
const setActiveHeading = (data) => {
activeHeading.value = data
}
const setEditorInstance = (data) => {
console.log(editorInstance.value)
editorInstance.value = data
}
return {
headings,
setHeadings,
activeHeading,
setActiveHeading,
editorInstance,
setEditorInstance
}
})
在src文件夹下创建Outline/index.vue组件,用于展示大纲,代码如下:
<script setup lang="ts">
import { h, ref, type Component } from 'vue'
import { useEditorStore } from '@/store'
import { storeToRefs } from 'pinia'
const editorStore = useEditorStore()
const { headings } = storeToRefs(editorStore)
/**
* 左侧区域
*/
const handleHeadingClick = (data) => {
setActiveHeading(data)
}
</script>
<template>
<div class="outline__list" style="display: flex; flex-direction: column;">
<h2 class="text-gray-400">大纲</h2>
<template v-for="(heading, index) in headings" :key="index">
<el-popover
trigger="click"
placement="right"
>
<template #reference>
<el-button
@click="handleHeadingClick(heading.text)"
text
class="outline__item"
:class="`outline__item--${heading.level}`"
>
{{ heading.text }}
<el-icon v-if="heading.icon"><component :is="heading.icon"/></el-icon>
</el-button>
</template>
<!-- 如果需要弹出内容,请在这里添加 -->
</el-popover>
</template>
</div>
</template>
<style scoped lang="scss">
.outline {
opacity: 0.75;
border-radius: 0.5rem;
padding: 0.75rem;
background: rgba(black, 0.1);
&__list {
list-style: none;
font-size: 18px;
padding: 0;
}
&__item {
a:hover {
opacity: 0.5;
}
&--1 {
font-size: 23px;
}
&--3 {
padding-left: 1rem;
}
&--4 {
padding-left: 2rem;
}
&--5 {
padding-left: 3rem;
}
&--6 {
padding-left: 4rem;
}
}
}
</style>
在Edit/index.vue中进行引入(完整代码):
<template>
<div class="EditMain">
<div class="lefttools"></div>
<div class="editor">
<div class="editorcard">
<div class="toptools">
<EditorMenu :editor="editor" />
</div>
<div class="editcont">
<EditorContent
style="padding: 8px; overflow-y: auto;"
:editor="editor"
/>
</div>
<div class="bottomcount">
字数统计:
{{ editor?.storage.characterCount.characters() }}
</div>
</div>
</div>
<div class="righttools">
<Outline></Outline>
</div>
</div>
</template>
<script lang="ts" setup>
import { defineComponent, onMounted, onBeforeUnmount, ref,watch } from 'vue';
import { Editor, EditorContent, useEditor, BubbleMenu } from '@tiptap/vue-3';
import { storeToRefs } from 'pinia'
import Underline from '@tiptap/extension-underline'
// 列表
import ListItem from '@tiptap/extension-list-item'
import OrderedList from '@tiptap/extension-ordered-list'
import BulletList from '@tiptap/extension-bullet-list'
// 代码
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import css from 'highlight.js/lib/languages/css'
import js from 'highlight.js/lib/languages/javascript'
import ts from 'highlight.js/lib/languages/typescript'
import html from 'highlight.js/lib/languages/xml'
import { common, createLowlight } from 'lowlight'
// 字数统计
import CharacterCount from '@tiptap/extension-character-count'
import Heading from '@tiptap/extension-heading'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import { UndoRound, MoreHorizOutlined } from '@vicons/material'
import TaskItem from '@tiptap/extension-task-item'
import TaskList from '@tiptap/extension-task-list'
import Outline from './Outline/index.vue'
// 使用Pinia
import { useEditorStore } from '@/store'
import EditorMenu from './EditorMenu/index.vue'
import { defineStore } from 'pinia'
import { ElMessage } from 'element-plus';
const lowlight = createLowlight()
lowlight.register({ html, ts, css, js })
const editorStore = useEditorStore()
// 加载headings
const loadHeadings = () => {
const headings = [] as any[]
if (!editor.value) return
const transaction = editor.value.state.tr
if (!transaction) return
editor.value?.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
console.log(pos, node)
const start = pos
const end = pos + node.content.size
// const end = pos + node
const id = `heading-${headings.length + 1}`
if (node.attrs.id !== id) {
transaction?.setNodeMarkup(pos, undefined, {
...node.attrs,
id
})
}
headings.push({
level: node.attrs.level,
text: node.textContent,
start,
end,
id
})
}
})
transaction?.setMeta('addToHistory', false)
transaction?.setMeta('preventUpdate', true)
editor.value?.view.dispatch(transaction)
editorStore.setHeadings(headings)
}
// 使用ref创建可变的响应式引用
// 编辑器初始化
const editor = useEditor({
content: ``,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3,4,5],
},
}),
TaskList,
TaskItem,
Placeholder.configure({
placeholder: '开始输入文本 …'
}),
OrderedList,
BulletList,
ListItem,
CharacterCount.configure({
limit: 10000
})
],
onUpdate({ edit }) {
loadHeadings()
editorStore.setEditorInstance(editor.value)
},
onCreate({ edit }) {
loadHeadings()
editorStore.setEditorInstance(editor.value)
},
injectCSS: false,
})
</script>
<style>
.EditMain{
position: relative;
width:100vw;
height: 100vh;
display: grid;
grid-template-columns: 20% 60% 20%;
}
.lefttools{
background-color: rgb(111 118 177 / 60%);
height: 100%;
width: 100%;
}
.righttools{
background-color: rgb(206 226 117);
height: 100%;
width: 100%;
}
.editor{
}
.editorcard{
position: relative;
width:95%;
height: 95%;
left: 2.5%;
top:2.5%;
display: grid;
grid-template-rows: 5% 92% 3%;
border: 1px solid #4f5c5765;
}
.editorcard .editor{
position: relative;
width:100%;
height: 100%;
left: 0;
top:0;
display: grid;
grid-template-rows: 10% 90%;
}
.editorcard .editor{
position: relative;
width:100%;
height: 100%;
left: 0;
top:0;
display: grid;
grid-template-rows: 10% 90%;
}
.toptools{
background-color: rgba(207, 220, 245, 0.199);
border-bottom: 1px dashed #9ca19f65;
}
.bottomcount{
background-color: rgba(207, 220, 245, 0.199);
border-top: 1px dashed #9ca19f65;
height: 100%;
width: 100%;
display: grid;
grid-template-columns: 100%;
grid-template-rows: 100%;
justify-items: center;
align-items: center;
}
.editcont{
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
<style lang="scss">
b {
font-weight: bold;
}
.ProseMirror {
overflow-y: scroll;
}
.ProseMirror p {
margin: 0;
}
.ProseMirror:focus {
outline: none;
}
.tiptap p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.tiptap {
> * + * {
margin-top: 0.75em;
}
ul {
padding: 0 2rem;
list-style: square;
}
ol {
padding: 0 2rem;
list-style: decimal;
}
table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
margin: 0;
overflow: hidden;
td,
th {
min-width: 1em;
border: 2px solid #ced4da;
padding: 3px 5px;
vertical-align: top;
box-sizing: border-box;
position: relative;
> * {
margin-bottom: 0;
}
}
th {
font-weight: bold;
text-align: left;
background-color: #f1f3f5;
}
.selectedCell:after {
z-index: 2;
position: absolute;
content: '';
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(200, 200, 255, 0.4);
pointer-events: none;
}
.column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
background-color: #adf;
pointer-events: none;
}
p {
margin: 0;
}
}
pre {
background: #0d0d0d;
color: #fff;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
code {
color: inherit;
padding: 0;
background: none;
font-size: 0.8rem;
}
.hljs-comment,
.hljs-quote {
color: #616161;
}
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #f98181;
}
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #fbbc88;
}
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #b9f18d;
}
.hljs-title,
.hljs-section {
color: #faf594;
}
.hljs-keyword,
.hljs-selector-tag {
color: #70cff8;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
}
}
.tableWrapper {
overflow-x: auto;
}
.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
</style>
实现的效果大致如下,样式可以根据需求自行调整:
六、划词AI润色
1、文本划词
在编辑器的操作过程中,当文本中的词汇被用户轻松划选时,随即展现的一系列AI辅助功能无疑极大地提升了用户体验和工作效率。接下来,将深入探讨这一功能的具体实现方式,以确保用户能够更为便捷地享受到这些智能化的编辑服务。
1.1、设计AI操作列表
在Edit/index.vue的中设计一个列表,如下:
<ul v-show="visiblemenu" :style="{ left: position.left + 'px', top: position.top + 'px', display: (visiblemenu ? 'block' : 'none') }" class="contextmenu">
<div class="item" @click="polish()">
<el-icon><Service /></el-icon>
润色
</div>
<div class="item" @click="continuation()">
<el-icon><Service /></el-icon>
续写
</div>
</ul>
在编辑器中加入变量
ref="filecont"
先设计好样式,编写css代码:
<style lang="less" scoped>
.contextmenu {
width: 180px;
margin: 0;
background: #fff;
z-index: 3000;
position: absolute;
list-style-type: none;
padding:5px;
padding-left: 15px;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
color: #333;
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3);
display: grid;
grid-template-columns:50% 50%;
.item {
height: 35px;
width:100%;
line-height: 35px;
color: rgb(29, 33, 41);
cursor: pointer;
}
.item:hover {
background: rgb(229, 230, 235);
}
}
</style>
1.2、定义所需函数和变量
有一些库需要引入,包括图标、弹框等,还有一些变量和函数需要定义,包括判断是否划词、操作的函数,在
import { ElMessage } from 'element-plus';
const aipolish = ref("")
const aicontinuation = ref("")
const visiblemenu = ref(false)
const position = ref({
top: 0,
left: 0
})
var hasmove=ref(false);
var hisstring:any;
//进行润色的函数
const polish=()=>{
ailoading.value=true
visiblemenu.value = false;
let formData = new FormData();
formData.append("username","123456");
formData.append("key","xxxxxxx");
formData.append("cont",hisstring);
let url = 'http://127.0.0.1:5000/getpolish' //访问后端接口的url
let method = 'post'
axios({
method,
url,
data: formData,
}).then(res => {
console.log(res.data);
var tpcard1={"title":"ai辅助评审","cont":hisstring,"review":res.data}
ailist.value.push(tpcard1)
navigator.clipboard.writeText(res.data)
showMessage()
ailoading.value=false
});
}
//进行aiaireview
const continuation=()=>{
ailoading.value=true
visiblemenu.value = false;
let formData = new FormData();
formData.append("username","123456");
formData.append("key","xxxxxxx");
formData.append("cont",hisstring);
let url = 'http://127.0.0.1:5000/getpolish' //访问后端接口的url
let method = 'post'
axios({
method,
url,
data: formData,
}).then(res => {
console.log(res.data);
showMessage()
ailoading.value=false
});
}
// 获取选中的文字
const selecttext= (e:MouseEvent)=>{
selection = window.getSelection();
if(selection!=null&&hisstring!=selection){
var content = selection.toString();
if(content!=""){
var rect = filecont.value.getBoundingClientRect();
visiblemenu.value = true
// alert(e.clientY)
// alert(e.clientX)
position.value.top = e.clientY;
position.value.left =e.clientX;
hisstring=content
}
// alert(content)
}
else{
hisstring=""
}
}
//鼠标移动
const mousemove=()=>{
hasmove.value=true;
}
//鼠标点击
const notsee=()=>{
visiblemenu.value = false;
selection=null;
}
//滚轮滚动
const hasscroll=()=>{
visiblemenu.value = false;
// window.getSelection().removeAllRanges()
}
设置好后,对EditorContent进行监听
<EditorContent
@scroll="hasscroll()"
@mousemove="mousemove()"
@mouseup="selecttext($event)"
style="padding: 8px; overflow-y: auto;"
:editor="editor"
/>
1.2、设计AI操作列表
完整代码如下所示:
<template>
<div class="EditMain" ref="filecont" @mousedown="notsee()" >
<ul @mousedown="see()" v-show="visiblemenu" :style="{ left: position.left + 'px', top: position.top + 'px', display: (visiblemenu ? 'grid' : 'none') }" class="contextmenu">
<div class="item" @click="polish()">
<el-icon><Brush /></el-icon>
润色
</div>
<div class="item" @click="continuation()">
<el-icon><EditPen /></el-icon>
续写
</div>
</ul>
<div class="lefttools"></div>
<div class="editor">
<div class="editorcard" >
<div class="toptools">
<EditorMenu :editor="editor" />
</div>
<div class="editcont" >
<EditorContent
@scroll="hasscroll()"
@mousedown="notsee()"
@mousemove="mousemove()"
@mouseup="selecttext($event)"
style="padding: 8px; overflow-y: auto;"
:editor="editor"
/>
</div>
<div class="bottomcount">
字数统计:
{{ editor?.storage.characterCount.characters() }}
</div>
</div>
</div>
<div class="righttools">
<Outline></Outline>
</div>
</div>
</template>
<script lang="ts" setup>
import {Brush,EditPen,} from '@element-plus/icons-vue'
import { defineComponent, onMounted, onBeforeUnmount, ref,watch } from 'vue';
import { Editor, EditorContent, useEditor, BubbleMenu } from '@tiptap/vue-3';
import { storeToRefs } from 'pinia'
import Underline from '@tiptap/extension-underline'
// 列表
import ListItem from '@tiptap/extension-list-item'
import OrderedList from '@tiptap/extension-ordered-list'
import BulletList from '@tiptap/extension-bullet-list'
// 代码
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import css from 'highlight.js/lib/languages/css'
import js from 'highlight.js/lib/languages/javascript'
import ts from 'highlight.js/lib/languages/typescript'
import html from 'highlight.js/lib/languages/xml'
import { common, createLowlight } from 'lowlight'
// 字数统计
import CharacterCount from '@tiptap/extension-character-count'
import Heading from '@tiptap/extension-heading'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import { UndoRound, MoreHorizOutlined } from '@vicons/material'
import TaskItem from '@tiptap/extension-task-item'
import TaskList from '@tiptap/extension-task-list'
import Outline from './Outline/index.vue'
// 使用Pinia
import { useEditorStore } from '@/store'
import EditorMenu from './EditorMenu/index.vue'
import { defineStore } from 'pinia'
import { ElMessage } from 'element-plus';
const lowlight = createLowlight()
lowlight.register({ html, ts, css, js })
const aipolish = ref("")
const filecont = ref(null);
const aicontinuation = ref("")
const visiblemenu = ref(false)
const position = ref({
top: 0,
left: 0
})
var hasmove=ref(false);
var hisstring:any;
var selection:any;
//进行润色的函数
const polish=()=>{
ailoading.value=true
visiblemenu.value = false;
let formData = new FormData();
formData.append("username","123456");
formData.append("key","xxxxxxx");
formData.append("cont",hisstring);
let url = 'http://127.0.0.1:5000/getpolish' //访问后端接口的url
let method = 'post'
axios({
method,
url,
data: formData,
}).then(res => {
console.log(res.data);
var tpcard1={"title":"ai辅助评审","cont":hisstring,"review":res.data}
ailist.value.push(tpcard1)
navigator.clipboard.writeText(res.data)
showMessage()
ailoading.value=false
});
}
//进行aireview
const continuation=()=>{
ailoading.value=true
visiblemenu.value = false;
let formData = new FormData();
formData.append("username","123456");
formData.append("key","xxxxxxx");
formData.append("cont",hisstring);
let url = 'http://127.0.0.1:5000/getpolish' //访问后端接口的url
let method = 'post'
axios({
method,
url,
data: formData,
}).then(res => {
console.log(res.data);
showMessage()
ailoading.value=false
});
}
// 获取选中的文字
const selecttext= (e:MouseEvent)=>{
selection = window.getSelection();
if(selection!=null&&hisstring!=selection){
var content = selection.toString();
if(content!=""){
var rect = filecont.value.getBoundingClientRect();
visiblemenu.value = true
// alert(e.clientY)
// alert(e.clientX)
position.value.top = e.clientY;
position.value.left =e.clientX;
hisstring=content
}
// alert(content)
}
else{
hisstring=""
}
}
//鼠标移动
const mousemove=()=>{
hasmove.value=true;
}
//鼠标点击
const notsee=()=>{
visiblemenu.value = false;
// selection.value="";
}
const see=()=>{
visiblemenu.value = true;
// selection.value="";
}
//滚轮滚动
const hasscroll=()=>{
visiblemenu.value = false;
// window.getSelection().removeAllRanges()
}
const editorStore = useEditorStore()
// 加载headings
const loadHeadings = () => {
const headings = [] as any[]
if (!editor.value) return
const transaction = editor.value.state.tr
if (!transaction) return
editor.value?.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
console.log(pos, node)
const start = pos
const end = pos + node.content.size
// const end = pos + node
const id = `heading-${headings.length + 1}`
if (node.attrs.id !== id) {
transaction?.setNodeMarkup(pos, undefined, {
...node.attrs,
id
})
}
headings.push({
level: node.attrs.level,
text: node.textContent,
start,
end,
id
})
}
})
transaction?.setMeta('addToHistory', false)
transaction?.setMeta('preventUpdate', true)
editor.value?.view.dispatch(transaction)
editorStore.setHeadings(headings)
}
// 使用ref创建可变的响应式引用
// 编辑器初始化
const editor = useEditor({
content: ``,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3,4,5],
},
}),
TaskList,
TaskItem,
Placeholder.configure({
placeholder: '开始输入文本 …'
}),
OrderedList,
BulletList,
ListItem,
CharacterCount.configure({
limit: 10000
})
],
onUpdate({ edit }) {
loadHeadings()
editorStore.setEditorInstance(editor.value)
},
onCreate({ edit }) {
loadHeadings()
editorStore.setEditorInstance(editor.value)
},
injectCSS: false,
})
</script>
<style>
.EditMain{
position: relative;
width:100vw;
height: 100vh;
display: grid;
grid-template-columns: 20% 60% 20%;
}
.lefttools{
background-color: rgb(111 118 177 / 60%);
height: 100%;
width: 100%;
}
.righttools{
background-color: rgb(206 226 117);
height: 100%;
width: 100%;
}
.editor{
}
.editorcard{
position: relative;
width:95%;
height: 95%;
left: 2.5%;
top:2.5%;
display: grid;
grid-template-rows: 5% 92% 3%;
border: 1px solid #4f5c5765;
}
.editorcard .editor{
position: relative;
width:100%;
height: 100%;
left: 0;
top:0;
display: grid;
grid-template-rows: 10% 90%;
}
.editorcard .editor{
position: relative;
width:100%;
height: 100%;
left: 0;
top:0;
display: grid;
grid-template-rows: 10% 90%;
}
.toptools{
background-color: rgba(207, 220, 245, 0.199);
border-bottom: 1px dashed #9ca19f65;
}
.bottomcount{
background-color: rgba(207, 220, 245, 0.199);
border-top: 1px dashed #9ca19f65;
height: 100%;
width: 100%;
display: grid;
grid-template-columns: 100%;
grid-template-rows: 100%;
justify-items: center;
align-items: center;
}
.editcont{
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
<style lang="scss">
b {
font-weight: bold;
}
.ProseMirror {
overflow-y: scroll;
}
.ProseMirror p {
margin: 0;
}
.ProseMirror:focus {
outline: none;
}
.tiptap p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.tiptap {
> * + * {
margin-top: 0.75em;
}
ul {
padding: 0 2rem;
list-style: square;
}
ol {
padding: 0 2rem;
list-style: decimal;
}
table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
margin: 0;
overflow: hidden;
td,
th {
min-width: 1em;
border: 2px solid #ced4da;
padding: 3px 5px;
vertical-align: top;
box-sizing: border-box;
position: relative;
> * {
margin-bottom: 0;
}
}
th {
font-weight: bold;
text-align: left;
background-color: #f1f3f5;
}
.selectedCell:after {
z-index: 2;
position: absolute;
content: '';
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(200, 200, 255, 0.4);
pointer-events: none;
}
.column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
background-color: #adf;
pointer-events: none;
}
p {
margin: 0;
}
}
pre {
background: #0d0d0d;
color: #fff;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
code {
color: inherit;
padding: 0;
background: none;
font-size: 0.8rem;
}
.hljs-comment,
.hljs-quote {
color: #616161;
}
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #f98181;
}
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #fbbc88;
}
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #b9f18d;
}
.hljs-title,
.hljs-section {
color: #faf594;
}
.hljs-keyword,
.hljs-selector-tag {
color: #70cff8;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
}
}
.tableWrapper {
overflow-x: auto;
}
.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
.contextmenu {
width: 120px;
margin: 0;
background: #fff;
z-index: 3000;
position: absolute;
list-style-type: none;
padding:5px;
padding-left: 15px;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
color: #333;
box-shadow: 1px 1px 2px 1px rgba(0, 0, 0, 0.3);
display: grid;
grid-template-columns:50% 50%;
}
.contextmenu .item {
height: 35px;
width:100%;
line-height: 35px;
color: rgb(29, 33, 41);
cursor: pointer;
}
.contextmenu .item {
height: 35px;
width:100%;
line-height: 35px;
color: rgb(29, 33, 41);
cursor: pointer;
}
.contextmenu .item:hover {
background: rgb(229, 230, 235);
}
</style>
实现效果如下:
2、erniebot安装与使用
接下来配置后端的接口。打开之前配置好的pycharm,选择合适的虚拟环境后(也可以是base环境),需要安装erniebot。
pip install erniebot==0.4.0 -i https://pypi.tuna.tsinghua.edu.cn/simple
安装好后需要获取访问令牌,打开飞桨https://aistudio.baidu.com/account/accessToken,这个页面。注册登录后获取访问令牌,下面备用,更多详细的操作可以参考下面这个说明文档:https://ernie-bot-agent.readthedocs.io/zh-cn/latest/。
先创建一个test.py,测试代码如下,访问令牌替换为自己的,运行测试:
import erniebot
import erniebot
erniebot.api_type = 'aistudio'
erniebot.access_token = '输入您的token'
response = erniebot.ChatCompletion.create(
model='ernie-bot',
messages=[{'role': 'user', 'content': "你是?"}],
)
print(response.get_result())
测试结果,代表可以接入erniebot。
3、润色接口配置
回到main.py文件,编写如下代码:
from flask import Flask, json, request, jsonify
from flask_cors import CORS
import pymysql
DEBUG = True
app = Flask(__name__)
app.config.from_object(__name__)
CORS(app, resource={r'/*': {'origins': '*'}})
import erniebot
erniebot.api_type = 'aistudio'
@app.route('/getpolish', methods=["GET", "POST"])
def getpolish():
# 获取用户名
username= request.form.get("username")
# 获取用户的访问令牌
key = request.form.get("key")
# 获取用户提问内容
quescont = request.form.get("cont")
askcont="帮我润色下面这段话:"+quescont
erniebot.access_token = key
try:
response = erniebot.ChatCompletion.create(
model='ernie-bot',
messages=[{'role': 'user', 'content':askcont}],
)
restext = response['result']
webdict = {'answer': restext}
return jsonify(webdict)
except:
return "error"
@app.route('/getcontinuation', methods=["GET", "POST"])
def getcontinuation():
# 获取用户名
username= request.form.get("username")
# 获取用户的访问令牌
key = request.form.get("key")
# 获取用户提问内容
quescont = request.form.get("cont")
askcont="帮我续写下面这段话:"+quescont
erniebot.access_token = key
try:
response = erniebot.ChatCompletion.create(
model='ernie-bot',
messages=[{'role': 'user', 'content':askcont}],
)
restext = response['result']
webdict = {'answer': restext}
return jsonify(webdict)
except:
return "error"
if __name__ == '__main__':
app.run(host="127.0.0.1", port=5000, debug=True)
运行。
4、润色接口调用
后端运行,之后,在前端进行调用,修改polish函数和continuation函数(注意修改key为自己的访问令牌)。
import axios from 'axios'
//进行润色的函数
const polish=()=>{
visiblemenu.value = false;
let formData = new FormData();
formData.append("username","xxxxxx");
formData.append("key","xxxxxx");
formData.append("cont",hisstring);
let url = 'http://127.0.0.1:5000/getpolish' //访问后端接口的url
let method = 'post'
axios({
method,
url,
data: formData,
}).then(res => {
alert(res.data.answer)
console.log(res.data.answer);
});
}
//进行aiaireview
const continuation=()=>{
visiblemenu.value = false;
let formData = new FormData();
formData.append("username","123456");
formData.append("key","xxxxxxx");
formData.append("cont",hisstring);
let url = 'http://127.0.0.1:5000/getcontinuation' //访问后端接口的url
let method = 'post'
axios({
method,
url,
data: formData,
}).then(res => {
alert(res.data.answer)
console.log(res.data.answer);
});
}
点击润色,润色完成后弹出结果,至此,完成接口调用。