feat: add import template downloads

This commit is contained in:
Codex
2026-06-29 14:02:38 +08:00
parent c07c9866f2
commit 215cbd05f7
8 changed files with 65 additions and 20 deletions

View File

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

View File

@@ -97,6 +97,33 @@ function openH5FilePicker(format: ImportSourceFormat) {
});
}
function base64ToUint8Array(base64: string) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function downloadTemplateFile(template: ImportTemplateItem) {
if (process.env.TARO_ENV !== 'h5' || typeof document === 'undefined') {
throw new Error('当前端暂不支持直接下载模板,请先使用模板预览。');
}
if (!template.contentBase64) throw new Error('模板内容为空,请重新加载模板。');
const blob = new Blob([base64ToUint8Array(template.contentBase64)], {
type: template.mimeType || 'application/octet-stream',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = template.fileName || `import-template.${template.format || 'json'}`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function safeJsonPreview(value: unknown, maxLength = 360) {
try {
return JSON.stringify(value, null, 2).slice(0, maxLength);
@@ -246,14 +273,15 @@ export default function TenantContentPage() {
}
}
async function previewTemplate(type = selectedImportType) {
async function previewTemplate(type = selectedImportType, format = sourceFormat) {
setError('');
try {
const [mappingPayload, templatePayload] = await Promise.all([
loadImportFieldMapping(type),
loadImportTemplate(type, 'json'),
loadImportTemplate(type, format === 'excel' ? 'csv' : format),
]);
setSelectedImportType(type);
if (format !== 'excel') setSourceFormat(format);
setMapping(mappingPayload.item || null);
setMappingOverrides(Object.fromEntries(
(mappingPayload.item?.fields || [])
@@ -266,6 +294,21 @@ export default function TenantContentPage() {
}
}
async function downloadCurrentTemplate() {
setError('');
try {
const payload = template && template.importType === selectedImportType && template.format === sourceFormat
? { item: template }
: await loadImportTemplate(selectedImportType, sourceFormat === 'excel' ? 'csv' : sourceFormat);
if (!payload.item) throw new Error('模板不存在。');
downloadTemplateFile(payload.item);
setTemplate(payload.item);
Taro.showToast({ title: '已下载', icon: 'success' });
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '模板下载失败');
}
}
async function chooseImportFile() {
setError('');
try {
@@ -510,7 +553,7 @@ export default function TenantContentPage() {
</View>
<View className='admin-tabs'>
{importTypes.map(type => (
<Button key={type} className={`admin-button ${selectedImportType === type ? 'active' : ''}`} onClick={() => previewTemplate(type)}>{type}</Button>
<Button key={type} className={`admin-button ${selectedImportType === type ? 'active' : ''}`} onClick={() => previewTemplate(type, sourceFormat)}>{type}</Button>
))}
</View>
<View className='admin-tabs'>
@@ -519,6 +562,7 @@ export default function TenantContentPage() {
setSourceFormat(format);
setPreviewResult(null);
setIssues([]);
previewTemplate(selectedImportType, format);
}}>{format}</Button>
))}
</View>
@@ -550,6 +594,7 @@ export default function TenantContentPage() {
setPreviewResult(null);
setIssues([]);
}}></Button>
<Button className='admin-button' onClick={downloadCurrentTemplate}></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>