|
@@ -0,0 +1,90 @@
|
|
|
|
|
+import { CommonModule } from '@angular/common';
|
|
|
|
|
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
|
|
|
|
|
+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 { read, utils, writeFile } from 'xlsx';
|
|
|
|
|
+import { OperationsPage, VocabularyImportPreview, VocabularyNode, VocabularyWord } from '../admin.models';
|
|
|
|
|
+import { OperationsService } from '../operations.service';
|
|
|
|
|
+
|
|
|
|
|
+type WordDraft = Pick<VocabularyWord, 'targetId'|'word'|'meaning'|'phonetic'|'example'|'audioUrl'|'nodeId'|'orderId'|'status'|'updatedAt'>;
|
|
|
|
|
+
|
|
|
|
|
+@Component({
|
|
|
|
|
+ selector: 'app-operations-vocabulary', standalone: true,
|
|
|
|
|
+ imports: [CommonModule, FormsModule, LucideAngularModule],
|
|
|
|
|
+ templateUrl: './operations-vocabulary.component.html',
|
|
|
|
|
+ styleUrls: ['./operations-professional.scss', './operations-vocabulary.component.scss'],
|
|
|
|
|
+ changeDetection: ChangeDetectionStrategy.OnPush,
|
|
|
|
|
+})
|
|
|
|
|
+export class OperationsVocabularyComponent implements OnInit {
|
|
|
|
|
+ readonly icons = { ChevronLeft, ChevronRight, Download, FileSpreadsheet, FolderPlus, Headphones, Languages, Pencil, Plus, RefreshCw, RotateCcw, Search, Trash2, Upload, X };
|
|
|
|
|
+ readonly tree = signal<VocabularyNode[]>([]); readonly page = signal<OperationsPage<VocabularyWord>|null>(null);
|
|
|
|
|
+ readonly selectedNodeId = signal(0); readonly query = signal(''); readonly status = signal(''); readonly missingAudio = signal(false);
|
|
|
|
|
+ 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 editorOpen = signal(false); readonly importOpen = signal(false); readonly nodeOpen = signal(false); readonly preview = signal<VocabularyImportPreview|null>(null);
|
|
|
|
|
+ readonly reason = signal(''); readonly importFileName = signal(''); readonly importRows = signal<Record<string, unknown>[]>([]);
|
|
|
|
|
+ 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 visibleNodes=computed(()=>{const nodes=this.tree(),query=this.treeQuery().trim().toLowerCase(),byId=new Map(nodes.map((node)=>[node.nodeId,node]));if(query){const visible=new Set<number>();for(const node of nodes){if(!node.name.toLowerCase().includes(query))continue;let current:VocabularyNode|undefined=node;while(current){visible.add(current.nodeId);current=byId.get(current.parentId);}}return nodes.filter((node)=>visible.has(node.nodeId));}const expanded=this.expandedNodeIds(),known=new Set(nodes.map((node)=>node.nodeId));return nodes.filter((node)=>{let parentId=node.parentId;while(known.has(parentId)){if(!expanded.has(parentId))return false;parentId=byId.get(parentId)?.parentId||0;}return true;});});
|
|
|
|
|
+ private readonly operations = inject(OperationsService);
|
|
|
|
|
+
|
|
|
|
|
+ ngOnInit(): void { void this.initialLoad(); }
|
|
|
|
|
+ async initialLoad(): Promise<void> {
|
|
|
|
|
+ this.loading.set(true); this.error.set('');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await this.operations.vocabularyTree(); this.tree.set(result.items);
|
|
|
|
|
+ const selected = [...result.items].filter((node) => node.canContainWords && node.wordCount > 0).sort((a,b)=>b.depth-a.depth)[0] || result.items.find((node) => node.canContainWords);
|
|
|
|
|
+ if (selected){this.selectedNodeId.set(selected.nodeId);this.expandLineage(selected,result.items);}
|
|
|
|
|
+ await this.loadWords(1, false);
|
|
|
|
|
+ } catch (error) { this.error.set(this.errorText(error, '词库加载失败')); this.loading.set(false); }
|
|
|
|
|
+ }
|
|
|
|
|
+ async refresh(): Promise<void> { await this.initialLoad(); this.message.set(`已从服务端刷新 · ${new Date().toLocaleTimeString('zh-CN')}`); }
|
|
|
|
|
+ async loadWords(page = 1, setLoading = true): Promise<void> {
|
|
|
|
|
+ if (setLoading) this.loading.set(true); this.error.set('');
|
|
|
|
|
+ try { this.page.set(await this.operations.vocabulary(page, 30, this.query(), { nodeId:this.selectedNodeId(), status:this.status(), missingAudio:this.missingAudio() })); }
|
|
|
|
|
+ catch (error) { this.error.set(this.errorText(error, '词条加载失败')); }
|
|
|
|
|
+ finally { this.loading.set(false); }
|
|
|
|
|
+ }
|
|
|
|
|
+ selectNode(node: VocabularyNode): void {if(this.hasChildren(node)){this.expandedNodeIds.update((current)=>{const next=new Set(current);next.has(node.nodeId)?next.delete(node.nodeId):next.add(node.nodeId);return next;});}if(!node.canContainWords||(!node.wordCount&&this.hasChildren(node)))return;this.selectedNodeId.set(node.nodeId);void this.loadWords(1);}
|
|
|
|
|
+ hasChildren(node:VocabularyNode):boolean{return this.tree().some((candidate)=>candidate.parentId===node.nodeId);}
|
|
|
|
|
+ isExpanded(node:VocabularyNode):boolean{return this.expandedNodeIds().has(node.nodeId);}
|
|
|
|
|
+ pageCount(): number { return Math.max(1, Math.ceil((this.page()?.total || 0) / (this.page()?.pageSize || 30))); }
|
|
|
|
|
+ openCreate(): void { const nodeId=this.selectedNodeId(); if(!nodeId){this.error.set('请先选择一个单元');return;} this.draft.set({...this.emptyDraft(),nodeId}); this.reason.set('新增词条'); this.editorOpen.set(true); }
|
|
|
|
|
+ openEdit(row: VocabularyWord): void { this.draft.set({targetId:row.targetId,word:row.word,meaning:row.meaning,phonetic:row.phonetic,example:row.example,audioUrl:row.audioUrl,nodeId:row.nodeId,orderId:row.orderId,status:row.status,updatedAt:row.updatedAt}); this.reason.set(''); this.editorOpen.set(true); }
|
|
|
|
|
+ closeEditor(): void { this.editorOpen.set(false); }
|
|
|
|
|
+ updateDraft<K extends keyof WordDraft>(key: K, value: WordDraft[K]): void { this.draft.update((draft) => ({...draft,[key]:value})); }
|
|
|
|
|
+ async saveWord(): Promise<void> {
|
|
|
|
|
+ const draft=this.draft(); if(!draft.word.trim()||!draft.nodeId||!this.reason().trim()){this.error.set('单词、所属单元和操作原因必填');return;}
|
|
|
|
|
+ this.saving.set(true); this.error.set('');
|
|
|
|
|
+ try { await this.operations.mutate('ops/vocabulary/save',draft.targetId,this.reason(),draft,draft.updatedAt); this.editorOpen.set(false); this.message.set(draft.targetId?'词条已更新':'词条已创建为草稿'); await this.loadWords(this.page()?.page||1); }
|
|
|
|
|
+ catch(error){this.error.set(this.errorText(error,'词条保存失败'));} finally{this.saving.set(false);}
|
|
|
|
|
+ }
|
|
|
|
|
+ async lifecycle(row: VocabularyWord, restore=false): Promise<void> {
|
|
|
|
|
+ const reason=window.prompt(restore?'请输入恢复原因':'请输入回收原因'); if(!reason)return;
|
|
|
|
|
+ try { await this.operations.mutate(restore?'ops/vocabulary/restore':'ops/vocabulary/recycle',row.targetId,reason,{},row.updatedAt); this.message.set(restore?'词条已恢复':'词条已移入回收站'); await this.loadWords(this.page()?.page||1); }
|
|
|
|
|
+ catch(error){this.error.set(this.errorText(error,'操作失败'));}
|
|
|
|
|
+ }
|
|
|
|
|
+ async uploadAudio(event: Event): Promise<void> {
|
|
|
|
|
+ const file=(event.target as HTMLInputElement).files?.[0]; if(!file)return; this.saving.set(true);
|
|
|
|
|
+ try { const url=await this.operations.uploadMedia(file,'audio',this.reason()||'上传词条音频'); this.updateDraft('audioUrl',url); this.message.set('音频上传完成'); }
|
|
|
|
|
+ catch(error){this.error.set(this.errorText(error,'音频上传失败'));} finally{this.saving.set(false); (event.target as HTMLInputElement).value='';}
|
|
|
|
|
+ }
|
|
|
|
|
+ playAudio(url: string): void { if(url) void new Audio(url).play().catch(()=>this.error.set('音频无法播放,请检查地址')); }
|
|
|
|
|
+ openNode(node?:VocabularyNode):void{this.nodeDraft.set(node?{targetId:node.objectId,name:node.name,parentId:node.parentId,orderId:node.orderId,canContainWords:node.canContainWords,updatedAt:String(node['updatedAt']||'')}:{targetId:'',name:'',parentId:this.selectedNodeId(),orderId:0,canContainWords:true,updatedAt:''});this.reason.set(node?'调整词库分类':'新增词库分类');this.nodeOpen.set(true);}
|
|
|
|
|
+ updateNodeDraft(key:'name'|'parentId'|'orderId',value:string|number):void{this.nodeDraft.update((draft)=>({...draft,[key]:value}));}
|
|
|
|
|
+ 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> {
|
|
|
|
|
+ const file=(event.target as HTMLInputElement).files?.[0]; if(!file)return;
|
|
|
|
|
+ try { const workbook=read(await file.arrayBuffer(),{type:'array',cellDates:true}); const sheet=workbook.Sheets[workbook.SheetNames[0]]; const rows=utils.sheet_to_json<Record<string,unknown>>(sheet,{defval:''}); if(!rows.length)throw new Error('Excel 没有可导入的数据'); 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='';}
|
|
|
|
|
+ }
|
|
|
|
|
+ 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);} }
|
|
|
|
|
+ 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);}}
|
|
|
|
|
+ downloadTemplate():void{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');}
|
|
|
|
|
+ exportCurrent():void{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`);}
|
|
|
|
|
+ depthStyle(node:VocabularyNode):Record<string,string>{return{'--depth':String(Math.max(0,node.depth))};}
|
|
|
|
|
+ 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 expandLineage(node:VocabularyNode,nodes:VocabularyNode[]):void{const byId=new Map(nodes.map((item)=>[item.nodeId,item])),expanded=new Set<number>();let parentId=node.parentId;while(byId.has(parentId)){expanded.add(parentId);parentId=byId.get(parentId)?.parentId||0;}this.expandedNodeIds.set(expanded);}
|
|
|
|
|
+ private errorText(error:unknown,fallback:string):string{return error instanceof Error?error.message:fallback;}
|
|
|
|
|
+}
|