Просмотр исходного кода

fix(vocabulary): clarify import template and resolve target units

彭峰 8 часов назад
Родитель
Сommit
a8c9dc0bb5

+ 2 - 2
projects/xiaoshu-admin/src/app/operations.service.ts

@@ -188,8 +188,8 @@ export class OperationsService {
     return this.functions.operations<SpecialTrainingPage>('ops/special-training/list', { page, pageSize, search, filters, refresh });
   }
 
-  previewVocabularyImport(fileName: string, rows: Record<string, unknown>[], reason: string): Promise<VocabularyImportPreview> {
-    return this.functions.operations<VocabularyImportPreview>('ops/vocabulary/import-preview', { reason, idempotencyKey: crypto.randomUUID(), payload: { fileName, rows } });
+  previewVocabularyImport(fileName: string, rows: Record<string, unknown>[], reason: string, targetNodeId = 0): Promise<VocabularyImportPreview> {
+    return this.functions.operations<VocabularyImportPreview>('ops/vocabulary/import-preview', { reason, idempotencyKey: crypto.randomUUID(), payload: { fileName, rows, ...(targetNodeId ? { targetNodeId } : {}) } });
   }
 
   commitVocabularyImport(batchId: string, reason: string): Promise<Record<string, unknown>> {

+ 7 - 3
projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.html

@@ -10,11 +10,15 @@
 @if(importOpen()){
   <div class="backdrop" (click)="importOpen.set(false)"></div><aside class="drawer import-drawer" appAccessibleDialog (dialogEscape)="importOpen.set(false)" aria-label="导入词库" role="dialog" aria-modal="true">
     <header><div><p>EXCEL IMPORT</p><h2>词库批量导入</h2><span>{{importFileName()}} · {{importRows().length}} 行</span></div><button aria-label="关闭导入" class="icon-action" type="button" (click)="importOpen.set(false)"><lucide-icon [img]="icons.X" [size]="19"/></button></header>
-    @if(error()){<p class="notice error">{{error()}}</p>}<label class="reason-field"><span>导入原因 *</span><textarea rows="3" [ngModel]="reason()" (ngModelChange)="reason.set($event)" placeholder="例如:2026 秋季教材词库更新"></textarea></label>
+    @if(error()){<p class="notice error">{{error()}}</p>}
+    <p class="notice">填写前请阅读下载模板中的“填写说明”,从“词库目录”复制真实单元ID。新教材、单元请先通过“新增分类”创建。</p>
+    <label class="reason-field"><span>目标单元</span><select [ngModel]="importTargetNodeId()" (ngModelChange)="changeImportTarget(+$event)" [disabled]="saving()"><option [ngValue]="0">按表格中的单元ID或学段 / 教材 / 单元匹配</option>@for(target of importTargets();track target.nodeId){<option [ngValue]="target.nodeId">{{target.label}}</option>}</select></label>
+    @if(importTargetNodeId()){<p class="notice">整份文件将导入上方选中的单元,覆盖表格中的目录信息。更换目标后需重新预检。</p>}
+    <label class="reason-field"><span>导入原因 *</span><textarea rows="3" [ngModel]="reason()" (ngModelChange)="reason.set($event)" placeholder="例如:2026 秋季教材词库更新"></textarea></label>
     @if(preview();as result){
       <div class="import-summary"><article><span>总计</span><strong>{{result.summary.total}}</strong></article><article class="green"><span>新增</span><strong>{{result.summary.create}}</strong></article><article class="blue"><span>更新</span><strong>{{result.summary.update}}</strong></article><article><span>无变化</span><strong>{{result.summary.unchanged}}</strong></article><article><span>媒体缺失</span><strong>{{result.summary.missingMedia}}</strong></article><article [class.red]="result.summary.error"><span>阻断错误 / 重复</span><strong>{{result.summary.error}} / {{result.summary.duplicate}}</strong></article></div>
-      <div class="preview-table">@for(item of result.items.slice(0,100);track item['row']){<div><span>第 {{item['row']}} 行 · {{item['word']}}</span><b [class.error-text]="item['action']==='error'">{{item['action']==='create'?'新增':item['action']==='update'?'更新':item['action']==='unchanged'?'无变化':'错误'}}</b><small>{{importErrors(item)}}</small></div>}</div>
-      @if(result.items.length>100){<p class="muted-text">仅展示前 100 行,提交时会处理全部数据。</p>}
+      <div class="preview-table">@for(item of importPreviewItems();track item['row']){<div><span>第 {{item['row']}} 行 · {{item['word']}}</span><b [class.error-text]="item['action']==='error'">{{item['action']==='create'?'新增':item['action']==='update'?'更新':item['action']==='unchanged'?'无变化':'错误'}}</b><small>{{importErrors(item)}}</small></div>}</div>
+      @if(result.items.length>100){<p class="muted-text">优先展示有错误的行,最多显示 100 行;提交时会处理全部数据。</p>}
     }@else{<div class="import-guide"><lucide-icon [img]="icons.FileSpreadsheet" [size]="32"/><strong>先执行预检,不会直接写入数据库</strong><span>系统会匹配 GeneralID 或“单元 + 单词”,并检查重复与缺失字段。</span></div>}
     <footer><button class="ui-button secondary" type="button" (click)="previewImport()" [disabled]="saving()">{{preview()?'重新预检':'开始预检'}}</button><button class="ui-button primary" type="button" (click)="commitImport()" [disabled]="saving()||!preview()||!!preview()?.summary?.error">确认发布</button></footer>
   </aside>

+ 27 - 1
projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.spec.ts

@@ -9,7 +9,7 @@ describe('OperationsVocabularyComponent', () => {
   let operations: jasmine.SpyObj<OperationsService>;
 
   beforeEach(async () => {
-    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['vocabularyTree', 'vocabulary']);
+    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['vocabularyTree', 'vocabulary', 'previewVocabularyImport']);
     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({
@@ -52,4 +52,30 @@ describe('OperationsVocabularyComponent', () => {
     const url = 'https://a018.2018.z01.com/UploadFiles/audio/c/cherish.mp3';
     expect(component.audioSource(url)).toBe(url);
   });
+
+  it('requires a fresh preview when the target changes and sends the explicit target', async () => {
+    const preview = { batchId: 'old', summary: { error: 0 }, items: [] } as any;
+    component.preview.set(preview); component.changeImportTarget(42);
+    expect(component.preview()).toBeNull();
+    component.reason.set('导入词库'); component.importFileName.set('words.xlsx');
+    component.importRows.set([{ 单词: 'time' }]);
+    operations.previewVocabularyImport.and.resolveTo(preview);
+    await component.previewImport();
+    expect(operations.previewVocabularyImport).toHaveBeenCalledWith('words.xlsx', [{ 单词: 'time' }], '导入词库', 42);
+  });
+
+  it('discards stale successful previews when a retry fails', async () => {
+    component.preview.set({ batchId: 'old', summary: { error: 0 }, items: [] } as any);
+    component.reason.set('导入词库');
+    operations.previewVocabularyImport.and.rejectWith(new Error('预检失败'));
+    await component.previewImport();
+    expect(component.preview()).toBeNull();
+    expect(component.error()).toBe('预检失败');
+  });
+
+  it('shows error rows beyond the first hundred before successful rows', () => {
+    const items = [...Array.from({ length: 110 }, (_, i) => ({ row: i + 2, action: 'create' })), { row: 112, action: 'error', errors: ['重复'] }];
+    component.preview.set({ items } as any);
+    expect(component.importPreviewItems()[0]['row']).toBe(112);
+  });
 });

+ 11 - 5
projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.ts

@@ -1,5 +1,6 @@
 import { AccessibleDialogDirective } from '../shared/accessible-dialog.directive';
 import { readBoundedWorkbook } from '../shared/bounded-workbook';
+import { vocabularyImportTemplate, vocabularyNodePath } from '../shared/vocabulary-import-template';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 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 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 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 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 selectedNode = computed(() => this.tree().find((node) => node.nodeId === this.selectedNodeId()) || null);
   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 chooseWorkbook(event: Event): Promise<void> {
     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 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)))};}
   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:''};}

+ 30 - 0
projects/xiaoshu-admin/src/app/shared/vocabulary-import-template.spec.ts

@@ -0,0 +1,30 @@
+import { utils, write } from 'xlsx';
+import { VocabularyNode } from '../admin.models';
+import { readBoundedWorkbook } from './bounded-workbook';
+import { vocabularyImportTemplate } from './vocabulary-import-template';
+
+describe('Vocabulary import template', () => {
+  const nodes = [
+    { nodeId: 1, parentId: 0, name: '小学' },
+    { nodeId: 2, parentId: 1, name: '真实教材' },
+    { nodeId: 42, parentId: 2, name: '第一单元', canContainWords: true },
+  ] as VocabularyNode[];
+  it('ships explicit instructions and real IDs without importing demonstration words', async () => {
+    const book = await vocabularyImportTemplate(nodes, 42);
+    expect(book.SheetNames).toEqual(['词库导入', '填写说明', '词库目录']);
+    expect(utils.sheet_to_json(book.Sheets['词库导入'])).toEqual([]);
+    const guide = utils.sheet_to_csv(book.Sheets['填写说明']);
+    expect(guide).toContain('新教材或新单元'); expect(guide).toContain('单词必填'); expect(guide).toContain('最大5 MB');
+    const catalog = utils.sheet_to_json<Record<string, unknown>>(book.Sheets['词库目录']);
+    expect(catalog[0]['单元ID']).toBe(42);
+    expect(catalog[0]['完整目录路径']).toBe('小学 / 真实教材 / 第一单元');
+  });
+  it('round trips a filled downloaded workbook through the real upload parser', async () => {
+    const book = await vocabularyImportTemplate(nodes);
+    utils.sheet_add_aoa(book.Sheets['词库导入'], [['', 42, '', '', '', 'time', 'n. 时间', '/taɪm/', 'What time will we go?', '']], { origin: 'A2' });
+    const file = new File([write(book, { type: 'array', bookType: 'xlsx' })], '小树陪练词库导入模板.xlsx');
+    const rows = await readBoundedWorkbook(file, 2000);
+    expect(rows.length).toBe(1); expect(rows[0]['单元ID']).toBe('42'); expect(rows[0]['单词']).toBe('time');
+    expect(rows[0]['GeneralID(可选)']).toBe('');
+  });
+});

+ 55 - 0
projects/xiaoshu-admin/src/app/shared/vocabulary-import-template.ts

@@ -0,0 +1,55 @@
+import type { VocabularyNode } from '../admin.models';
+
+export function vocabularyNodePath(nodes: VocabularyNode[], node: VocabularyNode): string {
+  const byId = new Map(nodes.map(item => [item.nodeId, item]));
+  const names: string[] = [], visited = new Set<number>();
+  let current: VocabularyNode | undefined = node;
+  while (current && !visited.has(current.nodeId)) {
+    visited.add(current.nodeId); names.unshift(current.name); current = byId.get(current.parentId);
+  }
+  return names.join(' / ');
+}
+
+export async function vocabularyImportTemplate(nodes: VocabularyNode[], selectedNodeId = 0) {
+  const { utils } = await import('xlsx');
+  const book = utils.book_new();
+  const selected = nodes.find(node => node.nodeId === selectedNodeId && node.canContainWords);
+  const headers = ['GeneralID(可选)', '单元ID', '学段', '教材', '单元', '单词', '释义', '音标', '例句', '音频文件名或URL'];
+  const data = utils.aoa_to_sheet([headers]);
+  data['!cols'] = [22, 14, 20, 30, 25, 24, 36, 24, 55, 55].map(wch => ({ wch }));
+  data['F1'].c = [{ a: '小树陪练', t: '必填。从第2行起每行填写一个单词。请先阅读“填写说明”。' }];
+  data['B1'].c = [{ a: '小树陪练', t: '从“词库目录”复制真实单元ID;填写后无需再填学段、教材、单元。也可在上传页面明确选择目标单元。' }];
+  utils.book_append_sheet(book, data, '词库导入');
+  const instructions = [
+    ['词库导入填写说明', '请在第一个工作表“词库导入”中填写词汇,其他工作表不会导入。'],
+    ['1. 填写位置', '保留第1行表头,从第2行起填写,每行一个单词。空白模板不能导入。'],
+    ['2. 推荐指定目录', '从“词库目录”复制目标单元的“单元ID”到每行;填写单元ID后,学段、教材、单元可以留空。'],
+    ['3. 全部导入同一单元', '上传后在页面选择“目标单元”。该选择会将整份文件导入此单元,覆盖表格中的目录和单元ID。'],
+    ['4. 按名称匹配目录', '未填写单元ID且页面未指定目标时,学段、教材、单元三列必须与“词库目录”完全一致;重名时请使用单元ID。'],
+    ['5. 新教材或新单元', '请先在词库管理中通过“新增分类”创建实际目录,再重新下载模板获取单元ID;导入不会自动创建目录。'],
+    ['6. 必填与可选', '单词必填;释义、音标、例句、音频可选。缺音频只会提醒,不阻止导入。'],
+    ['7. GeneralID(可选)', '新增单词留空。更新已有单词时可填导出的GeneralID;不填则按“单元ID+单词”匹配更新。'],
+    ['8. 重复与覆盖', '同一单元内相同单词不能在文件中重复。预检会指出重复行;更新已有单词时,留空的释义、音标、例句、音频会清空原值。'],
+    ['9. 文件限制', '仅支持.xlsx,最大5 MB,每次最多2000行词汇。不要修改表头,不要合并单元格。'],
+    ['10. 提交步骤', '填写并保存 → Excel导入 → 确认目标单元 → 开始预检 → 检查新增、更新及错误 → 确认发布。'],
+    ['当前选中目录', selected ? vocabularyNodePath(nodes, selected) : '未选择。请在“词库目录”查找目标,或在上传页面选择。'],
+    ['当前选中单元ID', selected?.nodeId ?? ''],
+    ['填写示例(仅说明,不导入)', '以下示例的单元ID请替换为“词库目录”中的真实ID。'],
+    headers,
+    ['', selected?.nodeId ?? '复制真实单元ID', '', '', '', 'time', 'n. 时间', '/taɪm/', 'What time will we go?', ''],
+  ];
+  const guide = utils.aoa_to_sheet(instructions);
+  guide['!cols'] = [{ wch: 27 }, { wch: 110 }, ...Array.from({ length: 8 }, () => ({ wch: 25 }))];
+  utils.book_append_sheet(book, guide, '填写说明');
+  const byId = new Map(nodes.map(node => [node.nodeId, node]));
+  const catalog = utils.aoa_to_sheet([
+    ['单元ID', '完整目录路径', '学段', '教材', '单元'],
+    ...nodes.filter(node => node.canContainWords).map(node => {
+      const parent = byId.get(node.parentId), grandparent = parent && byId.get(parent.parentId);
+      return [node.nodeId, vocabularyNodePath(nodes, node), grandparent?.name || '', parent?.name || '', node.name];
+    }),
+  ]);
+  catalog['!cols'] = [{ wch: 14 }, { wch: 85 }, { wch: 25 }, { wch: 35 }, { wch: 30 }];
+  utils.book_append_sheet(book, catalog, '词库目录');
+  return book;
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
scripts/deploy-admin-functions.mjs


+ 55 - 0
scripts/tests/vocabulary-import.test.mjs

@@ -0,0 +1,55 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+
+const source = readFileSync(new URL('../deploy-admin-functions.mjs', import.meta.url), 'utf8');
+const functions = source.slice(source.indexOf('function importCell('), source.indexOf('async function commitVocabularyImport('));
+function importer(nodes = [{ nodeId: 42, nodeName: '第一单元', bookName: '真实教材', stageName: '小学' }], existing = null) {
+  const queries = [];
+  class Batch { constructor() { this.id = 'batch'; this.createdAt = new Date(); } set() {} async save() {} }
+  const context = vm.createContext({
+    Parse: { Object: Batch }, Psql: { query: async () => nodes, oneOrNone: async (sql, args) => { queries.push({ sql, args }); return existing; } },
+    text: value => String(value ?? '').trim(), number: value => Number(value) || 0,
+    requireRole() {}, reasonOf: () => 'test', companyIdOf: () => 'tenant', companyPointer: () => ({}),
+    fail: (_status, message) => { throw new Error(message); }, randomObjectId: () => 'random', audit: async () => {},
+    dateIso: value => value.toISOString(), legacyVocabularyMedia: value => String(value ?? '').replace('https://a018.2018.z01.com/UploadFiles/', ''),
+  });
+  vm.runInContext(functions, context);
+  return { queries, run: async (rows, targetNodeId) => JSON.parse(JSON.stringify(await context.previewVocabularyImport({ current: { id: 'operator' } }, {}, { rows, ...(targetNodeId === undefined ? {} : { targetNodeId }) }))) };
+}
+
+test('old template headers preserve GeneralID and exported audio URLs', async () => {
+  const { run, queries } = importer(undefined, { objectId: 'word', title: 'time', nodeId: 42, sy: '时间', yp: 'time.mp3' });
+  const result = await run([{ ' GeneralID(可选) ': '123', 单元ID: '42', 单词: 'time', 释义: '时间', 音频URL: 'https://a018.2018.z01.com/UploadFiles/time.mp3' }]);
+  assert.equal(result.summary.unchanged, 1);
+  assert.equal(queries[0].args[1], '123');
+  assert.match(queries[0].sql, /generalId/);
+});
+test('old sample paths fail with actionable guidance, explicit chosen target resolves them', async () => {
+  const { run } = importer(), rows = [{ 学段: '小学', 教材: '示例教材', 单元: '第一单元', 单词: 'time' }];
+  const invalid = await run(rows);
+  assert.equal(invalid.summary.error, 1); assert.match(invalid.items[0].errors[0], /示例教材.*单元ID.*页面选择/);
+  const valid = await run(rows, 42);
+  assert.equal(valid.summary.create, 1); assert.equal(valid.items[0].nodeId, 42); assert.equal(valid.summary.missingMedia, 1);
+});
+test('explicit invalid, recycled or cross-tenant IDs never fall back to matching names', async () => {
+  const { run } = importer();
+  const result = await run([{ 单元ID: '999', 学段: '小学', 教材: '真实教材', 单元: '第一单元', 单词: 'time' }]);
+  assert.equal(result.summary.error, 1); assert.match(result.items[0].errors[0], /单元ID不存在/);
+  await assert.rejects(run([{ 单词: 'time' }], 999), /有效的目标/);
+});
+test('duplicate names require ID rather than choosing the last matching directory', async () => {
+  const { run } = importer([42, 43].map(nodeId => ({ nodeId, nodeName: '第一单元', bookName: '真实教材', stageName: '小学' })));
+  assert.match((await run([{ 学段: '小学', 教材: '真实教材', 单元: '第一单元', 单词: 'time' }])).items[0].errors[0], /不唯一/);
+});
+test('duplicate rows identify the original Excel row; different units remain separate', async () => {
+  const { run } = importer([42, 43].map(nodeId => ({ nodeId })));
+  const result = await run([{ 单元ID: 42, 单词: 'time' }, { 单元ID: 42, 单词: 'TIME' }, { 单元ID: 43, 单词: 'time' }]);
+  assert.equal(result.summary.duplicate, 1); assert.equal(result.summary.create, 2);
+  assert.match(result.items[1].errors[0], /第 2 行/);
+});
+test('unknown GeneralID blocks import rather than creating an unexpected word', async () => {
+  const result = await importer().run([{ 'GeneralID(可选)': 999, 单元ID: 42, 单词: 'time' }]);
+  assert.equal(result.summary.error, 1); assert.match(result.items[0].errors[0], /GeneralID.*不存在/);
+});

Некоторые файлы не были показаны из-за большого количества измененных файлов