feat(asl): Complete Week 4 - Results display and Excel export with hybrid solution

Features:
- Backend statistics API (cloud-native Prisma aggregation)
- Results page with hybrid solution (AI consensus + human final decision)
- Excel export (frontend generation, zero disk write, cloud-native)
- PRISMA-style exclusion reason analysis with bar chart
- Batch selection and export (3 export methods)
- Fixed logic contradiction (inclusion does not show exclusion reason)
- Optimized table width (870px, no horizontal scroll)

Components:
- Backend: screeningController.ts - add getProjectStatistics API
- Frontend: ScreeningResults.tsx - complete results page (hybrid solution)
- Frontend: excelExport.ts - Excel export utility (40 columns full info)
- Frontend: ScreeningWorkbench.tsx - add navigation button
- Utils: get-test-projects.mjs - quick test tool

Architecture:
- Cloud-native: backend aggregation reduces network transfer
- Cloud-native: frontend Excel generation (zero file persistence)
- Reuse platform: global prisma instance, logger
- Performance: statistics API < 500ms, Excel export < 3s (1000 records)

Documentation:
- Update module status guide (add Week 4 features)
- Update task breakdown (mark Week 4 completed)
- Update API design spec (add statistics API)
- Update database design (add field usage notes)
- Create Week 4 development plan
- Create Week 4 completion report
- Create technical debt list

Test:
- End-to-end flow test passed
- All features verified
- Performance test passed
- Cloud-native compliance verified

Ref: Week 4 Development Plan
Scope: ASL Module MVP - Title Abstract Screening Results
Cloud-Native: Backend aggregation + Frontend Excel generation
This commit is contained in:
2025-11-21 20:12:38 +08:00
parent 2e8699c217
commit 8eef9e0544
207 changed files with 11142 additions and 531 deletions

View File

@@ -0,0 +1,78 @@
/**
* 筛选结果列表Hook
* 用于获取和管理筛选结果列表(支持分页和筛选)
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { message } from 'antd';
import { aslApi } from '../api';
interface UseScreeningResultsOptions {
projectId: string;
page?: number;
pageSize?: number;
filter?: 'all' | 'conflict' | 'included' | 'excluded' | 'reviewed';
enabled?: boolean;
}
/**
* 使用筛选结果Hook
*/
export function useScreeningResults({
projectId,
page = 1,
pageSize = 50,
filter = 'all',
enabled = true,
}: UseScreeningResultsOptions) {
const queryClient = useQueryClient();
// 查询筛选结果列表
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['screening-results', projectId, page, pageSize, filter],
queryFn: () => aslApi.getScreeningResultsList(projectId, { page, pageSize, filter }),
enabled: enabled && !!projectId,
staleTime: 1000 * 30, // 30秒内认为数据是新鲜的
keepPreviousData: true, // 切换页面时保留上一页数据,避免闪烁
});
const results = data?.data?.items || [];
const total = data?.data?.total || 0;
const totalPages = data?.data?.totalPages || 0;
// 人工复核Mutation
const reviewMutation = useMutation({
mutationFn: ({ resultId, decision, note }: {
resultId: string;
decision: 'include' | 'exclude';
note?: string;
}) => aslApi.reviewScreeningResult(resultId, { decision, note }),
onSuccess: () => {
// 刷新列表
queryClient.invalidateQueries({ queryKey: ['screening-results', projectId] });
message.success('复核提交成功');
},
onError: (error: any) => {
message.error(`复核失败: ${error.message}`);
},
});
return {
results,
total,
totalPages,
page,
pageSize,
isLoading,
error,
refetch,
// 人工复核方法
review: reviewMutation.mutate,
isReviewing: reviewMutation.isPending,
};
}

View File

@@ -0,0 +1,68 @@
/**
* 筛选任务轮询Hook
* 用于获取和轮询筛选任务进度
*/
import { useQuery } from '@tanstack/react-query';
import { aslApi } from '../api';
interface UseScreeningTaskOptions {
projectId: string;
enabled?: boolean;
pollingInterval?: number; // 轮询间隔毫秒默认1000
}
/**
* 使用筛选任务Hook
*/
export function useScreeningTask({
projectId,
enabled = true,
pollingInterval = 1000,
}: UseScreeningTaskOptions) {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['screening-task', projectId],
queryFn: () => aslApi.getScreeningTask(projectId),
enabled: enabled && !!projectId,
refetchInterval: (query) => {
const task = query.state.data?.data;
// 如果任务已完成或失败,停止轮询
if (task?.status === 'completed' || task?.status === 'failed') {
return false;
}
return pollingInterval;
},
staleTime: 0, // 始终视为过时,确保轮询生效
});
const task = data?.data;
// 计算进度百分比
const progress = task
? Math.round((task.processedItems / task.totalItems) * 100)
: 0;
// 判断是否正在运行
const isRunning = task?.status === 'running' || task?.status === 'pending';
// 判断是否已完成
const isCompleted = task?.status === 'completed';
// 判断是否失败
const isFailed = task?.status === 'failed';
return {
task,
progress,
isRunning,
isCompleted,
isFailed,
isLoading,
error,
refetch,
};
}