|
@@ -7,7 +7,7 @@ import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal }
|
|
|
import { FormsModule } from '@angular/forms';
|
|
import { FormsModule } from '@angular/forms';
|
|
|
import { ChevronLeft, ChevronRight, Download, FileSpreadsheet, FolderPlus, Headphones, Languages, LucideAngularModule, Pencil, Plus, RefreshCw, RotateCcw, Search, Trash2, Upload, X } from 'lucide-angular';
|
|
import { ChevronLeft, ChevronRight, Download, FileSpreadsheet, FolderPlus, Headphones, Languages, LucideAngularModule, Pencil, Plus, RefreshCw, RotateCcw, Search, Trash2, Upload, X } from 'lucide-angular';
|
|
|
import { LEGACY_ASSET_BASE_URL } from '../admin.constants';
|
|
import { LEGACY_ASSET_BASE_URL } from '../admin.constants';
|
|
|
-import { OperationsPage, VocabularyImportPreview, VocabularyNode, VocabularyWord } from '../admin.models';
|
|
|
|
|
|
|
+import { OperationsPage, VocabularyImportPreview, VocabularyImportResult, VocabularyNode, VocabularyWord } from '../admin.models';
|
|
|
import { OperationsService } from '../operations.service';
|
|
import { OperationsService } from '../operations.service';
|
|
|
|
|
|
|
|
type WordDraft = Pick<VocabularyWord, 'targetId'|'word'|'meaning'|'phonetic'|'example'|'audioUrl'|'nodeId'|'orderId'|'status'|'updatedAt'>;
|
|
type WordDraft = Pick<VocabularyWord, 'targetId'|'word'|'meaning'|'phonetic'|'example'|'audioUrl'|'nodeId'|'orderId'|'status'|'updatedAt'>;
|
|
@@ -30,6 +30,9 @@ export class OperationsVocabularyComponent implements OnInit {
|
|
|
readonly importPreviewItems = computed(() => [...(this.preview()?.items || [])].sort((a,b) => Number(b['action']==='error')-Number(a['action']==='error')).slice(0,100));
|
|
readonly importPreviewItems = computed(() => [...(this.preview()?.items || [])].sort((a,b) => Number(b['action']==='error')-Number(a['action']==='error')).slice(0,100));
|
|
|
readonly reason = signal(''); readonly importFileName = signal(''); readonly importRows = signal<Record<string, unknown>[]>([]);
|
|
readonly reason = signal(''); readonly importFileName = signal(''); readonly importRows = signal<Record<string, unknown>[]>([]);
|
|
|
readonly importTargetNodeId = signal(0);
|
|
readonly importTargetNodeId = signal(0);
|
|
|
|
|
+ readonly importResult = signal<VocabularyImportResult|null>(null);
|
|
|
|
|
+ readonly importFailures = computed(() => this.importResult()?.items.filter(row => row.status === 'failed') || []);
|
|
|
|
|
+ closeImport(): void { if (!this.saving()) this.importOpen.set(false); }
|
|
|
readonly importTargets = computed(() => this.tree().filter(node => node.canContainWords).map(node => ({ nodeId: node.nodeId, label: `${vocabularyNodePath(this.tree(), node)}(ID ${node.nodeId})` })));
|
|
readonly importTargets = computed(() => this.tree().filter(node => node.canContainWords).map(node => ({ nodeId: node.nodeId, label: `${vocabularyNodePath(this.tree(), node)}(ID ${node.nodeId})` })));
|
|
|
readonly draft = signal<WordDraft>(this.emptyDraft()); readonly nodeDraft = signal({ targetId:'', name:'', parentId:0, orderId:0, canContainWords:true, updatedAt:'' });
|
|
readonly draft = signal<WordDraft>(this.emptyDraft()); readonly nodeDraft = signal({ targetId:'', name:'', parentId:0, orderId:0, canContainWords:true, updatedAt:'' });
|
|
|
readonly selectedNode = computed(() => this.tree().find((node) => node.nodeId === this.selectedNodeId()) || null);
|
|
readonly selectedNode = computed(() => this.tree().find((node) => node.nodeId === this.selectedNodeId()) || null);
|
|
@@ -67,7 +70,14 @@ export class OperationsVocabularyComponent implements OnInit {
|
|
|
private readonly operations = inject(OperationsService);
|
|
private readonly operations = inject(OperationsService);
|
|
|
private activeAudio: HTMLAudioElement | null = null;
|
|
private activeAudio: HTMLAudioElement | null = null;
|
|
|
|
|
|
|
|
- ngOnInit(): void { void this.initialLoad(); }
|
|
|
|
|
|
|
+ ngOnInit(): void { void this.initialLoad().then(()=>this.restoreImport()); }
|
|
|
|
|
+ async restoreImport(): Promise<void> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result=await this.operations.resumeVocabularyImport();if(!result||this.importOpen())return;
|
|
|
|
|
+ this.importResult.set(result);this.importFileName.set(result.fileName||'上次导入');this.reason.set(result.reason||'继续导入词库');
|
|
|
|
|
+ this.preview.set({batchId:result.batchId,status:result.status,summary:result.previewSummary||{total:result.summary.total,create:0,update:0,unchanged:0,duplicate:0,error:0,missingMedia:0},items:result.items,createdAt:''});
|
|
|
|
|
+ }catch{this.error.set('上次导入进度暂时无法读取,请稍后重新打开词库页面核对');}
|
|
|
|
|
+ }
|
|
|
async initialLoad(): Promise<void> {
|
|
async initialLoad(): Promise<void> {
|
|
|
this.loading.set(true); this.error.set('');
|
|
this.loading.set(true); this.error.set('');
|
|
|
this.selectedNodeId.set(0);
|
|
this.selectedNodeId.set(0);
|
|
@@ -143,13 +153,26 @@ export class OperationsVocabularyComponent implements OnInit {
|
|
|
async saveNode():Promise<void>{const draft=this.nodeDraft();if(!draft.name.trim()||!this.reason().trim()){this.error.set('分类名称和操作原因必填');return;}this.saving.set(true);try{await this.operations.mutate('ops/vocabulary/node-save',draft.targetId,this.reason(),draft,draft.updatedAt);this.nodeOpen.set(false);await this.initialLoad();this.message.set('词库分类已保存');}catch(error){this.error.set(this.errorText(error,'分类保存失败'));}finally{this.saving.set(false);}}
|
|
async saveNode():Promise<void>{const draft=this.nodeDraft();if(!draft.name.trim()||!this.reason().trim()){this.error.set('分类名称和操作原因必填');return;}this.saving.set(true);try{await this.operations.mutate('ops/vocabulary/node-save',draft.targetId,this.reason(),draft,draft.updatedAt);this.nodeOpen.set(false);await this.initialLoad();this.message.set('词库分类已保存');}catch(error){this.error.set(this.errorText(error,'分类保存失败'));}finally{this.saving.set(false);}}
|
|
|
async chooseWorkbook(event: Event): Promise<void> {
|
|
async chooseWorkbook(event: Event): Promise<void> {
|
|
|
const file=(event.target as HTMLInputElement).files?.[0]; if(!file)return;
|
|
const file=(event.target as HTMLInputElement).files?.[0]; if(!file)return;
|
|
|
|
|
+ if(this.saving())return;
|
|
|
this.error.set('');
|
|
this.error.set('');
|
|
|
- try { const rows=await readBoundedWorkbook(file,2000); this.importFileName.set(file.name);this.importRows.set(rows);this.preview.set(null);this.importTargetNodeId.set(0);this.importOpen.set(true);this.reason.set('导入词库'); }
|
|
|
|
|
|
|
+ try { const rows=await readBoundedWorkbook(file,2000); this.importResult.set(null);this.importFileName.set(file.name);this.importRows.set(rows);this.preview.set(null);this.importTargetNodeId.set(0);this.importOpen.set(true);this.reason.set('导入词库'); }
|
|
|
catch(error){this.error.set(error instanceof Error && error.message==='Excel 中没有可导入数据'?'模板尚未填写词汇,请阅读“填写说明”,在“词库导入”工作表第 2 行起填写后重试':this.errorText(error,'Excel 读取失败'));} finally{(event.target as HTMLInputElement).value='';}
|
|
catch(error){this.error.set(error instanceof Error && error.message==='Excel 中没有可导入数据'?'模板尚未填写词汇,请阅读“填写说明”,在“词库导入”工作表第 2 行起填写后重试':this.errorText(error,'Excel 读取失败'));} finally{(event.target as HTMLInputElement).value='';}
|
|
|
}
|
|
}
|
|
|
- changeImportTarget(nodeId: number): void { this.importTargetNodeId.set(nodeId); this.preview.set(null); this.error.set(''); }
|
|
|
|
|
- async previewImport(): Promise<void> { if(!this.reason().trim()){this.error.set('请填写导入原因');return;}this.saving.set(true);this.error.set('');this.preview.set(null);try{this.preview.set(await this.operations.previewVocabularyImport(this.importFileName(),this.importRows(),this.reason(),this.importTargetNodeId()));}catch(error){this.error.set(this.errorText(error,'导入预检失败'));}finally{this.saving.set(false);} }
|
|
|
|
|
- async commitImport(): Promise<void> {const preview=this.preview();if(!preview||preview.summary.error){this.error.set('请先解决全部阻断错误');return;}this.saving.set(true);try{await this.operations.commitVocabularyImport(preview.batchId,this.reason());this.importOpen.set(false);this.message.set(`导入完成:新增 ${preview.summary.create},更新 ${preview.summary.update}`);await this.initialLoad();}catch(error){this.error.set(this.errorText(error,'导入发布失败'));}finally{this.saving.set(false);}}
|
|
|
|
|
|
|
+ changeImportTarget(nodeId: number): void { if(this.saving()||this.importResult())return; this.importTargetNodeId.set(nodeId); this.preview.set(null); this.error.set(''); }
|
|
|
|
|
+ async previewImport(): Promise<void> { if(this.saving()||this.importResult())return; if(!this.reason().trim()){this.error.set('请填写导入原因');return;}this.saving.set(true);this.error.set('');this.preview.set(null);try{this.preview.set(await this.operations.previewVocabularyImport(this.importFileName(),this.importRows(),this.reason(),this.importTargetNodeId()));}catch(error){this.error.set(this.errorText(error,'导入预检失败'));}finally{this.saving.set(false);} }
|
|
|
|
|
+ async commitImport(): Promise<void> {
|
|
|
|
|
+ if(this.saving())return;
|
|
|
|
|
+ const preview=this.preview();if(!preview||preview.summary.error){this.error.set('请先解决全部阻断错误');return;}
|
|
|
|
|
+ this.saving.set(true);this.error.set('');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result=await this.operations.commitVocabularyImport(preview.batchId,this.reason(),progress=>this.importResult.set(progress),this.importResult()?.status==='failed');
|
|
|
|
|
+ this.importResult.set(result);
|
|
|
|
|
+ if(result.status==='completed'){
|
|
|
|
|
+ this.importOpen.set(false);this.message.set(`导入完成:新增 ${result.summary.created},更新 ${result.summary.updated},跳过 ${result.summary.skipped}`);await this.initialLoad();
|
|
|
|
|
+ }else this.error.set(`导入有 ${result.summary.failed} 行失败,原因见下方;已成功的行已保存,重试只处理失败行`);
|
|
|
|
|
+ }catch(error){this.error.set(this.errorText(error,'导入暂未完成,进度已保存,请点击继续导入'));}
|
|
|
|
|
+ finally{this.saving.set(false);}
|
|
|
|
|
+ }
|
|
|
async downloadTemplate():Promise<void>{this.error.set('');try{const tree=await this.operations.vocabularyTree();const book=await vocabularyImportTemplate(tree.items,this.selectedNodeId());const {writeFile}=await import('xlsx');writeFile(book,'小树陪练词库导入模板.xlsx');this.message.set('模板已下载:请先阅读“填写说明”,再从“词库目录”复制单元ID填写词汇');}catch(error){this.error.set(this.errorText(error,'模板下载失败'));}}
|
|
async downloadTemplate():Promise<void>{this.error.set('');try{const tree=await this.operations.vocabularyTree();const book=await vocabularyImportTemplate(tree.items,this.selectedNodeId());const {writeFile}=await import('xlsx');writeFile(book,'小树陪练词库导入模板.xlsx');this.message.set('模板已下载:请先阅读“填写说明”,再从“词库目录”复制单元ID填写词汇');}catch(error){this.error.set(this.errorText(error,'模板下载失败'));}}
|
|
|
async exportCurrent():Promise<void>{try{const {utils,writeFile}=await import('xlsx');const rows=(this.page()?.items||[]).map((row)=>({'GeneralID':row.generalId,'单元ID':row.nodeId,'学段':row.stageName,'教材':row.bookName,'单元':row.unitName,'单词':row.word,'释义':row.meaning,'音标':row.phonetic,'例句':row.example,'音频URL':row.audioUrl,'状态':row.statusLabel}));const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(rows),'词库');writeFile(book,`词库_${this.selectedNode()?.name||'筛选结果'}.xlsx`);}catch(error){this.error.set(this.errorText(error,'词库导出失败'));}}
|
|
async exportCurrent():Promise<void>{try{const {utils,writeFile}=await import('xlsx');const rows=(this.page()?.items||[]).map((row)=>({'GeneralID':row.generalId,'单元ID':row.nodeId,'学段':row.stageName,'教材':row.bookName,'单元':row.unitName,'单词':row.word,'释义':row.meaning,'音标':row.phonetic,'例句':row.example,'音频URL':row.audioUrl,'状态':row.statusLabel}));const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(rows),'词库');writeFile(book,`词库_${this.selectedNode()?.name||'筛选结果'}.xlsx`);}catch(error){this.error.set(this.errorText(error,'词库导出失败'));}}
|
|
|
depthStyle(node:VocabularyNode):Record<string,string>{return{'--depth':String(Math.max(0,Number(node['displayDepth']||0)))};}
|
|
depthStyle(node:VocabularyNode):Record<string,string>{return{'--depth':String(Math.max(0,Number(node['displayDepth']||0)))};}
|