Przeglądaj źródła

fix(admin): restore full vocabulary tree and audio playback

彭峰 2 miesięcy temu
rodzic
commit
c841092ff4

Plik diff jest za duży
+ 1 - 1
scripts/deploy-admin-functions.mjs


+ 6 - 1
scripts/smoke-operations-functions.mjs

@@ -93,9 +93,14 @@ try {
   if (!Array.isArray(payrolls.items) || typeof payrolls.total !== 'number') throw new Error('工资批次未返回统一分页结构');
 
   const vocabularyTree = (await ops({ operation: 'ops/vocabulary/tree' })).data;
-  if (!Array.isArray(vocabularyTree.items) || !vocabularyTree.items.some((item) => item.canContainWords) || !vocabularyTree.refreshedAt) throw new Error('词库教材单元树结构无效');
+  if (!Array.isArray(vocabularyTree.items) || !vocabularyTree.items.some((item) => item.canContainWords) || !vocabularyTree.refreshedAt || vocabularyTree.totalWords < 80000) throw new Error('词库教材单元树结构无效');
   const vocabulary = (await ops({ operation: 'ops/vocabulary/list', page: 1, pageSize: 5, search: '', filters: {} })).data;
   if (!Array.isArray(vocabulary.items) || vocabulary.total < 80000 || vocabulary.items.some((item) => !item.targetId || !item.word)) throw new Error(`词库业务投影数量或结构异常:${vocabulary.total}`);
+  if (vocabularyTree.totalWords !== vocabulary.total) throw new Error(`词库目录总量与列表总量不一致:tree=${vocabularyTree.totalWords}, list=${vocabulary.total}`);
+  const vocabularyParent = vocabularyTree.items.find((item) => item.childCount > 0 && item.wordCount > 0);
+  if (!vocabularyParent || typeof vocabularyParent.directWordCount !== 'number') throw new Error('词库目录缺少父级汇总统计');
+  const vocabularyScope = (await ops({ operation: 'ops/vocabulary/list', page: 1, pageSize: 5, search: '', filters: { nodeId: vocabularyParent.nodeId } })).data;
+  if (vocabularyScope.total !== vocabularyParent.wordCount) throw new Error(`词库父级未包含子目录词条:node=${vocabularyParent.nodeId}, tree=${vocabularyParent.wordCount}, list=${vocabularyScope.total}`);
   const specialTraining = (await ops({ operation: 'ops/special-training/list', page: 1, pageSize: 20, search: '', filters: {} })).data;
   if (!Array.isArray(specialTraining.items) || specialTraining.total < 6 || specialTraining.items.some((item) => !item.targetId || !item.title)) throw new Error(`专项训练业务投影数量或结构异常:${specialTraining.total}`);
   const specialDetail = (await ops({ operation: 'ops/special-training/detail', targetId: specialTraining.items[0].targetId })).data;

+ 3 - 0
src/app/admin/admin.models.ts

@@ -405,7 +405,9 @@ export interface VocabularyNode extends Record<string, unknown> {
   parentId: number;
   depth: number;
   orderId: number;
+  directWordCount: number;
   wordCount: number;
+  childCount: number;
   status: number;
   canContainWords: boolean;
 }
@@ -433,6 +435,7 @@ export interface VocabularyWord extends Record<string, unknown> {
 export interface VocabularyTree {
   items: VocabularyNode[];
   total: number;
+  totalWords: number;
   refreshedAt: string;
 }
 

Plik diff jest za duży
+ 1 - 3
src/app/admin/pages/operations-vocabulary.component.html


+ 7 - 5
src/app/admin/pages/operations-vocabulary.component.scss

@@ -1,13 +1,15 @@
 :host{display:block}.page-header{gap:20px}.file-button{position:relative;cursor:pointer}.file-button input{position:absolute;width:1px;height:1px;overflow:hidden;opacity:0}
-.vocabulary-layout{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.vocabulary-main{min-width:0}
+.vocabulary-layout{display:grid;grid-template-columns:310px minmax(0,1fr);gap:18px}.vocabulary-main{min-width:0}
 .tree-card{overflow:hidden;max-height:calc(100vh - 190px);border:1px solid #e2e9e6;border-radius:18px;background:#fff;box-shadow:0 10px 30px rgba(29,62,51,.05)}
 .tree-card>header{display:flex;align-items:center;justify-content:space-between;padding:15px 16px;border-bottom:1px solid #edf1ef}.tree-card>header div{display:flex;align-items:center;gap:8px}
+.all-words{display:flex;align-items:center;justify-content:space-between;width:calc(100% - 20px);margin:12px 10px 4px;padding:12px 13px;border:1px solid #dce7e2;border-radius:12px;color:#385249;background:#f8fbfa;text-align:left}.all-words:hover,.all-words.active{border-color:#9fcabb;background:#e8f5f0;color:#12664f}.all-words span{display:grid;gap:2px}.all-words small{color:#82938d;font-size:10px;font-weight:500}.all-words>b{font-size:15px}
 .tree-search{display:flex;align-items:center;gap:7px;margin:10px 10px 2px;padding:0 10px;border:1px solid #dce5e1;border-radius:10px;color:#76867f}.tree-search input{width:100%;min-height:36px;border:0;outline:0;background:transparent}
-.tree-list{overflow:auto;max-height:calc(100vh - 310px);padding:8px}.tree-list>button{display:grid;grid-template-columns:14px minmax(0,1fr) auto auto;align-items:center;width:100%;min-height:40px;padding:8px 9px 8px calc(8px + var(--depth)*9px);border:0;border-radius:10px;color:#3d5049;background:transparent;text-align:left}.tree-list>button:hover{background:#f0f6f3}.tree-list>button.active{color:#176c55;background:#e8f4f0;font-weight:800}.tree-list>button.disabled{color:#76857f}.tree-list b{display:block;color:#82918c;font-size:20px;font-weight:500;line-height:1;transform-origin:center;transition:transform .16s}.tree-list b.open{transform:rotate(90deg)}.tree-list small{color:#8a9893;font-size:10px}.tree-list i{display:grid;place-items:center;width:24px;height:24px;border-radius:7px}.tree-list i:hover{background:#d9ebe4}
+.tree-hint{margin:8px 13px 0;color:#8b9994;font-size:10px}.tree-list{overflow:auto;max-height:calc(100vh - 385px);padding:8px}.tree-list>button{display:grid;grid-template-columns:14px minmax(0,1fr) 48px 24px;align-items:center;width:100%;min-height:40px;padding:7px 7px 7px calc(8px + var(--depth)*12px);border:0;border-radius:10px;color:#3d5049;background:transparent;text-align:left}.tree-list>button:hover{background:#f0f6f3}.tree-list>button.active{color:#176c55;background:#e8f4f0;font-weight:800}.tree-list>button span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-list b{display:block;color:#82918c;font-size:20px;font-weight:500;line-height:1;transform-origin:center;transition:transform .16s}.tree-list b.open{transform:rotate(90deg)}.tree-list small{color:#788a84;font-size:10px;text-align:right}.tree-list i{display:grid;place-items:center;width:24px;height:24px;border-radius:7px}.tree-list i:hover{background:#d9ebe4}
+.selection-summary{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:14px;padding:15px 18px;border:1px solid #dfe8e4;border-radius:16px;background:linear-gradient(135deg,#f4faf7,#fff)}.selection-summary>div{display:grid;gap:3px}.selection-summary span,.selection-summary small{color:#788a84;font-size:11px}.selection-summary strong{color:#28453b}.selection-summary>b{color:#157158;font-size:28px}
 .toolbar .check{display:flex;min-height:42px;align-items:center;gap:8px;padding:0 10px;border:1px solid #d7e1dd;border-radius:12px}.toolbar .check input{min-height:0;width:16px;height:16px;padding:0}.content-card>header>div{display:flex;align-items:center;gap:10px}.content-card>header span,.content-card>header small{color:#84938e}
-.data-table{min-width:1150px}.data-table .example{max-width:260px;color:#5c6c67}.audio-button{display:inline-flex;align-items:center;gap:5px;padding:5px 8px;border:1px solid #cfe1da;border-radius:9px;color:#176c55;background:#f3faf7}.missing{color:#a66924}
+.data-table{min-width:1150px}.data-table .example{max-width:260px;color:#5c6c67}.audio-button{display:inline-flex;align-items:center;gap:5px;padding:5px 8px;border:1px solid #cfe1da;border-radius:9px;color:#176c55;background:#f3faf7}.audio-button.playing{color:#fff;background:#16745a}.missing{color:#a66924}
 .media-input{display:flex;gap:8px}.media-input>input{flex:1}.import-drawer{width:min(760px,100%)}.small-drawer{width:min(480px,100%)}.reason-field{display:grid;gap:7px;color:#5d6f68;font-size:12px;font-weight:700}.reason-field textarea{padding:10px 12px;border:1px solid #d7e1dd;border-radius:12px}
 .import-summary{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:18px 0}.import-summary article{display:grid;gap:4px;padding:13px;border-radius:13px;background:#f3f6f5}.import-summary article.green{background:#eaf6f1}.import-summary article.blue{background:#edf4fb}.import-summary article.red{background:#fff0ed}.import-summary span{color:#76857f;font-size:11px}.import-summary strong{font-size:23px}
 .preview-table{overflow:auto;max-height:430px;border:1px solid #e2e9e6;border-radius:13px}.preview-table>div{display:grid;grid-template-columns:1fr auto;gap:3px 10px;padding:10px 12px;border-bottom:1px solid #edf1ef}.preview-table b{color:#29715b;font-size:11px}.preview-table small{grid-column:1/-1;color:#a33c2c}.error-text{color:#b94136!important}.import-guide{display:grid;place-items:center;gap:8px;min-height:260px;color:#6d7c77;text-align:center}.muted-text{color:#81908b;font-size:12px}
-@media(max-width:1050px){.vocabulary-layout{grid-template-columns:220px minmax(0,1fr)}}
-@media(max-width:760px){.header-actions{width:100%}.vocabulary-layout{grid-template-columns:1fr}.tree-card{max-height:280px}.tree-list{max-height:165px}.toolbar>*{flex:1 1 145px}.toolbar .grow{flex-basis:100%}.import-summary{grid-template-columns:repeat(2,1fr)}.media-input{align-items:stretch;flex-direction:column}}
+@media(max-width:1050px){.vocabulary-layout{grid-template-columns:250px minmax(0,1fr)}}
+@media(max-width:760px){.header-actions{width:100%}.vocabulary-layout{grid-template-columns:1fr}.tree-card{max-height:400px}.tree-list{max-height:220px}.selection-summary{padding:13px 15px}.toolbar>*{flex:1 1 145px}.toolbar .grow{flex-basis:100%}.pagination{flex-wrap:wrap;gap:10px}.pagination label{order:3;width:100%}.import-summary{grid-template-columns:repeat(2,1fr)}.media-input{align-items:stretch;flex-direction:column}}

+ 40 - 0
src/app/admin/pages/operations-vocabulary.component.spec.ts

@@ -0,0 +1,40 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ApiService } from '../../core/api.service';
+import { OperationsService } from '../operations.service';
+import { OperationsVocabularyComponent } from './operations-vocabulary.component';
+
+describe('OperationsVocabularyComponent', () => {
+  let fixture: ComponentFixture<OperationsVocabularyComponent>;
+  let component: OperationsVocabularyComponent;
+  let operations: jasmine.SpyObj<OperationsService>;
+
+  beforeEach(async () => {
+    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['vocabularyTree', 'vocabulary']);
+    operations.vocabularyTree.and.resolveTo({ items: [], total: 0, totalWords: 89620, refreshedAt: '2026-08-22T12:00:00.000Z' });
+    operations.vocabulary.and.resolveTo({ page: 1, pageSize: 30, total: 89620, items: [], refreshedAt: '2026-08-22T12:00:00.000Z' });
+    await TestBed.configureTestingModule({
+      imports: [OperationsVocabularyComponent],
+      providers: [
+        { provide: OperationsService, useValue: operations },
+        { provide: ApiService, useValue: { assetUrl: (path: string) => `https://legacy.example${path}` } },
+      ],
+    }).compileComponents();
+    fixture = TestBed.createComponent(OperationsVocabularyComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+    await fixture.whenStable();
+  });
+
+  it('loads the complete vocabulary by default instead of one arbitrary unit', () => {
+    expect(component.totalWords()).toBe(89620);
+    expect(component.selectedNodeId()).toBe(0);
+    expect(operations.vocabulary).toHaveBeenCalledWith(1, 30, '', { nodeId: 0, status: '', missingAudio: false });
+  });
+
+  it('normalizes old audio filenames and UploadFiles paths through the legacy asset host', () => {
+    expect(component.audioSource('apple.mp3')).toBe('https://legacy.example/UploadFiles/apple.mp3');
+    expect(component.audioSource('UploadFiles/unit/apple.mp3')).toBe('https://legacy.example/UploadFiles/unit/apple.mp3');
+    expect(component.audioSource('/UploadFiles/unit/apple.mp3')).toBe('https://legacy.example/UploadFiles/unit/apple.mp3');
+    expect(component.audioSource('https://cdn.example/apple.mp3')).toBe('https://cdn.example/apple.mp3');
+  });
+});

+ 62 - 10
src/app/admin/pages/operations-vocabulary.component.ts

@@ -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;}
 }

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików