forked from wangziqi/gongxue-base
feat: add tenant import console
This commit is contained in:
@@ -5,6 +5,7 @@ import { HttpError } from '../../core/http.js';
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type SpreadsheetImportType = 'questions' | 'vocabulary' | 'handbook' | 'scoreline' | 'videos';
|
||||
type SupportedSpreadsheetFormat = 'json' | 'csv' | 'excel';
|
||||
type FieldMappingOverrides = Map<string, string>;
|
||||
|
||||
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<SpreadsheetImportType, Set<string>> = {
|
||||
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<string, string>();
|
||||
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<SpreadsheetImportType, Record<string, string>> = {
|
||||
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<ParsedSpreadsheet> {
|
||||
@@ -550,9 +716,10 @@ async function parseExcelBody(body: JsonObject, importType: SpreadsheetImportTyp
|
||||
|
||||
const sheets: Record<string, JsonObject[]> = {};
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ pages/tenant-admin/marketing/index 优惠券、激活码、CRM、分佣摘要
|
||||
pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板
|
||||
```
|
||||
|
||||
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、导入问题查看、字段映射预览、模板预览和导入后复检;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
|
||||
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、导入问题查看、模板预览、导入后复检,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
|
||||
|
||||
## 当前平台后台页面
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string>((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<string>((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<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function parseJsonImportText(text: string) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('JSON 内容格式不正确,请先修正后再预览。');
|
||||
}
|
||||
}
|
||||
|
||||
export default function TenantContentPage() {
|
||||
const [entries, setEntries] = useState<ContentEntryAdminItem[]>([]);
|
||||
const [jobs, setJobs] = useState<ImportJobItem[]>([]);
|
||||
const [publicBanks, setPublicBanks] = useState<PublicQuestionBankItem[]>([]);
|
||||
const [selectedJobId, setSelectedJobId] = useState('');
|
||||
const [selectedImportType, setSelectedImportType] = useState('questions');
|
||||
const [selectedImportType, setSelectedImportType] = useState<ImportType>('questions');
|
||||
const [sourceFormat, setSourceFormat] = useState<ImportSourceFormat>('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<ImportIssueItem[]>([]);
|
||||
const [mapping, setMapping] = useState<ImportFieldMapping | null>(null);
|
||||
const [mappingOverrides, setMappingOverrides] = useState<Record<string, string>>({});
|
||||
const [template, setTemplate] = useState<ImportTemplateItem | null>(null);
|
||||
const [previewResult, setPreviewResult] = useState<ImportPreviewResult | null>(null);
|
||||
const [postCheck, setPostCheck] = useState<Record<string, unknown> | null>(null);
|
||||
const [conflicts, setConflicts] = useState<Record<string, unknown> | 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<string, string[]>;
|
||||
}
|
||||
|
||||
function importRequestBody(options: { usePreviewJob?: boolean } = {}) {
|
||||
const body: Record<string, unknown> = {
|
||||
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() {
|
||||
<Button className='admin-button primary' loading={busy === 'post-check'} onClick={submitPostCheck}>复检任务</Button>
|
||||
</View>
|
||||
<View className='admin-tabs'>
|
||||
{['questions', 'vocabulary', 'handbook', 'scoreline', 'videos'].map(type => (
|
||||
{importTypes.map(type => (
|
||||
<Button key={type} className={`admin-button ${selectedImportType === type ? 'active' : ''}`} onClick={() => previewTemplate(type)}>{type}</Button>
|
||||
))}
|
||||
</View>
|
||||
<View className='admin-tabs'>
|
||||
{importFormats.map(format => (
|
||||
<Button key={format} className={`admin-button ${sourceFormat === format ? 'active' : ''}`} onClick={() => {
|
||||
setSourceFormat(format);
|
||||
setPreviewResult(null);
|
||||
setIssues([]);
|
||||
}}>{format}</Button>
|
||||
))}
|
||||
</View>
|
||||
<View className='admin-form-grid'>
|
||||
<Input className='admin-input' placeholder='来源名称,例如 天津英语题库.json' value={sourceName} onInput={event => setSourceName(String(event.detail.value || ''))} />
|
||||
<View className='admin-actions compact'>
|
||||
<Button className='admin-button' onClick={() => setAllowPartial(prev => !prev)}>{allowPartial ? '允许部分导入' : '必须全量有效'}</Button>
|
||||
<Button className='admin-button' onClick={() => setExecutionMode(prev => prev === 'sync' ? 'async' : 'sync')}>{executionMode === 'sync' ? '同步执行' : '异步入队'}</Button>
|
||||
<Button className='admin-button' onClick={chooseImportFile}>选择文件</Button>
|
||||
</View>
|
||||
</View>
|
||||
{sourceFormat === 'excel' ? (
|
||||
<View className='admin-empty'>已选择:{sourceName || '暂无文件'} · {fileBase64 ? '文件内容已读取,点击预览会交给后端解析。' : '请选择 .xlsx 文件,或切换 JSON/CSV 粘贴内容。'}</View>
|
||||
) : (
|
||||
<Textarea
|
||||
className='admin-textarea'
|
||||
placeholder={sourceFormat === 'json' ? '粘贴题目/单词/手册/分数线/视频 JSON 内容' : '粘贴 CSV 内容,首行为表头'}
|
||||
value={importText}
|
||||
onInput={event => setImportText(String(event.detail.value || ''))}
|
||||
/>
|
||||
)}
|
||||
<View className='admin-actions compact'>
|
||||
<Button className='admin-button primary' loading={busy === 'import-preview'} onClick={submitPreviewImport}>后端预览</Button>
|
||||
<Button className='admin-button primary' loading={busy === 'import-execute'} onClick={submitExecuteImport}>执行导入</Button>
|
||||
<Button className='admin-button' onClick={() => {
|
||||
setImportText(template?.contentPreview || '');
|
||||
setSourceFormat((template?.format as ImportSourceFormat) || 'json');
|
||||
setSourceName(template?.fileName || '');
|
||||
setPreviewResult(null);
|
||||
setIssues([]);
|
||||
}}>填入模板</Button>
|
||||
</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(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>
|
||||
@@ -252,7 +491,7 @@ export default function TenantContentPage() {
|
||||
<Button className='admin-mini-button' onClick={() => selectJob(item)}>查看问题</Button>
|
||||
<Button className='admin-mini-button' onClick={() => {
|
||||
setSelectedJobId(item.id);
|
||||
setSelectedImportType(item.importType || 'questions');
|
||||
setSelectedImportType(importTypes.includes(item.importType as ImportType) ? item.importType as ImportType : 'questions');
|
||||
}}>选择复检</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -274,16 +513,33 @@ export default function TenantContentPage() {
|
||||
{!issues.length ? <View className='admin-empty'>选择导入任务后可查看问题行。</View> : null}
|
||||
{mapping?.fields?.length ? (
|
||||
<View className='admin-list'>
|
||||
{mapping.fields.slice(0, 6).map(field => (
|
||||
{mapping.fields.slice(0, 10).map(field => (
|
||||
<View className='admin-row' key={field.field || field.label}>
|
||||
<Text className='admin-row-main'>{field.label || field.field}{field.required ? ' · 必填' : ''}</Text>
|
||||
<Text className='admin-row-meta'>{field.field || '-'} · {(field.aliases || []).join(' / ') || '无别名'}</Text>
|
||||
<Text className='admin-row-meta'>{field.description || ''}</Text>
|
||||
<Input
|
||||
className='admin-input compact'
|
||||
placeholder='本次导入字段别名,用逗号分隔'
|
||||
value={mappingOverrides[field.field || ''] || ''}
|
||||
onInput={event => setMappingOverrides(prev => ({ ...prev, [field.field || '']: String(event.detail.value || '') }))}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{template?.contentPreview ? <View className='admin-empty'>{template.fileName || '模板'}:{template.contentPreview.slice(0, 240)}</View> : null}
|
||||
{previewResult?.items?.length ? (
|
||||
<View className='admin-list'>
|
||||
{previewResult.items.slice(0, 5).map((item, index) => (
|
||||
<View className='admin-row' key={`${item.rowNo || index}-${item.externalId || item.status}`}>
|
||||
<Text className='admin-row-main'>预览第 {item.rowNo || index + 1} 行 · {item.status || '-'}</Text>
|
||||
<Text className='admin-row-meta'>外部 ID {item.externalId || '-'} · 问题 {String(item.issues?.length || 0)}</Text>
|
||||
<Text className='admin-row-meta break-line'>{safeJsonPreview(item.normalized, 260)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
|
||||
@@ -122,6 +122,47 @@ export interface ImportTemplateItem {
|
||||
fields?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface ImportPreviewResult {
|
||||
job?: {
|
||||
id: string;
|
||||
status?: string;
|
||||
totalCount?: number;
|
||||
validCount?: number;
|
||||
errorCount?: number;
|
||||
warningCount?: number;
|
||||
};
|
||||
items?: Array<{
|
||||
rowNo?: number;
|
||||
status?: string;
|
||||
externalId?: string | null;
|
||||
normalized?: Record<string, unknown> | null;
|
||||
issues?: ImportIssueItem[];
|
||||
}>;
|
||||
issues?: ImportIssueItem[];
|
||||
}
|
||||
|
||||
export type ImportType = 'questions' | 'vocabulary' | 'handbook' | 'scoreline' | 'videos';
|
||||
export type ImportSourceFormat = 'json' | 'csv' | 'excel';
|
||||
|
||||
export interface ImportRequestBody {
|
||||
sourceFormat?: ImportSourceFormat;
|
||||
sourceName?: string;
|
||||
payload?: unknown;
|
||||
items?: unknown[];
|
||||
csvText?: string;
|
||||
fileBase64?: string;
|
||||
allowPartial?: boolean;
|
||||
executionMode?: 'sync' | 'async';
|
||||
previewJobId?: string;
|
||||
jobId?: string;
|
||||
regionId?: string;
|
||||
entryId?: string;
|
||||
contentNodeId?: string;
|
||||
collectionId?: string;
|
||||
fieldMappingOverrides?: Record<string, string[]>;
|
||||
fieldMappingPreset?: ImportFieldMapping | null;
|
||||
}
|
||||
|
||||
export interface CouponItem {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -239,6 +280,20 @@ export async function loadImportTemplate(importType: string, format: 'json' | 'c
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewContentImport(importType: ImportType, body: ImportRequestBody) {
|
||||
return apiRequest<ImportPreviewResult>(`/api/tenant-content/imports/preview/${importType}`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeContentImport(importType: ImportType, body: ImportRequestBody) {
|
||||
return apiRequest<{ item?: Record<string, unknown>; preview?: ImportPreviewResult }>(`/api/tenant-content/imports/${importType}`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadCoupons() {
|
||||
return apiRequest<{ items?: CouponItem[] }>('/api/tenant-admin/coupons');
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
| 知识手册 JSON preview/import | 可联调 | 支持书籍/章节/小节/知识点归一化 |
|
||||
| 分数线 JSON preview/import | 可联调 | 支持 `fields/schools/majors/records` 分桶或 `items` 列表,后端校验租户地区和院校/专业引用 |
|
||||
| 视频 JSON preview/import | 可联调 | 支持 `videos/items`,后端校验题目、科目、资源引用,导入后写入 `question_videos` |
|
||||
| Excel/CSV 导入 | 可联调 | 题目、单词、知识手册、分数线、视频已支持 CSV 和 `.xlsx` 解析,解析后复用 `content_import_jobs/items/issues` 管线并保留 `parser_metadata`;模板下载、字段映射 API 和导入后复检已接入 |
|
||||
| Excel/CSV 导入 | 可联调 | 题目、单词、知识手册、分数线、视频已支持 CSV 和 `.xlsx` 解析,解析后复用 `content_import_jobs/items/issues` 管线并保留 `parser_metadata`;模板下载、字段映射 API、字段映射覆盖白名单、导入后复检已接入;Taro 租户内容页已接上传/粘贴 preview/import 和字段别名编辑第一版 |
|
||||
| 大批量异步导入 | 可联调 | `executionMode=async` 会将 preview job 置为 `pending`;`apps/worker --job imports` 抢占 queued job,复用 API 导入 executor,支持重试、清锁和审计 |
|
||||
| 题库导出任务 | 可联调 | `content_export_jobs` 记录导出范围、格式、题量、输出 hash、选项和执行人;当前返回 inline base64 JSON 文件,前端可先下载 `.json` 或交给后续 PDF/Word worker 渲染 |
|
||||
| 公共题库自动同步增强 | 部分覆盖 | `apps/worker --job public-banks` 已可抢占待同步采纳记录、自动同步平台新增/更新题目、记录失败和审计;后续需接入生产定时调度、版本升级通知、冲突操作台和批量确认/跳过 |
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置和入队能力。
|
||||
- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
|
||||
|
||||
因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信/OAuth 生产账号、真实数据 dry-run 迁移仍需要继续补齐或联调;导入后复检、模板下载和字段映射 API 已可联调,前端操作台待接。
|
||||
因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信/OAuth 生产账号、真实数据 dry-run 迁移仍需要继续补齐或联调;导入后复检、模板下载和字段映射 API 已可联调,Taro 租户内容页已接入上传/粘贴预览、字段别名覆盖和同步/异步执行导入第一版。
|
||||
|
||||
## 后端模块进度
|
||||
|
||||
@@ -32,10 +32,10 @@
|
||||
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放 | 完整资金流水对账、异常订单运营台 |
|
||||
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
|
||||
| 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker |
|
||||
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、PocketBase JSON dry-run 报告 | 字段映射 UI、真实数据 dry-run 执行验收和导入性能压测 |
|
||||
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、字段别名编辑和同步/异步执行导入第一版 | 异步 job 轮询、模板文件下载按钮、复检结果详情、真实数据 dry-run 执行验收和导入性能压测 |
|
||||
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
||||
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 |
|
||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户内容页已接公共题库采纳/同步、导入问题、字段模板和复检第一版 | 刷题细节 UI、租户后台导入上传/字段映射编辑/冲突处理详情、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户内容页已接公共题库采纳/同步、导入问题、字段模板、上传/粘贴预览、字段别名覆盖、同步/异步导入和复检第一版 | 刷题细节 UI、租户后台异步导入轮询/模板下载/复检详情/冲突处理详情、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
||||
|
||||
## 前端接入建议
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。
|
||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户内容页已接公共题库采纳/同步、导入问题、字段模板和复检第一版;下一步补刷题细节 UI、租户后台导入上传/字段映射编辑/冲突处理详情、平台后台审计增强和小程序兼容验证。
|
||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户内容页已接公共题库采纳/同步、导入问题、字段模板、上传/粘贴预览、字段别名覆盖、同步/异步导入和复检第一版;下一步补刷题细节 UI、租户后台异步导入轮询/模板下载/复检详情/冲突处理详情、平台后台审计增强和小程序兼容验证。
|
||||
|
||||
### P1:商用收费和运营能力
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
- XPay 或其它实际支付网关 adapter。
|
||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。
|
||||
- 公共题库/地区题库自动同步 worker 已具备单批执行能力;继续补版本通知、冲突操作台,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
|
||||
- 导入模板、字段映射和复检 API 已可用;前端继续补模板下载按钮、字段映射 UI、job 状态轮询和复检结果面板。
|
||||
- 导入模板、字段映射和复检 API 已可用;Taro 租户内容页已接字段别名覆盖和导入执行第一版。前端继续补模板下载按钮、job 状态轮询、复检结果面板和真实导入目标选择体验。
|
||||
- 视频深度防盗链、动态水印和播放统计。
|
||||
- 数据看板 API:收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。
|
||||
|
||||
|
||||
@@ -129,6 +129,25 @@ questions | vocabulary | handbook | scoreline | videos
|
||||
|
||||
前端可用 `contentBase64` 生成下载文件,或用 `contentPreview` 做在线预览。`fields` 包含规范字段、中文别名、是否必填和示例,适合做字段映射 UI。
|
||||
|
||||
CSV/Excel 支持本次导入字段别名覆盖,字段名必须是后端支持的规范字段。后端会按导入类型做目标字段白名单校验,并拒绝 `__proto__`、`constructor`、`prototype` 等危险对象键;前端不能把字段映射当成绕过后端 schema 的扩展机制。JSON 导入应直接提交规范字段,通常不需要 `fieldMappingOverrides`。
|
||||
|
||||
字段别名覆盖示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"sourceFormat": "csv",
|
||||
"sourceName": "questions-custom-headers.csv",
|
||||
"csvText": "旧编号,自定义题干,左选项,右选项,正确项\nq1,题干,A,B,B",
|
||||
"fieldMappingOverrides": {
|
||||
"legacyId": ["旧编号"],
|
||||
"content": ["自定义题干"],
|
||||
"optionA": ["左选项"],
|
||||
"optionB": ["右选项"],
|
||||
"answer": ["正确项"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
导入完成后,租户后台应主动触发复检:
|
||||
|
||||
```json
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
## 当前可进入的前端工作
|
||||
|
||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;题库内容页已具备公共题库采纳/同步、冲突查看、导入问题查看、模板预览和导入后复检的第一版操作能力。
|
||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;题库内容页已具备公共题库采纳/同步、冲突查看、导入问题查看、模板预览、导入后复检、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力。
|
||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。
|
||||
- 可以继续复刻旧题库学生端主要视觉和交互:刷题细节、勋章展示和小程序端分享/支付体验。地区选择、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||
- 可以按新后端主模型接入内容导航:
|
||||
@@ -47,7 +47,7 @@
|
||||
- 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”;订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。
|
||||
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。
|
||||
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览和资源复检 worker,但 CDN 防盗链、视频水印和杀毒扫描还要补。
|
||||
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API 和导入后复检已可用。租户内容页已经可以查看问题行、预览字段映射/模板并触发复检,后续还要补上传预览、排队轮询、模板文件下载按钮、字段映射编辑 UI 和复检结果详情面板。
|
||||
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API 和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、执行后端预览、编辑本次字段别名、同步/异步提交导入、查看问题行并触发复检;后续还要补异步 job 轮询、模板文件下载按钮、复检结果详情面板、真实数据 dry-run 验收和更完整的目标入口/集合选择。
|
||||
- 数据看板、分佣结算和勋章手动发放基础 API 已可联调;勋章自动发放、分佣真实打款/导出/凭证、AI 择校、主题模板市场等仍是后续商用增强项。
|
||||
|
||||
## 前后端协作建议
|
||||
@@ -91,7 +91,7 @@
|
||||
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/queue`、`commission/summary` |
|
||||
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`role-templates` |
|
||||
|
||||
当前租户后台已有第一批运营操作:题库内容页支持公共题库采纳/同步、同步冲突查看、导入问题查看、字段映射/模板预览和导入后复检。下一批需要继续补完整后台写入表单、导入上传 preview/import 操作台、字段映射编辑、学生批量导入、角色模板配置 UI 和权限驱动菜单。
|
||||
当前租户后台已有第一批运营操作:题库内容页支持公共题库采纳/同步、同步冲突查看、导入问题查看、字段映射/模板预览、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检。下一批需要继续补完整后台写入表单、异步导入轮询、模板文件下载按钮、复检结果详情、学生批量导入、角色模板配置 UI 和权限驱动菜单。
|
||||
|
||||
## 已落地的 Taro 平台后台页面
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
| 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券幂等、下单抵扣、全额优惠自动开通权益已有;复杂活动规则和核销报表待补 |
|
||||
| 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 |
|
||||
| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示和权限隔离已覆盖;自动发放规则、积分活动联动和前端运营 UI 待补 |
|
||||
| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载和字段映射 API 已有;字段映射 UI 和复检结果操作台待补 |
|
||||
| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载和字段映射 API 已有;Taro 租户内容页已接上传/粘贴预览、字段别名编辑和执行导入第一版;异步轮询、模板下载按钮、复检结果详情和真实数据验收待补 |
|
||||
| 题库导出 PDF/Word/JSON | 部分覆盖 | 服务端 JSON、`paper_json` 和打印 payload 导出基础已补,含租户内容编辑权限、跨租户拒绝、答案/解析开关、子题脱敏、导出 job 和审计;PDF/Word 二进制、水印、发布到资料下载和导出 worker 待补 |
|
||||
| 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 |
|
||||
| 背单词维护 | 已覆盖 | 单元/单词 CRUD 和导入已有 |
|
||||
@@ -98,7 +98,7 @@
|
||||
1. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。
|
||||
2. 账号设置完整流:绑定/更换手机号基础 API 已完成;仍缺头像上传、微信/QQ 账号合并、密码/邮箱能力。
|
||||
3. 题库导出:服务端 JSON/试卷 payload 导出、权限审计和答案脱敏已补;仍缺 PDF/Word 二进制生成、水印、资料发布和后台导出操作台。
|
||||
4. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、模板下载、字段映射 API 和 PocketBase JSON dry-run 报告已补,仍缺前端字段映射 UI 和真实数据执行验收。
|
||||
4. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、模板下载、字段映射 API、Taro 字段别名编辑和 PocketBase JSON dry-run 报告已补,仍缺异步轮询、模板下载按钮、复检详情和真实数据执行验收。
|
||||
5. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、冲突查询和租户自改冲突保护已完成基础闭环;还需版本通知、冲突处理操作台和运营后台 UI。
|
||||
6. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。
|
||||
7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。
|
||||
@@ -117,7 +117,7 @@
|
||||
|
||||
1. 微信/支付宝支付和 webhook 幂等。
|
||||
2. 对象存储 PDF 预览、视频深度防盗链、动态水印。
|
||||
3. 字段映射 UI、真实数据 dry-run 执行验收和导入复检结果操作台。
|
||||
3. 异步导入轮询、模板下载按钮、真实数据 dry-run 执行验收和导入复检结果操作台。
|
||||
4. 数据看板预聚合 worker、销售/代理转化看板和分佣结算。
|
||||
5. 公共题库版本通知、冲突处理操作台和租户确认/跳过策略。
|
||||
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
3. 导入体系扩展
|
||||
- 已完成题目、单词、知识手册、分数线、视频的 CSV/Excel 到规范 JSON 解析适配。
|
||||
- 已完成大批量异步导入 worker、重试、导入后复检和审计。
|
||||
- 模板下载、字段映射 API、导入后复检和 PocketBase JSON dry-run 报告工具已补;继续补字段映射 UI、真实数据执行验收、导入前抽样校验和复检结果操作台。
|
||||
- 模板下载、字段映射 API、导入后复检和 PocketBase JSON dry-run 报告工具已补;Taro 租户内容页已接 JSON/CSV/Excel 文件或粘贴内容、后端 preview、字段别名覆盖、同步/异步执行导入第一版;后端已对字段映射目标做白名单和危险 key 拦截。
|
||||
- 继续补异步 job 轮询、模板文件下载按钮、复检结果详情面板、真实数据执行验收、导入前抽样校验和导入性能压测。
|
||||
|
||||
4. 题库导出增强
|
||||
- 已完成服务端 JSON、`paper_json`、`print_payload` 基础导出和导出 job 台账。
|
||||
@@ -211,7 +212,7 @@
|
||||
|
||||
## 推荐下一步顺序
|
||||
|
||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、导入问题、模板预览和导入后复检已接第一版;继续补导入上传 preview/import、字段映射编辑、冲突处理详情、角色模板、CRM/分佣。
|
||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、导入问题、模板预览、上传/粘贴 preview/import、字段映射编辑和导入后复检已接第一版;继续补异步导入轮询、模板文件下载、复检详情、冲突处理详情、角色模板、CRM/分佣。
|
||||
2. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补刷题细节 UI、小程序支付容器、分享场景和状态管理。
|
||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
|
||||
@@ -763,6 +763,10 @@ GET /api/tenant-content/imports/post-check
|
||||
7. 导入进入 `completed/completed_with_errors` 后调用 `POST /api/tenant-content/imports/post-check`。
|
||||
8. 展示 `summary.importPostCheck` 或 `GET /api/tenant-content/imports/post-check?jobId=...` 返回的复检结果,再刷新内容列表、分数线列表或题目视频列表。
|
||||
|
||||
当前 `apps/taro/src/pages/tenant-admin/content/index.tsx` 已接第一版 H5 操作台:可切换导入类型和 JSON/CSV/Excel 格式,选择本地文件或粘贴内容,编辑本次字段别名,调用后端 preview,再同步执行或传 `executionMode=async` 入队。下一步继续补模板文件下载、异步 job 轮询、复检详情面板和目标入口/集合选择的完整表单。
|
||||
|
||||
`fieldMappingOverrides` 只影响 CSV/Excel 表头归一化,不改变 JSON 导入 schema。后端会按导入类型校验目标字段白名单并拒绝危险对象键;前端可以用它提高旧表格兼容性,但不能用它制造新业务字段或绕过后端校验。
|
||||
|
||||
CSV 请求示例:
|
||||
|
||||
```json
|
||||
@@ -770,6 +774,10 @@ CSV 请求示例:
|
||||
"sourceFormat": "csv",
|
||||
"sourceName": "questions.csv",
|
||||
"csvText": "legacyId,题型,题干,选项A,选项B,答案\nq1,choice,题干,A,B,B",
|
||||
"fieldMappingOverrides": {
|
||||
"content": ["自定义题干"],
|
||||
"answer": ["正确项"]
|
||||
},
|
||||
"subjectId": "...",
|
||||
"categoryId": "...",
|
||||
"entryId": "...",
|
||||
|
||||
@@ -3173,6 +3173,55 @@ async function testTenantContentAssetsAndImports() {
|
||||
assert.equal(csvQuestionPreview.job?.validCount, 1, 'CSV question preview should normalize one row');
|
||||
assert.equal(csvQuestionPreview.items?.[0]?.normalized?.correctOptionIndices?.[0], 1, 'CSV answer B should map to option index 1');
|
||||
|
||||
const mappedQuestionCsv = [
|
||||
['旧编号', '题型显示', '自定义题干', '左选项', '右选项', '正确项', '讲解'].join(','),
|
||||
['integration-import-csv-mapped-001', 'choice', 'CSV字段映射题:导入别名应由谁最终校验?', '前端页面', '后端导入管线', 'B', '字段映射只能进入后端白名单字段。'].join(','),
|
||||
].join('\n');
|
||||
const mappedCsvPreview = await request('/api/tenant-content/imports/preview/questions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
sourceFormat: 'csv',
|
||||
sourceName: 'questions-mapped.csv',
|
||||
csvText: mappedQuestionCsv,
|
||||
subjectId: ids.subject,
|
||||
categoryId: ids.category,
|
||||
regionId: ids.region,
|
||||
entryId: ids.contentEntry,
|
||||
contentNodeId: ids.contentNodeSchoolTarget,
|
||||
collectionId: ids.questionCollection,
|
||||
fieldMappingOverrides: {
|
||||
legacyId: ['旧编号'],
|
||||
type: ['题型显示'],
|
||||
content: ['自定义题干'],
|
||||
optionA: ['左选项'],
|
||||
optionB: ['右选项'],
|
||||
answer: ['正确项'],
|
||||
explanation: ['讲解'],
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(mappedCsvPreview.job?.errorCount, 0, 'CSV field mapping override preview should have no errors');
|
||||
assert.equal(mappedCsvPreview.items?.[0]?.normalized?.content, 'CSV字段映射题:导入别名应由谁最终校验?', 'field mapping override should map custom stem header');
|
||||
assert.equal(mappedCsvPreview.items?.[0]?.normalized?.correctOptionIndices?.[0], 1, 'field mapping override should map custom answer header');
|
||||
|
||||
const unsafeMappingOverrides = Object.fromEntries([['__proto__', ['旧编号']]]);
|
||||
const deniedMappingTarget = await request('/api/tenant-content/imports/preview/questions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
sourceFormat: 'csv',
|
||||
sourceName: 'questions-bad-mapping.csv',
|
||||
csvText: mappedQuestionCsv,
|
||||
subjectId: ids.subject,
|
||||
categoryId: ids.category,
|
||||
regionId: ids.region,
|
||||
fieldMappingOverrides: unsafeMappingOverrides,
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(deniedMappingTarget.code, 'IMPORT_HEADER_UNSAFE', 'field mapping override should reject unsafe target fields');
|
||||
|
||||
const csvQuestionImport = await request('/api/tenant-content/imports/questions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user