feat: 添加图片OCR结果转换为待办事项的功能
- 后端新增 `/images/:id/convert-to-todo` 路由及控制器方法,支持将图片OCR结果创建为待办事项 - 前端添加 `useConvertImageToTodo` hook 和 `ImageService.convertToTodo` 方法 - 在图片列表页面为已识别图片添加“转为待办”操作按钮 - 新增文档详情页面路由及基础UI - 修复PaddleOCR和RapidOCR提供商的配置及可用性检查逻辑 - 优化图片上传时对document_id的处理(空字符串转为null)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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('无效的图片来源');
|
||||
|
||||
Reference in New Issue
Block a user