|
@@ -1,5 +1,6 @@
|
|
|
import { AccessibleDialogDirective } from '../shared/accessible-dialog.directive';
|
|
import { AccessibleDialogDirective } from '../shared/accessible-dialog.directive';
|
|
|
import { readBoundedWorkbook } from '../shared/bounded-workbook';
|
|
import { readBoundedWorkbook } from '../shared/bounded-workbook';
|
|
|
|
|
+import { vocabularyImportTemplate, vocabularyNodePath } from '../shared/vocabulary-import-template';
|
|
|
import { DropdownDirective } from '../shared/dropdown.directive';
|
|
import { DropdownDirective } from '../shared/dropdown.directive';
|
|
|
import { CommonModule } from '@angular/common';
|
|
import { CommonModule } from '@angular/common';
|
|
|
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
|
|
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
|
|
@@ -26,7 +27,10 @@ export class OperationsVocabularyComponent implements OnInit {
|
|
|
readonly treeQuery=signal('');readonly expandedNodeIds=signal<Set<number>>(new Set());
|
|
readonly treeQuery=signal('');readonly expandedNodeIds=signal<Set<number>>(new Set());
|
|
|
readonly loading = signal(true); readonly saving = signal(false); readonly error = signal(''); readonly message = signal('');
|
|
readonly loading = signal(true); readonly saving = signal(false); readonly error = signal(''); readonly message = signal('');
|
|
|
readonly editorOpen = signal(false); readonly importOpen = signal(false); readonly nodeOpen = signal(false); readonly preview = signal<VocabularyImportPreview|null>(null);
|
|
readonly editorOpen = signal(false); readonly importOpen = signal(false); readonly nodeOpen = signal(false); readonly preview = signal<VocabularyImportPreview|null>(null);
|
|
|
|
|
+ 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 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);
|
|
|
readonly selectedPath = computed(() => this.nodePath(this.selectedNode()));
|
|
readonly selectedPath = computed(() => this.nodePath(this.selectedNode()));
|
|
@@ -139,13 +143,15 @@ 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;
|
|
|
- try { const rows=await readBoundedWorkbook(file,2000); this.importFileName.set(file.name);this.importRows.set(rows);this.preview.set(null);this.importOpen.set(true);this.reason.set('导入词库'); }
|
|
|
|
|
- catch(error){this.error.set(this.errorText(error,'Excel 读取失败'));} finally{(event.target as HTMLInputElement).value='';}
|
|
|
|
|
|
|
+ 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('导入词库'); }
|
|
|
|
|
+ catch(error){this.error.set(error instanceof Error && error.message==='Excel 中没有可导入数据'?'模板尚未填写词汇,请阅读“填写说明”,在“词库导入”工作表第 2 行起填写后重试':this.errorText(error,'Excel 读取失败'));} finally{(event.target as HTMLInputElement).value='';}
|
|
|
}
|
|
}
|
|
|
- async previewImport(): Promise<void> { if(!this.reason().trim()){this.error.set('请填写导入原因');return;}this.saving.set(true);try{this.preview.set(await this.operations.previewVocabularyImport(this.importFileName(),this.importRows(),this.reason()));}catch(error){this.error.set(this.errorText(error,'导入预检失败'));}finally{this.saving.set(false);} }
|
|
|
|
|
|
|
+ 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);}}
|
|
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);}}
|
|
|
- async downloadTemplate():Promise<void>{try{const {utils,writeFile}=await import('xlsx');const sheet=utils.aoa_to_sheet([['GeneralID(可选)','学段','教材','单元','单词','释义','音标','例句','音频文件名或URL'],['','小学','示例教材','第一单元','example','例子','/ɪɡˈzɑːmpəl/','This is an example.','']]);const book=utils.book_new();utils.book_append_sheet(book,sheet,'词库导入');writeFile(book,'小树陪练词库导入模板.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,'学段':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 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,'词库导出失败'));}}
|
|
|
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)))};}
|
|
|
importErrors(item:Record<string,unknown>):string{return Array.isArray(item['errors'])?item['errors'].map(String).join('、'):'';}
|
|
importErrors(item:Record<string,unknown>):string{return Array.isArray(item['errors'])?item['errors'].map(String).join('、'):'';}
|
|
|
private emptyDraft():WordDraft{return{targetId:'',word:'',meaning:'',phonetic:'',example:'',audioUrl:'',nodeId:0,orderId:0,status:0,updatedAt:''};}
|
|
private emptyDraft():WordDraft{return{targetId:'',word:'',meaning:'',phonetic:'',example:'',audioUrl:'',nodeId:0,orderId:0,status:0,updatedAt:''};}
|