forked from wangziqi/gongxue-base
feat: add import job detail polling
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user