Bladeren bron

feat(admin): add vocabulary and special training operations

彭峰 2 maanden geleden
bovenliggende
commit
6500097363

+ 104 - 0
package-lock.json

@@ -21,6 +21,7 @@
         "mustache": "^4.2.0",
         "rxjs": "~7.8.0",
         "tslib": "^2.3.0",
+        "xlsx": "^0.18.5",
         "zone.js": "~0.14.10"
       },
       "devDependencies": {
@@ -5196,6 +5197,15 @@
         "node": ">=8.9.0"
       }
     },
+    "node_modules/adler-32": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
+      "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/agent-base": {
       "version": "7.1.4",
       "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz",
@@ -5927,6 +5937,19 @@
       "license": "MIT",
       "optional": true
     },
+    "node_modules/cfb": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
+      "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "adler-32": "~1.3.0",
+        "crc-32": "~1.2.0"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/chalk": {
       "version": "4.1.2",
       "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
@@ -6178,6 +6201,15 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/codepage": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
+      "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/color-convert": {
       "version": "2.0.1",
       "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
@@ -6484,6 +6516,18 @@
         }
       }
     },
+    "node_modules/crc-32": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
+      "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+      "license": "Apache-2.0",
+      "bin": {
+        "crc32": "bin/crc32.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/critters": {
       "version": "0.0.24",
       "resolved": "https://registry.npmmirror.com/critters/-/critters-0.0.24.tgz",
@@ -7672,6 +7716,15 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/frac": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
+      "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/fraction.js": {
       "version": "4.3.7",
       "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -12803,6 +12856,18 @@
         "wbuf": "^1.7.3"
       }
     },
+    "node_modules/ssf": {
+      "version": "0.11.2",
+      "resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
+      "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "frac": "~1.1.2"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/ssri": {
       "version": "10.0.6",
       "resolved": "https://registry.npmmirror.com/ssri/-/ssri-10.0.6.tgz",
@@ -14484,6 +14549,24 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/wmf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
+      "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/word": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
+      "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/wrap-ansi": {
       "version": "6.2.0",
       "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
@@ -14611,6 +14694,27 @@
         }
       }
     },
+    "node_modules/xlsx": {
+      "version": "0.18.5",
+      "resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
+      "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "adler-32": "~1.3.0",
+        "cfb": "~1.2.1",
+        "codepage": "~1.15.0",
+        "crc-32": "~1.2.1",
+        "ssf": "~0.11.2",
+        "wmf": "~1.0.1",
+        "word": "~0.3.0"
+      },
+      "bin": {
+        "xlsx": "bin/xlsx.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/y18n": {
       "version": "5.0.8",
       "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",

+ 1 - 0
package.json

@@ -34,6 +34,7 @@
     "mustache": "^4.2.0",
     "rxjs": "~7.8.0",
     "tslib": "^2.3.0",
+    "xlsx": "^0.18.5",
     "zone.js": "~0.14.10"
   },
   "devDependencies": {

File diff suppressed because it is too large
+ 8 - 2
scripts/deploy-admin-functions.mjs


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

@@ -92,6 +92,15 @@ try {
   const payrolls = (await ops({ operation: 'ops/payroll/list', page: 1, pageSize: 20 })).data;
   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('词库教材单元树结构无效');
+  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}`);
+  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;
+  if (!specialDetail.title || !('contentHtml' in specialDetail) || !('synopsis' in specialDetail) || !('videoUrl' in specialDetail)) throw new Error('专项训练详情未正确联查 ContentArticle');
+
   for (const operation of ['ops/relations/list', 'ops/course-bindings/list', 'ops/appointments/list', 'ops/lessons/list', 'ops/learning-records/list', 'ops/finance/list', 'ops/money-logs/list']) {
     const page = (await ops({ operation, page: 1, pageSize: 5, search: '' })).data;
     if (!Array.isArray(page.items) || typeof page.total !== 'number') throw new Error(`${operation} 未返回统一分页结构`);
@@ -99,7 +108,7 @@ try {
 
   const denied = await call('xiaoshu/admin/gateway', token, { operation: 'catalog' }, 403);
   if (denied.success !== false) throw new Error('普通运营管理员仍可访问技术数据目录');
-  console.log(`Operations smoke passed: users=${users.total}, newestMember=${users.items[0]?.displayName || 'none'}@${users.items[0]?.registeredAt || 'none'}, coaches=${coaches.total}, activeCoaches=${activeCoaches.total}, dashboardCoaches=${dashboardCoachMetric.value}, liveCoachAppointments=${coaches.liveSourceCount}, appointments=${schedule.totalAppointments}, latest=${latestSchedule.appointments.length}, identityConflicts=${identityConflicts.total}, conflictNames=${identityConflicts.items.map((item) => item.displayName).join('|') || 'none'}, metrics=${dashboard.metrics.length}, learning=${JSON.stringify(learningCounts)}.`);
+  console.log(`Operations smoke passed: users=${users.total}, newestMember=${users.items[0]?.displayName || 'none'}@${users.items[0]?.registeredAt || 'none'}, coaches=${coaches.total}, activeCoaches=${activeCoaches.total}, dashboardCoaches=${dashboardCoachMetric.value}, liveCoachAppointments=${coaches.liveSourceCount}, appointments=${schedule.totalAppointments}, latest=${latestSchedule.appointments.length}, vocabulary=${vocabulary.total}, specialTraining=${specialTraining.total}, identityConflicts=${identityConflicts.total}, conflictNames=${identityConflicts.items.map((item) => item.displayName).join('|') || 'none'}, metrics=${dashboard.metrics.length}, learning=${JSON.stringify(learningCounts)}.`);
 } finally {
   if (userId) {
     const sessions = await request(`${PARSE_URL}/classes/_Session?where=${encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: userId } }))}&limit=1000`, {}, true).catch(() => ({ results: [] }));

+ 4 - 0
src/app/admin/admin.constants.ts

@@ -20,6 +20,10 @@ export const OPERATIONS_NAVIGATION: readonly AdminNavigationGroup[] = [
     { label: '陪练老师', icon: 'graduation-cap', route: '/admin/coaches' },
     { label: '工资结算', icon: 'wallet-cards', route: '/admin/payroll' },
   ] },
+  { label: '内容运营', items: [
+    { label: '词库管理', icon: 'languages', route: '/admin/vocabulary' },
+    { label: '专项训练', icon: 'book-open', route: '/admin/special-training' },
+  ] },
   { label: '个人设置', items: [{ label: '修改密码', icon: 'key-round', route: '/admin/change-password' }] },
 ];
 

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

@@ -397,3 +397,79 @@ export interface PayrollDetail extends PayrollSummary {
   lines: PayrollLine[];
   exceptions: PayrollLine[];
 }
+
+export interface VocabularyNode extends Record<string, unknown> {
+  objectId: string;
+  nodeId: number;
+  name: string;
+  parentId: number;
+  depth: number;
+  orderId: number;
+  wordCount: number;
+  status: number;
+  canContainWords: boolean;
+}
+
+export interface VocabularyWord extends Record<string, unknown> {
+  targetId: string;
+  generalId: number;
+  itemId: number;
+  word: string;
+  meaning: string;
+  phonetic: string;
+  example: string;
+  audioUrl: string;
+  nodeId: number;
+  unitName: string;
+  bookName: string;
+  stageName: string;
+  orderId: number;
+  status: number;
+  statusLabel: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface VocabularyTree {
+  items: VocabularyNode[];
+  total: number;
+  refreshedAt: string;
+}
+
+export interface VocabularyImportPreview {
+  batchId: string;
+  status: string;
+  summary: { total: number; create: number; update: number; unchanged: number; duplicate: number; error: number; missingMedia: number };
+  items: Array<Record<string, unknown>>;
+  createdAt: string;
+}
+
+export interface SpecialTrainingItem extends Record<string, unknown> {
+  targetId: string;
+  generalId: number;
+  itemId: number;
+  title: string;
+  synopsis: string;
+  coverUrl: string;
+  tags: string[];
+  contentHtml: string;
+  videoUrl: string;
+  author: string;
+  source: string;
+  orderId: number;
+  hits: number;
+  status: number;
+  statusLabel: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface MediaUploadTicket {
+  ticketId: string;
+  method: 'PUT';
+  uploadUrl: string;
+  publicUrl: string;
+  objectKey: string;
+  headers: Record<string, string>;
+  expiresAt: string;
+}

+ 2 - 0
src/app/admin/admin.routes.ts

@@ -13,6 +13,8 @@ const operationsChildren: Routes = [
   { path: 'coaches', loadComponent: () => import('./pages/operations-coaches.component').then((module) => module.OperationsCoachesComponent) },
   { path: 'coaches/:objectId', loadComponent: () => import('./pages/operations-coach-detail.component').then((module) => module.OperationsCoachDetailComponent) },
   { path: 'payroll', loadComponent: () => import('./pages/operations-payroll.component').then((module) => module.OperationsPayrollComponent) },
+  { path: 'vocabulary', loadComponent: () => import('./pages/operations-vocabulary.component').then((module) => module.OperationsVocabularyComponent) },
+  { path: 'special-training', loadComponent: () => import('./pages/operations-special-training.component').then((module) => module.OperationsSpecialTrainingComponent) },
   { path: 'change-password', loadComponent: () => import('./pages/admin-change-password.component').then((module) => module.AdminChangePasswordComponent) },
   { path: 'forbidden', loadComponent: () => import('./pages/admin-forbidden.component').then((module) => module.AdminForbiddenComponent) },
 ];

+ 16 - 0
src/app/admin/operations.service.spec.ts

@@ -46,4 +46,20 @@ describe('OperationsService', () => {
       targetId: 'appointment-1', reason: '运营改期', payload: { coachId: 3 }, expectedUpdatedAt: '2026-08-20T08:00:00.000Z', idempotencyKey: 'stable-key'
     });
   });
+
+  it('loads vocabulary and special training through dedicated content operations', async () => {
+    functions.operations.and.resolveTo({ page: 1, pageSize: 30, total: 0, items: [] });
+    await service.vocabulary(1, 30, 'apple', { nodeId: 123, status: 99, missingAudio: false });
+    await service.specialTraining(1, 50, '自然拼读', { status: 99 });
+    expect(functions.operations.calls.argsFor(0)).toEqual(['ops/vocabulary/list', { page: 1, pageSize: 30, search: 'apple', filters: { nodeId: 123, status: 99, missingAudio: false } }]);
+    expect(functions.operations.calls.argsFor(1)).toEqual(['ops/special-training/list', { page: 1, pageSize: 50, search: '自然拼读', filters: { status: 99 } }]);
+  });
+
+  it('sends Excel rows to preview before committing an import batch', async () => {
+    spyOn(crypto, 'randomUUID').and.returnValue('00000000-0000-4000-8000-000000000001');
+    functions.operations.and.resolveTo({ batchId: 'batch-1', status: 'preview', summary: { total: 1, create: 1, update: 0, unchanged: 0, duplicate: 0, error: 0, missingMedia: 1 }, items: [], createdAt: '' });
+    const rows = [{ 学段: '小学', 教材: '人教版', 单元: '第一单元', 单词: 'apple' }];
+    await service.previewVocabularyImport('words.xlsx', rows, '秋季更新');
+    expect(functions.operations).toHaveBeenCalledWith('ops/vocabulary/import-preview', { reason: '秋季更新', idempotencyKey: '00000000-0000-4000-8000-000000000001', payload: { fileName: 'words.xlsx', rows } });
+  });
 });

+ 29 - 1
src/app/admin/operations.service.ts

@@ -1,5 +1,5 @@
 import { inject, Injectable } from '@angular/core';
-import { CoachAvailability, CoachDetailData, CoachPage, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod } from './admin.models';
+import { CoachAvailability, CoachDetailData, CoachPage, MediaUploadTicket, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingItem, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
 import { CloudFunctionsService } from './cloud-functions.service';
 
 export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs';
@@ -68,6 +68,34 @@ export class OperationsService {
     return this.functions.operations<PayrollDetail>('ops/payroll/detail', { targetId });
   }
 
+  vocabularyTree(): Promise<VocabularyTree> {
+    return this.functions.operations<VocabularyTree>('ops/vocabulary/tree');
+  }
+
+  vocabulary(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}): Promise<OperationsPage<VocabularyWord>> {
+    return this.functions.operations<OperationsPage<VocabularyWord>>('ops/vocabulary/list', { page, pageSize, search, filters });
+  }
+
+  specialTraining(page = 1, pageSize = 50, search = '', filters: Record<string, unknown> = {}): Promise<OperationsPage<SpecialTrainingItem>> {
+    return this.functions.operations<OperationsPage<SpecialTrainingItem>>('ops/special-training/list', { page, pageSize, search, filters });
+  }
+
+  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 } });
+  }
+
+  commitVocabularyImport(batchId: string, reason: string): Promise<Record<string, unknown>> {
+    return this.functions.operations<Record<string, unknown>>('ops/vocabulary/import-commit', { targetId: batchId, reason, idempotencyKey: crypto.randomUUID() });
+  }
+
+  async uploadMedia(file: File, kind: 'image' | 'audio' | 'video', reason: string): Promise<string> {
+    const ticket = await this.functions.operations<MediaUploadTicket>('ops/media/presign', { reason, idempotencyKey: crypto.randomUUID(), payload: { fileName: file.name, contentType: file.type, size: file.size, kind } });
+    const response = await fetch(ticket.uploadUrl, { method: ticket.method, headers: ticket.headers, body: file });
+    if (!response.ok) throw new Error(`媒体上传失败(${response.status})`);
+    const completed = await this.functions.operations<{ publicUrl: string }>('ops/media/complete', { targetId: ticket.ticketId, reason, idempotencyKey: crypto.randomUUID() });
+    return completed.publicUrl;
+  }
+
   list(kind: OperationsListKind, page: number, pageSize: number, search: string, filters: Record<string, unknown> = {}): Promise<OperationsPage> {
     const operation = kind === 'relations' ? 'ops/relations/list'
       : kind === 'course-bindings' ? 'ops/course-bindings/list'

File diff suppressed because it is too large
+ 4 - 0
src/app/admin/pages/operations-special-training.component.html


File diff suppressed because it is too large
+ 0 - 0
src/app/admin/pages/operations-special-training.component.scss


+ 30 - 0
src/app/admin/pages/operations-special-training.component.ts

@@ -0,0 +1,30 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { BookOpen, Eye, Image, LayoutGrid, List, LucideAngularModule, Pencil, Plus, RefreshCw, RotateCcw, Search, Trash2, Upload, Video, X } from 'lucide-angular';
+import { OperationsPage, SpecialTrainingItem } from '../admin.models';
+import { OperationsService } from '../operations.service';
+
+type SpecialDraft = Pick<SpecialTrainingItem,'targetId'|'title'|'synopsis'|'coverUrl'|'tags'|'contentHtml'|'videoUrl'|'author'|'source'|'orderId'|'status'|'updatedAt'>;
+
+@Component({selector:'app-operations-special-training',standalone:true,imports:[CommonModule,FormsModule,LucideAngularModule],templateUrl:'./operations-special-training.component.html',styleUrls:['./operations-professional.scss','./operations-special-training.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
+export class OperationsSpecialTrainingComponent implements OnInit{
+  readonly icons={BookOpen,Eye,Image,LayoutGrid,List,Pencil,Plus,RefreshCw,RotateCcw,Search,Trash2,Upload,Video,X};
+  readonly page=signal<OperationsPage<SpecialTrainingItem>|null>(null);readonly query=signal('');readonly status=signal('');readonly view=signal<'cards'|'list'>('cards');readonly loading=signal(true);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');
+  readonly publishedCount=computed(()=>this.page()?.items.filter((item)=>item.status===99).length||0);readonly draftCount=computed(()=>this.page()?.items.filter((item)=>item.status===0).length||0);readonly mediaMissingCount=computed(()=>this.page()?.items.filter((item)=>!item.coverUrl||(!item.contentHtml&&!item.videoUrl)).length||0);
+  readonly editorOpen=signal(false);readonly previewOpen=signal(false);readonly previewItem=signal<SpecialTrainingItem|null>(null);readonly reason=signal('');readonly tagsText=signal('');readonly draft=signal<SpecialDraft>(this.emptyDraft());
+  private readonly operations=inject(OperationsService);
+  ngOnInit():void{void this.load();}
+  async load():Promise<void>{this.loading.set(true);this.error.set('');try{this.page.set(await this.operations.specialTraining(1,50,this.query(),{status:this.status()}));}catch(error){this.error.set(this.errorText(error,'专项训练加载失败'));}finally{this.loading.set(false);}}
+  refresh():void{void this.load().then(()=>this.message.set(`已从服务端刷新 · ${new Date().toLocaleTimeString('zh-CN')}`));}
+  openCreate():void{this.draft.set(this.emptyDraft());this.tagsText.set('');this.reason.set('新增专项训练草稿');this.error.set('');this.editorOpen.set(true);}
+  openEdit(row:SpecialTrainingItem):void{this.draft.set({targetId:row.targetId,title:row.title,synopsis:row.synopsis,coverUrl:row.coverUrl,tags:[...row.tags],contentHtml:row.contentHtml,videoUrl:row.videoUrl,author:row.author,source:row.source,orderId:row.orderId,status:row.status,updatedAt:row.updatedAt});this.tagsText.set(row.tags.join(','));this.reason.set('');this.error.set('');this.editorOpen.set(true);}
+  closeEditor():void{this.editorOpen.set(false);}
+  updateDraft<K extends keyof SpecialDraft>(key:K,value:SpecialDraft[K]):void{this.draft.update((draft)=>({...draft,[key]:value}));}
+  async save():Promise<void>{const draft=this.draft();if(!draft.title.trim()||!this.reason().trim()){this.error.set('标题和操作原因必填');return;}this.saving.set(true);this.error.set('');try{const payload={...draft,tags:this.tagsText().split(/[,,]/).map((tag)=>tag.trim()).filter(Boolean)};await this.operations.mutate('ops/special-training/save',draft.targetId,this.reason(),payload,draft.updatedAt);this.editorOpen.set(false);this.message.set(draft.targetId?'专项内容已更新':'专项内容已创建为草稿');await this.load();}catch(error){this.error.set(this.errorText(error,'专项内容保存失败'));}finally{this.saving.set(false);}}
+  async preview(row?:SpecialTrainingItem):Promise<void>{if(row){this.previewItem.set(row);this.previewOpen.set(true);return;}const draft=this.draft();try{const result=await this.operations.request<SpecialTrainingItem>('ops/special-training/preview',{targetId:draft.targetId,payload:{...draft,tags:this.tagsText().split(/[,,]/).map((tag)=>tag.trim()).filter(Boolean)}});this.previewItem.set(result);this.previewOpen.set(true);}catch(error){this.error.set(this.errorText(error,'预览失败'));}}
+  async lifecycle(row:SpecialTrainingItem,action:'publish'|'unpublish'|'recycle'|'restore'):Promise<void>{const labels={publish:'发布',unpublish:'下架',recycle:'回收',restore:'恢复'};const reason=window.prompt(`请输入${labels[action]}原因`);if(!reason)return;try{await this.operations.mutate(`ops/special-training/${action}`,row.targetId,reason,{},row.updatedAt);this.message.set(`“${row.title}”已${labels[action]}`);await this.load();}catch(error){this.error.set(this.errorText(error,`${labels[action]}失败`));}}
+  async upload(event:Event,kind:'image'|'video'):Promise<void>{const input=event.target as HTMLInputElement,file=input.files?.[0];if(!file)return;this.saving.set(true);try{const url=await this.operations.uploadMedia(file,kind,this.reason()||`上传专项${kind==='image'?'封面':'视频'}`);this.updateDraft(kind==='image'?'coverUrl':'videoUrl',url);this.message.set('媒体上传完成');}catch(error){this.error.set(this.errorText(error,'媒体上传失败'));}finally{this.saving.set(false);input.value='';}}
+  private emptyDraft():SpecialDraft{return{targetId:'',title:'',synopsis:'',coverUrl:'',tags:[],contentHtml:'',videoUrl:'',author:'',source:'',orderId:0,status:0,updatedAt:''};}
+  private errorText(error:unknown,fallback:string):string{return error instanceof Error?error.message:fallback;}
+}

File diff suppressed because it is too large
+ 5 - 0
src/app/admin/pages/operations-vocabulary.component.html


+ 13 - 0
src/app/admin/pages/operations-vocabulary.component.scss

@@ -0,0 +1,13 @@
+: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}
+.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}
+.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}
+.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}
+.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}}

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

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

Some files were not shown because too many files changed in this diff