feat: add import job detail polling

This commit is contained in:
Codex
2026-06-29 13:59:11 +08:00
parent 7fc92986a5
commit c07c9866f2
14 changed files with 246 additions and 41 deletions

View File

@@ -6,6 +6,7 @@ import {
executeContentImport,
loadContentEntriesAdmin,
loadImportFieldMapping,
loadImportJobDetail,
loadImportIssues,
loadImportJobs,
loadImportPostCheck,
@@ -18,6 +19,7 @@ import {
syncPublicQuestionBank,
type ContentEntryAdminItem,
type ImportFieldMapping,
type ImportJobDetail,
type ImportIssueItem,
type ImportJobItem,
type ImportPreviewResult,
@@ -111,6 +113,15 @@ function objectRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function importJobActive(item?: ImportJobItem | null) {
return !!item && ['pending', 'importing'].includes(String(item.status || ''));
}
function statusCountText(value?: Record<string, number>) {
if (!value || !Object.keys(value).length) return '-';
return Object.entries(value).map(([key, count]) => `${key}:${count}`).join(' · ');
}
function parseJsonImportText(text: string) {
try {
return JSON.parse(text);
@@ -137,6 +148,7 @@ export default function TenantContentPage() {
const [mappingOverrides, setMappingOverrides] = useState<Record<string, string>>({});
const [template, setTemplate] = useState<ImportTemplateItem | null>(null);
const [previewResult, setPreviewResult] = useState<ImportPreviewResult | null>(null);
const [jobDetail, setJobDetail] = useState<ImportJobDetail | null>(null);
const [postCheck, setPostCheck] = useState<Record<string, unknown> | null>(null);
const [conflicts, setConflicts] = useState<PublicQuestionBankConflictsResult | null>(null);
const [busy, setBusy] = useState('');
@@ -158,28 +170,55 @@ export default function TenantContentPage() {
reload();
}, []);
useEffect(() => {
if (!selectedJobId || !importJobActive(jobDetail?.item)) return undefined;
const timer = setInterval(() => {
refreshSelectedJob(false);
}, 3000);
return () => clearInterval(timer);
}, [selectedJobId, jobDetail?.item?.status]);
async function confirm(title: string, content: string) {
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
return result.confirm;
}
async function refreshSelectedJob(showError = true, jobId = selectedJobId) {
if (!jobId) return;
try {
const detailPayload = await loadImportJobDetail(jobId);
setJobDetail(detailPayload);
if (detailPayload.recentIssues) setIssues(detailPayload.recentIssues);
setPostCheck(detailPayload.importPostCheck ? { importPostCheck: detailPayload.importPostCheck } : null);
if (detailPayload.item?.importType && importTypes.includes(detailPayload.item.importType as ImportType)) {
setSelectedImportType(detailPayload.item.importType as ImportType);
}
if (!importJobActive(detailPayload.item)) reload();
} catch (nextError) {
if (showError) setError(nextError instanceof Error ? nextError.message : '导入任务详情刷新失败');
}
}
async function selectJob(item: ImportJobItem) {
const importType = importTypes.includes(item.importType as ImportType) ? item.importType as ImportType : 'questions';
setSelectedJobId(item.id);
setSelectedImportType(importType);
setJobDetail(null);
setPostCheck(null);
setError('');
try {
const [issuePayload, mappingPayload, templatePayload, postCheckPayload] = await Promise.all([
const [detailPayload, issuePayload, mappingPayload, templatePayload, postCheckPayload] = await Promise.all([
loadImportJobDetail(item.id).catch(() => null),
loadImportIssues(item.id).catch(() => ({ items: [] })),
loadImportFieldMapping(importType).catch(() => ({ item: null })),
loadImportTemplate(importType, 'json').catch(() => ({ item: null })),
loadImportPostCheck(item.id).catch(() => ({ item: null })),
]);
setIssues(issuePayload.items || []);
setJobDetail(detailPayload);
setIssues(detailPayload?.recentIssues || issuePayload.items || []);
setMapping(mappingPayload.item || null);
setTemplate(templatePayload.item || null);
setPostCheck(postCheckPayload.item || null);
setPostCheck(detailPayload?.importPostCheck ? { importPostCheck: detailPayload.importPostCheck } : postCheckPayload.item || null);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '导入任务详情加载失败');
}
@@ -198,6 +237,7 @@ export default function TenantContentPage() {
const payload = await runImportPostCheck(selectedJobId);
setPostCheck(payload.item || null);
Taro.showToast({ title: '复检完成', icon: 'success' });
await refreshSelectedJob(false, selectedJobId);
reload();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '导入复检失败');
@@ -327,6 +367,7 @@ export default function TenantContentPage() {
const nextJobId = String(payload.item?.jobId || payload.item?.id || previewResult?.job?.id || '');
if (nextJobId) setSelectedJobId(nextJobId);
Taro.showToast({ title: executionMode === 'async' ? '已入队' : '导入完成', icon: 'success' });
if (nextJobId) await refreshSelectedJob(false, nextJobId);
reload();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '执行导入失败');
@@ -436,6 +477,7 @@ export default function TenantContentPage() {
? postCheck.importPostCheck as Record<string, unknown>
: postCheck;
const postCheckIssueCount = Array.isArray(postCheckSummary?.issues) ? postCheckSummary.issues.length : 0;
const selectedJob = jobDetail?.item || jobs.find(item => item.id === selectedJobId) || null;
return (
<View className='admin-page'>
@@ -464,6 +506,7 @@ export default function TenantContentPage() {
<View className='admin-actions'>
<Input className='admin-input' placeholder='采纳/同步复制题量,默认 200' type='number' value={copyLimit} onInput={event => setCopyLimit(String(event.detail.value || ''))} />
<Button className='admin-button primary' loading={busy === 'post-check'} onClick={submitPostCheck}></Button>
<Button className='admin-button' onClick={() => refreshSelectedJob()}></Button>
</View>
<View className='admin-tabs'>
{importTypes.map(type => (
@@ -510,12 +553,26 @@ export default function TenantContentPage() {
</View>
<View className='admin-grid'>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{selectedJobId ? selectedJobId.slice(0, 8) : '-'}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(selectedJob?.status || '-')}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(selectedJob?.executionMode || executionMode)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(issues.length)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(mapping?.fields?.length || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(previewResult?.job?.validCount ?? '-')}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(postCheckSummary?.status || '-')}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(postCheckIssueCount)}</Text></View>
</View>
{jobDetail ? (
<View className='admin-list'>
<View className='admin-row'>
<Text className='admin-row-main'> · {selectedJob?.sourceName || selectedJob?.id}</Text>
<Text className='admin-row-meta'> {selectedJob?.status || '-'} · {selectedJob?.executionMode || '-'} · {String(jobDetail.worker?.attemptCount ?? selectedJob?.attemptCount ?? 0)}/{String(jobDetail.worker?.maxAttempts ?? selectedJob?.maxAttempts ?? '-')}</Text>
<Text className='admin-row-meta'>Item {statusCountText(jobDetail.itemStatusSummary)} · Issue {statusCountText(jobDetail.issueSummary)}</Text>
<Text className='admin-row-meta'> {String(jobDetail.worker?.queuedAt || '-')} · {String(jobDetail.worker?.lockedAt || '-')} · {String(jobDetail.worker?.nextAttemptAt || '-')}</Text>
{selectedJob?.errorMessage ? <Text className='admin-row-meta'>{selectedJob.errorMessage}</Text> : null}
{jobDetail.importPostCheck ? <Text className='admin-row-meta'>{String(jobDetail.importPostCheck.status || '-')} · {String((jobDetail.importPostCheck.issues as unknown[] | undefined)?.length || 0)} </Text> : null}
</View>
</View>
) : null}
</View>
<View className='admin-section'>
@@ -524,10 +581,11 @@ export default function TenantContentPage() {
{jobs.map(item => (
<View className='admin-row' key={item.id}>
<Text className='admin-row-main'>{item.importType || 'import'} · {item.status || 'unknown'}</Text>
<Text className='admin-row-meta'> {item.totalCount || 0} · {item.successCount || 0} · {item.errorCount || 0} · {item.warningCount || 0}</Text>
<Text className='admin-row-meta'> {item.id} · {item.updatedAt || item.createdAt || ''}</Text>
<Text className='admin-row-meta'> {item.totalCount || 0} · {item.validCount || 0} · {(item.insertedCount || 0) + (item.updatedCount || 0) + (item.skippedCount || 0)} · {item.errorCount || 0} · {item.warningCount || 0}</Text>
<Text className='admin-row-meta'> {item.executionMode || 'sync'} · {String(item.attemptCount ?? 0)}/{String(item.maxAttempts ?? '-')} · {item.id}</Text>
<Text className='admin-row-meta'>{item.updatedAt || item.createdAt || ''}</Text>
<View className='admin-row-actions'>
<Button className='admin-mini-button' onClick={() => selectJob(item)}></Button>
<Button className='admin-mini-button' onClick={() => selectJob(item)}></Button>
<Button className='admin-mini-button' onClick={() => {
setSelectedJobId(item.id);
setSelectedImportType(importTypes.includes(item.importType as ImportType) ? item.importType as ImportType : 'questions');

View File

@@ -46,17 +46,49 @@ export interface TenantStudentItem {
export interface ImportJobItem {
id: string;
importType?: string;
sourceFormat?: string;
status?: string;
sourceName?: string | null;
totalCount?: number;
validCount?: number;
successCount?: number;
errorCount?: number;
warningCount?: number;
insertedCount?: number;
updatedCount?: number;
skippedCount?: number;
executionMode?: string;
queuedAt?: string | null;
lockedAt?: string | null;
lockedBy?: string | null;
attemptCount?: number;
maxAttempts?: number;
nextAttemptAt?: string | null;
errorMessage?: string | null;
createdAt?: string;
updatedAt?: string;
finishedAt?: string | null;
summary?: Record<string, unknown>;
}
export interface ImportJobDetail {
item?: ImportJobItem;
issueSummary?: Record<string, number>;
itemStatusSummary?: Record<string, number>;
recentIssues?: ImportIssueItem[];
importPostCheck?: Record<string, unknown> | null;
worker?: {
executionMode?: string;
queuedAt?: string | null;
lockedAt?: string | null;
lockedBy?: string | null;
attemptCount?: number;
maxAttempts?: number;
nextAttemptAt?: string | null;
lastWorkerError?: Record<string, unknown>;
};
}
export interface ContentEntryAdminItem {
id: string;
name: string;
@@ -247,6 +279,10 @@ export async function loadImportJobs(limit = 20) {
return apiRequest<{ items?: ImportJobItem[] }>('/api/tenant-content/imports', { query: { limit } });
}
export async function loadImportJobDetail(jobId: string) {
return apiRequest<ImportJobDetail>('/api/tenant-content/imports/detail', { query: { jobId } });
}
export async function loadImportIssues(jobId: string) {
return apiRequest<{ items?: ImportIssueItem[] }>('/api/tenant-content/imports/issues', { query: { jobId } });
}