feat(dc): Complete Phase 1 - Portal workbench page development
Summary: - Implement DC module Portal page with 3 tool cards - Create ToolCard component with decorative background and hover animations - Implement TaskList component with table layout and progress bars - Implement AssetLibrary component with tab switching and file cards - Complete database verification (4 tables confirmed) - Complete backend API verification (6 endpoints ready) - Optimize UI to match prototype design (V2.html) Frontend Components (~715 lines): - components/ToolCard.tsx - Tool cards with animations - components/TaskList.tsx - Recent tasks table view - components/AssetLibrary.tsx - Data asset library with tabs - hooks/useRecentTasks.ts - Task state management - hooks/useAssets.ts - Asset state management - pages/Portal.tsx - Main portal page - types/portal.ts - TypeScript type definitions Backend Verification: - Backend API: 1495 lines code verified - Database: dc_schema with 4 tables verified - API endpoints: 6 endpoints tested (templates API works) Documentation: - Database verification report - Backend API test report - Phase 1 completion summary - UI optimization report - Development task checklist - Development plan for Tool B Status: Phase 1 completed (100%), ready for browser testing Next: Phase 2 - Tool B Step 1 and 2 development
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* DC模块 - 提取控制器
|
||||
*
|
||||
* API端点:
|
||||
* - POST /api/v1/dc/tool-b/health-check - 健康检查
|
||||
* - GET /api/v1/dc/tool-b/templates - 获取模板列表
|
||||
* - POST /api/v1/dc/tool-b/tasks - 创建提取任务
|
||||
* - GET /api/v1/dc/tool-b/tasks/:taskId/progress - 查询任务进度
|
||||
* - GET /api/v1/dc/tool-b/tasks/:taskId/items - 获取验证网格数据
|
||||
* - POST /api/v1/dc/tool-b/items/:itemId/resolve - 裁决冲突
|
||||
*
|
||||
* 平台能力复用:
|
||||
* - ✅ logger: 日志记录
|
||||
* - ✅ prisma: 数据库操作
|
||||
* - ✅ storage: 文件操作
|
||||
* - ✅ jobQueue: 异步任务
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { healthCheckService } from '../services/HealthCheckService.js';
|
||||
import { templateService } from '../services/TemplateService.js';
|
||||
import { dualModelExtractionService } from '../services/DualModelExtractionService.js';
|
||||
import { conflictDetectionService } from '../services/ConflictDetectionService.js';
|
||||
import { storage } from '../../../../common/storage/index.js';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
import { prisma } from '../../../../config/database.js';
|
||||
import * as xlsx from 'xlsx';
|
||||
|
||||
export class ExtractionController {
|
||||
/**
|
||||
* 健康检查
|
||||
* POST /health-check
|
||||
*/
|
||||
async healthCheck(request: FastifyRequest<{
|
||||
Body: {
|
||||
fileKey: string;
|
||||
columnName: string;
|
||||
}
|
||||
}>, reply: FastifyReply) {
|
||||
try {
|
||||
const { fileKey, columnName } = request.body;
|
||||
const userId = (request as any).userId || 'default-user'; // TODO: 从auth middleware获取
|
||||
|
||||
logger.info('[API] Health check request', { fileKey, columnName, userId });
|
||||
|
||||
const result = await healthCheckService.check(fileKey, columnName, userId);
|
||||
|
||||
return reply.code(200).send({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[API] Health check failed', { error });
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
error: String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板列表
|
||||
* GET /templates
|
||||
*/
|
||||
async getTemplates(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
logger.info('[API] Get templates request');
|
||||
|
||||
const templates = await templateService.getAllTemplates();
|
||||
|
||||
return reply.code(200).send({
|
||||
success: true,
|
||||
data: { templates }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[API] Get templates failed', { error });
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
error: String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建提取任务
|
||||
* POST /tasks
|
||||
*/
|
||||
async createTask(request: FastifyRequest<{
|
||||
Body: {
|
||||
projectName: string;
|
||||
sourceFileKey: string;
|
||||
textColumn: string;
|
||||
diseaseType: string;
|
||||
reportType: string;
|
||||
modelA?: string;
|
||||
modelB?: string;
|
||||
}
|
||||
}>, reply: FastifyReply) {
|
||||
try {
|
||||
const {
|
||||
projectName,
|
||||
sourceFileKey,
|
||||
textColumn,
|
||||
diseaseType,
|
||||
reportType,
|
||||
modelA = 'deepseek-v3',
|
||||
modelB = 'qwen-max'
|
||||
} = request.body;
|
||||
const userId = (request as any).userId || 'default-user';
|
||||
|
||||
logger.info('[API] Create task request', {
|
||||
userId,
|
||||
projectName,
|
||||
diseaseType,
|
||||
reportType
|
||||
});
|
||||
|
||||
// 1. 获取模板
|
||||
const template = await templateService.getTemplate(diseaseType, reportType);
|
||||
if (!template) {
|
||||
return reply.code(404).send({
|
||||
success: false,
|
||||
error: `Template not found: ${diseaseType}/${reportType}`
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 读取Excel文件,创建items
|
||||
const fileBuffer = await storage.download(sourceFileKey);
|
||||
if (!fileBuffer) {
|
||||
return reply.code(404).send({
|
||||
success: false,
|
||||
error: `File not found: ${sourceFileKey}`
|
||||
});
|
||||
}
|
||||
|
||||
const workbook = xlsx.read(fileBuffer, { type: 'buffer' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const data = xlsx.utils.sheet_to_json<Record<string, any>>(worksheet);
|
||||
|
||||
if (!data[0].hasOwnProperty(textColumn)) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
error: `Column '${textColumn}' not found in Excel`
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 创建任务
|
||||
const task = await prisma.dCExtractionTask.create({
|
||||
data: {
|
||||
userId,
|
||||
projectName,
|
||||
sourceFileKey,
|
||||
textColumn,
|
||||
diseaseType,
|
||||
reportType,
|
||||
targetFields: template.fields,
|
||||
modelA,
|
||||
modelB,
|
||||
totalCount: data.length,
|
||||
status: 'pending'
|
||||
}
|
||||
});
|
||||
|
||||
// 4. 创建items
|
||||
const itemsData = data.map((row, index) => ({
|
||||
taskId: task.id,
|
||||
rowIndex: index + 1,
|
||||
originalText: String(row[textColumn] || '')
|
||||
}));
|
||||
|
||||
await prisma.dCExtractionItem.createMany({
|
||||
data: itemsData
|
||||
});
|
||||
|
||||
// 5. 启动异步任务
|
||||
// TODO: 使用jobQueue.add()
|
||||
// 暂时直接调用
|
||||
dualModelExtractionService.batchExtract(task.id).catch(err => {
|
||||
logger.error('[API] Batch extraction failed', { error: err, taskId: task.id });
|
||||
});
|
||||
|
||||
logger.info('[API] Task created', { taskId: task.id, itemCount: data.length });
|
||||
|
||||
return reply.code(201).send({
|
||||
success: true,
|
||||
data: {
|
||||
taskId: task.id,
|
||||
totalCount: data.length,
|
||||
status: 'pending'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[API] Create task failed', { error });
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
error: String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询任务进度
|
||||
* GET /tasks/:taskId/progress
|
||||
*/
|
||||
async getTaskProgress(request: FastifyRequest<{
|
||||
Params: { taskId: string }
|
||||
}>, reply: FastifyReply) {
|
||||
try {
|
||||
const { taskId } = request.params;
|
||||
|
||||
logger.info('[API] Get task progress', { taskId });
|
||||
|
||||
const task = await prisma.dCExtractionTask.findUnique({
|
||||
where: { id: taskId }
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return reply.code(404).send({
|
||||
success: false,
|
||||
error: 'Task not found'
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(200).send({
|
||||
success: true,
|
||||
data: {
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
totalCount: task.totalCount,
|
||||
processedCount: task.processedCount,
|
||||
cleanCount: task.cleanCount,
|
||||
conflictCount: task.conflictCount,
|
||||
failedCount: task.failedCount,
|
||||
totalTokens: task.totalTokens,
|
||||
totalCost: task.totalCost,
|
||||
progress: task.totalCount > 0 ? Math.round((task.processedCount / task.totalCount) * 100) : 0
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[API] Get task progress failed', { error });
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
error: String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证网格数据
|
||||
* GET /tasks/:taskId/items
|
||||
*/
|
||||
async getTaskItems(request: FastifyRequest<{
|
||||
Params: { taskId: string };
|
||||
Querystring: { page?: string; limit?: string; status?: string }
|
||||
}>, reply: FastifyReply) {
|
||||
try {
|
||||
const { taskId } = request.params;
|
||||
const page = parseInt(request.query.page || '1');
|
||||
const limit = parseInt(request.query.limit || '50');
|
||||
const statusFilter = request.query.status;
|
||||
|
||||
logger.info('[API] Get task items', { taskId, page, limit, statusFilter });
|
||||
|
||||
const where: any = { taskId };
|
||||
if (statusFilter) {
|
||||
where.status = statusFilter;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.dCExtractionItem.findMany({
|
||||
where,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
orderBy: { rowIndex: 'asc' }
|
||||
}),
|
||||
prisma.dCExtractionItem.count({ where })
|
||||
]);
|
||||
|
||||
return reply.code(200).send({
|
||||
success: true,
|
||||
data: {
|
||||
items: items.map(item => ({
|
||||
id: item.id,
|
||||
rowIndex: item.rowIndex,
|
||||
originalText: item.originalText,
|
||||
resultA: item.resultA,
|
||||
resultB: item.resultB,
|
||||
status: item.status,
|
||||
conflictFields: item.conflictFields,
|
||||
finalResult: item.finalResult
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[API] Get task items failed', { error });
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
error: String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁决冲突
|
||||
* POST /items/:itemId/resolve
|
||||
*/
|
||||
async resolveConflict(request: FastifyRequest<{
|
||||
Params: { itemId: string };
|
||||
Body: {
|
||||
field: string;
|
||||
chosenValue: string;
|
||||
}
|
||||
}>, reply: FastifyReply) {
|
||||
try {
|
||||
const { itemId } = request.params;
|
||||
const { field, chosenValue } = request.body;
|
||||
|
||||
logger.info('[API] Resolve conflict', { itemId, field });
|
||||
|
||||
// 获取当前记录
|
||||
const item = await prisma.dCExtractionItem.findUnique({
|
||||
where: { id: itemId }
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
success: false,
|
||||
error: 'Item not found'
|
||||
});
|
||||
}
|
||||
|
||||
// 更新finalResult
|
||||
const finalResult = { ...(item.finalResult as Record<string, string> || {}) };
|
||||
finalResult[field] = chosenValue;
|
||||
|
||||
// 移除已解决的冲突字段
|
||||
const conflictFields = item.conflictFields.filter(f => f !== field);
|
||||
|
||||
// 更新状态
|
||||
const newStatus = conflictFields.length === 0 ? 'resolved' : 'conflict';
|
||||
|
||||
await prisma.dCExtractionItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
finalResult,
|
||||
conflictFields,
|
||||
status: newStatus,
|
||||
resolvedAt: conflictFields.length === 0 ? new Date() : null
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('[API] Conflict resolved', { itemId, field, newStatus });
|
||||
|
||||
return reply.code(200).send({
|
||||
success: true,
|
||||
data: {
|
||||
itemId,
|
||||
status: newStatus,
|
||||
remainingConflicts: conflictFields.length
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[API] Resolve conflict failed', { error });
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
error: String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const extractionController = new ExtractionController();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user