|
|
@@ -1,32 +1,62 @@
|
|
|
import { AccessibleDialogDirective } from '../shared/accessible-dialog.directive';
|
|
|
import { DropdownDirective } from '../shared/dropdown.directive';
|
|
|
import { CommonModule } from '@angular/common';
|
|
|
-import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
|
|
|
+import { ChangeDetectionStrategy, Component, OnInit, ViewChild, 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 { SpecialTrainingItem, SpecialTrainingPage } from '../admin.models';
|
|
|
import { OperationsService } from '../operations.service';
|
|
|
|
|
|
+import { TrainingBodyEditorComponent } from '../shared/training-body-editor.component';
|
|
|
+import { prepareTrainingImage } from '../shared/training-image';
|
|
|
+
|
|
|
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: [AccessibleDialogDirective, DropdownDirective, CommonModule,FormsModule,LucideAngularModule],templateUrl:'./operations-special-training.component.html',styleUrls:['./operations-professional.scss','./operations-special-training.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
|
|
|
+@Component({selector:'app-operations-special-training',standalone:true,imports: [TrainingBodyEditorComponent, AccessibleDialogDirective, DropdownDirective, 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<SpecialTrainingPage|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.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());
|
|
|
+ @ViewChild(TrainingBodyEditorComponent) bodyEditor?: TrainingBodyEditorComponent;
|
|
|
+ readonly uploading = signal(false);
|
|
|
+ readonly previewing = signal(false);
|
|
|
+ readonly uploadProgress = signal('');
|
|
|
+ readonly busy = computed(() => this.saving() || this.uploading() || this.previewing());
|
|
|
private readonly operations=inject(OperationsService);
|
|
|
ngOnInit():void{void this.load();}
|
|
|
async load(refresh=false):Promise<void>{this.loading.set(true);this.error.set('');try{this.page.set(await this.operations.specialTraining(1,50,this.query(),{status:this.status()},refresh));}catch(error){this.error.set(this.errorText(error,'专项训练加载失败'));}finally{this.loading.set(false);}}
|
|
|
refresh():void{void this.load(true).then(()=>{const source=this.page()?.sourceStatus;this.message.set(source?.available?`已对齐旧后台 ${source.legacyTotal} 条内容,本次新增 ${source.created} 条、更新 ${source.updated} 条`:`已刷新新后台缓存;${source?.message||'旧后台实时同步暂不可用'}`);});}
|
|
|
- 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);}
|
|
|
+ openCreate():void{if(this.busy())return;this.uploadProgress.set('');this.draft.set(this.emptyDraft());this.tagsText.set('');this.reason.set('新增专项训练草稿');this.error.set('');this.editorOpen.set(true);}
|
|
|
+ openEdit(row:SpecialTrainingItem):void{if(this.busy())return;this.uploadProgress.set('');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{if(!this.busy())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 save():Promise<void>{if(this.busy())return;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(this.busy())return;if(row){this.previewItem.set(row);this.previewOpen.set(true);return;}const draft=this.draft();this.previewing.set(true);this.error.set('');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,'预览失败'));}finally{this.previewing.set(false);}}
|
|
|
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);this.error.set('');try{if(kind==='image'){const cover=await this.cropCover(file),url=await this.operations.uploadCover(cover.file,this.reason()||'上传专项封面',cover.width,cover.height);this.updateDraft('coverUrl',url);this.message.set(`封面已按 4:3 居中裁切并上传(${cover.width}×${cover.height})`);}else{const url=await this.operations.uploadMedia(file,kind,this.reason()||'上传专项视频');this.updateDraft('videoUrl',url);this.message.set('视频上传完成');}}catch(error){this.error.set(this.errorText(error,kind==='image'?'封面上传失败':'视频上传失败'));}finally{this.saving.set(false);input.value='';}}
|
|
|
+ async upload(event:Event,kind:'image'|'video'):Promise<void>{if(this.busy())return;const input=event.target as HTMLInputElement,file=input.files?.[0];if(!file)return;this.uploading.set(true);this.uploadProgress.set(kind==='image'?'正在上传封面…':'正在上传视频…');this.error.set('');try{if(kind==='image'){const cover=await this.cropCover(file),url=await this.operations.uploadCover(cover.file,this.reason()||'上传专项封面',cover.width,cover.height);this.updateDraft('coverUrl',url);this.message.set(`封面已按 4:3 居中裁切并上传(${cover.width}×${cover.height})`);}else{const url=await this.operations.uploadMedia(file,kind,this.reason()||'上传专项视频');this.updateDraft('videoUrl',url);this.message.set('视频上传完成');}}catch(error){this.error.set(this.errorText(error,kind==='image'?'封面上传失败':'视频上传失败'));}finally{this.uploading.set(false);this.uploadProgress.set('');input.value='';}}
|
|
|
+ async uploadBodyImages(files: File[]): Promise<void> {
|
|
|
+ if (this.busy() || !files.length) return;
|
|
|
+ if (files.length > 20) { this.error.set('每次最多添加 20 张图片,请分批上传'); return; }
|
|
|
+ this.uploading.set(true);
|
|
|
+ this.error.set('');
|
|
|
+ const uploaded: {url: string; name: string}[] = [];
|
|
|
+ const failures: string[] = [];
|
|
|
+ try {
|
|
|
+ for (const [index, file] of files.entries()) {
|
|
|
+ this.uploadProgress.set(`正在上传正文图片 ${index + 1}/${files.length}:${file.name}`);
|
|
|
+ try {
|
|
|
+ const image = await prepareTrainingImage(file);
|
|
|
+ const url = await this.operations.uploadImage(image.file, this.reason() || '上传专项正文图片', image.width, image.height);
|
|
|
+ uploaded.push({ url, name: file.name });
|
|
|
+ } catch (error) { failures.push(`${file.name}:${this.errorText(error, '上传失败')}`); }
|
|
|
+ }
|
|
|
+ this.bodyEditor?.insertImages(uploaded);
|
|
|
+ this.uploadProgress.set(`已插入 ${uploaded.length} 张图片${failures.length ? `,${failures.length} 张失败,请重新选择失败图片重试` : ',保存草稿后生效'}`);
|
|
|
+ if (failures.length) this.error.set(failures.join(';'));
|
|
|
+ } catch (error) { this.error.set(this.errorText(error, '图片插入失败')); }
|
|
|
+ finally { this.uploading.set(false); }
|
|
|
+ }
|
|
|
private async cropCover(file:File):Promise<{file:File;width:number;height:number}>{if(!['image/jpeg','image/png','image/webp','image/gif'].includes(file.type))throw new Error('封面仅支持 JPG、PNG、WebP 或 GIF');const bitmap=await createImageBitmap(file);try{const ratio=4/3;let sourceWidth=bitmap.width,sourceHeight=bitmap.height,sourceX=0,sourceY=0;if(sourceWidth/sourceHeight>ratio){sourceWidth=sourceHeight*ratio;sourceX=(bitmap.width-sourceWidth)/2;}else{sourceHeight=sourceWidth/ratio;sourceY=(bitmap.height-sourceHeight)/2;}const scaledWidth=Math.min(1200,sourceWidth),width=Math.max(4,Math.floor(scaledWidth/4)*4),height=width/4*3,canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;const context=canvas.getContext('2d');if(!context)throw new Error('浏览器无法处理封面图片');context.fillStyle='#eef5f2';context.fillRect(0,0,width,height);context.drawImage(bitmap,sourceX,sourceY,sourceWidth,sourceHeight,0,0,width,height);const blob=await new Promise<Blob>((resolve,reject)=>canvas.toBlob((value)=>value?resolve(value):reject(new Error('封面裁切失败')),'image/jpeg',.88));const baseName=file.name.replace(/\.[^.]+$/,'')||'cover';return{file:new File([blob],`${baseName}-4x3.jpg`,{type:'image/jpeg'}),width,height};}finally{bitmap.close();}}
|
|
|
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;}
|