百度飞桨AI达人创造营实战(二)【自制“飞书”】智能编辑器开发
本文最后更新于 2025-03-30,文章内容可能已经过时。
简介
OCR
识别,是一种通过扫描、拍照等光学输入方式将各种票据、报刊、书籍、文稿及其他印刷品的文字转化为图像信息,再利用文字识别技术将图像信息转化为可以编辑和检索的文本的技术。OCR
识别技术在现代社会中发挥着重要作用,它极大地提高了数据输入的效率和准确性,为文档处理和信息管理提供了高效的解决方案。本章节将详细阐述如何结合PaddlePaddle
框架,实现前后端协同工作以完成输入图像中的文字识别任务。
首先创建feishu
文件夹,后续项目均在此进行
前端基础
创建vue项目
在feishu
文件夹下创建vue
项目
npm create vite Editfront
安装依赖
cd Editfront
npm install
npm run dev
执行demo
npm run dev
查看如下:
安装依赖
按照开发基础文档安装依赖
npm install vue-router pinia axios element-plus remixicon
npm install -D sass sass-loader
npm install --save-dev @types/node
注意都需要在前端项目文件夹下安装
创建目录与文件
在src
目录下创建/views/HomePage/index.vue
目录结构如下
src/
├── views/
│ └── HomePage/
│ └── index.vue
├── assets/
│ └── vue.svg
├── components/
│ └── HelloWorld.vue
├── App.vue
├── vite-env.d.ts
├── main.ts
└── style.css
添加如下代码
<template>
<div class="mainbody">
<div class="content-container">
<h1 class="headtitle">图片上传中心</h1>
<div class="pinia-message">
<h2>{{ demoStore.helloPinia}}</h2>
</div>
<div class="uploadimage">
<div class="upload-area">
<el-upload
ref="uploadRef"
list-type="picture-card"
:auto-upload="false"
:on-change="handleChanges"
:before-remove="beforeRemove"
:on-preview="handlePictureCardPreview"
:file-list="fileList"
multiple
:limit="1"
class="custom-upload"
>
<template #default>
<div class="upload-content">
<el-icon class="avatar-uploader-icon">
<Plus />
</el-icon>
<div class="upload-text">点击上传图片</div>
</div>
</template>
</el-upload>
</div>
<el-dialog v-model="dialogVisible" class="preview-dialog">
<img w-full class="imageshow" :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
</div>
<el-button type="primary" @click="submitUpload" class="upload-button" :disabled="fileList.length === 0">上传图片</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { mainStore} from '../../store/index'
import { ref } from 'vue'
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import type { UploadFile } from 'element-plus'
// 获取Store
const demoStore = mainStore()
// 测试接口调用
const adduser = () => {
let formData = new FormData();
formData.append("username", "12345");
formData.append("password", "54321");
axios({
method: 'post',
url: 'http://127.0.0.1:5000/adduser',
data: formData,
}).then(res => {
console.log("接口测试成功")
});
}
adduser()
// 对话框相关
const dialogImageUrl = ref('')
const dialogVisible = ref(false)
// 文件上传相关
const fileList = ref([])
const boxdisplay = ref("show_box")
// 文件选择变化
const handleChanges = (uploadFile) => {
console.log("文件选择变化:", uploadFile)
// 避免重复添加
if (!fileList.value.some(file => file.uid === uploadFile.uid)) {
fileList.value.push(uploadFile)
boxdisplay.value = "hide_box"
}
}
// 预览图片
const handlePictureCardPreview = (file: UploadFile) => {
console.log("预览图片:", file)
dialogImageUrl.value = file.url || URL.createObjectURL(file.raw!)
dialogVisible.value = true
}
// 移除文件前确认
const beforeRemove = () => {
return new Promise((resolve, reject) => {
ElMessageBox.confirm("此操作将删除该图片, 是否继续?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
})
.then(() => {
boxdisplay.value = "show_box"
fileList.value = []
resolve(true)
})
.catch(() => {
reject(false)
})
})
}
// 提交上传
const submitUpload = () => {
// 检查是否有选择文件
if (fileList.value.length === 0) {
ElMessage({
message: '请先选择图片',
type: 'warning'
});
return;
}
// 创建FormData对象
const formData = new FormData();
formData.append("username", "12345");
// 添加文件
fileList.value.forEach((file) => {
if (file.raw) {
formData.append("file", file.raw);
console.log("正在上传文件:", file.name);
}
});
// 显示上传中提示
ElMessage({
message: '图片上传中...',
type: 'info'
});
// 手动发送axios请求
axios({
method: 'post',
url: 'http://127.0.0.1:5000/uploadimages',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then(response => {
console.log("上传成功:", response);
// 清空文件列表
fileList.value = [];
// 显示成功消息
ElMessage({
message: '图片上传成功',
type: 'success'
});
})
.catch(error => {
console.error("上传失败:", error);
ElMessage({
message: '图片上传失败: ' + (error.message || '未知错误'),
type: 'error'
});
});
}
</script>
<style scoped>
/* 确保整个页面没有边距和滚动条 */
:deep(html),
:deep(body) {
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
}
.mainbody {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(120deg, #2b87c5, #55b9e4, #7dd3f0);
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
overflow: hidden;
margin: 0;
padding: 0;
}
.content-container {
width: 450px;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 35px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
align-items: center;
transform: translateX(0);
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.headtitle {
font-size: 26px;
color: #2c3e50;
margin-bottom: 16px;
text-align: center;
position: relative;
padding-bottom: 10px;
font-weight: 600;
}
.headtitle::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 50px;
height: 3px;
background: #409EFF;
border-radius: 3px;
}
.pinia-message {
margin-bottom: 25px;
text-align: center;
}
.pinia-message h2 {
font-size: 16px;
color: #606266;
font-weight: normal;
}
.uploadimage {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin: 15px 0 30px;
}
.upload-area {
width: 100%;
display: flex;
justify-content: center;
}
/* 调整上传图片组件尺寸和样式 */
:deep(.el-upload) {
width: 100%;
display: flex;
justify-content: center;
}
:deep(.el-upload--picture-card) {
width: 250px;
height: 250px;
border-radius: 12px;
border: 2px dashed #c0c4cc;
transition: all 0.3s;
display: flex;
justify-content: center;
align-items: center;
}
:deep(.el-upload--picture-card:hover) {
border-color: #409EFF;
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(64, 158, 255, 0.2);
}
:deep(.el-upload-list--picture-card) {
justify-content: center;
}
:deep(.el-upload-list--picture-card .el-upload-list__item) {
width: 250px;
height: 250px;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s;
margin: 0;
}
:deep(.el-upload-list--picture-card .el-upload-list__item:hover) {
transform: scale(1.02);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
}
.upload-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
}
/* 调整上传图标大小和样式 */
:deep(.avatar-uploader-icon) {
font-size: 50px;
color: #409EFF;
text-align: center;
margin-bottom: 15px;
}
.upload-text {
font-size: 16px;
color: #606266;
text-align: center;
}
.upload-button {
padding: 12px 40px;
font-size: 16px;
border-radius: 30px;
background: linear-gradient(90deg, #409EFF, #36b4eb);
border: none;
box-shadow: 0 4px 15px rgba(64, 158, 255, 0.4);
transition: all 0.3s;
margin-top: 10px;
}
.upload-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(64, 158, 255, 0.5);
}
.upload-button:disabled {
background: #a0cfff;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.hide_box {
display: none;
}
.show_box {
display: inline-flex;
}
.imageshow {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 4px;
}
/* 调整预览对话框大小和样式 */
:deep(.el-dialog) {
max-width: 80%;
max-height: 80%;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
:deep(.el-dialog__header) {
padding: 15px 20px;
background: #f5f7fa;
border-bottom: 1px solid #e6e6e6;
}
:deep(.el-dialog__body) {
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
background: #f0f2f5;
}
</style>
<style lang="scss" scoped>
/* 在此处编写代码 */
</style>
<style scoped>
.hide_box {
display: none;
}
.show_box {
display: inline-flex;
}
.imageshow {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 4px;
}
/* 调整预览对话框大小和样式 */
:deep(.el-dialog) {
max-width: 80%;
max-height: 80%;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
:deep(.el-dialog__header) {
padding: 15px 20px;
background: #f5f7fa;
border-bottom: 1px solid #e6e6e6;
}
:deep(.el-dialog__body) {
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
background: #f0f2f5;
}
</style>
修改App.vue
,删除原有示例,完整代码如下:
<script setup lang="ts">
import HomePage from './views/HomePage/index.vue'
</script>
<template>
<HomePage />
</template>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
}
</style>
修改main.ts
,完整代码如下:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
// 引入Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
const app = createApp(App)
app.use(createPinia())
// 注册Element Plus
app.use(ElementPlus, {
locale: zhCn
})
app.mount('#app')
在src
下创建store/index.ts
import { defineStore } from 'pinia'
// 定义并导出容器
// 参数1:容器的ID,必须唯一,将来Pinia会把所有的容器挂载到根容器
// 参数2:选项对象
export const mainStore = defineStore('main', {
// 状态数据,类似于组件的data
state: () => {
return {
// 状态数据
helloPinia: '欢迎使用Pinia状态管理工具'
}
},
// 类似于组件的computed,通过getter修饰的变量获取状态数据
getters: {
},
// 类似于组件的methods,用于修改state的值
actions: {
// 修改状态数据
updateHelloPinia(value: string) {
this.helloPinia = value
}
}
})
在src
下创建shims-vue.d.ts
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
修改src/style.css
/* 全局样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
}
#app {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
src
完整目录如下:
src/
├── assets/
│ └── vue.svg
├── components/
│ └── HelloWorld.vue
├── store/
│ └── index.ts
├── views/
│ └── HomePage/
│ └── index.vue
├── App.vue
├── main.ts
├── shims-vue.d.ts
├── style.css
└── vite-env.d.ts
运行后效果如下:
后端基础
在feishu
目录下创建Editbackend
目录,在该目录下创建static/images
目录,static
目录用于存储静态资源
使用conda
创建环境后我们来安装依赖
pip install opencv-python flask flask_cors pymysql -i https://pypi.tuna.tsinghua.edu.cn/simple
在Editbackend
下创建app.py
,添加以下代码
from flask import Flask, json, request, jsonify
from flask_cors import CORS
import pymysql
import cv2
import numpy as np
import os
DEBUG = True
app = Flask(__name__)
app.config.from_object(__name__)
CORS(app, resource={r'/*': {'origins': '*'}})
@app.route('/adduser', methods=['get', 'post'])
def adduser():
username = request.form.get("username")
password = request.form.get("password")
print(username)
print(password)
return "已接收用户信息"
@app.route('/uploadimages', methods=['get', 'post'])
def uploadimages():
username = request.form.get("username")
img = request.files['file']
picname=img.filename
file = img.read()
file = cv2.imdecode(np.frombuffer(file, np.uint8), cv2.IMREAD_COLOR) # 解码为ndarray
imgfile1_path = "./static/images/"+username+"/"
if not os.path.exists(imgfile1_path):
os.makedirs(imgfile1_path)
img1_path = os.path.join(imgfile1_path, picname)
cv2.imwrite(filename=img1_path, img=file)
url = "http://127.0.0.1:5000/static/images/"+username+"/" + picname
print(url)
tempmap = {"url": url}
return jsonify(tempmap)
return "已接收用户图片信息"
if __name__ == '__main__':
app.run(host="127.0.0.1", port=5000, debug=True)
执行后端python app.py
同时运行前端,效果如下:
在后端images
目录可以找到图片并且通过链接可以访问到图像说明成功了
数据库存储
存储图片信息可以为存Base64
编码,但是比较吃资源,这里实现存储图片在本地,存储图片路径,用户通过路径访问服务器的图片从而实现存储功能。上一步骤已经生成了图像的链接,接下来把这部分存储到数据库当中。
首先在DataGrip
(或任何数据库管理软件)中创建editor
数据库
使用如下SQL
语句建表:
CREATE TABLE images (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
url VARCHAR(255) NOT NULL,
picname VARCHAR(100) NOT NULL
);
后端编写存储数据库的代码,完整的main.py
代码如下:
注意其中关于数据库的配置需要改成自己的设置
from flask import Flask, json, request, jsonify
from flask_cors import CORS
import pymysql
import cv2
import numpy as np
import os
DEBUG = True
app = Flask(__name__)
app.config.from_object(__name__)
CORS(app, resource={r'/*': {'origins': '*'}})
@app.route('/adduser', methods=['get', 'post'])
def adduser():
username = request.form.get("username")
password = request.form.get("password")
print(username)
print(password)
return "已接收用户信息"
@app.route('/uploadimages', methods=['get', 'post'])
def uploadimages():
username = request.form.get("username")
img = request.files['file']
picname=img.filename
file = img.read()
file = cv2.imdecode(np.frombuffer(file, np.uint8), cv2.IMREAD_COLOR) # 解码为ndarray
imgfile1_path = "./static/images/"+username+"/"
if not os.path.exists(imgfile1_path):
os.makedirs(imgfile1_path)
img1_path = os.path.join(imgfile1_path, picname)
cv2.imwrite(filename=img1_path, img=file)
url = "http://127.0.0.1:5000/static/images/"+username+"/" + picname
print(url)
conn = pymysql.connect(host='127.0.0.1', port=3306, user='root', password='root', charset='utf8')
cursor = conn.cursor()
conn.commit()
cursor.execute('use editdata')
sql = "INSERT INTO `imgpath` (`username`,`url`,`picname`) VALUES('" + str(username) + "','" + str(url) + "','" + str(picname) + "');"
""
print(sql)
count = cursor.execute(sql)
# print(count)
conn.commit()
cursor.close()
conn.close()
tempmap = {"url": url}
return jsonify(tempmap)
return "已接收用户图片信息"
if __name__ == '__main__':
app.run(host="127.0.0.1", port=5000, debug=True)
前端上传图像,后端接收对应的数据,保存在本地后生成链接,存入地址信息至数据库。测试后发现,可以存储到数据库当中了。
OCR本地部署
安装环境
PaddleOCR/doc/doc_ch/installation.md at static · PaddlePaddle/PaddleOCR
首先根据自己的配置安装paddlepaddle
python -m pip install paddlepaddle-gpu==2.6.2 -i https://www.paddlepaddle.org.cn/packages/stable/cu118/
克隆PaddleOCR
git clone https://github.com/PaddlePaddle/PaddleOCR
安装依赖
cd PaddleOCR
pip install -r requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
使用源码安装
pip install .
安装完成后,上传一张需要识别的测试图片在test.py
相同路径下,修改test.py
代码进行测试
import paddle
print(paddle.__version__)
paddle.utils.run_check()
from paddleocr import PaddleOCR, draw_ocr
# Paddleocr目前支持的多语言语种可以通过修改lang参数进行切换
# 例如`ch`, `en`, `fr`, `german`, `korean`, `japan`
ocr = PaddleOCR(use_angle_cls=True, lang="ch") # need to run only once to download and load model into memory
img_path = './test.png'
result = ocr.ocr(img_path, cls=True)
for idx in range(len(result)):
res = result[idx]
for line in res:
print(line)
# 显示结果
from PIL import Image
result = result[0]
image = Image.open(img_path).convert('RGB')
boxes = [line[0] for line in result]
txts = [line[1][0] for line in result]
scores = [line[1][1] for line in result]
im_show = draw_ocr(image, boxes, txts, scores, font_path='./fonts/simfang.ttf')
im_show = Image.fromarray(im_show)
im_show.save('result.jpg')
至此,本地的环境配置完成,其他一些paddle
模型本地运行也可以参考这种方式,安装对应的库,导入训练好的模型(或者自己重新训练也ok
),接着进行跑通即可在后续后端进行模型的使用。
封装为api
加载模型其实比较费时,特别是加载一些比较大的模型时候,推理的时间会较少,因此,可以定义全局的模型,让flask
运行时候就先加载好对应的模型,用的时候直接进行推理,能提高响应的速度,修改app.py
代码如下。
from flask import Flask, json, request, jsonify
from flask_cors import CORS
import pymysql
import cv2
import numpy as np
import os
import paddle
from paddleocr import PaddleOCR, draw_ocr
DEBUG = True
app = Flask(__name__)
app.config.from_object(__name__)
CORS(app, resource={r'/*': {'origins': '*'}})
ocr = PaddleOCR(use_angle_cls=True, lang="ch") # need to run only once to download and load model into memory
@app.route('/adduser', methods=['get', 'post'])
def adduser():
username = request.form.get("username")
password = request.form.get("password")
print(username)
print(password)
return "已接收用户信息"
@app.route('/uploadimages', methods=['get', 'post'])
def uploadimages():
username = request.form.get("username")
img = request.files['file']
picname=img.filename
file = img.read()
file = cv2.imdecode(np.frombuffer(file, np.uint8), cv2.IMREAD_COLOR) # 解码为ndarray
imgfile1_path = "./static/images/"+username+"/"
if not os.path.exists(imgfile1_path):
os.makedirs(imgfile1_path)
img1_path = os.path.join(imgfile1_path, picname)
cv2.imwrite(filename=img1_path, img=file)
img_path=imgfile1_path+picname
result = ocr.ocr(img_path, cls=True)
for idx in range(len(result)):
res = result[idx]
for line in res:
print(line)
url = "http://127.0.0.1:5000/static/images/"+username+"/" + picname
print(url)
conn = pymysql.connect(host='127.0.0.1', port=3306, user='root', password='root', charset='utf8')
cursor = conn.cursor()
conn.commit()
cursor.execute('use editdata')
sql = "INSERT INTO `imgpath` (`username`,`url`,`picname`) VALUES('" + str(username) + "','" + str(url) + "','" + str(picname) + "');"
""
print(sql)
count = cursor.execute(sql)
# print(count)
conn.commit()
cursor.close()
conn.close()
tempmap = {"url": url}
return jsonify(tempmap)
return "已接收用户图片信息"
if __name__ == '__main__':
app.run(host="127.0.0.1", port=5000, debug=True)
前端上传需要识别的图片,传到后端后进行图像保存并识别文字,识别结果你可以根据自己的任务需求进行存数据库、传回前端等各种操作,至此,本地部署小模型的小模块实现完成了。
产线环境配置(可选)
如果本地资源不足(如无GPU算力,可以使用飞桨的模型产线功能)打开aistudio
的模型产线模块。
创建一个新的产线,选择OCR,设置好产线名称后确认创建:
可以选择数据集后进行训练后再部署,也可以直接进行部署。
如果选择直接部署,选择环境之后即可进行,因为需要使用CPU或GPU资源。需要A币进行开启,推荐大家开一个小会员体验一下,送1000个A币+100万Token。
进行必须项的配置后点击开始部署。
即可发现部署完成了,给出了使用的示例。
在本地的test.py
中编写代码测试一下,记得替换为自己的token
:
import base64
import pathlib
import pprint
import requests
API_URL = "https://105b32tccaj145nd.aistudio-hub.baidu.com/ocr"
# 请前往 https://aistudio.baidu.com/index/accessToken 查看 访问令牌 并替换
TOKEN = "替换为您的token"
# 设置鉴权信息
headers = {
"Authorization": f"token {TOKEN}",
"Content-Type": "application/json"
}
# 对本地图片进行Base64编码
image_path = "./test.png"
image_bytes = pathlib.Path(image_path).read_bytes()
image_base64 = base64.b64encode(image_bytes).decode('ascii')
# 设置请求体
payload = {
"image": image_base64 # Base64编码的文件内容或者文件链接
}
# 调用
resp = requests.post(url=API_URL, json=payload, headers=headers)
# 处理接口返回数据
assert resp.status_code == 200
result = resp.json()["result"]
output_image_path = "output.jpg"
with open(output_image_path, "wb") as f:
f.write(base64.b64decode(result["image"]))
print(f"OCR结果图保存在 {output_image_path}")
print("\n文本信息:")
pprint.pp(result["texts"])
运行后发现能够使用图像识别的功能,像本地部署一样封装为接口就可以前端调用使用了,可以让用户输入自己的接口,其他部分大家可以自行尝试。
编辑器引入与操作方法
目前开源的编辑器框架有很多,这里主要介绍一款自定义能力强大的富文本编辑器Tiptap
。
Tiptap介绍
Tiptap
是一个基于 ProseMirror
构建的富文本编辑器,它是一个灵活、可扩展的富文本编辑器,同时适用于 Vue.js
和 React
。Tiptap
的核心思路是通过插件系统提供丰富的功能,使得开发者可以根据需求定制编辑器的功能和样式。
官网地址:https://tiptap.dev/
github地址:https://github.com/ueberdosis/tiptap
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
可以根据需求灵活地扩展功能和样式。
Tiptap安装
在终端执行如下npm
安装指令安装Tiptap
npm install @tiptap/vue-3 @tiptap/pm @tiptap/starter-kit @tiptap/extension-image
安装插件参考Extensions | Tiptap Editor Docs
安装完成后重新启动项目,在之前创建Editfront/src/HomePage
文件夹下的index.vue
中编写下面的测试代码:
<template>
<div class="mainbody">
<div class="content-container">
<h1 class="headtitle">图片上传与编辑中心</h1>
<div class="pinia-message">
<h2>{{ demoStore.helloPinia}}</h2>
</div>
<!-- 添加标签页组件 -->
<el-tabs v-model="activeTab" class="tab-container">
<el-tab-pane label="图片上传" name="upload">
<div class="uploadimage">
<div class="upload-area">
<el-upload
ref="uploadRef"
list-type="picture-card"
:auto-upload="false"
:on-change="handleChanges"
:before-remove="beforeRemove"
:on-preview="handlePictureCardPreview"
:file-list="fileList"
multiple
:limit="1"
class="custom-upload"
>
<template #default>
<div class="upload-content">
<el-icon class="avatar-uploader-icon">
<Plus />
</el-icon>
<div class="upload-text">点击上传图片</div>
</div>
</template>
</el-upload>
</div>
<el-dialog v-model="dialogVisible" class="preview-dialog">
<img w-full class="imageshow" :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
</div>
<el-button type="primary" @click="submitUpload" class="upload-button" :disabled="fileList.length === 0">上传图片</el-button>
</el-tab-pane>
<el-tab-pane label="富文本编辑器" name="editor">
<div class="editor-container">
<div class="editor-menu">
<el-button
size="small"
@click="editor.chain().focus().toggleBold().run()"
:class="{ 'is-active': editor.isActive('bold') }"
>
加粗
</el-button>
<el-button
size="small"
@click="editor.chain().focus().toggleItalic().run()"
:class="{ 'is-active': editor.isActive('italic') }"
>
斜体
</el-button>
<el-button
size="small"
@click="editor.chain().focus().toggleStrike().run()"
:class="{ 'is-active': editor.isActive('strike') }"
>
删除线
</el-button>
<el-button
size="small"
@click="editor.chain().focus().setParagraph().run()"
:class="{ 'is-active': editor.isActive('paragraph') }"
>
段落
</el-button>
<el-button
size="small"
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
>
H1
</el-button>
<el-button
size="small"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
>
H2
</el-button>
<el-button
size="small"
@click="editor.chain().focus().toggleBulletList().run()"
:class="{ 'is-active': editor.isActive('bulletList') }"
>
无序列表
</el-button>
<el-button
size="small"
@click="editor.chain().focus().toggleOrderedList().run()"
:class="{ 'is-active': editor.isActive('orderedList') }"
>
有序列表
</el-button>
<el-button
size="small"
@click="addImage"
>
插入图片
</el-button>
</div>
<editor-content :editor="editor" class="editor-content" />
<div class="editor-actions">
<el-button type="primary" @click="saveContent">保存内容</el-button>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script setup lang="ts">
import { mainStore} from '../../store/index'
import { ref, onBeforeUnmount, shallowRef } from 'vue'
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import type { UploadFile } from 'element-plus'
// TipTap相关导入
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
// 获取Store
const demoStore = mainStore()
// 标签页状态
const activeTab = ref('upload')
// TipTap编辑器
const editor = shallowRef()
// 初始化编辑器
editor.value = new Editor({
extensions: [
StarterKit,
Image
],
content: '<p>欢迎使用富文本编辑器!</p>',
autofocus: true,
editable: true,
injectCSS: false,
})
// 在组件销毁前销毁编辑器
onBeforeUnmount(() => {
editor.value.destroy()
})
// 添加图片
const addImage = () => {
const url = prompt('输入图片URL')
if (url) {
editor.value.chain().focus().setImage({ src: url }).run()
}
}
// 保存内容
const saveContent = () => {
const content = editor.value.getHTML()
console.log('保存的内容:', content)
ElMessage({
message: '内容已保存',
type: 'success'
})
}
// 测试接口调用
const adduser = () => {
let formData = new FormData();
formData.append("username", "12345");
formData.append("password", "54321");
axios({
method: 'post',
url: 'http://127.0.0.1:5000/adduser',
data: formData,
}).then(res => {
console.log("接口测试成功")
});
}
adduser()
// 对话框相关
const dialogImageUrl = ref('')
const dialogVisible = ref(false)
// 文件上传相关
const fileList = ref([])
const boxdisplay = ref("show_box")
// 文件选择变化
const handleChanges = (uploadFile) => {
console.log("文件选择变化:", uploadFile)
// 避免重复添加
if (!fileList.value.some(file => file.uid === uploadFile.uid)) {
fileList.value.push(uploadFile)
boxdisplay.value = "hide_box"
}
}
// 预览图片
const handlePictureCardPreview = (file: UploadFile) => {
console.log("预览图片:", file)
dialogImageUrl.value = file.url || URL.createObjectURL(file.raw!)
dialogVisible.value = true
}
// 移除文件前确认
const beforeRemove = () => {
return new Promise((resolve, reject) => {
ElMessageBox.confirm("此操作将删除该图片, 是否继续?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
})
.then(() => {
boxdisplay.value = "show_box"
fileList.value = []
resolve(true)
})
.catch(() => {
reject(false)
})
})
}
// 提交上传
const submitUpload = () => {
// 检查是否有选择文件
if (fileList.value.length === 0) {
ElMessage({
message: '请先选择图片',
type: 'warning'
});
return;
}
// 创建FormData对象
const formData = new FormData();
formData.append("username", "12345");
// 添加文件
fileList.value.forEach((file) => {
if (file.raw) {
formData.append("file", file.raw);
console.log("正在上传文件:", file.name);
}
});
// 显示上传中提示
ElMessage({
message: '图片上传中...',
type: 'info'
});
// 手动发送axios请求
axios({
method: 'post',
url: 'http://127.0.0.1:5000/uploadimages',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then(response => {
console.log("上传成功:", response);
// 清空文件列表
fileList.value = [];
// 显示成功消息
ElMessage({
message: '图片上传成功',
type: 'success'
});
})
.catch(error => {
console.error("上传失败:", error);
ElMessage({
message: '图片上传失败: ' + (error.message || '未知错误'),
type: 'error'
});
});
}
</script>
<style scoped>
/* 确保整个页面没有边距和滚动条 */
:deep(html),
:deep(body) {
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
}
.mainbody {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(120deg, #2b87c5, #55b9e4, #7dd3f0);
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
overflow: hidden;
margin: 0;
padding: 0;
}
.content-container {
width: 700px;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 35px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
align-items: center;
transform: translateX(0);
animation: fadeIn 0.6s ease-out;
}
.tab-container {
width: 100%;
margin-bottom: 20px;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.headtitle {
font-size: 26px;
color: #2c3e50;
margin-bottom: 16px;
text-align: center;
position: relative;
padding-bottom: 10px;
font-weight: 600;
}
.headtitle::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 50px;
height: 3px;
background: #409EFF;
border-radius: 3px;
}
.pinia-message {
margin-bottom: 25px;
text-align: center;
}
.pinia-message h2 {
font-size: 16px;
color: #606266;
font-weight: normal;
}
.uploadimage {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin: 15px 0 30px;
}
.upload-area {
width: 100%;
display: flex;
justify-content: center;
}
/* 调整上传图片组件尺寸和样式 */
:deep(.el-upload) {
width: 100%;
display: flex;
justify-content: center;
}
:deep(.el-upload--picture-card) {
width: 250px;
height: 250px;
border-radius: 12px;
border: 2px dashed #c0c4cc;
transition: all 0.3s;
display: flex;
justify-content: center;
align-items: center;
}
:deep(.el-upload--picture-card:hover) {
border-color: #409EFF;
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(64, 158, 255, 0.2);
}
:deep(.el-upload-list--picture-card) {
justify-content: center;
}
:deep(.el-upload-list--picture-card .el-upload-list__item) {
width: 250px;
height: 250px;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s;
margin: 0;
}
:deep(.el-upload-list--picture-card .el-upload-list__item:hover) {
transform: scale(1.02);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
}
.upload-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
}
/* 调整上传图标大小和样式 */
:deep(.avatar-uploader-icon) {
font-size: 50px;
color: #409EFF;
text-align: center;
margin-bottom: 15px;
}
.upload-text {
font-size: 16px;
color: #606266;
text-align: center;
}
.upload-button {
padding: 12px 40px;
font-size: 16px;
border-radius: 30px;
background: linear-gradient(90deg, #409EFF, #36b4eb);
border: none;
box-shadow: 0 4px 15px rgba(64, 158, 255, 0.4);
transition: all 0.3s;
margin-top: 10px;
}
.upload-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(64, 158, 255, 0.5);
}
.upload-button:disabled {
background: #a0cfff;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* TipTap编辑器样式 */
.editor-container {
width: 100%;
display: flex;
flex-direction: column;
border: 1px solid #dcdfe6;
border-radius: 8px;
overflow: hidden;
}
.editor-menu {
display: flex;
flex-wrap: wrap;
padding: 8px;
background-color: #f5f7fa;
border-bottom: 1px solid #dcdfe6;
}
.editor-menu .el-button {
margin: 3px;
}
.editor-menu .el-button.is-active {
background-color: #409EFF;
color: white;
}
.editor-content {
padding: 16px;
min-height: 300px;
max-height: 500px;
overflow-y: auto;
background-color: white;
}
.editor-content p {
margin: 0 0 0.8em;
}
.editor-actions {
padding: 12px;
display: flex;
justify-content: flex-end;
background-color: #f5f7fa;
border-top: 1px solid #dcdfe6;
}
.hide_box {
display: none;
}
.show_box {
display: inline-flex;
}
.imageshow {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 4px;
}
/* 调整预览对话框大小和样式 */
:deep(.el-dialog) {
max-width: 80%;
max-height: 80%;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
:deep(.el-dialog__header) {
padding: 15px 20px;
background: #f5f7fa;
border-bottom: 1px solid #e6e6e6;
}
:deep(.el-dialog__body) {
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
background: #f0f2f5;
}
</style>
编辑器搭建
对于整个页面,是需要进行框架搭建的,暂不深入探讨具体布局细节。
参照众多现行在线编辑器的用户交互模式,编辑区域通常位于界面中央,作为内容创作的核心地带,而辅助性功能如目录概览、个性化设置等,则巧妙地分布在界面的左侧与右侧,旨在提升用户的操作便捷性与效率。
本教程将采纳这一成熟布局,优化用户交互体验:左侧配备一系列创新辅助工具,便于用户快速调用;顶部则部署基础编辑工具栏,集成了必备的编辑功能,确保用户能够轻松驾驭;右侧设计为详尽的大纲视图,帮助用户直观把握文档结构;而在底部,加入字数统计功能。这种布局比较通用,不要限制你的想象力,可以自行发挥,遵循简洁、易用原则即可。
修改index.vue
如下
<template>
<div class="mainbody">
<div class="content-container">
<h1 class="headtitle">文本编辑中心</h1>
<!-- 移除标签页,直接显示编辑器内容 -->
<div class="editor-container">
<!-- 现代编辑器布局 - 三栏式设计 -->
<div class="modern-editor">
<!-- 左侧工具面板 -->
<div class="editor-sidebar">
<div class="sidebar-title">辅助工具</div>
<div class="sidebar-tools">
<el-tooltip
content="插入项目计划模板"
placement="right"
effect="light"
>
<div class="tool-item" @click="insertTemplate('项目计划')">
<el-icon><Document /></el-icon>
<span>项目计划模板</span>
</div>
</el-tooltip>
<el-tooltip
content="插入会议记录模板"
placement="right"
effect="light"
>
<div class="tool-item" @click="insertTemplate('会议记录')">
<el-icon><ChatDotRound /></el-icon>
<span>会议记录模板</span>
</div>
</el-tooltip>
<el-tooltip
content="插入工作报告模板"
placement="right"
effect="light"
>
<div class="tool-item" @click="insertTemplate('工作报告')">
<el-icon><Histogram /></el-icon>
<span>工作报告模板</span>
</div>
</el-tooltip>
<el-tooltip
content="从URL添加图片"
placement="right"
effect="light"
>
<div class="tool-item" @click="addImage">
<el-icon><Picture /></el-icon>
<span>插入图片</span>
</div>
</el-tooltip>
<el-tooltip
content="上传图片并提取文字"
placement="right"
effect="light"
>
<div class="tool-item" @click="ocrImage">
<el-icon><Document /></el-icon>
<span>图片上传(OCR)</span>
</div>
</el-tooltip>
<el-tooltip
content="查看编辑历史记录"
placement="right"
effect="light"
>
<div class="tool-item" @click="toggleHistory">
<el-icon><Timer /></el-icon>
<span>历史记录</span>
</div>
</el-tooltip>
</div>
</div>
<!-- 中央编辑区域 -->
<div class="editor-main">
<!-- 顶部编辑工具栏 -->
<div class="editor-toolbar">
<div class="toolbar-group">
<el-tooltip
content="加粗"
placement="top"
effect="light"
>
<el-button
size="small"
@click="editor.chain().focus().toggleBold().run()"
:class="{ 'is-active': editor.isActive('bold') }"
>
<el-icon><Edit /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip
content="斜体"
placement="top"
effect="light"
>
<el-button
size="small"
@click="editor.chain().focus().toggleItalic().run()"
:class="{ 'is-active': editor.isActive('italic') }"
>
<el-icon><Pointer /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip
content="删除线"
placement="top"
effect="light"
>
<el-button
size="small"
@click="editor.chain().focus().toggleStrike().run()"
:class="{ 'is-active': editor.isActive('strike') }"
>
<el-icon><Delete /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip
content="切换代码块"
placement="top"
effect="light"
>
<el-button
size="small"
@click="editor.chain().focus().toggleCodeBlock().run()"
:class="{ 'is-active': editor.isActive('codeBlock') }"
>
<el-icon><Monitor /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip
content="设置代码块"
placement="top"
effect="light"
>
<el-button
size="small"
@click="editor.chain().focus().setCodeBlock().run()"
:disabled="editor.isActive('codeBlock')"
>
<el-icon><EditPen /></el-icon>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip
content="段落"
placement="top"
effect="light"
>
<el-button
size="small"
@click="editor.chain().focus().setParagraph().run()"
:class="{ 'is-active': editor.isActive('paragraph') }"
>
<el-icon><Notebook /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip
content="标题级别"
placement="top"
effect="light"
>
<el-dropdown trigger="click" @command="handleHeadingCommand">
<el-button size="small">
<span>标题</span>
<el-icon><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="1" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }">H1</el-dropdown-item>
<el-dropdown-item command="2" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }">H2</el-dropdown-item>
<el-dropdown-item command="3" :class="{ 'is-active': editor.isActive('heading', { level: 3 }) }">H3</el-dropdown-item>
<el-dropdown-item command="4" :class="{ 'is-active': editor.isActive('heading', { level: 4 }) }">H4</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip
content="无序列表"
placement="top"
effect="light"
>
<el-button
size="small"
@click="editor.chain().focus().toggleBulletList().run()"
:class="{ 'is-active': editor.isActive('bulletList') }"
>
<el-icon><Collection /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip
content="有序列表"
placement="top"
effect="light"
>
<el-button
size="small"
@click="editor.chain().focus().toggleOrderedList().run()"
:class="{ 'is-active': editor.isActive('orderedList') }"
>
<el-icon><Tickets /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip
content="引用"
placement="top"
effect="light"
>
<el-button
size="small"
@click="editor.chain().focus().toggleBlockquote().run()"
:class="{ 'is-active': editor.isActive('blockquote') }"
>
<el-icon><ChatSquare /></el-icon>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip
content="撤销"
placement="top"
effect="light"
>
<el-button
size="small"
@click="editor.chain().focus().undo().run()"
>
<el-icon><RefreshLeft /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip
content="重做"
placement="top"
effect="light"
>
<el-button
size="small"
@click="editor.chain().focus().redo().run()"
>
<el-icon><RefreshRight /></el-icon>
</el-button>
</el-tooltip>
</div>
<!-- 添加预览按钮 -->
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip
content="在新窗口中预览"
placement="top"
effect="light"
>
<el-button
size="small"
type="primary"
@click="openPreviewWindow"
>
<el-icon><View /></el-icon>
预览
</el-button>
</el-tooltip>
</div>
</div>
<!-- 编辑器内容区域 -->
<editor-content :editor="editor" class="editor-content" />
<!-- 底部状态栏 -->
<div class="editor-statusbar">
<div class="word-count">
字数统计: {{ wordCount }}
</div>
<div class="editor-actions">
<el-tooltip
content="保存当前编辑内容"
placement="top"
effect="light"
>
<el-button type="primary" @click="saveContent">保存内容</el-button>
</el-tooltip>
</div>
</div>
</div>
<!-- 右侧大纲视图 -->
<div class="editor-outline">
<div class="outline-title">文档大纲</div>
<div class="outline-content">
<template v-if="outlineItems.length">
<el-tooltip
v-for="(item, index) in outlineItems"
:key="index"
:content="`跳转到: ${item.text}`"
placement="left"
effect="light"
>
<div
class="outline-item"
:class="'outline-level-' + item.level"
@click="scrollToHeading(item.id)"
>
{{ item.text }}
</div>
</el-tooltip>
</template>
<div v-else class="outline-empty">
文档暂无大纲
</div>
</div>
</div>
</div>
</div>
<!-- 添加图片上传对话框 -->
<el-dialog
v-model="ocrDialogVisible"
title="图片上传(OCR)"
width="60%"
:before-close="handleCloseOcrDialog"
destroy-on-close
>
<div class="uploadimage">
<div class="upload-area">
<el-upload
ref="uploadRef"
list-type="picture-card"
:auto-upload="false"
:on-change="handleChanges"
:before-remove="beforeRemove"
:on-preview="handlePictureCardPreview"
:file-list="fileList"
multiple
:limit="1"
class="custom-upload"
>
<template #default>
<div class="upload-content">
<el-icon class="avatar-uploader-icon">
<Plus />
</el-icon>
<div class="upload-text">点击上传图片</div>
</div>
</template>
</el-upload>
</div>
<el-dialog v-model="previewDialogVisible" class="preview-dialog">
<img w-full class="imageshow" :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleCloseOcrDialog">取消</el-button>
<el-button type="primary" @click="processOcr" :disabled="fileList.length === 0">
开始识别
</el-button>
</span>
</template>
</el-dialog>
</div>
</div>
</template>
<script setup lang="ts">
import { mainStore} from '../../store/index'
import { ref, onBeforeUnmount, shallowRef, computed, watch, onMounted } from 'vue'
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Document,
ChatDotRound,
Histogram,
Picture,
Timer,
Edit,
EditPen,
Pointer,
Delete,
Monitor,
Notebook,
ArrowDown,
Collection,
Tickets,
ChatSquare,
RefreshLeft,
RefreshRight,
View
} from '@element-plus/icons-vue'
import type { UploadFile } from 'element-plus'
// TipTap相关导入
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import CodeBlock from '@tiptap/extension-code-block'
// 获取Store
const demoStore = mainStore()
// TipTap编辑器
const editor = shallowRef()
// 大纲项目
const outlineItems = ref([])
// 初始化编辑器
editor.value = new Editor({
extensions: [
StarterKit.configure({
// 禁用默认的代码块,我们将使用单独导入的代码块
codeBlock: false,
}),
Image,
CodeBlock.configure({
HTMLAttributes: {
class: 'code-block',
},
languageClassPrefix: 'language-',
}),
],
content: '<h1>项目介绍</h1><p>欢迎使用富文本编辑器!</p><h2>背景介绍</h2><p>这是一个示例文档。</p><h2>模型介绍</h2><p>可以编辑各种格式的文本。</p><h2>Vue3介绍</h2><p>使用Vue3和TipTap开发。</p><h1>项目实现</h1><h2>环境配置</h2><p>需要配置开发环境。</p><h2>代码编写</h2><p>参考文档进行编码。</p>',
autofocus: true,
editable: true,
injectCSS: false,
onUpdate: ({ editor }) => {
updateOutline()
updateWordCount()
},
onSelectionUpdate: ({ editor }) => {
updateOutline()
},
})
// 更新大纲
const updateOutline = () => {
if (!editor.value) return
const headings = []
const content = editor.value.getJSON()
// 处理文档,提取所有标题
if (content.content) {
content.content.forEach((node, index) => {
if (node.type === 'heading') {
headings.push({
id: `heading-${index}`,
level: node.attrs.level,
text: node.content ? node.content.map(c => c.text).join('') : ''
})
}
})
}
outlineItems.value = headings
}
// 字数统计
const wordCount = ref(0)
// 更新字数统计
const updateWordCount = () => {
if (!editor.value) return
const text = editor.value.getText()
// 中文和英文单词统计略有不同
// 这里用一个简单的方法:移除所有空白字符后计算长度
wordCount.value = text.replace(/\s+/g, '').length
}
// 滚动到特定标题
const scrollToHeading = (id) => {
// 实际实现滚动功能
const editorElement = document.querySelector('.editor-content')
if (!editorElement) return
// 找到对应ID的标题元素
const headingId = id.replace('heading-', '')
const content = editor.value.getJSON()
if (content.content && headingId < content.content.length) {
let headingCounter = 0
let targetPosition = 0
// 计算目标标题在编辑器中的位置
for (let i = 0; i < content.content.length; i++) {
if (content.content[i].type === 'heading') {
if (i.toString() === headingId) {
targetPosition = headingCounter
break
}
headingCounter++
}
}
// 找到所有标题元素
const headings = editorElement.querySelectorAll('h1, h2, h3, h4, h5, h6')
if (headings.length > targetPosition) {
// 滚动到对应标题
headings[targetPosition].scrollIntoView({ behavior: 'smooth', block: 'start' })
// 高亮显示一下,提示用户
const originalBg = headings[targetPosition].style.backgroundColor
headings[targetPosition].style.backgroundColor = 'rgba(64, 158, 255, 0.1)'
setTimeout(() => {
headings[targetPosition].style.backgroundColor = originalBg
}, 2000)
} else {
ElMessage({
message: `无法找到标题: ${id}`,
type: 'warning'
})
}
}
}
// 处理标题命令
const handleHeadingCommand = (level) => {
editor.value.chain().focus().toggleHeading({ level: parseInt(level) }).run()
}
// 插入模板
const insertTemplate = (templateType) => {
let content = ''
switch(templateType) {
case '项目计划':
content = `
<h1>项目计划</h1>
<h2>1. 背景介绍</h2>
<p>xxxxxxxxxxxxxxxxxx</p>
<h2>2. 大模型介绍</h2>
<p>xxxxxxxxxxxxxxxxxx</p>
<h2>3. Vue3介绍</h2>
<p>xxxxxxxxxxxxxxxxxx</p>
<h1>二、项目实现</h1>
<h2>1. 环境配置</h2>
<p>xxxxxxxxxxxxxxxxxx</p>
<h2>2. 代码编写</h2>
<p>xxxxxxxxxxxxxxxxxx</p>
`
break
case '会议记录':
content = `
<h1>会议记录</h1>
<h2>会议信息</h2>
<p>日期:2025年x月x日</p>
<p>参会人员:xxx,xxx,xxx</p>
<h2>议题</h2>
<ul>
<li>议题一</li>
<li>议题二</li>
<li>议题三</li>
</ul>
<h2>会议决议</h2>
<ol>
<li>决议一</li>
<li>决议二</li>
<li>决议三</li>
</ol>
`
break
case '工作报告':
content = `
<h1>工作报告</h1>
<h2>工作概述</h2>
<p>本周完成的工作内容总结...</p>
<h2>工作详情</h2>
<h3>项目一</h3>
<p>已完成xxxx</p>
<h3>项目二</h3>
<p>进行中xxxx</p>
<h2>下周计划</h2>
<ul>
<li>计划一</li>
<li>计划二</li>
</ul>
`
break
}
// 清空现有内容并设置新内容
editor.value.commands.setContent(content)
ElMessage({
message: `已插入${templateType}模板`,
type: 'success'
})
// 更新大纲和字数
updateOutline()
updateWordCount()
}
// 显示历史记录
const toggleHistory = () => {
ElMessage({
message: '历史记录功能正在开发中',
type: 'info'
})
}
// 在组件销毁前销毁编辑器
onBeforeUnmount(() => {
editor.value.destroy()
})
// 初始更新大纲和字数
onMounted(() => {
updateOutline()
updateWordCount()
// 测试后端连接
testBackendConnection()
})
// 测试后端连接和CORS
const testBackendConnection = () => {
axios.get('http://127.0.0.1:5000/health')
.then(response => {
console.log('后端连接测试成功:', response.data);
})
.catch(error => {
console.error('后端连接测试失败:', error);
ElMessage({
message: '无法连接到后端服务,OCR功能可能无法正常工作',
type: 'warning',
duration: 5000
});
});
}
// 添加图片
const addImage = () => {
const url = prompt('输入图片URL')
if (url) {
editor.value.chain().focus().setImage({ src: url }).run()
}
}
// 对话框相关
const dialogImageUrl = ref('')
const previewDialogVisible = ref(false)
const ocrDialogVisible = ref(false)
// 文件上传相关
const fileList = ref([])
const boxdisplay = ref("show_box")
// 处理关闭OCR对话框
const handleCloseOcrDialog = () => {
fileList.value = []
ocrDialogVisible.value = false
}
// OCR图片识别
const ocrImage = () => {
// 打开对话框
ocrDialogVisible.value = true
}
// 处理OCR识别
const processOcr = () => {
// 检查是否有选择文件
if (fileList.value.length === 0) {
ElMessage({
message: '请先选择图片',
type: 'warning'
});
return;
}
// 创建FormData对象
const formData = new FormData();
formData.append("username", "12345");
// 添加文件
fileList.value.forEach((file) => {
if (file.raw) {
formData.append("file", file.raw);
console.log("正在处理图片:", file.name);
}
});
// 显示处理中提示
ElMessage({
message: '正在识别图片文字...',
type: 'info'
});
// 发送OCR请求
axios({
method: 'post',
url: 'http://127.0.0.1:5000/uploadimages',
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
'Access-Control-Allow-Origin': '*'
},
// 添加CORS设置
withCredentials: false,
timeout: 60000, // 增加超时时间为60秒,OCR处理可能需要较长时间
validateStatus: function (status) {
return status < 500; // 只有状态码小于500的请求被视为成功
}
})
.then(response => {
console.log("收到服务器响应:", response);
if (response.status !== 200) {
// 处理非200状态码
ElMessage({
message: `服务器返回错误状态: ${response.status}`,
type: 'error'
});
return;
}
// 将识别的文本插入编辑器
if (response.data && response.data.text) {
editor.value.chain().focus().insertContent(response.data.text).run();
ElMessage({
message: '文字识别成功',
type: 'success'
});
// 关闭对话框
handleCloseOcrDialog();
} else if (response.data && response.data.url) {
// 如果没有text但有url,插入图片和提示信息
editor.value.chain().focus().insertContent(`<p>识别的图片: <img src="${response.data.url}" alt="Uploaded Image" /></p><p>(未能识别出文字)</p>`).run();
ElMessage({
message: '图片上传成功,但未识别到文字',
type: 'info'
});
// 关闭对话框
handleCloseOcrDialog();
} else if (response.data && response.data.error) {
// 处理服务器返回的错误信息
ElMessage({
message: `图片处理失败: ${response.data.error}`,
type: 'error'
});
} else {
ElMessage({
message: '未能识别文字或返回数据格式不正确',
type: 'warning'
});
}
})
.catch(error => {
console.error("OCR识别失败:", error);
let errorMsg = '文字识别失败: ';
if (error.response) {
// 服务器返回了错误状态码
errorMsg += `服务器返回 ${error.response.status} 错误`;
if (error.response.data && error.response.data.error) {
errorMsg += ` - ${error.response.data.error}`;
}
} else if (error.request) {
// 请求发出但没有收到响应
errorMsg += '未收到服务器响应,请检查后端服务是否正常运行';
} else {
// 请求设置时发生错误
errorMsg += error.message || '未知错误';
}
if (error.message && error.message.includes('Network Error')) {
errorMsg += '\n可能是CORS跨域问题,请确保后端正确配置了CORS';
}
ElMessage({
message: errorMsg,
type: 'error'
});
});
}
// 文件选择变化
const handleChanges = (uploadFile) => {
console.log("文件选择变化:", uploadFile)
// 避免重复添加
if (!fileList.value.some(file => file.uid === uploadFile.uid)) {
fileList.value.push(uploadFile)
boxdisplay.value = "hide_box"
}
}
// 预览图片
const handlePictureCardPreview = (file: UploadFile) => {
console.log("预览图片:", file)
dialogImageUrl.value = file.url || URL.createObjectURL(file.raw!)
previewDialogVisible.value = true
}
// 移除文件前确认
const beforeRemove = () => {
return new Promise((resolve, reject) => {
ElMessageBox.confirm("此操作将删除该图片, 是否继续?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
})
.then(() => {
boxdisplay.value = "show_box"
fileList.value = []
resolve(true)
})
.catch(() => {
reject(false)
})
})
}
// 保存内容
const saveContent = () => {
const content = editor.value.getHTML()
console.log('保存的内容:', content)
ElMessage({
message: '内容已保存',
type: 'success'
})
}
// 获取编辑器HTML内容
const editorHtml = computed(() => {
if (!editor.value) return ''
return editor.value.getHTML()
})
// 在新窗口中打开预览
const openPreviewWindow = () => {
// 创建一个新的HTML文档字符串,简化样式定义
const previewHTML = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文档预览</title>
<style>
body {
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
line-height: 1.6;
color: #2c3e50;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
h1, h2, h3, h4, h5, h6 {
margin: 1em 0 0.5em;
}
h1 {
font-size: 1.8em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
h2 { font-size: 1.5em; }
h3 { font-size: 1.3em; }
p { margin: 0 0 1em; }
ul, ol {
padding-left: 20px;
margin: 0 0 1em;
}
li { margin-bottom: 0.5em; }
blockquote {
border-left: 4px solid #dfe2e5;
padding-left: 1em;
color: #6a737d;
margin: 0 0 1em;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 1em auto;
}
/* 代码块样式统一定义 */
pre, .code-block {
background: #1e1e1e;
border-radius: 0.5rem;
color: #f8f8f2;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
overflow-x: auto;
position: relative;
}
pre code {
background: none;
color: inherit;
font-size: 0.9rem;
padding: 0;
font-family: inherit;
white-space: pre;
}
.preview-header {
text-align: center;
padding: 10px;
margin-bottom: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.preview-content {
background-color: white;
padding: 25px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
</style>
</head>
<body>
<div class="preview-header">
<h1>文档预览</h1>
</div>
<div class="preview-content">
${editorHtml.value}
</div>
</body>
</html>
`;
// 打开新窗口并写入内容
const previewWindow = window.open('', '_blank');
if (previewWindow) {
previewWindow.document.write(previewHTML);
previewWindow.document.close();
ElMessage({
message: '预览已在新窗口打开',
type: 'success'
});
} else {
ElMessage({
message: '无法打开预览窗口,请检查浏览器是否阻止了弹出窗口',
type: 'error'
});
}
}
</script>
<style scoped>
/* 确保整个页面没有边距和滚动条 */
:deep(html),
:deep(body) {
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
}
.mainbody {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(120deg, #2b87c5, #55b9e4, #7dd3f0);
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
overflow: hidden;
margin: 0;
padding: 0;
}
.content-container {
width: 90%;
height: 90%;
max-width: 1400px;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 25px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
align-items: center;
animation: fadeIn 0.6s ease-out;
overflow: hidden;
}
.editor-container {
width: 100%;
height: calc(100% - 70px);
overflow: hidden;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.headtitle {
font-size: 26px;
color: #2c3e50;
margin-bottom: 16px;
text-align: center;
position: relative;
padding-bottom: 10px;
font-weight: 600;
}
.headtitle::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 50px;
height: 3px;
background: #409EFF;
border-radius: 3px;
}
/* 现代编辑器布局 */
.modern-editor {
display: grid;
grid-template-columns: 220px 1fr 220px;
grid-template-rows: 1fr;
gap: 15px;
height: 100%;
width: 100%;
}
/* 左侧工具面板 */
.editor-sidebar {
background-color: #f5f7fa;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.sidebar-title {
padding: 12px 15px;
font-size: 16px;
font-weight: 500;
background-color: #ecf5ff;
color: #409EFF;
border-bottom: 1px solid #e0e6ed;
}
.sidebar-tools {
padding: 10px;
overflow-y: auto;
flex: 1;
}
.tool-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 8px;
}
.tool-item:hover {
background-color: #ecf5ff;
}
.tool-item .el-icon {
margin-right: 10px;
font-size: 18px;
color: #409EFF;
}
.tool-item span {
font-size: 14px;
color: #606266;
}
/* 中央编辑区域 */
.editor-main {
display: flex;
flex-direction: column;
background-color: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
/* 顶部工具栏 */
.editor-toolbar {
padding: 8px 12px;
background-color: #f5f7fa;
border-bottom: 1px solid #e0e6ed;
display: flex;
align-items: center;
flex-wrap: wrap;
}
.toolbar-group {
display: flex;
align-items: center;
margin-right: 5px;
}
.toolbar-divider {
width: 1px;
height: 24px;
background-color: #dcdfe6;
margin: 0 8px;
}
.toolbar-group .el-button {
margin: 2px;
}
.toolbar-group .el-button.is-active {
background-color: #ecf5ff;
color: #409EFF;
border-color: #d9ecff;
}
/* 编辑器内容区域 */
.editor-content {
flex: 1;
padding: 20px;
overflow-y: auto;
background-color: white;
}
.editor-content :deep(p) {
margin: 0 0 1em;
line-height: 1.6;
}
.editor-content :deep(h1) {
font-size: 1.8em;
margin: 1em 0 0.5em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
.editor-content :deep(h2) {
font-size: 1.5em;
margin: 1em 0 0.5em;
}
.editor-content :deep(h3) {
font-size: 1.3em;
margin: 1em 0 0.5em;
}
.editor-content :deep(ul),
.editor-content :deep(ol) {
padding-left: 20px;
margin: 0 0 1em;
}
.editor-content :deep(li) {
margin-bottom: 0.5em;
}
.editor-content :deep(blockquote) {
border-left: 4px solid #dfe2e5;
padding-left: 1em;
color: #6a737d;
margin: 0 0 1em;
}
.editor-content :deep(img) {
max-width: 100%;
height: auto;
display: block;
margin: 1em auto;
}
/* 底部状态栏 */
.editor-statusbar {
padding: 8px 15px;
background-color: #f5f7fa;
border-top: 1px solid #e0e6ed;
display: flex;
justify-content: space-between;
align-items: center;
}
.word-count {
font-size: 13px;
color: #606266;
}
/* 右侧大纲视图 */
.editor-outline {
background-color: #f5f7fa;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.outline-title {
padding: 12px 15px;
font-size: 16px;
font-weight: 500;
background-color: #ecf5ff;
color: #409EFF;
border-bottom: 1px solid #e0e6ed;
}
.outline-content {
padding: 10px;
overflow-y: auto;
flex: 1;
}
.outline-item {
padding: 8px 10px;
border-radius: 4px;
cursor: pointer;
margin-bottom: 5px;
font-size: 14px;
color: #606266;
transition: background-color 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.outline-item:hover {
background-color: #ecf5ff;
color: #409EFF;
}
.outline-level-1 {
font-weight: 600;
}
.outline-level-2 {
padding-left: 20px;
}
.outline-level-3 {
padding-left: 30px;
font-size: 13px;
}
.outline-level-4 {
padding-left: 40px;
font-size: 13px;
}
.outline-empty {
padding: 20px;
text-align: center;
color: #909399;
font-size: 14px;
}
/* 保留上传页面相关样式 */
.uploadimage {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin: 15px 0 30px;
}
.upload-area {
width: 100%;
display: flex;
justify-content: center;
}
/* 调整上传图片组件尺寸和样式 */
:deep(.el-upload) {
width: 100%;
display: flex;
justify-content: center;
}
:deep(.el-upload--picture-card) {
width: 250px;
height: 250px;
border-radius: 12px;
border: 2px dashed #c0c4cc;
transition: all 0.3s;
display: flex;
justify-content: center;
align-items: center;
}
:deep(.el-upload--picture-card:hover) {
border-color: #409EFF;
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(64, 158, 255, 0.2);
}
:deep(.el-upload-list--picture-card) {
justify-content: center;
}
:deep(.el-upload-list--picture-card .el-upload-list__item) {
width: 250px;
height: 250px;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s;
margin: 0;
}
:deep(.el-upload-list--picture-card .el-upload-list__item:hover) {
transform: scale(1.02);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
}
.upload-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
}
/* 调整上传图标大小和样式 */
:deep(.avatar-uploader-icon) {
font-size: 50px;
color: #409EFF;
text-align: center;
margin-bottom: 15px;
}
.upload-text {
font-size: 16px;
color: #606266;
text-align: center;
}
.upload-button {
padding: 12px 40px;
font-size: 16px;
border-radius: 30px;
background: linear-gradient(90deg, #409EFF, #36b4eb);
border: none;
box-shadow: 0 4px 15px rgba(64, 158, 255, 0.4);
transition: all 0.3s;
margin-top: 10px;
}
.upload-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(64, 158, 255, 0.5);
}
.upload-button:disabled {
background: #a0cfff;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.hide_box {
display: none;
}
.show_box {
display: inline-flex;
}
.imageshow {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 4px;
}
/* 调整预览对话框大小和样式 */
:deep(.el-dialog) {
max-width: 80%;
max-height: 80%;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
:deep(.el-dialog__header) {
padding: 15px 20px;
background: #f5f7fa;
border-bottom: 1px solid #e6e6e6;
}
:deep(.el-dialog__body) {
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
background: #f0f2f5;
}
/* 下拉菜单中的active项 */
:deep(.el-dropdown-menu__item.is-active) {
color: #409EFF;
background-color: #ecf5ff;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.modern-editor {
grid-template-columns: 180px 1fr 180px;
}
}
@media (max-width: 992px) {
.modern-editor {
grid-template-columns: 160px 1fr 160px;
}
}
/* 整合代码块相关样式,避免重复 */
/* 代码块样式 - 统一定义 */
.editor-content :deep(pre),
.editor-content :deep(.code-block) {
background: #1e1e1e;
border-radius: 0.5rem;
color: #f8f8f2;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
overflow-x: auto;
position: relative;
}
.editor-content :deep(pre code) {
background: none;
color: inherit;
font-size: 0.9rem;
padding: 0;
font-family: inherit;
white-space: pre;
}
/* 为代码块添加标识 */
.editor-content :deep(.code-block::before) {
content: 'code';
position: absolute;
top: 5px;
right: 10px;
font-size: 0.7em;
color: #666;
font-family: sans-serif;
font-style: italic;
opacity: 0.7;
}
/* 对预览内容样式统一 */
.preview-content pre,
.preview-content .code-block {
background: #1e1e1e;
border-radius: 0.5rem;
color: #f8f8f2;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
overflow-x: auto;
position: relative;
}
.preview-content pre code {
background: none;
color: inherit;
font-size: 0.9rem;
padding: 0;
font-family: inherit;
}
.preview-content .code-block::before {
content: 'code';
position: absolute;
top: 5px;
right: 10px;
font-size: 0.7em;
color: #666;
font-family: sans-serif;
font-style: italic;
opacity: 0.7;
}
</style>
修改app.py
如下
from flask import Flask, json, request, jsonify, send_from_directory
from flask_cors import CORS
import pymysql
import cv2
import numpy as np
import os
import paddle
from paddleocr import PaddleOCR, draw_ocr
import uuid
import re
import time
# 获取当前文件所在目录的绝对路径
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_FOLDER = os.path.join(CURRENT_DIR, 'static')
# 确保静态文件夹存在
if not os.path.exists(STATIC_FOLDER):
os.makedirs(STATIC_FOLDER)
DEBUG = True
app = Flask(__name__, static_folder=STATIC_FOLDER, static_url_path='/static')
app.config.from_object(__name__)
# 修正CORS配置
CORS(app, resources={r"/*": {"origins": "*"}}, supports_credentials=False)
ocr = PaddleOCR(use_angle_cls=True, lang="ch") # need to run only once to download and load model into memory
# 生成安全的文件名
def generate_safe_filename(original_filename):
# 获取文件扩展名
_, ext = os.path.splitext(original_filename)
# 生成基于时间戳和UUID的唯一文件名
safe_name = f"{int(time.time())}_{uuid.uuid4().hex}{ext}"
return safe_name
# 静态文件服务
@app.route('/static/<path:filename>')
def serve_static(filename):
return send_from_directory(app.static_folder, filename)
@app.route('/adduser', methods=['get', 'post'])
def adduser():
username = request.form.get("username")
password = request.form.get("password")
print(username)
print(password)
return "已接收用户信息"
@app.route('/uploadimages', methods=['get', 'post'])
def uploadimages():
try:
username = request.form.get("username")
img = request.files['file']
original_filename = img.filename
print(f"原始文件名: {original_filename}")
# 生成安全的文件名,避免中文乱码问题
safe_filename = generate_safe_filename(original_filename)
print(f"安全文件名: {safe_filename}")
file = img.read()
# 确保static目录存在
static_dir = os.path.join(CURRENT_DIR, "static")
if not os.path.exists(static_dir):
os.makedirs(static_dir)
# 确保images目录存在
images_dir = os.path.join(static_dir, "images")
if not os.path.exists(images_dir):
os.makedirs(images_dir)
# 确保用户目录存在
user_dir = os.path.join(images_dir, username)
if not os.path.exists(user_dir):
os.makedirs(user_dir)
# 使用安全文件名保存文件
file = cv2.imdecode(np.frombuffer(file, np.uint8), cv2.IMREAD_COLOR) # 解码为ndarray
img_path = os.path.join(user_dir, safe_filename)
print(f"图片保存路径: {img_path}")
cv2.imwrite(filename=img_path, img=file)
# 验证文件是否成功保存
if not os.path.exists(img_path):
raise Exception(f"文件保存失败: {img_path}")
else:
print(f"文件已成功保存到: {img_path}")
# 运行OCR
print(f"正在处理图片: {img_path}")
result = ocr.ocr(img_path, cls=True)
# 提取识别到的文本
extracted_text = ""
if result is not None:
for idx in range(len(result)):
res = result[idx]
for line in res:
print(line)
# 每行的结构是: [[坐标], [文本内容, 置信度]]
extracted_text += line[1][0] + "\n"
url = f"http://127.0.0.1:5000/static/images/{username}/{safe_filename}"
print(f"生成的URL: {url}")
try:
conn = pymysql.connect(host='192.168.195.189', port=3307, user='root', password='123456', charset='utf8')
cursor = conn.cursor()
conn.commit()
cursor.execute('use editor')
sql = "INSERT INTO `images` (`username`,`url`,`picname`) VALUES('" + str(username) + "','" + str(url) + "','" + str(original_filename) + "');"
print(f"执行SQL: {sql}")
count = cursor.execute(sql)
conn.commit()
cursor.close()
conn.close()
except Exception as e:
print(f"数据库操作出错: {str(e)}")
# 返回JSON结果,包含识别到的文本
response = {
"url": url,
"text": extracted_text.strip(),
"original_filename": original_filename
}
return jsonify(response)
except Exception as e:
import traceback
print(f"处理图片出错: {str(e)}")
print(traceback.format_exc()) # 打印完整的堆栈跟踪
return jsonify({"error": str(e)}), 500
# 健康检查接口,用于测试CORS
@app.route('/health', methods=['GET'])
def health_check():
return jsonify({"status": "ok"})
if __name__ == '__main__':
app.run(host="127.0.0.1", port=5000, debug=True)
实现的效果如图所示:
划词AI
文本划词
在编辑器的操作过程中,当文本中的词汇被用户轻松划选时,随即展现的一系列AI辅助功能无疑极大地提升了用户体验和工作效率。接下来,将深入探讨这一功能的具体实现方式,以确保用户能够更为便捷地享受到这些智能化的编辑服务。
- 感谢你赐予我前进的力量