feat: 添加图片OCR结果转换为待办事项的功能

- 后端新增 `/images/:id/convert-to-todo` 路由及控制器方法,支持将图片OCR结果创建为待办事项
- 前端添加 `useConvertImageToTodo` hook 和 `ImageService.convertToTodo` 方法
- 在图片列表页面为已识别图片添加“转为待办”操作按钮
- 新增文档详情页面路由及基础UI
- 修复PaddleOCR和RapidOCR提供商的配置及可用性检查逻辑
- 优化图片上传时对document_id的处理(空字符串转为null)
This commit is contained in:
congsh
2026-02-27 23:22:29 +08:00
parent ecf3999d2a
commit 762cc41c5a
9 changed files with 445 additions and 52 deletions
+75 -1
View File
@@ -26,6 +26,7 @@ export class ImageController {
path: file?.path,
size: file?.size,
mimetype: file?.mimetype,
document_id,
});
if (!file) {
@@ -36,12 +37,15 @@ export class ImageController {
return;
}
// 处理 document_id:空字符串转换为 null
const processedDocumentId = document_id && document_id.trim() !== '' ? document_id : null;
const image = await ImageService.create({
user_id: userId,
file_path: `/uploads/${file.filename}`,
file_size: file.size,
mime_type: file.mimetype,
document_id,
document_id: processedDocumentId,
});
// 触发异步 OCR 处理(不等待完成)
@@ -190,6 +194,76 @@ export class ImageController {
}
}
/**
* Convert image to todo
* POST /api/images/:id/convert-to-todo
*/
static async convertToTodo(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { id } = req.params as { id: string };
const { title, description, priority } = req.body;
// 获取图片信息
const image = await ImageService.findById(id, userId);
if (!image) {
res.status(404).json({
success: false,
error: '图片不存在',
});
return;
}
// 检查是否有 OCR 结果
if (!image.ocr_result || image.processing_status !== 'completed') {
res.status(400).json({
success: false,
error: '图片尚未完成 OCR 识别,无法转换为待办',
});
return;
}
// 动态导入服务避免循环依赖
const { TodoService } = await import('../services/todo.service');
const { DocumentService } = await import('../services/document.service');
// 如果图片还没有关联文档,先创建文档
let documentId = image.document_id;
if (!documentId) {
const doc = await DocumentService.create({
user_id: userId,
title: title || `从图片提取: ${image.file_path.split('/').pop()}`,
content: image.ocr_result,
});
documentId = doc.id;
// 将图片关联到文档
await ImageService.linkToDocument(id, userId, documentId);
}
// 从文档创建待办事项
const todo = await TodoService.create({
user_id: userId,
title: title || `待办: ${image.ocr_result.slice(0, 50)}${image.ocr_result.length > 50 ? '...' : ''}`,
description: description || image.ocr_result,
priority: priority || 'medium',
document_id: documentId,
});
res.status(201).json({
success: true,
data: todo,
message: '已将 OCR 结果转换为待办事项',
});
} catch (error) {
const message = error instanceof Error ? error.message : '转换失败';
res.status(400).json({
success: false,
error: message,
});
}
}
/**
* Delete image
* DELETE /api/images/:id
+7
View File
@@ -137,6 +137,13 @@ router.put('/:id/ocr', authenticate, ImageController.updateOCR);
*/
router.put('/:id/link', authenticate, ImageController.linkToDocument);
/**
* @route POST /api/images/:id/convert-to-todo
* @desc Convert image OCR result to todo
* @access Private
*/
router.post('/:id/convert-to-todo', authenticate, ImageController.convertToTodo);
/**
* @route DELETE /api/images/:id
* @desc Delete image
@@ -32,7 +32,7 @@ export class PaddleOCRProvider extends BaseOCRProvider {
constructor(config: OCRProviderConfig & { apiUrl?: string } = {}) {
super(config);
this.apiUrl = config.apiUrl || process.env.PADDLEOCR_API_URL || 'http://localhost:8866';
this.apiUrl = config.apiUrl || process.env.PADDLEOCR_API_URL || 'http://localhost:13059';
}
getName(): string {
@@ -45,15 +45,15 @@ export class PaddleOCRProvider extends BaseOCRProvider {
/**
* 检查 PaddleOCR 服务是否可用
* PaddleOCR 通过 Docker Compose 运行,默认假设可用
*/
async isAvailable(): Promise<boolean> {
try {
const response = await fetch(`${this.apiUrl}/`, { signal: AbortSignal.timeout(3000) });
return response.status === 200;
// PaddleOCR 服务正常时返回 200
return response.ok || response.status === 200;
} catch {
// 即使健康检查失败,也返回 true(因为服务在 Docker 网络中运行)
return true;
// 服务不可用
return false;
}
}
@@ -24,21 +24,12 @@ interface RapidOCRResponse {
}>;
}
interface RapidOCRRequest {
images: string[];
options?: {
use_dilation?: boolean;
use_cls?: boolean;
use_tensorrt?: boolean;
};
}
export class RapidOCRProvider extends BaseOCRProvider {
private apiUrl: string;
constructor(config: OCRProviderConfig & { apiUrl?: string } = {}) {
super(config);
this.apiUrl = config.apiUrl || process.env.RAPIDOCR_API_URL || 'http://localhost:8080';
this.apiUrl = config.apiUrl || process.env.RAPIDOCR_API_URL || 'http://localhost:13058';
}
getName(): string {
@@ -73,21 +64,18 @@ export class RapidOCRProvider extends BaseOCRProvider {
): Promise<OCRRecognitionResult> {
const startTime = Date.now();
// 获取图片 Base64
const imageBase64 = await this.getImageBase64(source);
// 获取图片 Buffer
const imageBuffer = await this.getImageBuffer(source);
// 使用 FormData 发送图片
const formData = new FormData();
formData.append('file', new Blob([imageBuffer]), 'image.png');
// 调用 RapidOCR API
const response = await this.withTimeout(
fetch(`${this.apiUrl}/ocr`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
images: [imageBase64],
options: {
use_dilation: true, // 使用膨胀增强识别
use_cls: true, // 使用文字方向分类
},
} as RapidOCRRequest),
body: formData,
}),
options?.timeout || this.config.timeout || 15000
);
@@ -95,12 +83,12 @@ export class RapidOCRProvider extends BaseOCRProvider {
const data = (await response.json()) as RapidOCRResponse;
const duration = Date.now() - startTime;
// 检查错误(支持两种错误格式)
if (data.code !== 200 && 'error' in data) {
throw new Error(`RapidOCR 错误: ${(data as any).error || data.msg} (${data.code})`);
// 检查错误
if (data.code !== 200) {
throw new Error(`RapidOCR 错误: ${data.msg} (${data.code})`);
}
// 提取文本和置信度(确保 data.data 存在)
// 提取文本和置信度
const ocrResults = Array.isArray(data.data) ? data.data : [];
const text = ocrResults.map((r) => r.text).join('\n');
@@ -129,23 +117,23 @@ export class RapidOCRProvider extends BaseOCRProvider {
}
/**
* 获取图片 Base64
* 获取图片 Buffer
*/
private async getImageBase64(source: IImageSource): Promise<string> {
if (source.base64) {
// 移除 data URL 前缀
return source.base64.replace(/^data:image\/\w+;base64,/, '');
}
private async getImageBuffer(source: IImageSource): Promise<Buffer> {
if (source.buffer) {
return source.buffer.toString('base64');
return source.buffer;
}
if (source.path) {
// 使用基类的路径解析方法
const fullPath = this.resolveImagePath(source.path);
const buffer = fs.readFileSync(fullPath);
return buffer.toString('base64');
return fs.readFileSync(fullPath);
}
if (source.base64) {
// 移除 data URL 前缀并转换为 Buffer
const base64 = source.base64.replace(/^data:image\/\w+;base64,/, '');
return Buffer.from(base64, 'base64');
}
throw new Error('无效的图片来源');