feat: 异步解析大文件WPS报备资料

This commit is contained in:
hectorzhao
2026-09-04 22:51:07 +08:00
parent aaf96db2d0
commit 5925cf493b
22 changed files with 720 additions and 80 deletions
@@ -6,6 +6,7 @@ import { ReportMaterialImportModal } from './ReportMaterialImportModal';
const { adminApi } = vi.hoisted(() => ({
adminApi: {
analyzeReportMaterialImport: vi.fn(),
getReportMaterialImportAnalysisStatus: vi.fn(),
listDrainageFields: vi.fn(),
listEnterpriseApplicationOptions: vi.fn(),
listReportImportProfiles: vi.fn(),
@@ -27,6 +28,15 @@ describe('ReportMaterialImportModal mapping profile action', () => {
adminApi.listReportImportProfiles.mockResolvedValue([]);
adminApi.analyzeReportMaterialImport.mockResolvedValue({
id: 'analysis-1',
status: 'queued',
progress: 10,
progressStage: '文件已上传,等待解析',
});
adminApi.getReportMaterialImportAnalysisStatus.mockResolvedValue({
id: 'analysis-1',
status: 'analyzed',
progress: 100,
progressStage: '解析完成',
columns: [
{ sourceColumnIndex: 0, columnLetter: 'A', sourceHeader: '签名', sourceHeaderPath: '签名', imageCount: 0 },
],
@@ -59,5 +69,5 @@ describe('ReportMaterialImportModal mapping profile action', () => {
expect(screen.getByRole('button', { name: '本次将保存/更新映射方案' })).toHaveAttribute('aria-pressed', 'true'),
);
expect(screen.getByLabelText('映射方案名称')).toBeVisible();
});
}, 10_000);
});
+85 -8
View File
@@ -25,6 +25,7 @@ type AnalyzeResult = {
rows: Array<{ rowNumber: number; values: Record<string, string>; imageColumns: number[] }>;
suggestedMappings: ReportImportMapping[];
};
type AnalysisProgress = { id: string; status: string; progress: number; progressStage?: string; errorMessage?: string };
const transforms = [
{ label: '保持原值', value: '' },
@@ -60,6 +61,7 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
const [headerRowCount, setHeaderRowCount] = useState(1);
const [dataStartRow, setDataStartRow] = useState(2);
const [analysis, setAnalysis] = useState<AnalyzeResult>();
const [analysisJob, setAnalysisJob] = useState<AnalysisProgress>();
const [mappings, setMappings] = useState<ReportImportMapping[]>([]);
const [profileName, setProfileName] = useState('');
const [saveProfile, setSaveProfile] = useState(false);
@@ -87,10 +89,52 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
.catch(() => setProfiles([]));
}, [reportType]);
useEffect(() => {
if (!analysisJob?.id || !tenantId || analysisJob.status === 'analyzed' || analysisJob.status === 'failed') return;
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const poll = async () => {
try {
const result = await adminApi.getReportMaterialImportAnalysisStatus(
analysisJob.id,
tenantId,
controller.signal,
);
if (controller.signal.aborted) return;
setError('');
setAnalysisJob(result);
if (result.status === 'analyzed') {
const completed = result as AnalyzeResult & AnalysisProgress;
setAnalysis(completed);
setMappings(completed.suggestedMappings ?? []);
const selectedProfile = profiles.find((item) => item.id === profileId);
if (selectedProfile) setProfileName(selectedProfile.name);
return;
}
if (result.status === 'failed') {
setError(result.errorMessage || '文件解析失败');
return;
}
timer = setTimeout(() => void poll(), 1200);
} catch (failure) {
if (!controller.signal.aborted) {
setError(failure instanceof Error ? `${failure.message},正在重试` : '解析进度查询失败,正在重试');
timer = setTimeout(() => void poll(), 2000);
}
}
};
void poll();
return () => {
controller.abort();
if (timer) clearTimeout(timer);
};
}, [analysisJob?.id, analysisJob?.status, profileId, profiles, tenantId]);
function changeReportType(next: ReportType) {
setReportType(next);
setProfileId('');
setAnalysis(undefined);
setAnalysisJob(undefined);
setMappings([]);
}
@@ -108,18 +152,15 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
setBusy(true);
setError('');
try {
const result = (await adminApi.analyzeReportMaterialImport(file, {
const result = await adminApi.analyzeReportMaterialImport(file, {
tenantId,
applicationId,
reportType,
headerRowCount,
dataStartRow,
profileId: profileId || undefined,
})) as AnalyzeResult;
setAnalysis(result);
setMappings(result.suggestedMappings ?? []);
const selectedProfile = profiles.find((item) => item.id === profileId);
if (selectedProfile) setProfileName(selectedProfile.name);
});
setAnalysisJob(result);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '文件解析失败');
} finally {
@@ -216,8 +257,21 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
{busy ? '提交中...' : '提交导入审核'}
</Button>
) : (
<Button disabled={busy || !file || !tenantId || !applicationId} onClick={() => void analyze()}>
{busy ? '解析中...' : '解析文件并配置映射'}
<Button
disabled={
busy ||
Boolean(analysisJob && !['failed', 'analyzed'].includes(analysisJob.status)) ||
!file ||
!tenantId ||
!applicationId
}
onClick={() => void analyze()}
>
{busy
? '上传中...'
: analysisJob && !['failed', 'analyzed'].includes(analysisJob.status)
? '后台解析中...'
: '解析文件并配置映射'}
</Button>
)}
</>
@@ -306,10 +360,33 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
onChange={(event) => {
setFile(event.target.files?.[0]);
setAnalysis(undefined);
setAnalysisJob(undefined);
setError('');
}}
type="file"
/>
</label>
{analysisJob && analysisJob.status !== 'analyzed' ? (
<div className="report-import-analysis-progress" aria-live="polite">
<div>
<strong>{analysisJob.progressStage || '正在处理'}</strong>
<span>{Math.max(0, Math.min(100, analysisJob.progress))}%</span>
</div>
<div
className="batch-progress__track"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={analysisJob.progress}
>
<span
className="batch-progress__bar batch-progress__bar--sending"
style={{ width: `${Math.max(0, Math.min(100, analysisJob.progress))}%` }}
/>
</div>
<small></small>
</div>
) : null}
{analysis ? (
<div className="report-import-mapping">
<div className="channel-field-section-head">