feat: add tenant import console

This commit is contained in:
Codex
2026-06-29 13:35:34 +08:00
parent 65905aaf8f
commit 93ec1a07d2
13 changed files with 623 additions and 30 deletions

View File

@@ -64,7 +64,7 @@ pages/tenant-admin/marketing/index 优惠券、激活码、CRM、分佣摘要
pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板
```
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、导入问题查看、字段映射预览、模板预览导入后复检;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、导入问题查看、模板预览导入后复检,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
## 当前平台后台页面

View File

@@ -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;
}

View File

@@ -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'>

View File

@@ -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');
}