diff --git a/apps/api/src/features/tenant-content/imports.ts b/apps/api/src/features/tenant-content/imports.ts index 804b1dbc..1a35d7f1 100644 --- a/apps/api/src/features/tenant-content/imports.ts +++ b/apps/api/src/features/tenant-content/imports.ts @@ -3662,6 +3662,27 @@ export async function importVideosRoute(ctx: RequestContext) { ); } +const importJobSelect = ` + id, import_type as "importType", source_format as "sourceFormat", + status, source_name as "sourceName", source_hash as "sourceHash", + target_region_id as "targetRegionId", target_subject_id as "targetSubjectId", + target_category_id as "targetCategoryId", target_node_id as "targetNodeId", + target_question_bank_id as "targetQuestionBankId", + target_entry_id as "targetEntryId", target_content_node_id as "targetContentNodeId", + target_collection_id as "targetCollectionId", + dry_run as "dryRun", total_count as "totalCount", + valid_count as "validCount", error_count as "errorCount", + warning_count as "warningCount", inserted_count as "insertedCount", + updated_count as "updatedCount", skipped_count as "skippedCount", + execution_mode as "executionMode", queued_at as "queuedAt", + locked_at as "lockedAt", locked_by as "lockedBy", + attempt_count as "attemptCount", max_attempts as "maxAttempts", + next_attempt_at as "nextAttemptAt", parser_metadata as "parserMetadata", + summary, error_message as "errorMessage", + started_at as "startedAt", finished_at as "finishedAt", + created_by as "createdBy", created_at as "createdAt", updated_at as "updatedAt" +`; + export async function importJobsRoute(ctx: RequestContext) { const { intParam, requireTenantContentEditor, stringParam } = await routeDeps(); const auth = await requireTenantContentEditor(ctx); @@ -3683,19 +3704,7 @@ export async function importJobsRoute(ctx: RequestContext) { const items = await query( ` - select id, import_type as "importType", source_format as "sourceFormat", - status, source_name as "sourceName", source_hash as "sourceHash", - target_region_id as "targetRegionId", target_subject_id as "targetSubjectId", - target_category_id as "targetCategoryId", target_node_id as "targetNodeId", - target_question_bank_id as "targetQuestionBankId", - dry_run as "dryRun", total_count as "totalCount", - valid_count as "validCount", error_count as "errorCount", - warning_count as "warningCount", inserted_count as "insertedCount", - updated_count as "updatedCount", skipped_count as "skippedCount", - execution_mode as "executionMode", parser_metadata as "parserMetadata", - summary, error_message as "errorMessage", - started_at as "startedAt", finished_at as "finishedAt", - created_by as "createdBy", created_at as "createdAt", updated_at as "updatedAt" + select ${importJobSelect} from public.content_import_jobs where ${filters.join(' and ')} order by created_at desc @@ -3707,6 +3716,80 @@ export async function importJobsRoute(ctx: RequestContext) { return { items }; } +export async function importJobDetailRoute(ctx: RequestContext) { + const { intParam, requireTenantContentEditor, requiredString, stringParam } = await routeDeps(); + const auth = await requireTenantContentEditor(ctx); + const jobId = requiredString({ jobId: stringParam(ctx, 'jobId') }, 'jobId'); + const issueLimit = intParam(ctx, 'issueLimit', 20, 200); + + const item = await queryOne>( + ` + select ${importJobSelect} + from public.content_import_jobs + where tenant_id = $1 and id = $2 + limit 1 + `, + [auth.tenantId, jobId], + ); + if (!item) { + throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND'); + } + + const issueSummary = await query<{ severity: string; count: string }>( + ` + select severity, count(*)::text as count + from public.content_import_issues + where tenant_id = $1 and job_id = $2 + group by severity + order by case severity when 'error' then 1 when 'warning' then 2 else 9 end + `, + [auth.tenantId, jobId], + ); + const itemStatusSummary = await query<{ status: string; count: string }>( + ` + select status, count(*)::text as count + from public.content_import_items + where tenant_id = $1 and job_id = $2 + group by status + order by status asc + `, + [auth.tenantId, jobId], + ); + const recentIssues = await query( + ` + select i.id, i.row_no as "rowNo", i.severity, i.code, + i.field_path as "fieldPath", i.message, i.details, + item.external_id as "externalId", item.status as "itemStatus", + i.created_at as "createdAt" + from public.content_import_issues i + left join public.content_import_items item on item.id = i.item_id + where i.tenant_id = $1 and i.job_id = $2 + order by i.row_no asc nulls last, case i.severity when 'error' then 1 else 2 end, i.created_at asc + limit $3 + `, + [auth.tenantId, jobId, issueLimit], + ); + + const summary = objectValue(item.summary); + return { + item, + issueSummary: Object.fromEntries(issueSummary.map(row => [row.severity, Number(row.count || 0)])), + itemStatusSummary: Object.fromEntries(itemStatusSummary.map(row => [row.status, Number(row.count || 0)])), + recentIssues, + importPostCheck: summary.importPostCheck || null, + worker: { + executionMode: item.executionMode, + queuedAt: item.queuedAt, + lockedAt: item.lockedAt, + lockedBy: item.lockedBy, + attemptCount: item.attemptCount, + maxAttempts: item.maxAttempts, + nextAttemptAt: item.nextAttemptAt, + lastWorkerError: objectValue(summary.lastWorkerError), + }, + }; +} + export async function importIssuesRoute(ctx: RequestContext) { const { intParam, requireTenantContentEditor, requiredString, stringParam } = await routeDeps(); const auth = await requireTenantContentEditor(ctx); diff --git a/apps/api/src/features/tenant-content/index.ts b/apps/api/src/features/tenant-content/index.ts index 72d2374d..edeca272 100644 --- a/apps/api/src/features/tenant-content/index.ts +++ b/apps/api/src/features/tenant-content/index.ts @@ -9,6 +9,7 @@ import { } from './assets.js'; import { importHandbookRoute, + importJobDetailRoute, importIssuesRoute, importJobsRoute, importQuestionsRoute, @@ -111,6 +112,7 @@ export const tenantContentRoutes: RouteDefinition[] = [ ['POST', '/api/tenant-content/imports/preview/videos', previewVideosImportRoute], ['POST', '/api/tenant-content/imports/videos', importVideosRoute], ['GET', '/api/tenant-content/imports', importJobsRoute], + ['GET', '/api/tenant-content/imports/detail', importJobDetailRoute], ['GET', '/api/tenant-content/imports/issues', importIssuesRoute], ['GET', '/api/tenant-content/imports/field-mapping', importFieldMappingRoute], ['GET', '/api/tenant-content/imports/templates', importTemplateRoute], diff --git a/apps/taro/README.md b/apps/taro/README.md index 1a1ed95c..cab5e35f 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -59,12 +59,12 @@ pages/student/profile/index 个人中心、会员、订单、签到、激 pages/tenant-admin/workbench/index 租户后台工作台 pages/tenant-admin/dashboard/index 数据看板 pages/tenant-admin/students/index 学生与班级 -pages/tenant-admin/content/index 内容入口、导入任务、字段模板、复检、公共题库采纳/同步/冲突处理 +pages/tenant-admin/content/index 内容入口、导入任务、字段模板、异步轮询、复检、公共题库采纳/同步/冲突处理 pages/tenant-admin/marketing/index 优惠券、激活码、CRM、分佣摘要 pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板 ``` -当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条采纳平台版本/保留本地版本、导入问题查看、模板预览、导入后复检,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。 +当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条采纳平台版本/保留本地版本、导入任务详情、异步任务轮询、导入问题查看、模板预览、导入后复检详情,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。 ## 当前平台后台页面 diff --git a/apps/taro/src/pages/tenant-admin/content/index.tsx b/apps/taro/src/pages/tenant-admin/content/index.tsx index f8609938..4be89f0e 100644 --- a/apps/taro/src/pages/tenant-admin/content/index.tsx +++ b/apps/taro/src/pages/tenant-admin/content/index.tsx @@ -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 { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } +function importJobActive(item?: ImportJobItem | null) { + return !!item && ['pending', 'importing'].includes(String(item.status || '')); +} + +function statusCountText(value?: Record) { + 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>({}); const [template, setTemplate] = useState(null); const [previewResult, setPreviewResult] = useState(null); + const [jobDetail, setJobDetail] = useState(null); const [postCheck, setPostCheck] = useState | null>(null); const [conflicts, setConflicts] = useState(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 : postCheck; const postCheckIssueCount = Array.isArray(postCheckSummary?.issues) ? postCheckSummary.issues.length : 0; + const selectedJob = jobDetail?.item || jobs.find(item => item.id === selectedJobId) || null; return ( @@ -464,6 +506,7 @@ export default function TenantContentPage() { setCopyLimit(String(event.detail.value || ''))} /> + {importTypes.map(type => ( @@ -510,12 +553,26 @@ export default function TenantContentPage() { 当前任务{selectedJobId ? selectedJobId.slice(0, 8) : '-'} + 任务状态{String(selectedJob?.status || '-')} + 执行模式{String(selectedJob?.executionMode || executionMode)} 问题行{String(issues.length)} 字段数{String(mapping?.fields?.length || 0)} 预览有效{String(previewResult?.job?.validCount ?? '-')} 复检状态{String(postCheckSummary?.status || '-')} 复检问题{String(postCheckIssueCount)} + {jobDetail ? ( + + + 任务详情 · {selectedJob?.sourceName || selectedJob?.id} + 状态 {selectedJob?.status || '-'} · 模式 {selectedJob?.executionMode || '-'} · 尝试 {String(jobDetail.worker?.attemptCount ?? selectedJob?.attemptCount ?? 0)}/{String(jobDetail.worker?.maxAttempts ?? selectedJob?.maxAttempts ?? '-')} + Item 状态 {statusCountText(jobDetail.itemStatusSummary)} · Issue {statusCountText(jobDetail.issueSummary)} + 入队 {String(jobDetail.worker?.queuedAt || '-')} · 锁定 {String(jobDetail.worker?.lockedAt || '-')} · 下次重试 {String(jobDetail.worker?.nextAttemptAt || '-')} + {selectedJob?.errorMessage ? 错误:{selectedJob.errorMessage} : null} + {jobDetail.importPostCheck ? 复检:{String(jobDetail.importPostCheck.status || '-')} · {String((jobDetail.importPostCheck.issues as unknown[] | undefined)?.length || 0)} 个问题 : null} + + + ) : null} @@ -524,10 +581,11 @@ export default function TenantContentPage() { {jobs.map(item => ( {item.importType || 'import'} · {item.status || 'unknown'} - 总数 {item.totalCount || 0} · 成功 {item.successCount || 0} · 错误 {item.errorCount || 0} · 警告 {item.warningCount || 0} - 任务 {item.id} · {item.updatedAt || item.createdAt || ''} + 总数 {item.totalCount || 0} · 有效 {item.validCount || 0} · 已处理 {(item.insertedCount || 0) + (item.updatedCount || 0) + (item.skippedCount || 0)} · 错误 {item.errorCount || 0} · 警告 {item.warningCount || 0} + 模式 {item.executionMode || 'sync'} · 尝试 {String(item.attemptCount ?? 0)}/{String(item.maxAttempts ?? '-')} · 任务 {item.id} + {item.updatedAt || item.createdAt || ''} - +