From 93ec1a07d2025e23edde647f85bba41a1342b53b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 13:35:34 +0800 Subject: [PATCH] feat: add tenant import console --- .../tenant-content/import-spreadsheet.ts | 186 +++++++++++- apps/taro/README.md | 2 +- apps/taro/src/pages/tenant-admin/admin.css | 37 +++ .../src/pages/tenant-admin/content/index.tsx | 268 +++++++++++++++++- apps/taro/src/services/tenantAdmin.ts | 55 ++++ docs/refactor/backend-capability-status.md | 2 +- docs/refactor/backend-handoff-roadmap.md | 10 +- docs/refactor/content-import-contract.md | 19 ++ docs/refactor/frontend-handoff-index.md | 6 +- docs/refactor/legacy-feature-gap-matrix.md | 6 +- docs/refactor/next-development-todo.md | 5 +- docs/refactor/taro-frontend-integration.md | 8 + scripts/api-integration-test.js | 49 ++++ 13 files changed, 623 insertions(+), 30 deletions(-) diff --git a/apps/api/src/features/tenant-content/import-spreadsheet.ts b/apps/api/src/features/tenant-content/import-spreadsheet.ts index bf262c6d..d531ff46 100644 --- a/apps/api/src/features/tenant-content/import-spreadsheet.ts +++ b/apps/api/src/features/tenant-content/import-spreadsheet.ts @@ -5,6 +5,7 @@ import { HttpError } from '../../core/http.js'; type JsonObject = Record; type SpreadsheetImportType = 'questions' | 'vocabulary' | 'handbook' | 'scoreline' | 'videos'; type SupportedSpreadsheetFormat = 'json' | 'csv' | 'excel'; +type FieldMappingOverrides = Map; interface ParsedSpreadsheet { rows: JsonObject[]; @@ -18,6 +19,134 @@ const MAX_SPREADSHEET_CELL_CHARS = 100_000; const MAX_EXCEL_FILE_BYTES = 8 * 1024 * 1024; const QUESTION_OPTION_KEYS = ['optionA', 'optionB', 'optionC', 'optionD', 'optionE', 'optionF', 'optionG', 'optionH']; +const DANGEROUS_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor']); +const SAFE_DYNAMIC_FIELD = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; + +const OVERRIDE_TARGET_FIELDS: Record> = { + questions: new Set([ + 'legacyId', + 'type', + 'typeLabel', + 'content', + 'options', + ...QUESTION_OPTION_KEYS, + 'answer', + 'correctOptionIndex', + 'correctOptionIndices', + 'answerText', + 'explanation', + 'difficulty', + 'tags', + 'mediaUrl', + 'subQuestions', + 'examMarkers', + 'codeLang', + 'codeTemplate', + 'metadata', + ]), + vocabulary: new Set([ + 'legacyId', + 'unitLegacyId', + 'unitId', + 'unitName', + 'unitDescription', + 'unitOrder', + 'wordLegacyId', + 'word', + 'phonetic', + 'meaning', + 'example', + 'exampleTranslation', + 'difficulty', + 'tags', + 'order', + 'wordCount', + 'isActive', + 'metadata', + ]), + handbook: new Set([ + 'legacyId', + 'subjectLegacyId', + 'subjectId', + 'subjectName', + 'subjectType', + 'chapterLegacyId', + 'chapterId', + 'chapterName', + 'sectionLegacyId', + 'sectionId', + 'sectionName', + 'entryLegacyId', + 'entryId', + 'title', + 'content', + 'summary', + 'tags', + 'description', + 'type', + 'icon', + 'color', + 'order', + 'isActive', + 'metadata', + ]), + scoreline: new Set([ + 'legacyId', + 'kind', + 'regionId', + 'schoolId', + 'schoolLegacyId', + 'schoolName', + 'schoolShortName', + 'schoolType', + 'shortName', + 'majorId', + 'majorLegacyId', + 'majorName', + 'year', + 'fieldKey', + 'fieldName', + 'fieldType', + 'unit', + 'isFilter', + 'isRequired', + 'isVisible', + 'isTrend', + 'options', + 'placeholder', + 'description', + 'sortOrder', + 'order', + 'fieldValues', + 'hasRestriction', + 'restrictionDesc', + 'isHot', + 'metadata', + ]), + videos: new Set([ + 'legacyId', + 'title', + 'description', + 'videoUrl', + 'thumbnailUrl', + 'duration', + 'durationSeconds', + 'knowledgeTags', + 'isGeneral', + 'subjectId', + 'difficulty', + 'order', + 'isActive', + 'assetId', + 'accessMode', + 'freePreviewSeconds', + 'questionId', + 'legacyQuestionId', + 'videoType', + 'bindings', + 'metadata', + ]), +}; function objectValue(value: unknown): JsonObject { return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {}; @@ -34,6 +163,17 @@ function normalizedKey(value: string) { .replace(/[\s_\-./\\::()()[\]【】]/g, ''); } +function assertSafeObjectKey(key: string) { + if (DANGEROUS_OBJECT_KEYS.has(key)) { + throw new HttpError(400, `Unsupported spreadsheet header: ${key}`, 'IMPORT_HEADER_UNSAFE'); + } +} + +function isAllowedOverrideTarget(importType: SpreadsheetImportType, field: string) { + if (OVERRIDE_TARGET_FIELDS[importType].has(field)) return true; + return importType === 'scoreline' && SAFE_DYNAMIC_FIELD.test(field) && !DANGEROUS_OBJECT_KEYS.has(field); +} + function sourceFormatValue(value: unknown): SupportedSpreadsheetFormat { const raw = stringValue(value).toLowerCase(); if (!raw || raw === 'json') return 'json'; @@ -192,8 +332,29 @@ function commonHeaderAlias(header: string) { return aliases[key] || null; } -function importHeaderAlias(header: string, importType: SpreadsheetImportType) { +function fieldMappingOverrides(body: JsonObject, importType: SpreadsheetImportType): FieldMappingOverrides { + const raw = objectValue(body.fieldMappingOverrides ?? body.field_mapping_overrides); + const mapping = new Map(); + for (const [targetField, aliases] of Object.entries(raw)) { + const canonicalField = stringValue(targetField); + if (!canonicalField) continue; + assertSafeObjectKey(canonicalField); + if (!isAllowedOverrideTarget(importType, canonicalField)) { + throw new HttpError(400, `Unsupported fieldMappingOverrides target field: ${canonicalField}`, 'IMPORT_FIELD_MAPPING_TARGET_INVALID'); + } + const values = Array.isArray(aliases) ? aliases : String(aliases || '').split(/[,\n,]/); + for (const alias of values) { + const key = normalizedKey(String(alias || '')); + if (key) mapping.set(key, canonicalField); + } + } + return mapping; +} + +function importHeaderAlias(header: string, importType: SpreadsheetImportType, overrides: FieldMappingOverrides) { const key = normalizedKey(header); + const override = overrides.get(key); + if (override) return override; const common = commonHeaderAlias(header); const aliases: Record> = { questions: { @@ -380,10 +541,12 @@ function importHeaderAlias(header: string, importType: SpreadsheetImportType) { return aliases[importType][key] || common || header.trim(); } -function normalizeSpreadsheetRow(raw: JsonObject, importType: SpreadsheetImportType) { +function normalizeSpreadsheetRow(raw: JsonObject, importType: SpreadsheetImportType, overrides: FieldMappingOverrides) { const row: JsonObject = {}; for (const [header, value] of Object.entries(raw)) { - const key = importHeaderAlias(header, importType); + assertSafeObjectKey(header.trim()); + const key = importHeaderAlias(header, importType, overrides); + assertSafeObjectKey(key); let coerced = coerceValue(key, value); if (importType === 'scoreline' && key === header.trim()) { coerced = parseNumericLike(coerced); @@ -475,7 +638,7 @@ function autoDelimiter(text: string) { .sort((left, right) => right.count - left.count)[0]?.delimiter || ','; } -function rowsFromMatrix(matrix: string[][], importType: SpreadsheetImportType) { +function rowsFromMatrix(matrix: string[][], importType: SpreadsheetImportType, overrides: FieldMappingOverrides) { if (matrix.length < 2) return []; const headers = matrix[0].map((header, index) => stringValue(header) || `column${index + 1}`); if (headers.length > MAX_SPREADSHEET_COLUMNS) { @@ -485,9 +648,10 @@ function rowsFromMatrix(matrix: string[][], importType: SpreadsheetImportType) { const row: JsonObject = {}; headers.forEach((header, index) => { const value = stringValue(line[index]); + assertSafeObjectKey(header.trim()); if (value) row[header] = value; }); - return normalizeSpreadsheetRow(row, importType); + return normalizeSpreadsheetRow(row, importType, overrides); }).filter(row => Object.keys(row).length > 0); assertRowLimit(rows.length); return rows; @@ -500,7 +664,8 @@ function parseCsvBody(body: JsonObject, importType: SpreadsheetImportType): Pars throw new HttpError(400, 'CSV import requires csvText, fileContent, payload, or fileBase64', 'CSV_CONTENT_REQUIRED'); } const delimiter = stringValue(body.delimiter) || autoDelimiter(text); - const rows = rowsFromMatrix(parseCsv(text, delimiter), importType); + const overrides = fieldMappingOverrides(body, importType); + const rows = rowsFromMatrix(parseCsv(text, delimiter), importType, overrides); return { rows, sheets: { csv: rows }, @@ -508,11 +673,12 @@ function parseCsvBody(body: JsonObject, importType: SpreadsheetImportType): Pars parser: 'csv', delimiter: delimiter === '\t' ? 'tab' : delimiter, rowCount: rows.length, + fieldMappingOverrides: overrides.size, }, }; } -function worksheetRows(worksheet: ExcelJS.Worksheet, importType: SpreadsheetImportType) { +function worksheetRows(worksheet: ExcelJS.Worksheet, importType: SpreadsheetImportType, overrides: FieldMappingOverrides) { const matrix: string[][] = []; worksheet.eachRow({ includeEmpty: false }, row => { const values: string[] = []; @@ -523,7 +689,7 @@ function worksheetRows(worksheet: ExcelJS.Worksheet, importType: SpreadsheetImpo } if (values.some(value => value.trim())) matrix.push(values); }); - return rowsFromMatrix(matrix, importType); + return rowsFromMatrix(matrix, importType, overrides); } async function parseExcelBody(body: JsonObject, importType: SpreadsheetImportType): Promise { @@ -550,9 +716,10 @@ async function parseExcelBody(body: JsonObject, importType: SpreadsheetImportTyp const sheets: Record = {}; let totalRows = 0; + const overrides = fieldMappingOverrides(body, importType); for (const sheet of workbook.worksheets) { if (sheet.actualRowCount === 0) continue; - const rows = worksheetRows(sheet, importType); + const rows = worksheetRows(sheet, importType, overrides); sheets[normalizedKey(sheet.name)] = rows; totalRows += rows.length; } @@ -568,6 +735,7 @@ async function parseExcelBody(body: JsonObject, importType: SpreadsheetImportTyp selectedSheet: selected.name, rowCount: selectedRows.length, totalParsedRows: totalRows, + fieldMappingOverrides: overrides.size, }, }; } diff --git a/apps/taro/README.md b/apps/taro/README.md index 21177a7d..6f53a042 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -64,7 +64,7 @@ pages/tenant-admin/marketing/index 优惠券、激活码、CRM、分佣摘要 pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板 ``` -当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、导入问题查看、字段映射预览、模板预览和导入后复检;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。 +当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、导入问题查看、模板预览、导入后复检,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。 ## 当前平台后台页面 diff --git a/apps/taro/src/pages/tenant-admin/admin.css b/apps/taro/src/pages/tenant-admin/admin.css index 3a7df363..e699a55f 100644 --- a/apps/taro/src/pages/tenant-admin/admin.css +++ b/apps/taro/src/pages/tenant-admin/admin.css @@ -48,6 +48,11 @@ padding-bottom: 6px; } +.admin-actions.compact { + flex-wrap: wrap; + overflow-x: visible; +} + .admin-button { min-width: 132px; height: 62px; @@ -167,6 +172,32 @@ font-size: 24px; } +.admin-input.compact { + margin-top: 14px; + height: 58px; + font-size: 22px; +} + +.admin-form-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + margin-top: 12px; +} + +.admin-textarea { + width: 100%; + min-height: 220px; + margin-top: 12px; + padding: 18px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #fff; + color: #111827; + font-size: 23px; + line-height: 1.45; +} + .admin-row-actions { display: flex; gap: 10px; @@ -198,3 +229,9 @@ color: #be123c; font-size: 22px; } + +.break-line { + word-break: break-all; + overflow-wrap: anywhere; + white-space: pre-wrap; +} diff --git a/apps/taro/src/pages/tenant-admin/content/index.tsx b/apps/taro/src/pages/tenant-admin/content/index.tsx index 743c4cf2..27e1c081 100644 --- a/apps/taro/src/pages/tenant-admin/content/index.tsx +++ b/apps/taro/src/pages/tenant-admin/content/index.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; import Taro from '@tarojs/taro'; -import { Button, Input, Text, View } from '@tarojs/components'; +import { Button, Input, Text, Textarea, View } from '@tarojs/components'; import { adoptPublicQuestionBank, + executeContentImport, loadContentEntriesAdmin, loadImportFieldMapping, loadImportIssues, @@ -11,13 +12,17 @@ import { loadImportTemplate, loadPublicQuestionBankConflicts, loadPublicQuestionBanks, + previewContentImport, runImportPostCheck, syncPublicQuestionBank, type ContentEntryAdminItem, type ImportFieldMapping, type ImportIssueItem, type ImportJobItem, + type ImportPreviewResult, type ImportTemplateItem, + type ImportType, + type ImportSourceFormat, type PublicQuestionBankItem, } from '@/services/tenantAdmin'; import '../admin.css'; @@ -34,16 +39,97 @@ function adoptionIdOf(item: PublicQuestionBankItem) { return item.adoptedId || (item.adoption && String(item.adoption.id || '')) || ''; } +const importTypes: ImportType[] = ['questions', 'vocabulary', 'handbook', 'scoreline', 'videos']; +const importFormats: ImportSourceFormat[] = ['json', 'csv', 'excel']; + +function readLocalFileAsBase64(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = typeof reader.result === 'string' ? reader.result : ''; + resolve(result.includes(',') ? result.slice(result.indexOf(',') + 1) : result); + }; + reader.onerror = () => reject(new Error('文件读取失败')); + reader.readAsDataURL(file); + }); +} + +function readLocalFileAsText(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); + reader.onerror = () => reject(new Error('文件读取失败')); + reader.readAsText(file, 'utf-8'); + }); +} + +function openH5FilePicker(format: ImportSourceFormat) { + return new Promise<{ fileName: string; text?: string; fileBase64?: string }>((resolve, reject) => { + if (process.env.TARO_ENV !== 'h5' || typeof document === 'undefined') { + reject(new Error('当前端暂未接入文件选择,请粘贴 JSON/CSV 内容后预览导入。')); + return; + } + const input = document.createElement('input'); + input.type = 'file'; + input.accept = format === 'excel' ? '.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : format === 'csv' ? '.csv,text/csv,text/plain' : '.json,application/json,text/plain'; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) { + reject(new Error('未选择文件')); + return; + } + try { + if (format === 'excel') { + resolve({ fileName: file.name, fileBase64: await readLocalFileAsBase64(file) }); + } else { + resolve({ fileName: file.name, text: await readLocalFileAsText(file) }); + } + } catch (error) { + reject(error); + } + }; + input.click(); + }); +} + +function safeJsonPreview(value: unknown, maxLength = 360) { + try { + return JSON.stringify(value, null, 2).slice(0, maxLength); + } catch { + return String(value).slice(0, maxLength); + } +} + +function objectRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function parseJsonImportText(text: string) { + try { + return JSON.parse(text); + } catch { + throw new Error('JSON 内容格式不正确,请先修正后再预览。'); + } +} + export default function TenantContentPage() { const [entries, setEntries] = useState([]); const [jobs, setJobs] = useState([]); const [publicBanks, setPublicBanks] = useState([]); const [selectedJobId, setSelectedJobId] = useState(''); - const [selectedImportType, setSelectedImportType] = useState('questions'); + const [selectedImportType, setSelectedImportType] = useState('questions'); + const [sourceFormat, setSourceFormat] = useState('json'); + const [sourceName, setSourceName] = useState(''); + const [importText, setImportText] = useState(''); + const [fileBase64, setFileBase64] = useState(''); + const [allowPartial, setAllowPartial] = useState(false); + const [executionMode, setExecutionMode] = useState<'sync' | 'async'>('sync'); const [copyLimit, setCopyLimit] = useState('200'); const [issues, setIssues] = useState([]); const [mapping, setMapping] = useState(null); + const [mappingOverrides, setMappingOverrides] = useState>({}); const [template, setTemplate] = useState(null); + const [previewResult, setPreviewResult] = useState(null); const [postCheck, setPostCheck] = useState | null>(null); const [conflicts, setConflicts] = useState | null>(null); const [busy, setBusy] = useState(''); @@ -71,7 +157,7 @@ export default function TenantContentPage() { } async function selectJob(item: ImportJobItem) { - const importType = item.importType || 'questions'; + const importType = importTypes.includes(item.importType as ImportType) ? item.importType as ImportType : 'questions'; setSelectedJobId(item.id); setSelectedImportType(importType); setPostCheck(null); @@ -122,12 +208,126 @@ export default function TenantContentPage() { ]); setSelectedImportType(type); setMapping(mappingPayload.item || null); + setMappingOverrides(Object.fromEntries( + (mappingPayload.item?.fields || []) + .map(field => [field.field || '', (field.aliases || []).join(', ')] as const) + .filter(([key]) => key), + )); setTemplate(templatePayload.item || null); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '模板加载失败'); } } + async function chooseImportFile() { + setError(''); + try { + const file = await openH5FilePicker(sourceFormat); + setSourceName(file.fileName); + if (file.text !== undefined) { + setImportText(file.text); + setFileBase64(''); + } + if (file.fileBase64 !== undefined) { + setFileBase64(file.fileBase64); + setImportText(''); + } + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '文件选择失败'); + } + } + + function mappingOverridePayload() { + return Object.fromEntries( + Object.entries(mappingOverrides) + .map(([field, aliases]) => [field, aliases.split(/[,\n,]/).map(item => item.trim()).filter(Boolean)]) + .filter(([, aliases]) => (aliases as string[]).length > 0), + ) as Record; + } + + function importRequestBody(options: { usePreviewJob?: boolean } = {}) { + const body: Record = { + sourceFormat, + sourceName: sourceName || `${selectedImportType}.${sourceFormat}`, + allowPartial, + fieldMappingOverrides: mappingOverridePayload(), + fieldMappingPreset: mapping, + }; + if (options.usePreviewJob && previewResult?.job?.id) { + body.previewJobId = previewResult.job.id; + body.executionMode = executionMode; + return body; + } + if (sourceFormat === 'json') { + const parsed = parseJsonImportText(importText.trim()); + if (Array.isArray(parsed)) { + body.items = parsed; + } else { + const payload = objectRecord(parsed); + if (!Object.keys(payload).length) throw new Error('JSON 内容必须是数组或对象。'); + Object.entries(payload).forEach(([key, value]) => { + if (!['sourceFormat', 'sourceName', 'allowPartial', 'executionMode', 'fieldMappingOverrides', 'fieldMappingPreset', 'previewJobId', 'jobId'].includes(key)) { + body[key] = value; + } + }); + } + } else if (sourceFormat === 'csv') { + body.csvText = importText.trim(); + } else { + body.fileBase64 = fileBase64; + } + return body; + } + + function validateImportInput() { + if (sourceFormat === 'excel' && !fileBase64) { + setError('请先选择 .xlsx 文件。'); + return false; + } + if (sourceFormat !== 'excel' && !importText.trim()) { + setError(sourceFormat === 'json' ? '请粘贴 JSON 内容或选择 .json 文件。' : '请粘贴 CSV 内容或选择 .csv 文件。'); + return false; + } + return true; + } + + async function submitPreviewImport() { + if (!validateImportInput()) return; + setBusy('import-preview'); + setError(''); + try { + const payload = await previewContentImport(selectedImportType, importRequestBody()); + setPreviewResult(payload); + setSelectedJobId(payload.job?.id || ''); + setIssues((payload.issues || []) as ImportIssueItem[]); + Taro.showToast({ title: '预览完成', icon: 'success' }); + reload(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '导入预览失败'); + } finally { + setBusy(''); + } + } + + async function submitExecuteImport() { + if (!previewResult?.job?.id && !validateImportInput()) return; + const ok = await confirm('执行导入', executionMode === 'async' ? '确认提交异步导入任务?worker 会处理队列并写入审计。' : '确认同步执行导入?后端会按预览结果、权限和幂等规则写入数据。'); + if (!ok) return; + setBusy('import-execute'); + setError(''); + try { + const payload = await executeContentImport(selectedImportType, importRequestBody({ usePreviewJob: true })); + const nextJobId = String(payload.item?.jobId || payload.item?.id || previewResult?.job?.id || ''); + if (nextJobId) setSelectedJobId(nextJobId); + Taro.showToast({ title: executionMode === 'async' ? '已入队' : '导入完成', icon: 'success' }); + reload(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '执行导入失败'); + } finally { + setBusy(''); + } + } + async function submitAdopt(item: PublicQuestionBankItem) { if (!item.grantId) { setError('该公共题库缺少授权 ID,无法采纳。'); @@ -227,14 +427,53 @@ export default function TenantContentPage() { - {['questions', 'vocabulary', 'handbook', 'scoreline', 'videos'].map(type => ( + {importTypes.map(type => ( ))} + + {importFormats.map(format => ( + + ))} + + + setSourceName(String(event.detail.value || ''))} /> + + + + + + + {sourceFormat === 'excel' ? ( + 已选择:{sourceName || '暂无文件'} · {fileBase64 ? '文件内容已读取,点击预览会交给后端解析。' : '请选择 .xlsx 文件,或切换 JSON/CSV 粘贴内容。'} + ) : ( +