|
|
@@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal }
|
|
|
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 { ApiService } from '../../core/api.service';
|
|
|
import { OperationsPage, VocabularyImportPreview, VocabularyNode, VocabularyWord } from '../admin.models';
|
|
|
import { OperationsService } from '../operations.service';
|
|
|
|
|
|
@@ -18,6 +19,7 @@ type WordDraft = Pick<VocabularyWord, 'targetId'|'word'|'meaning'|'phonetic'|'ex
|
|
|
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 totalWords = signal(0); readonly pageSize = signal(30); readonly playingAudioUrl = signal('');
|
|
|
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('');
|
|
|
@@ -25,31 +27,65 @@ export class OperationsVocabularyComponent implements OnInit {
|
|
|
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;});});
|
|
|
+ readonly selectedPath = computed(() => this.nodePath(this.selectedNode()));
|
|
|
+ readonly canCreateInSelection = computed(() => Boolean(this.selectedNode()?.canContainWords));
|
|
|
+ readonly visibleNodes = computed(() => {
|
|
|
+ const nodes = this.tree();
|
|
|
+ const byId = new Map(nodes.map((node) => [node.nodeId, node]));
|
|
|
+ const children = new Map<number, VocabularyNode[]>();
|
|
|
+ for (const node of nodes) {
|
|
|
+ const siblings = children.get(node.parentId) || [];
|
|
|
+ siblings.push(node);
|
|
|
+ children.set(node.parentId, siblings);
|
|
|
+ }
|
|
|
+ for (const siblings of children.values()) siblings.sort((a, b) => a.orderId - b.orderId || a.nodeId - b.nodeId || a.name.localeCompare(b.name, 'zh-CN'));
|
|
|
+ const query = this.treeQuery().trim().toLowerCase();
|
|
|
+ const included = new Set<number>();
|
|
|
+ if (query) {
|
|
|
+ for (const node of nodes) {
|
|
|
+ if (!node.name.toLowerCase().includes(query)) continue;
|
|
|
+ let current: VocabularyNode | undefined = node;
|
|
|
+ while (current) { included.add(current.nodeId); current = byId.get(current.parentId); }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const roots = nodes.filter((node) => !byId.has(node.parentId)).sort((a, b) => a.orderId - b.orderId || a.nodeId - b.nodeId);
|
|
|
+ const output: VocabularyNode[] = [];
|
|
|
+ const walk = (node: VocabularyNode, level: number): void => {
|
|
|
+ if (query && !included.has(node.nodeId)) return;
|
|
|
+ output.push({ ...node, displayDepth: level });
|
|
|
+ if (query || this.expandedNodeIds().has(node.nodeId)) for (const child of children.get(node.nodeId) || []) walk(child, level + 1);
|
|
|
+ };
|
|
|
+ for (const root of roots) walk(root, 0);
|
|
|
+ return output;
|
|
|
+ });
|
|
|
private readonly operations = inject(OperationsService);
|
|
|
+ private readonly api = inject(ApiService);
|
|
|
+ private activeAudio: HTMLAudioElement | null = null;
|
|
|
|
|
|
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);}
|
|
|
+ const result = await this.operations.vocabularyTree(); this.tree.set(result.items); this.totalWords.set(result.totalWords || 0);
|
|
|
+ this.selectedNodeId.set(0);
|
|
|
+ this.expandedNodeIds.set(new Set(result.items.filter((node) => !result.items.some((candidate) => candidate.nodeId === node.parentId)).map((node) => node.nodeId)));
|
|
|
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() })); }
|
|
|
+ try { this.page.set(await this.operations.vocabulary(page, this.pageSize(), 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);}
|
|
|
+ selectAll(): void { this.selectedNodeId.set(0); void this.loadWords(1); }
|
|
|
+ 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;});}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); }
|
|
|
+ changePageSize(value: number): void { this.pageSize.set(value); void this.loadWords(1); }
|
|
|
+ openCreate(): void { const node=this.selectedNode(); if(!node?.canContainWords){this.error.set('请先选择一个可以保存词条的教材或单元');return;} this.draft.set({...this.emptyDraft(),nodeId:node.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})); }
|
|
|
@@ -69,7 +105,23 @@ export class OperationsVocabularyComponent implements OnInit {
|
|
|
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('音频无法播放,请检查地址')); }
|
|
|
+ audioSource(path: string): string {
|
|
|
+ const value=String(path||'').trim().replace(/\\/g,'/');
|
|
|
+ if(!value)return '';
|
|
|
+ if(/^(blob:|data:audio\/|https?:\/\/)/i.test(value))return value;
|
|
|
+ if(value.startsWith('//'))return `${globalThis.location?.protocol||'https:'}${value}`;
|
|
|
+ const legacyPath=value.startsWith('/')?value:value.toLowerCase().startsWith('uploadfiles/')?`/${value}`:`/UploadFiles/${value}`;
|
|
|
+ return this.api.assetUrl(legacyPath);
|
|
|
+ }
|
|
|
+ playAudio(url: string): void {
|
|
|
+ const source=this.audioSource(url); if(!source)return;
|
|
|
+ if(this.activeAudio&&this.playingAudioUrl()===source){this.activeAudio.pause();this.activeAudio=null;this.playingAudioUrl.set('');return;}
|
|
|
+ this.activeAudio?.pause(); const audio=new Audio(source); this.activeAudio=audio;this.playingAudioUrl.set(source);this.error.set('');
|
|
|
+ audio.addEventListener('ended',()=>{if(this.activeAudio===audio){this.activeAudio=null;this.playingAudioUrl.set('');}},{once:true});
|
|
|
+ audio.addEventListener('error',()=>{if(this.activeAudio===audio){this.activeAudio=null;this.playingAudioUrl.set('');this.error.set('音频文件无法访问,请检查旧资源服务器或重新上传音频');}},{once:true});
|
|
|
+ void audio.play().catch(()=>{if(this.activeAudio===audio){this.activeAudio=null;this.playingAudioUrl.set('');this.error.set('浏览器未能播放该音频,请检查文件格式或资源地址');}});
|
|
|
+ }
|
|
|
+ isPlaying(path:string):boolean{return this.playingAudioUrl()===this.audioSource(path);}
|
|
|
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);}}
|
|
|
@@ -82,9 +134,9 @@ export class OperationsVocabularyComponent implements OnInit {
|
|
|
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))};}
|
|
|
+ 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('、'):'';}
|
|
|
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 nodePath(node:VocabularyNode|null):string{if(!node)return '全部词库';const byId=new Map(this.tree().map((item)=>[item.nodeId,item])),names:string[]=[];let current:VocabularyNode|undefined=node;while(current){names.unshift(current.name);current=byId.get(current.parentId);}return names.join(' / ');}
|
|
|
private errorText(error:unknown,fallback:string):string{return error instanceof Error?error.message:fallback;}
|
|
|
}
|