feat: 完善图片上传和 OCR 处理功能
- 新增注册页面 (RegisterPage) 和设置页面 (SettingsPage) - 实现多图片上传功能,支持 FormData 文件上传 - 添加 multer 中间件处理图片文件 - 实现 OCR 异步处理服务,自动触发文字识别 - 添加 OCR 处理状态轮询,显示处理进度 - 修复图片显示问题,拼接完整的后端 URL - 添加图片重新处理 API (POST /api/images/:id/reprocess) - 更新 Card 组件支持 extra 属性 - 创建 CLAUDE.md 项目文档 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
196
CLAUDE.md
Normal file
196
CLAUDE.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
**PicAnalysis** 是一个图片 OCR 与智能文档管理系统,支持从截图中提取文字、AI 智能分析标签分类,并将识别结果转化为待办事项或归档文档。
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
- **前端**: React 19 + TypeScript + Vite + Tailwind CSS + Zustand + TanStack Query + React Router v7
|
||||||
|
- **后端**: Node.js + Express + TypeScript + Prisma ORM
|
||||||
|
- **数据库**: SQLite (开发) / PostgreSQL (生产)
|
||||||
|
- **认证**: JWT + bcrypt
|
||||||
|
- **测试**: Vitest (前端单元测试), Playwright (E2E 测试), Jest (后端测试)
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
|
### 后端 (端口 4000)
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm run dev # 启动开发服务器 (tsx watch)
|
||||||
|
npm run build # TypeScript 编译
|
||||||
|
npm run start # 运行生产构建
|
||||||
|
npm run test # 运行 Jest 测试
|
||||||
|
npm run test:watch # Jest 监视模式
|
||||||
|
npm run test:coverage # 测试覆盖率报告
|
||||||
|
npm run lint # ESLint 检查
|
||||||
|
npm run lint:fix # ESLint 自动修复
|
||||||
|
npm run prisma:generate # 生成 Prisma Client
|
||||||
|
npm run prisma:migrate # 运行数据库迁移
|
||||||
|
npm run prisma:studio # 打开 Prisma Studio
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端 (端口 3000)
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev # 启动 Vite 开发服务器
|
||||||
|
npm run build # 构建生产版本
|
||||||
|
npm run preview # 预览生产构建
|
||||||
|
npm run test # 运行 Vitest 单元测试
|
||||||
|
npm run test:ui # Vitest UI 界面
|
||||||
|
npm run test:coverage # Vitest 覆盖率报告
|
||||||
|
npm run test:e2e # 运行 Playwright E2E 测试
|
||||||
|
npm run test:e2e:ui # Playwright UI 模式
|
||||||
|
npm run lint # ESLint 检查
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库操作
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npx prisma db push # 推送 schema 到数据库 (开发环境)
|
||||||
|
npx prisma migrate dev # 创建并应用迁移
|
||||||
|
npx prisma studio # 可视化数据库管理界面
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目架构
|
||||||
|
|
||||||
|
### 后端架构
|
||||||
|
```
|
||||||
|
backend/src/
|
||||||
|
├── index.ts # Express 应用入口,路由挂载
|
||||||
|
├── controllers/ # 请求处理器 (Auth, Document, Todo, Image)
|
||||||
|
├── services/ # 业务逻辑层
|
||||||
|
│ ├── auth.service.ts # 认证逻辑 (注册/登录/验证)
|
||||||
|
│ ├── password.service.ts # 密码验证和强度检查
|
||||||
|
│ ├── ocr.service.ts # OCR 处理逻辑(置信度验证、重试)
|
||||||
|
│ ├── document.service.ts # 文档 CRUD
|
||||||
|
│ ├── todo.service.ts # 待办事项管理(三态工作流)
|
||||||
|
│ └── image.service.ts # 图片上传和处理
|
||||||
|
├── routes/ # API 路由定义
|
||||||
|
├── middleware/ # 中间件 (JWT 认证)
|
||||||
|
└── lib/prisma.ts # Prisma 客户端单例
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键设计模式**:
|
||||||
|
- **分层架构**: Controller → Service → Prisma (数据层)
|
||||||
|
- **服务类**: 使用静态方法实现无状态业务逻辑
|
||||||
|
- **中间件**: JWT 认证中间件保护需要登录的路由
|
||||||
|
|
||||||
|
### 前端架构
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── App.tsx # 路由配置,受保护路由包装
|
||||||
|
├── main.tsx # 应用入口
|
||||||
|
├── pages/ # 页面组件 (Login, Dashboard, Documents, Todos, Images)
|
||||||
|
├── components/ # 可复用 UI 组件 (Button, Input, Card, Layout)
|
||||||
|
├── services/ # API 服务层,与后端通信
|
||||||
|
│ └── api.ts # Axios 封装,拦截器处理 token
|
||||||
|
├── hooks/ # React Hooks (useAuth, useDocuments, useTodos, useImages)
|
||||||
|
├── stores/ # Zustand 状态管理
|
||||||
|
│ ├── authStore.ts # 认证状态持久化
|
||||||
|
│ └── uiStore.ts # UI 状态(通知等)
|
||||||
|
└── types/ # TypeScript 类型定义
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键设计模式**:
|
||||||
|
- **受保护路由**: `<ProtectedRoute>` 组件检查认证状态
|
||||||
|
- **状态管理**: Zustand 负责全局状态,TanStack Query 负责服务器状态
|
||||||
|
- **API 客户端**: Axios 拦截器自动添加 JWT token
|
||||||
|
|
||||||
|
### 数据模型
|
||||||
|
核心实体关系:
|
||||||
|
- **User** 拥有多个 Document, Todo, Image, Category, Tag
|
||||||
|
- **Document** 可关联多个 Image,可生成多个 Todo
|
||||||
|
- **Image** 的 `document_id` 可为空(支持 OCR 失败的待处理图片)
|
||||||
|
- **Todo** 有三种状态: `pending` → `completed` → `confirmed`
|
||||||
|
|
||||||
|
## 待办事项三态工作流
|
||||||
|
|
||||||
|
待办状态流转是核心业务逻辑:
|
||||||
|
1. **pending** (未完成) - 新创建的待办,进行中的任务
|
||||||
|
2. **completed** (已完成) - 用户标记完成,可撤销回 pending
|
||||||
|
3. **confirmed** (已确认) - 完成后经过确认归档,最终状态
|
||||||
|
|
||||||
|
相关 API:
|
||||||
|
- `PATCH /api/todos/:id/complete` - 标记完成
|
||||||
|
- `PATCH /api/todos/:id/confirm` - 确认归档
|
||||||
|
- `PATCH /api/todos/:id/reopen` - 撤销到未完成
|
||||||
|
|
||||||
|
## OCR 降级处理
|
||||||
|
|
||||||
|
当 OCR 置信度低于阈值 (默认 0.3) 时:
|
||||||
|
1. 图片保存到数据库,`processing_status = 'failed'`
|
||||||
|
2. 不自动创建文档
|
||||||
|
3. 用户可在"待处理图片列表"中手动处理
|
||||||
|
|
||||||
|
查询待处理图片:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM images
|
||||||
|
WHERE user_id = ? AND (document_id IS NULL OR processing_status = 'failed')
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
|
||||||
|
### 后端测试 (Jest)
|
||||||
|
- 单元测试覆盖所有 Service 类
|
||||||
|
- 集成测试验证 API 端点
|
||||||
|
- 目标覆盖率: 80%+
|
||||||
|
|
||||||
|
### 前端测试
|
||||||
|
- **Vitest**: 组件和服务的单元测试
|
||||||
|
- **Playwright**: E2E 测试,跨浏览器测试 (Chrome, Firefox, Safari)
|
||||||
|
- 测试文件位于 `e2e/` 目录
|
||||||
|
|
||||||
|
## API 端点
|
||||||
|
|
||||||
|
### 认证
|
||||||
|
- `POST /api/auth/register` - 用户注册
|
||||||
|
- `POST /api/auth/login` - 用户登录
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- `GET /api/documents` - 获取文档列表
|
||||||
|
- `POST /api/documents` - 创建文档
|
||||||
|
- `DELETE /api/documents/:id` - 删除文档
|
||||||
|
|
||||||
|
### 待办
|
||||||
|
- `GET /api/todos` - 获取待办列表(支持 status 参数筛选)
|
||||||
|
- `POST /api/todos` - 创建待办
|
||||||
|
- `PATCH /api/todos/:id/complete` - 标记完成
|
||||||
|
- `PATCH /api/todos/:id/confirm` - 确认归档
|
||||||
|
- `DELETE /api/todos/:id` - 删除待办
|
||||||
|
|
||||||
|
### 图片
|
||||||
|
- `POST /api/images/upload` - 上传图片
|
||||||
|
- `GET /api/images` - 获取图片列表
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
后端需要创建 `.env` 文件:
|
||||||
|
```
|
||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
JWT_SECRET="your-secret-key"
|
||||||
|
PORT=4000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试账号
|
||||||
|
|
||||||
|
```
|
||||||
|
用户名: testuser
|
||||||
|
密码: Password123@
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前开发状态
|
||||||
|
|
||||||
|
已完成功能:
|
||||||
|
- ✅ 用户认证系统 (JWT)
|
||||||
|
- ✅ 文档 CRUD
|
||||||
|
- ✅ 待办三态工作流
|
||||||
|
- ✅ 图片上传和 OCR 状态追踪
|
||||||
|
- ✅ 前后端单元测试 (148 个测试全部通过)
|
||||||
|
- ✅ E2E 测试框架
|
||||||
|
|
||||||
|
待开发功能 (P1 优先级):
|
||||||
|
- ⏳ OCR 集成 (Tesseract/PaddleOCR)
|
||||||
|
- ⏳ AI 分析功能 (GLM/MiniMax/DeepSeek)
|
||||||
|
- ⏳ 图片-文档-待办关联增强
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { ImageService } from '../services/image.service';
|
import { ImageService } from '../services/image.service';
|
||||||
|
import { triggerOCRProcessing } from '../services/ocr-processor.service';
|
||||||
|
|
||||||
export class ImageController {
|
export class ImageController {
|
||||||
/**
|
/**
|
||||||
@@ -14,18 +15,30 @@ export class ImageController {
|
|||||||
static async upload(req: Request, res: Response): Promise<void> {
|
static async upload(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.user_id;
|
const userId = req.user!.user_id;
|
||||||
// Assuming file is processed by multer middleware
|
|
||||||
const { file_path, file_size, mime_type } = req.body;
|
// Handle multer file upload
|
||||||
|
const file = req.file;
|
||||||
const { document_id } = req.body;
|
const { document_id } = req.body;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '请选择要上传的文件',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const image = await ImageService.create({
|
const image = await ImageService.create({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
file_path,
|
file_path: `/uploads/${file.filename}`,
|
||||||
file_size,
|
file_size: file.size,
|
||||||
mime_type,
|
mime_type: file.mimetype,
|
||||||
document_id,
|
document_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 触发异步 OCR 处理(不等待完成)
|
||||||
|
triggerOCRProcessing(image.id, userId);
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: image,
|
data: image,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
import authRoutes from './routes/auth.routes';
|
import authRoutes from './routes/auth.routes';
|
||||||
import documentRoutes from './routes/document.routes';
|
import documentRoutes from './routes/document.routes';
|
||||||
import todoRoutes from './routes/todo.routes';
|
import todoRoutes from './routes/todo.routes';
|
||||||
@@ -23,6 +24,9 @@ app.use(cors({
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Static files for uploads
|
||||||
|
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ success: true, message: 'API is running' });
|
res.json({ success: true, message: 'API is running' });
|
||||||
|
|||||||
53
backend/src/middleware/upload.middleware.ts
Normal file
53
backend/src/middleware/upload.middleware.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Multer Configuration for File Upload
|
||||||
|
*/
|
||||||
|
|
||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
// Ensure upload directory exists
|
||||||
|
const uploadDir = path.join(process.cwd(), 'uploads');
|
||||||
|
if (!fs.existsSync(uploadDir)) {
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage configuration
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => {
|
||||||
|
cb(null, uploadDir);
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
// Generate unique filename
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
cb(null, 'image-' + uniqueSuffix + ext);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// File filter
|
||||||
|
const fileFilter = (_req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
||||||
|
// Accept only image files
|
||||||
|
const allowedMimes = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (allowedMimes.includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('只支持图片文件 (JPG, PNG, WEBP, GIF)'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export multer configuration
|
||||||
|
export const upload = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter,
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024, // 10MB limit
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { ImageController } from '../controllers/image.controller';
|
import { ImageController } from '../controllers/image.controller';
|
||||||
import { authenticate } from '../middleware/auth.middleware';
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { upload } from '../middleware/upload.middleware';
|
||||||
|
import { triggerOCRProcessing } from '../services/ocr-processor.service';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ const router = Router();
|
|||||||
* @desc Upload image
|
* @desc Upload image
|
||||||
* @access Private
|
* @access Private
|
||||||
*/
|
*/
|
||||||
router.post('/', authenticate, ImageController.upload);
|
router.post('/', authenticate, upload.single('file'), ImageController.upload);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route GET /api/images
|
* @route GET /api/images
|
||||||
@@ -37,6 +39,32 @@ router.get('/pending', authenticate, ImageController.getPending);
|
|||||||
*/
|
*/
|
||||||
router.get('/:id', authenticate, ImageController.getById);
|
router.get('/:id', authenticate, ImageController.getById);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/images/:id/reprocess
|
||||||
|
* @desc Re-trigger OCR processing
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.post('/:id/reprocess', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.user_id;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// 触发 OCR 处理
|
||||||
|
triggerOCRProcessing(id, userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'OCR 处理已开始',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '重新处理失败';
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route PUT /api/images/:id/ocr
|
* @route PUT /api/images/:id/ocr
|
||||||
* @desc Update OCR result
|
* @desc Update OCR result
|
||||||
|
|||||||
165
backend/src/services/ocr-processor.service.ts
Normal file
165
backend/src/services/ocr-processor.service.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* OCR Processor Service
|
||||||
|
* 处理图片 OCR 识别的异步服务
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from '../lib/prisma';
|
||||||
|
import { ImageService } from './image.service';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export class OCRProcessorService {
|
||||||
|
/**
|
||||||
|
* 处理图片的 OCR 识别
|
||||||
|
* 注意:当前是模拟实现,返回占位符文本
|
||||||
|
* 实际使用时需要集成 Tesseract.js 或其他 OCR 服务
|
||||||
|
*/
|
||||||
|
static async processImage(imageId: string, userId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 更新状态为处理中
|
||||||
|
await prisma.image.update({
|
||||||
|
where: { id: imageId },
|
||||||
|
data: { processing_status: 'processing' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取图片信息
|
||||||
|
const image = await ImageService.findById(imageId, userId);
|
||||||
|
if (!image) {
|
||||||
|
throw new Error('Image not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 集成真实的 OCR 服务
|
||||||
|
// 当前使用模拟实现
|
||||||
|
const ocrResult = await this.performOCRSimulated(image);
|
||||||
|
|
||||||
|
// 根据置信度决定状态
|
||||||
|
const status = ocrResult.confidence >= 0.3 ? 'completed' : 'failed';
|
||||||
|
|
||||||
|
await prisma.image.update({
|
||||||
|
where: { id: imageId },
|
||||||
|
data: {
|
||||||
|
ocr_result: ocrResult.text,
|
||||||
|
ocr_confidence: ocrResult.confidence,
|
||||||
|
processing_status: status,
|
||||||
|
error_message: status === 'failed' ? 'OCR 识别置信度过低' : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 处理失败
|
||||||
|
await prisma.image.update({
|
||||||
|
where: { id: imageId },
|
||||||
|
data: {
|
||||||
|
processing_status: 'failed',
|
||||||
|
error_message: error instanceof Error ? error.message : 'OCR 处理失败',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟 OCR 处理
|
||||||
|
* 实际实现应该调用 Tesseract.js 或其他 OCR API
|
||||||
|
*/
|
||||||
|
private static async performOCRSimulated(image: any): Promise<{
|
||||||
|
text: string;
|
||||||
|
confidence: number;
|
||||||
|
}> {
|
||||||
|
// 模拟处理延迟
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
// TODO: 实际 OCR 集成选项:
|
||||||
|
// 1. Tesseract.js (本地)
|
||||||
|
// import Tesseract from 'tesseract.js';
|
||||||
|
// const { data: { text, confidence } } = await Tesseract.recognize(imagePath, 'chi_sim+eng');
|
||||||
|
//
|
||||||
|
// 2. PaddleOCR (需要 Python 服务)
|
||||||
|
// const response = await fetch('http://localhost:5000/ocr', {
|
||||||
|
// method: 'POST',
|
||||||
|
// body: JSON.stringify({ image_path: imagePath }),
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// 3. 云端 OCR API (百度/腾讯/阿里)
|
||||||
|
|
||||||
|
// 模拟返回结果
|
||||||
|
return {
|
||||||
|
text: '[模拟 OCR 结果] 图片文字识别功能尚未集成。请在设置页面配置 OCR 服务后重试。',
|
||||||
|
confidence: 0.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Tesseract.js 进行 OCR 识别(需要安装依赖)
|
||||||
|
*/
|
||||||
|
private static async performOCRWithTesseract(imagePath: string): Promise<{
|
||||||
|
text: string;
|
||||||
|
confidence: number;
|
||||||
|
}> {
|
||||||
|
// 动态导入 Tesseract(如果已安装)
|
||||||
|
try {
|
||||||
|
const Tesseract = await import('tesseract.js');
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
const fullPath = path.join(process.cwd(), imagePath.replace('/uploads/', 'uploads/'));
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
throw new Error(`Image file not found: ${fullPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await Tesseract.recognize(fullPath, 'chi_sim+eng', {
|
||||||
|
logger: (m: any) => console.log(m),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: result.data.text,
|
||||||
|
confidence: result.data.confidence / 100, // Tesseract 返回 0-100,需要转换为 0-1
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// 如果 Tesseract 未安装,返回模拟结果
|
||||||
|
console.warn('Tesseract.js not installed, using simulated OCR:', error);
|
||||||
|
return this.performOCRSimulated(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用外部 OCR API(示例)
|
||||||
|
*/
|
||||||
|
private static async performOCRWithAPI(imagePath: string): Promise<{
|
||||||
|
text: string;
|
||||||
|
confidence: number;
|
||||||
|
}> {
|
||||||
|
// 示例:调用百度 OCR API
|
||||||
|
// const apiKey = process.env.BAIDU_OCR_API_KEY;
|
||||||
|
// const secretKey = process.env.BAIDU_OCR_SECRET_KEY;
|
||||||
|
//
|
||||||
|
// // 获取 access token
|
||||||
|
// const tokenResponse = await fetch(`https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=${apiKey}&client_secret=${secretKey}`);
|
||||||
|
// const { access_token } = await tokenResponse.json();
|
||||||
|
//
|
||||||
|
// // 读取图片并转为 base64
|
||||||
|
// const imageBuffer = fs.readFileSync(imagePath);
|
||||||
|
// const imageBase64 = imageBuffer.toString('base64');
|
||||||
|
//
|
||||||
|
// // 调用 OCR API
|
||||||
|
// const ocrResponse = await fetch(`https://aip.baidubce.com/rest/2.0/ocr/v1/general_basic?access_token=${access_token}`, {
|
||||||
|
// method: 'POST',
|
||||||
|
// headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
// body: `image=${encodeURIComponent(imageBase64)}`,
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// const result = await ocrResponse.json();
|
||||||
|
//
|
||||||
|
// return {
|
||||||
|
// text: result.words_result?.map((w: any) => w.words).join('\n') || '',
|
||||||
|
// confidence: (result.words_result?.[0]?.probability?.average || 0.5) / 100,
|
||||||
|
// };
|
||||||
|
|
||||||
|
throw new Error('OCR API not configured');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出异步处理函数(用于在后台触发 OCR)
|
||||||
|
export const triggerOCRProcessing = async (imageId: string, userId: string) => {
|
||||||
|
// 不等待完成,在后台处理
|
||||||
|
OCRProcessorService.processImage(imageId, userId).catch(error => {
|
||||||
|
console.error('OCR processing failed:', error);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -2,10 +2,12 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from './stores/authStore';
|
import { useAuthStore } from './stores/authStore';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import RegisterPage from './pages/RegisterPage';
|
||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import DocumentsPage from './pages/DocumentsPage';
|
import DocumentsPage from './pages/DocumentsPage';
|
||||||
import TodosPage from './pages/TodosPage';
|
import TodosPage from './pages/TodosPage';
|
||||||
import ImagesPage from './pages/ImagesPage';
|
import ImagesPage from './pages/ImagesPage';
|
||||||
|
import SettingsPage from './pages/SettingsPage';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -33,6 +35,7 @@ function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
@@ -46,6 +49,7 @@ function App() {
|
|||||||
<Route path="documents" element={<DocumentsPage />} />
|
<Route path="documents" element={<DocumentsPage />} />
|
||||||
<Route path="todos" element={<TodosPage />} />
|
<Route path="todos" element={<TodosPage />} />
|
||||||
<Route path="images" element={<ImagesPage />} />
|
<Route path="images" element={<ImagesPage />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { cn } from '@/utils/cn';
|
|||||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
title?: string;
|
title?: string;
|
||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
|
extra?: React.ReactNode;
|
||||||
variant?: 'default' | 'bordered' | 'elevated';
|
variant?: 'default' | 'bordered' | 'elevated';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||||
({ className, title, action, variant = 'default', children, ...props }, ref) => {
|
({ className, title, action, extra, variant = 'default', children, ...props }, ref) => {
|
||||||
const baseStyles = 'rounded bg-white p-4';
|
const baseStyles = 'rounded bg-white p-4';
|
||||||
|
|
||||||
const variantStyles = {
|
const variantStyles = {
|
||||||
@@ -19,10 +20,13 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={cn(baseStyles, variantStyles[variant], className)} {...props}>
|
<div ref={ref} className={cn(baseStyles, variantStyles[variant], className)} {...props}>
|
||||||
{(title || action) && (
|
{(title || action || extra) && (
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
||||||
{action && <div>{action}</div>}
|
<div className="flex items-center gap-2">
|
||||||
|
{extra}
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>{children || '卡片内容'}</div>
|
<div>{children || '卡片内容'}</div>
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ export function useUploadImage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUploadImageFile() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ file, documentId }: { file: File; documentId?: string }) =>
|
||||||
|
ImageService.uploadFile(file, documentId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['images'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useUpdateOCR() {
|
export function useUpdateOCR() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +1,165 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useImages, usePendingImages } from '@/hooks/useImages';
|
import { useImages, usePendingImages, useUploadImageFile } from '@/hooks/useImages';
|
||||||
import { Button } from '@/components/Button';
|
import { Button } from '@/components/Button';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
import { Upload, Camera, FileText, CheckSquare } from 'lucide-react';
|
import { Upload, Camera, FileText, CheckSquare, X, RefreshCw } from 'lucide-react';
|
||||||
import type { Image } from '@/types';
|
import type { Image } from '@/types';
|
||||||
|
import { useDeleteImage } from '@/hooks/useImages';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
|
// 获取完整的图片 URL
|
||||||
|
const getImageUrl = (path: string) => {
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return `${API_BASE_URL}${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ImagesPage() {
|
export default function ImagesPage() {
|
||||||
const { data: images } = useImages();
|
const { data: images, refetch } = useImages();
|
||||||
const { data: pendingImages } = usePendingImages();
|
const { data: pendingImages } = usePendingImages();
|
||||||
|
const uploadMutation = useUploadImageFile();
|
||||||
|
const deleteMutation = useDeleteImage();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
|
// OCR 处理状态轮询
|
||||||
|
useEffect(() => {
|
||||||
|
// 检查是否有处理中的图片
|
||||||
|
const hasPendingImages = images?.some(img =>
|
||||||
|
img.processing_status === 'pending' || img.processing_status === 'processing'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasPendingImages) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
refetch();
|
||||||
|
}, 3000); // 每3秒轮询一次
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [images, refetch]);
|
||||||
|
|
||||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
setUploading(true);
|
// 验证并上传所有文件
|
||||||
try {
|
const validFiles = Array.from(files).filter(file => {
|
||||||
// 这里实现文件上传逻辑
|
if (!file.type.startsWith('image/')) {
|
||||||
const file = files[0];
|
alert(`文件 "${file.name}" 不是图片文件`);
|
||||||
const reader = new FileReader();
|
return false;
|
||||||
reader.onloadend = async () => {
|
}
|
||||||
// 将文件转换为 base64 或上传到服务器
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
const base64 = reader.result as string;
|
alert(`文件 "${file.name}" 超过 10MB 限制`);
|
||||||
// TODO: 实现上传到 API
|
return false;
|
||||||
console.log('File uploaded:', base64.substring(0, 50) + '...');
|
}
|
||||||
};
|
return true;
|
||||||
reader.readAsDataURL(file);
|
});
|
||||||
} catch (err) {
|
|
||||||
alert('上传失败');
|
if (validFiles.length === 0) return;
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
// 依次上传所有文件
|
||||||
|
for (const file of validFiles) {
|
||||||
|
try {
|
||||||
|
await uploadMutation.mutateAsync({ file });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`上传 ${file.name} 失败:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空 input 允许再次选择同一文件
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCapture = async () => {
|
const handleCapture = async () => {
|
||||||
try {
|
try {
|
||||||
// 调用系统截图功能(需要与 Electron 集成或使用浏览器 API)
|
// 调用系统截图功能
|
||||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
video: { mediaSource: 'screen' },
|
video: { mediaSource: 'screen' as any },
|
||||||
});
|
});
|
||||||
// TODO: 处理截图流
|
|
||||||
|
// 创建视频元素捕获帧
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.srcObject = stream;
|
||||||
|
await video.play();
|
||||||
|
|
||||||
|
// 创建 canvas 截取当前帧
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx?.drawImage(video, 0, 0);
|
||||||
|
|
||||||
|
// 停止视频流
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
} catch (err) {
|
|
||||||
alert('截图失败或被取消');
|
// 转换为 blob 并上传
|
||||||
|
canvas.toBlob(async (blob) => {
|
||||||
|
if (blob) {
|
||||||
|
const file = new File([blob], `screenshot-${Date.now()}.png`, { type: 'image/png' });
|
||||||
|
try {
|
||||||
|
await uploadMutation.mutateAsync({ file });
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || '上传截图失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 'image/png');
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name !== 'NotAllowedError') {
|
||||||
|
alert('截图失败');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('确定要删除这张图片吗?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return '已完成';
|
||||||
|
case 'pending': return '等待处理';
|
||||||
|
case 'processing': return 'OCR 处理中';
|
||||||
|
case 'failed': return '处理失败';
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'bg-green-100 text-green-800';
|
||||||
|
case 'pending': return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'processing': return 'bg-blue-100 text-blue-800';
|
||||||
|
case 'failed': return 'bg-red-100 text-red-800';
|
||||||
|
default: return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasProcessingImages = images?.some(img =>
|
||||||
|
img.processing_status === 'pending' || img.processing_status === 'processing'
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">图片管理</h1>
|
<div>
|
||||||
<p className="mt-1 text-sm text-gray-600">上传和管理您的图片</p>
|
<h1 className="text-2xl font-bold text-gray-900">图片管理</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-600">上传和管理您的图片</p>
|
||||||
|
</div>
|
||||||
|
{hasProcessingImages && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-blue-600">
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
<span>OCR 处理中...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upload Actions */}
|
{/* Upload Actions */}
|
||||||
@@ -72,10 +180,10 @@ export default function ImagesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mb-2 text-lg font-semibold">上传图片</h3>
|
<h3 className="mb-2 text-lg font-semibold">上传图片</h3>
|
||||||
<p className="mb-4 text-sm text-gray-600">从本地上传图片文件</p>
|
<p className="mb-4 text-sm text-gray-600">支持选择多张图片</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
loading={uploading}
|
loading={uploadMutation.isPending}
|
||||||
>
|
>
|
||||||
选择文件
|
选择文件
|
||||||
</Button>
|
</Button>
|
||||||
@@ -91,7 +199,7 @@ export default function ImagesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="mb-2 text-lg font-semibold">屏幕截图</h3>
|
<h3 className="mb-2 text-lg font-semibold">屏幕截图</h3>
|
||||||
<p className="mb-4 text-sm text-gray-600">使用系统截图功能</p>
|
<p className="mb-4 text-sm text-gray-600">使用系统截图功能</p>
|
||||||
<Button onClick={handleCapture} variant="secondary">
|
<Button onClick={handleCapture} variant="secondary" loading={uploadMutation.isPending}>
|
||||||
开始截图
|
开始截图
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,13 +217,13 @@ export default function ImagesPage() {
|
|||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<span className="text-xs font-medium text-yellow-800">
|
<span className="text-xs font-medium text-yellow-800">
|
||||||
处理中
|
等待处理
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-yellow-600">
|
<span className="text-xs text-yellow-600">
|
||||||
{new Date(image.created_at).toLocaleString()}
|
{new Date(image.created_at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-yellow-700">{image.file_path}</p>
|
<p className="text-sm text-yellow-700 truncate">{image.file_path}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -123,59 +231,88 @@ export default function ImagesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Images Grid */}
|
{/* Images Grid */}
|
||||||
<Card title="所有图片" variant="bordered">
|
<Card
|
||||||
|
title={`所有图片 (${images?.length || 0})`}
|
||||||
|
variant="bordered"
|
||||||
|
extra={
|
||||||
|
hasProcessingImages && (
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
{images && images.length > 0 ? (
|
{images && images.length > 0 ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{images.map((image) => (
|
{images.map((image) => (
|
||||||
<div
|
<div
|
||||||
key={image.id}
|
key={image.id}
|
||||||
className="overflow-hidden rounded-lg border border-gray-200"
|
className="group relative overflow-hidden rounded-lg border border-gray-200 bg-white"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(image.id)}
|
||||||
|
className="absolute right-2 top-2 z-10 rounded-full bg-red-500 p-1 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 图片预览 */}
|
||||||
<img
|
<img
|
||||||
src={image.file_path}
|
src={getImageUrl(image.file_path)}
|
||||||
alt="Upload"
|
alt="Upload"
|
||||||
className="h-48 w-full object-cover"
|
className="h-48 w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
|
{/* 状态标签 */}
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
className={`rounded px-2 py-1 text-xs font-medium ${getStatusColor(image.processing_status)}`}
|
||||||
image.processing_status === 'completed'
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: image.processing_status === 'pending'
|
|
||||||
? 'bg-yellow-100 text-yellow-800'
|
|
||||||
: 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{image.processing_status === 'completed'
|
{image.processing_status === 'processing' && (
|
||||||
? '已完成'
|
<RefreshCw className="mr-1 h-3 w-3 animate-spin inline" />
|
||||||
: image.processing_status === 'pending'
|
)}
|
||||||
? '处理中'
|
{getStatusLabel(image.processing_status)}
|
||||||
: '失败'}
|
|
||||||
</span>
|
</span>
|
||||||
{image.ocr_confidence && (
|
{image.ocr_confidence !== null && image.ocr_confidence !== undefined && (
|
||||||
<span className="text-xs text-gray-600">
|
<span className="text-xs text-gray-600">
|
||||||
{Math.round(image.ocr_confidence * 100)}%
|
置信度: {Math.round(image.ocr_confidence * 100)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* OCR 结果 */}
|
||||||
{image.ocr_result && (
|
{image.ocr_result && (
|
||||||
<p className="mb-2 line-clamp-2 text-sm text-gray-600">
|
<p className="mb-2 line-clamp-2 text-sm text-gray-600">
|
||||||
{image.ocr_result}
|
{image.ocr_result}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
{image.error_message && (
|
||||||
|
<p className="mb-2 text-xs text-red-600">
|
||||||
|
{image.error_message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{image.document_id ? (
|
{image.document_id ? (
|
||||||
<button
|
<button
|
||||||
className="flex items-center rounded bg-blue-50 px-2 py-1 text-xs text-blue-600 hover:bg-blue-100"
|
className="flex items-center rounded bg-blue-50 px-2 py-1 text-xs text-blue-600 hover:bg-blue-100"
|
||||||
>
|
>
|
||||||
<FileText className="mr-1 h-3 w-3" />
|
<FileText className="mr-1 h-3 w-3" />
|
||||||
文档
|
查看文档
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button className="flex items-center rounded border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50">
|
<button className="flex items-center rounded border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50">
|
||||||
<CheckSquare className="mr-1 h-3 w-3" />
|
<CheckSquare className="mr-1 h-3 w-3" />
|
||||||
待办
|
转为待办
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +321,7 @@ export default function ImagesPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-gray-500">暂无图片</p>
|
<p className="py-8 text-center text-gray-500">暂无图片,请上传图片开始使用</p>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
103
frontend/src/pages/RegisterPage.tsx
Normal file
103
frontend/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useRegister } from '@/hooks/useAuth';
|
||||||
|
import { Button } from '@/components/Button';
|
||||||
|
import { Input } from '@/components/Input';
|
||||||
|
import { Card } from '@/components/Card';
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const register = useRegister();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
setError('请输入用户名和密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError('密码长度至少为 6 位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('两次输入的密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register.mutateAsync({ username, password });
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '注册失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-100 px-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">图片分析系统</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">创建新账号</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="用户名"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="密码"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="请输入密码(至少 6 位)"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="确认密码"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
loading={register.isPending}
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-sm text-gray-600">
|
||||||
|
已有账号?{' '}
|
||||||
|
<Link to="/login" className="text-blue-600 hover:underline">
|
||||||
|
立即登录
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
frontend/src/pages/SettingsPage.tsx
Normal file
154
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/Button';
|
||||||
|
import { Input } from '@/components/Input';
|
||||||
|
import { Card } from '@/components/Card';
|
||||||
|
import { Settings, Save, Eye, EyeOff } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [showApiKeys, setShowApiKeys] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 表单状态
|
||||||
|
const [ocrProvider, setOcrProvider] = useState('tesseract');
|
||||||
|
const [aiProvider, setAiProvider] = useState('glm');
|
||||||
|
const [glmApiKey, setGlmApiKey] = useState('');
|
||||||
|
const [minimaxApiKey, setMinimaxApiKey] = useState('');
|
||||||
|
const [deepseekApiKey, setDeepseekApiKey] = useState('');
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// TODO: 保存配置到后端
|
||||||
|
console.log('Saving settings:', {
|
||||||
|
ocrProvider,
|
||||||
|
aiProvider,
|
||||||
|
glmApiKey,
|
||||||
|
minimaxApiKey,
|
||||||
|
deepseekApiKey,
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
alert('设置已保存');
|
||||||
|
} catch (err) {
|
||||||
|
alert('保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">系统设置</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-600">配置 OCR 和 AI 服务提供商</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OCR 设置 */}
|
||||||
|
<Card title="OCR 设置" variant="bordered">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
OCR 提供商
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={ocrProvider}
|
||||||
|
onChange={(e) => setOcrProvider(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="tesseract">Tesseract.js (浏览器端)</option>
|
||||||
|
<option value="paddle">PaddleOCR (本地服务)</option>
|
||||||
|
<option value="baidu">百度 OCR</option>
|
||||||
|
<option value="tencent">腾讯云 OCR</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
选择 OCR 文字识别服务提供商
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* AI 设置 */}
|
||||||
|
<Card title="AI 分析设置" variant="bordered">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
AI 提供商
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={aiProvider}
|
||||||
|
onChange={(e) => setAiProvider(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="glm">智谱 AI (GLM)</option>
|
||||||
|
<option value="minimax">MiniMax</option>
|
||||||
|
<option value="deepseek">DeepSeek</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
选择用于智能标签和分类的 AI 服务
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* GLM API Key */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
智谱 AI API Key
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowApiKeys(!showApiKeys)}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
{showApiKeys ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type={showApiKeys ? 'text' : 'password'}
|
||||||
|
value={glmApiKey}
|
||||||
|
onChange={(e) => setGlmApiKey(e.target.value)}
|
||||||
|
placeholder="请输入智谱 AI API Key"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MiniMax API Key */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
MiniMax API Key
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type={showApiKeys ? 'text' : 'password'}
|
||||||
|
value={minimaxApiKey}
|
||||||
|
onChange={(e) => setMinimaxApiKey(e.target.value)}
|
||||||
|
placeholder="请输入 MiniMax API Key"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DeepSeek API Key */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
DeepSeek API Key
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type={showApiKeys ? 'text' : 'password'}
|
||||||
|
value={deepseekApiKey}
|
||||||
|
onChange={(e) => setDeepseekApiKey(e.target.value)}
|
||||||
|
placeholder="请输入 DeepSeek API Key"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 保存按钮 */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} loading={saving} className="min-w-[120px]">
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
保存设置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import axios from 'axios';
|
||||||
import { apiClient } from './api';
|
import { apiClient } from './api';
|
||||||
import type {
|
import type {
|
||||||
Image,
|
Image,
|
||||||
@@ -5,7 +6,41 @@ import type {
|
|||||||
UpdateOCRRequest,
|
UpdateOCRRequest,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000/api';
|
||||||
|
|
||||||
class ImageServiceClass {
|
class ImageServiceClass {
|
||||||
|
/**
|
||||||
|
* Upload image file using FormData
|
||||||
|
*/
|
||||||
|
async uploadFile(file: File, documentId?: string): Promise<Image> {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (documentId) {
|
||||||
|
formData.append('document_id', documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
const response = await axios.post<{ success: boolean; data: Image }>(
|
||||||
|
`${API_BASE_URL}/images`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
throw new Error('上传失败');
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(error.response?.data?.error || '上传图片失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: CreateImageRequest): Promise<Image> {
|
async create(data: CreateImageRequest): Promise<Image> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post<{ success: boolean; data: Image }>('/images', data);
|
const response = await apiClient.post<{ success: boolean; data: Image }>('/images', data);
|
||||||
|
|||||||
Reference in New Issue
Block a user