refactor(asl): ASL frontend architecture refactoring with left navigation
- feat: Create ASLLayout component with 7-module left navigation - feat: Implement Title Screening Settings page with optimized PICOS layout - feat: Add placeholder pages for Workbench and Results - fix: Fix nested routing structure for React Router v6 - fix: Resolve Spin component warning in MainLayout - fix: Add QueryClientProvider to App.tsx - style: Optimize PICOS form layout (P+I left, C+O+S right) - style: Align Inclusion/Exclusion criteria side-by-side - docs: Add architecture refactoring and routing fix reports Ref: Week 2 Frontend Development Scope: ASL module MVP - Title Abstract Screening
This commit is contained in:
258
backend/src/modules/asl/controllers/literatureController.ts
Normal file
258
backend/src/modules/asl/controllers/literatureController.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* ASL 文献控制器
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { ImportLiteratureDto, LiteratureDto } from '../types/index.js';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
/**
|
||||
* 导入文献(从Excel或JSON)
|
||||
*/
|
||||
export async function importLiteratures(
|
||||
request: FastifyRequest<{ Body: ImportLiteratureDto }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = (request as any).userId || 'asl-test-user-001';
|
||||
const { projectId, literatures } = request.body;
|
||||
|
||||
// 验证项目归属
|
||||
const project = await prisma.aslScreeningProject.findFirst({
|
||||
where: { id: projectId, userId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return reply.status(404).send({
|
||||
error: 'Project not found',
|
||||
});
|
||||
}
|
||||
|
||||
// 批量创建文献
|
||||
const created = await prisma.aslLiterature.createMany({
|
||||
data: literatures.map((lit) => ({
|
||||
projectId,
|
||||
pmid: lit.pmid,
|
||||
title: lit.title,
|
||||
abstract: lit.abstract,
|
||||
authors: lit.authors,
|
||||
journal: lit.journal,
|
||||
publicationYear: lit.publicationYear,
|
||||
doi: lit.doi,
|
||||
})),
|
||||
skipDuplicates: true, // 跳过重复的PMID
|
||||
});
|
||||
|
||||
logger.info('Literatures imported', {
|
||||
projectId,
|
||||
count: created.count,
|
||||
});
|
||||
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
data: {
|
||||
importedCount: created.count,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to import literatures', { error });
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to import literatures',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Excel文件导入文献
|
||||
*/
|
||||
export async function importLiteraturesFromExcel(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = (request as any).userId || 'asl-test-user-001';
|
||||
|
||||
// 获取上传的文件
|
||||
const data = await request.file();
|
||||
if (!data) {
|
||||
return reply.status(400).send({
|
||||
error: 'No file uploaded',
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = (request.body as any).projectId;
|
||||
if (!projectId) {
|
||||
return reply.status(400).send({
|
||||
error: 'projectId is required',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证项目归属
|
||||
const project = await prisma.aslScreeningProject.findFirst({
|
||||
where: { id: projectId, userId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return reply.status(404).send({
|
||||
error: 'Project not found',
|
||||
});
|
||||
}
|
||||
|
||||
// 解析Excel(内存中)
|
||||
const buffer = await data.toBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json<any>(sheet);
|
||||
|
||||
// 映射字段(支持中英文列名)
|
||||
const literatures: LiteratureDto[] = jsonData.map((row) => ({
|
||||
pmid: row.PMID || row.pmid || row['PMID编号'],
|
||||
title: row.Title || row.title || row['标题'],
|
||||
abstract: row.Abstract || row.abstract || row['摘要'],
|
||||
authors: row.Authors || row.authors || row['作者'],
|
||||
journal: row.Journal || row.journal || row['期刊'],
|
||||
publicationYear: row.Year || row.year || row['年份'],
|
||||
doi: row.DOI || row.doi,
|
||||
}));
|
||||
|
||||
// 批量创建
|
||||
const created = await prisma.aslLiterature.createMany({
|
||||
data: literatures.map((lit) => ({
|
||||
projectId,
|
||||
...lit,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
logger.info('Literatures imported from Excel', {
|
||||
projectId,
|
||||
count: created.count,
|
||||
});
|
||||
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
data: {
|
||||
importedCount: created.count,
|
||||
totalRows: jsonData.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to import literatures from Excel', { error });
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to import literatures from Excel',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目的所有文献
|
||||
*/
|
||||
export async function getLiteratures(
|
||||
request: FastifyRequest<{
|
||||
Params: { projectId: string };
|
||||
Querystring: { page?: number; limit?: number };
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = (request as any).userId || 'asl-test-user-001';
|
||||
const { projectId } = request.params;
|
||||
const { page = 1, limit = 50 } = request.query;
|
||||
|
||||
// 验证项目归属
|
||||
const project = await prisma.aslScreeningProject.findFirst({
|
||||
where: { id: projectId, userId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return reply.status(404).send({
|
||||
error: 'Project not found',
|
||||
});
|
||||
}
|
||||
|
||||
const [literatures, total] = await Promise.all([
|
||||
prisma.aslLiterature.findMany({
|
||||
where: { projectId },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
screeningResults: {
|
||||
select: {
|
||||
conflictStatus: true,
|
||||
finalDecision: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.aslLiterature.count({
|
||||
where: { projectId },
|
||||
}),
|
||||
]);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
literatures,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get literatures', { error });
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to get literatures',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文献
|
||||
*/
|
||||
export async function deleteLiterature(
|
||||
request: FastifyRequest<{ Params: { literatureId: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = (request as any).userId || 'asl-test-user-001';
|
||||
const { literatureId } = request.params;
|
||||
|
||||
// 验证文献归属
|
||||
const literature = await prisma.aslLiterature.findFirst({
|
||||
where: {
|
||||
id: literatureId,
|
||||
project: { userId },
|
||||
},
|
||||
});
|
||||
|
||||
if (!literature) {
|
||||
return reply.status(404).send({
|
||||
error: 'Literature not found',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.aslLiterature.delete({
|
||||
where: { id: literatureId },
|
||||
});
|
||||
|
||||
logger.info('Literature deleted', { literatureId });
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: 'Literature deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete literature', { error });
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to delete literature',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
224
backend/src/modules/asl/controllers/projectController.ts
Normal file
224
backend/src/modules/asl/controllers/projectController.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* ASL 筛选项目控制器
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { CreateScreeningProjectDto } from '../types/index.js';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
/**
|
||||
* 创建筛选项目
|
||||
*/
|
||||
export async function createProject(
|
||||
request: FastifyRequest<{ Body: CreateScreeningProjectDto & { userId?: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
// 临时测试模式:优先从JWT获取,否则从请求体获取
|
||||
const userId = (request as any).userId || (request.body as any).userId || 'asl-test-user-001';
|
||||
const { projectName, picoCriteria, inclusionCriteria, exclusionCriteria, screeningConfig } = request.body;
|
||||
|
||||
// 验证必填字段
|
||||
if (!projectName || !picoCriteria || !inclusionCriteria || !exclusionCriteria) {
|
||||
return reply.status(400).send({
|
||||
error: 'Missing required fields',
|
||||
});
|
||||
}
|
||||
|
||||
// 创建项目
|
||||
const project = await prisma.aslScreeningProject.create({
|
||||
data: {
|
||||
userId,
|
||||
projectName,
|
||||
picoCriteria,
|
||||
inclusionCriteria,
|
||||
exclusionCriteria,
|
||||
screeningConfig: screeningConfig || {
|
||||
models: ['deepseek-chat', 'qwen-max'],
|
||||
temperature: 0,
|
||||
},
|
||||
status: 'draft',
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('ASL screening project created', {
|
||||
projectId: project.id,
|
||||
userId,
|
||||
projectName,
|
||||
});
|
||||
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
data: project,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to create ASL project', { error });
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to create project',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有筛选项目
|
||||
*/
|
||||
export async function getProjects(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).userId || 'asl-test-user-001';
|
||||
|
||||
const projects = await prisma.aslScreeningProject.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
literatures: true,
|
||||
screeningResults: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: projects,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get ASL projects', { error });
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to get projects',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个项目详情
|
||||
*/
|
||||
export async function getProjectById(
|
||||
request: FastifyRequest<{ Params: { projectId: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = (request as any).userId || 'asl-test-user-001';
|
||||
const { projectId } = request.params;
|
||||
|
||||
const project = await prisma.aslScreeningProject.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
literatures: true,
|
||||
screeningResults: true,
|
||||
screeningTasks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return reply.status(404).send({
|
||||
error: 'Project not found',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: project,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get ASL project', { error });
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to get project',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新项目
|
||||
*/
|
||||
export async function updateProject(
|
||||
request: FastifyRequest<{
|
||||
Params: { projectId: string };
|
||||
Body: Partial<CreateScreeningProjectDto>;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = (request as any).userId || 'asl-test-user-001';
|
||||
const { projectId } = request.params;
|
||||
const updateData = request.body;
|
||||
|
||||
// 验证项目归属
|
||||
const existingProject = await prisma.aslScreeningProject.findFirst({
|
||||
where: { id: projectId, userId },
|
||||
});
|
||||
|
||||
if (!existingProject) {
|
||||
return reply.status(404).send({
|
||||
error: 'Project not found',
|
||||
});
|
||||
}
|
||||
|
||||
const project = await prisma.aslScreeningProject.update({
|
||||
where: { id: projectId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
logger.info('ASL project updated', { projectId, userId });
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: project,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update ASL project', { error });
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to update project',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目
|
||||
*/
|
||||
export async function deleteProject(
|
||||
request: FastifyRequest<{ Params: { projectId: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = (request as any).userId || 'asl-test-user-001';
|
||||
const { projectId } = request.params;
|
||||
|
||||
// 验证项目归属
|
||||
const existingProject = await prisma.aslScreeningProject.findFirst({
|
||||
where: { id: projectId, userId },
|
||||
});
|
||||
|
||||
if (!existingProject) {
|
||||
return reply.status(404).send({
|
||||
error: 'Project not found',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.aslScreeningProject.delete({
|
||||
where: { id: projectId },
|
||||
});
|
||||
|
||||
logger.info('ASL project deleted', { projectId, userId });
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: 'Project deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete ASL project', { error });
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to delete project',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user