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:
2025-11-18 21:51:51 +08:00
parent e3e7e028e8
commit 3634933ece
213 changed files with 20054 additions and 442 deletions

View 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',
});
}
}

View 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',
});
}
}