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

feat(admin): add multi-image rich editor for special training

彭峰 3 часов назад
Родитель
Сommit
064a6becc0

BIN
docs/qa/images/special-training-editor-2026-10-22.jpg


BIN
docs/qa/images/special-training-editor-narrow-2026-10-22.jpg


+ 49 - 0
docs/qa/special-training-editor-2026-10-22.md

@@ -0,0 +1,49 @@
+# 专项训练图文编辑验收(2026-10-22)
+
+## 变更
+
+- 正文由 HTML 文本框改为可视化编辑器:加粗、斜体、下划线、小标题、正文、两种列表、引用、清除格式、撤销和重做。
+- 支持多选、粘贴、拖入图片;一批最多 20 张,单张最大 10 MiB,GIF 最大 2 MiB。
+- 正文图片不裁切。小图保留原文件,大图等比例缩至最长边 2000 像素并压缩到现有 inline 上传接口的容量范围内;GIF 保留动画,不静态化压缩。
+- 复用已存在的 inline 图片上传接口,不让正文上传依赖可选的 S3 配置。封面仍单独按 4:3 裁切。
+- 上传期间锁定正文、保存、预览和关闭;按文件显示进度。单张失败不丢弃其他成功图片,展示文件名及错误并允许重新选择失败图片。
+- 预览增加等待状态;修正动态 HTML 图片未受组件样式约束的问题,避免预览超宽。
+
+## 验证结果
+
+| 项目 | 结果与证据 |
+| --- | --- |
+| 自动化测试 | ChromeHeadless 33 项通过,包含正文编辑、图片准备、页面保存及 OperationsService。 |
+| 图片处理 | 比例保留、大图缩放、格式与大小校验、损坏文件拒绝通过。 |
+| 编辑交互 | 光标处顺序插入多图、父组件回写不丢光标、加粗及原生撤销、HTML 安全渲染、纯文字粘贴、忙碌状态锁定通过。 |
+| 失败分支 | 单张失败保留其他成功图片;保存失败保留草稿;标题和原因校验、超过 20 张拒绝通过。 |
+| 实际浏览器上传 | localhost:4300 前端连接 server.xiaoshu.pro 后端,以现有管理员登录,粘贴两张测试 PNG 并真实上传。两张图片均加载成功,分别为 32×48、64×32。 |
+| 创建并回读 | 保存未发布草稿后重新打开,两张图片的 URL、标题排版和标签完整,首图自动作封面。 |
+| 二次编辑 | 修改简介并再次保存、重新打开,更新内容完整;仍为草稿。 |
+| 服务端预览 | 返回并显示标题、简介、小标题及两张图片;两张图片 naturalWidth 均大于 0。 |
+| 窄屏与键盘 | 375×812 下工具栏换行;抽屉 clientWidth/scrollWidth 均为 375,正文均为 325;工具按钮可通过 Tab 聚焦,焦点框可见。 |
+| 生产构建 | npm run build:admin 成功;有既有样式预算及 CommonJS 警告。 |
+| 差异检查 | git diff --check 通过。 |
+
+测试命令:
+
+```sh
+npm run test:admin:ci -- --include='**/training-*.spec.ts' --include='**/operations-special-training.component.spec.ts' --include='**/operations.service.spec.ts'
+npm run build:admin
+git diff --check
+```
+
+## 真实验收记录及边界
+
+- 保留草稿:`【验收草稿】专项多图编辑 20261022`,展示 ID `839154386020572`。仅含测试色块和验收说明,没有发布到学生端。
+- 实际媒体地址:`https://a018.2018.z01.com/UploadFiles/System/wxapp/images/20261022/165750953261.png`、`https://a018.2018.z01.com/UploadFiles/System/wxapp/images/20261022/165735933633.png`。
+- 过程中一次上传触发现有 20 秒请求超时,正文及成功图片保留。最终两张上传、保存、二次编辑、预览均成功。这不证明旧媒体服务的持续稳定性,未以延长所有接口超时掩盖该现象。
+- 浏览器扩展未开启本地文件 URL 访问,自动化的原生文件选择器 setFiles 被阻止。已确认选择器允许多选,批量处理由 ChromeHeadless 测试覆盖;真实后端上传使用粘贴图片完成。用户手动多选仍需操作复核。
+- 本次未执行正式发布/下架、学生端或微信 WebView 验收,也未进行视频文件上传实测。视频仍使用原有 S3 预签名流程,不能将本次图文验收外推为视频已通过。
+- 本次修改管理端并同步 Git,没有部署公网管理站或替换云函数。
+
+## 截图
+
+![正文编辑与保存回显](images/special-training-editor-2026-10-22.jpg)
+
+![375 像素窄屏](images/special-training-editor-narrow-2026-10-22.jpg)

+ 15 - 0
projects/xiaoshu-admin/src/app/operations.service.spec.ts

@@ -18,6 +18,21 @@ describe('OperationsService', () => {
     service = TestBed.inject(OperationsService);
   });
 
+  it('uploads body images through the existing inline endpoint with original dimensions', async () => {
+    functions.operations.and.resolveTo({publicUrl:'https://example.com/body.png'});
+    const file = new File(['image bytes'], 'body.png', {type:'image/png'});
+    expect(await service.uploadImage(file, '专项正文', 320, 480)).toBe('https://example.com/body.png');
+    expect(functions.operations).toHaveBeenCalledWith('ops/media/upload-inline', jasmine.objectContaining({
+      reason: '专项正文',
+      payload: jasmine.objectContaining({fileName:'body.png',kind:'image',contentType:'image/png',width:320,height:480,base64:btoa('image bytes')}),
+    }));
+  });
+
+  it('rejects a successful upload response without a usable public URL', async () => {
+    functions.operations.and.resolveTo({});
+    await expectAsync(service.uploadImage(new File(['x'],'x.png',{type:'image/png'}),'验收',1,1)).toBeRejectedWithError('上传服务未返回有效图片地址');
+  });
+
   it('keeps a same-session dashboard snapshot and invalidates it after an operations change', async () => {
     const data = { date: new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }), identity: {}, metrics: [], alerts: [] } as any;
     functions.operations.and.resolveTo(data);

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

@@ -197,10 +197,14 @@ export class OperationsService {
   }
 
   async uploadCover(file: File, reason: string, width: number, height: number): Promise<string> {
+    return this.uploadImage(file, reason, width, height);
+  }
+
+  async uploadImage(file: File, reason: string, width: number, height: number): Promise<string> {
     const dataUrl = await new Promise<string>((resolve, reject) => {
       const reader = new FileReader();
-      reader.onload = () => typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('无法读取封面文件'));
-      reader.onerror = () => reject(new Error('无法读取封面文件'));
+      reader.onload = () => typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('无法读取图片文件'));
+      reader.onerror = () => reject(new Error('无法读取图片文件'));
       reader.readAsDataURL(file);
     });
     const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
@@ -209,6 +213,7 @@ export class OperationsService {
       idempotencyKey: crypto.randomUUID(),
       payload: { fileName: file.name, contentType: file.type, size: file.size, kind: 'image', base64, width, height },
     });
+    if (!/^https?:\/\//i.test(result.publicUrl || '')) throw new Error('上传服务未返回有效图片地址');
     return result.publicUrl;
   }
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/operations-special-training.component.html


+ 4 - 1
projects/xiaoshu-admin/src/app/pages/operations-special-training.component.scss

@@ -50,7 +50,7 @@
 .preview-synopsis { padding: 14px; border-left: 4px solid #2b8369; color: #566963; background: #f1f7f4; }
 .preview-video { width: 100%; max-height: 420px; border-radius: 14px; background: #111; }
 .preview-content { overflow-wrap: anywhere; line-height: 1.75; }
-.preview-content :is(img, video) { max-width: 100%; }
+:host ::ng-deep .preview-content :is(img, video) { max-width: 100%; }
 
 @media (max-width: 1100px) {
   .special-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -64,3 +64,6 @@
   .card-body footer { align-items: flex-start; flex-direction: column; }
   .row-actions { width: 100%; justify-content: flex-start; }
 }
+
+.body-field { min-width: 0; display: grid; gap: 8px; }
+.body-field > span { color: #60716b; font-size: 13px; font-weight: 700; }

+ 72 - 0
projects/xiaoshu-admin/src/app/pages/operations-special-training.component.spec.ts

@@ -0,0 +1,72 @@
+import { TestBed } from '@angular/core/testing';
+import { OperationsSpecialTrainingComponent } from './operations-special-training.component';
+import { OperationsService } from '../operations.service';
+
+describe('OperationsSpecialTrainingComponent', () => {
+  let component: OperationsSpecialTrainingComponent;
+  let operations: jasmine.SpyObj<OperationsService>;
+  beforeEach(() => {
+    operations = jasmine.createSpyObj('OperationsService', ['specialTraining', 'mutate', 'request', 'uploadImage']);
+    operations.specialTraining.and.resolveTo({items:[],total:0} as any);
+    operations.mutate.and.resolveTo({});
+    TestBed.configureTestingModule({providers:[{provide:OperationsService,useValue:operations}]});
+    component = TestBed.createComponent(OperationsSpecialTrainingComponent).componentInstance;
+    component.openCreate();
+  });
+
+  it('requires a title and audit reason and preserves the draft on server failure', async () => {
+    await component.save();
+    expect(operations.mutate).not.toHaveBeenCalled();
+    component.updateDraft('title', '专项练习');
+    component.updateDraft('contentHtml', '<p>内容</p>');
+    operations.mutate.and.rejectWith(new Error('同名内容'));
+    await component.save();
+    expect(component.editorOpen()).toBeTrue();
+    expect(component.draft().contentHtml).toBe('<p>内容</p>');
+    expect(component.error()).toBe('同名内容');
+  });
+
+  it('saves formatted text, all images, and normalized tags as a draft', async () => {
+    component.updateDraft('title', '专项练习');
+    component.updateDraft('contentHtml', '<h2>练习</h2><p><img src="https://example.com/a.png"></p><img src="https://example.com/b.png">');
+    component.tagsText.set(' 阅读, 语法 ,');
+    await component.save();
+    const payload = operations.mutate.calls.mostRecent().args[3];
+    expect(payload?.['tags']).toEqual(['阅读','语法']);
+    expect(payload?.['contentHtml']).toBe(component.draft().contentHtml);
+    expect(payload?.['status']).toBe(0);
+    expect(component.editorOpen()).toBeFalse();
+  });
+
+  it('blocks duplicate saves, preview, and closing during an upload', async () => {
+    component.uploading.set(true);
+    component.updateDraft('title', '专项练习');
+    await component.save(); await component.preview(); component.closeEditor();
+    expect(component.editorOpen()).toBeTrue();
+    expect(operations.mutate).not.toHaveBeenCalled();
+    expect(operations.request).not.toHaveBeenCalled();
+  });
+
+  it('rejects more than 20 files before uploading', async () => {
+    await component.uploadBodyImages(Array.from({length:21}, () => new File(['x'], 'x.png', {type:'image/png'})));
+    expect(component.error()).toContain('20');
+    expect(operations.uploadImage).not.toHaveBeenCalled();
+  });
+
+  it('retains successful uploads in order when one file fails', async () => {
+    const canvas = document.createElement('canvas'); canvas.width=20; canvas.height=10;
+    const blob = await new Promise<Blob>(resolve => canvas.toBlob(value => resolve(value!)));
+    const files = ['one','two','three'].map(name => new File([blob],name+'.png',{type:'image/png'}));
+    operations.uploadImage.and.callFake(async file => {
+      if(file.name==='two.png') throw new Error('网络错误');
+      return 'https://example.com/'+file.name;
+    });
+    const insertImages = jasmine.createSpy('insertImages');
+    component.bodyEditor = {insertImages} as any;
+    await component.uploadBodyImages(files);
+    expect(insertImages).toHaveBeenCalledWith([{url:'https://example.com/one.png',name:'one.png'},{url:'https://example.com/three.png',name:'three.png'}]);
+    expect(component.error()).toContain('two.png:网络错误');
+    expect(component.uploading()).toBeFalse();
+    expect(component.uploadProgress()).toContain('2 张');
+  });
+});

+ 38 - 8
projects/xiaoshu-admin/src/app/pages/operations-special-training.component.ts

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

+ 14 - 0
projects/xiaoshu-admin/src/app/shared/training-body-editor.component.html

@@ -0,0 +1,14 @@
+<div class="editor" [attr.aria-busy]="disabled">
+  <div class="tools" role="group" aria-label="正文编辑工具">
+    <button type="button" class="add-images" [disabled]="disabled" (mousedown)="$event.preventDefault()" (click)="chooseImages(images)">添加多张图片</button>
+    <input #images type="file" hidden multiple accept="image/jpeg,image/png,image/webp,image/gif" [disabled]="disabled" (change)="filesChanged($event)"/>
+    @for (tool of tools; track tool.label) {
+      <button type="button" [disabled]="disabled" (mousedown)="$event.preventDefault()" (click)="format(tool.command, tool.value)">{{tool.label}}</button>
+    }
+  </div>
+  <div #body class="body" role="textbox" aria-label="图文正文" aria-multiline="true" aria-describedby="training-body-help" tabindex="0"
+    [attr.contenteditable]="!disabled" [attr.aria-disabled]="disabled" data-placeholder="在这里输入正文,或添加多张图片…"
+    (input)="changed()" (keyup)="rememberSelection()" (mouseup)="rememberSelection()" (blur)="rememberSelection()"
+    (paste)="paste($event)" (dragover)="$event.preventDefault()" (drop)="drop($event)"></div>
+</div>
+<small id="training-body-help">支持多选、粘贴或拖入图片,每次最多 20 张、每张不超过 10 MB(GIF 不超过 2 MB)。图片保留比例,大图自动压缩;选中图片后可按删除键移除。粘贴文字时自动去除外部格式。</small>

+ 15 - 0
projects/xiaoshu-admin/src/app/shared/training-body-editor.component.scss

@@ -0,0 +1,15 @@
+:host { display: block; min-width: 0; }
+.editor { overflow: hidden; border: 1px solid var(--line, #d8e4df); border-radius: 12px; background: #fff; }
+.editor:focus-within { border-color: #176c55; box-shadow: 0 0 0 2px #176c5520; }
+.tools { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; border-bottom: 1px solid #d8e4df; background: #f1f7f4; }
+button { min-height: 40px; padding: 8px 10px; border: 1px solid transparent; border-radius: 6px; color: #314a41; background: transparent; font: inherit; font-size: 13px; cursor: pointer; }
+button:hover:not(:disabled) { background: #e0ece6; }
+button:focus-visible { outline: 2px solid #176c55; outline-offset: -2px; }
+button:disabled { opacity: .5; cursor: wait; }
+.add-images { color: #fff; background: #176c55; }
+.add-images:hover:not(:disabled) { background: #125640; }
+.body { min-height: 320px; max-height: 65vh; overflow-y: auto; padding: 16px; outline: none; color: #263a32; font-size: 16px; font-weight: 400; line-height: 1.8; overflow-wrap: anywhere; }
+.body:empty::before { content: attr(data-placeholder); color: #687b72; pointer-events: none; }
+:host ::ng-deep .body img { display: inline-block; max-width: 100%; height: auto; }
+:host ::ng-deep .body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid #176c55; color: #52685d; }
+small { display: block; margin-top: 8px; color: #60716b; font-size: 12px; line-height: 1.6; font-weight: 400; }

+ 84 - 0
projects/xiaoshu-admin/src/app/shared/training-body-editor.component.spec.ts

@@ -0,0 +1,84 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { TrainingBodyEditorComponent } from './training-body-editor.component';
+
+describe('TrainingBodyEditorComponent', () => {
+  let fixture: ComponentFixture<TrainingBodyEditorComponent>;
+  let editor: TrainingBodyEditorComponent;
+  let body: HTMLDivElement;
+  beforeEach(() => {
+    fixture = TestBed.createComponent(TrainingBodyEditorComponent);
+    editor = fixture.componentInstance;
+    fixture.componentRef.setInput('value', '<p>Before after</p>');
+    fixture.detectChanges();
+    body = fixture.nativeElement.querySelector('[contenteditable]');
+  });
+
+  function select(start: number, end = start): void {
+    body.focus();
+    const range = document.createRange();
+    range.setStart(body.querySelector('p')!.firstChild!, start);
+    range.setEnd(body.querySelector('p')!.firstChild!, end);
+    window.getSelection()!.removeAllRanges();
+    window.getSelection()!.addRange(range);
+    editor.rememberSelection();
+  }
+
+  it('inserts multiple images at the saved caret in selection order without replacing surrounding text', () => {
+    select(7);
+    editor.insertImages([{url:'https://example.com/1.png',name:'one'}, {url:'https://example.com/2.png',name:'two'}]);
+    expect(Array.from(body.querySelectorAll('img')).map(img => img.alt)).toEqual(['one', 'two']);
+    expect(body.innerHTML.indexOf('Before')).toBeLessThan(body.innerHTML.indexOf('1.png'));
+    expect(body.innerHTML.indexOf('2.png')).toBeLessThan(body.innerHTML.lastIndexOf('after'));
+  });
+
+  it('keeps the caret and DOM when the parent echoes a typed value', () => {
+    select(3);
+    editor.changed();
+    const original = body.firstChild;
+    fixture.componentRef.setInput('value', body.innerHTML);
+    fixture.detectChanges();
+    expect(body.firstChild).toBe(original);
+    expect(window.getSelection()!.anchorOffset).toBe(3);
+  });
+
+  it('formats a selection and preserves native undo', () => {
+    select(0, 6);
+    editor.format('bold');
+    expect(body.querySelector('b, strong')?.textContent).toBe('Before');
+    editor.format('undo');
+    expect(body.querySelector('b, strong')).toBeNull();
+    expect(body.textContent).toBe('Before after');
+  });
+
+  it('sanitizes imported HTML before displaying it', () => {
+    fixture.componentRef.setInput('value', '<p>Hello<script>alert(1)</script><img src="x" onerror="alert(1)"></p>');
+    fixture.detectChanges();
+    expect(body.querySelector('script')).toBeNull();
+    expect(body.querySelector('img')!.hasAttribute('onerror')).toBeFalse();
+  });
+
+  it('pastes external content as plain text', () => {
+    select(0, 6);
+    const data = new DataTransfer();
+    data.setData('text/plain', '<safe>');
+    data.setData('text/html', '<img src=x onerror=alert(1)>');
+    editor.paste(new ClipboardEvent('paste', {clipboardData:data}));
+    expect(body.textContent).toBe('<safe> after');
+    expect(body.querySelector('img')).toBeNull();
+  });
+
+  it('disables editing and toolbar commands during upload', () => {
+    fixture.componentRef.setInput('disabled', true);
+    fixture.detectChanges();
+    expect(body.contentEditable).toBe('false');
+    expect(fixture.nativeElement.querySelector('button').disabled).toBeTrue();
+    select(0, 6);
+    editor.format('bold');
+    expect(body.querySelector('b')).toBeNull();
+  });
+
+  it('rejects unsafe upload URLs', () => {
+    expect(() => editor.insertImages([{url:'javascript:alert(1)', name:'bad'}])).toThrowError('图片地址无效');
+    expect(body.querySelector('img')).toBeNull();
+  });
+});

+ 126 - 0
projects/xiaoshu-admin/src/app/shared/training-body-editor.component.ts

@@ -0,0 +1,126 @@
+import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, Input, OnChanges, SecurityContext, ViewChild, inject, output } from '@angular/core';
+import { DomSanitizer } from '@angular/platform-browser';
+
+@Component({
+  selector: 'app-training-body-editor',
+  standalone: true,
+  templateUrl: './training-body-editor.component.html',
+  styleUrl: './training-body-editor.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class TrainingBodyEditorComponent implements AfterViewInit, OnChanges {
+  @Input() value = '';
+  @Input() disabled = false;
+  readonly valueChange = output<string>();
+  readonly imagesSelected = output<File[]>();
+  @ViewChild('body') private body?: ElementRef<HTMLDivElement>;
+  private readonly sanitizer = inject(DomSanitizer);
+  private selection: Range | null = null;
+  private emittedValue: string | null = null;
+  readonly tools = [
+    { label: '加粗', command: 'bold', value: '' },
+    { label: '斜体', command: 'italic', value: '' },
+    { label: '下划线', command: 'underline', value: '' },
+    { label: '小标题', command: 'formatBlock', value: 'h2' },
+    { label: '正文', command: 'formatBlock', value: 'p' },
+    { label: '无序列表', command: 'insertUnorderedList', value: '' },
+    { label: '有序列表', command: 'insertOrderedList', value: '' },
+    { label: '引用', command: 'formatBlock', value: 'blockquote' },
+    { label: '清除格式', command: 'removeFormat', value: '' },
+    { label: '撤销', command: 'undo', value: '' },
+    { label: '重做', command: 'redo', value: '' },
+  ];
+
+  ngAfterViewInit(): void { this.renderValue(); }
+  ngOnChanges(): void { this.renderValue(); }
+
+  private renderValue(): void {
+    if (!this.body || this.value === this.emittedValue) return;
+    this.body.nativeElement.innerHTML = this.sanitizer.sanitize(SecurityContext.HTML, this.value) || '';
+    this.selection = null;
+    this.emittedValue = this.value;
+  }
+
+  rememberSelection(): void {
+    const selected = window.getSelection();
+    if (selected?.rangeCount && this.body?.nativeElement.contains(selected.getRangeAt(0).commonAncestorContainer)) {
+      this.selection = selected.getRangeAt(0).cloneRange();
+    }
+  }
+
+  private restoreSelection(): void {
+    const element = this.body!.nativeElement;
+    element.focus();
+    const range = this.selection && element.contains(this.selection.commonAncestorContainer) ? this.selection : document.createRange();
+    if (range !== this.selection) { range.selectNodeContents(element); range.collapse(false); }
+    const selected = window.getSelection();
+    selected?.removeAllRanges();
+    selected?.addRange(range);
+  }
+
+  changed(): void {
+    this.emittedValue = this.body!.nativeElement.innerHTML;
+    this.valueChange.emit(this.emittedValue);
+    this.rememberSelection();
+  }
+
+  format(command: string, value = ''): void {
+    if (this.disabled) return;
+    this.restoreSelection();
+    // Native editing commands preserve the browser's undo history and IME handling.
+    document.execCommand(command, false, value);
+    this.changed();
+  }
+
+  chooseImages(input: HTMLInputElement): void {
+    if (this.disabled) return;
+    this.rememberSelection();
+    input.click();
+  }
+
+  filesChanged(event: Event): void {
+    const input = event.target as HTMLInputElement;
+    const files = Array.from(input.files || []);
+    input.value = '';
+    if (!this.disabled && files.length) this.imagesSelected.emit(files);
+  }
+
+  paste(event: ClipboardEvent): void {
+    event.preventDefault();
+    if (this.disabled) return;
+    this.rememberSelection();
+    const files = Array.from(event.clipboardData?.files || []);
+    if (files.length) { this.imagesSelected.emit(files); return; }
+    this.format('insertText', event.clipboardData?.getData('text/plain') || '');
+  }
+
+  drop(event: DragEvent): void {
+    event.preventDefault();
+    if (this.disabled) return;
+    const files = Array.from(event.dataTransfer?.files || []);
+    if (files.length) this.imagesSelected.emit(files);
+  }
+
+  insertImages(images: { url: string; name: string }[]): void {
+    if (!images.length) return;
+    const container = document.createElement('div');
+    for (const image of images) {
+      const url = new URL(image.url, window.location.origin);
+      if (!['https:', 'http:'].includes(url.protocol)) throw new Error('图片地址无效');
+      const paragraph = document.createElement('p');
+      const img = document.createElement('img');
+      img.src = url.href;
+      img.alt = image.name;
+      paragraph.append(img);
+      container.append(paragraph);
+    }
+    container.insertAdjacentHTML('beforeend', '<p><br></p>');
+    // Uploads lock user editing, but insertion must still be one undoable operation.
+    const element = this.body!.nativeElement;
+    element.contentEditable = 'true';
+    this.restoreSelection();
+    document.execCommand('insertHTML', false, container.innerHTML);
+    element.contentEditable = String(!this.disabled);
+    this.changed();
+  }
+}

+ 27 - 0
projects/xiaoshu-admin/src/app/shared/training-image.spec.ts

@@ -0,0 +1,27 @@
+import { prepareTrainingImage } from './training-image';
+
+describe('prepareTrainingImage', () => {
+  async function png(width: number, height: number): Promise<File> {
+    const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height;
+    const blob = await new Promise<Blob>(resolve => canvas.toBlob(value => resolve(value!)));
+    return new File([blob], 'training.png', {type:'image/png'});
+  }
+  it('keeps small images unchanged without cropping', async () => {
+    const file = await png(100, 300);
+    const result = await prepareTrainingImage(file);
+    expect(result.file).toBe(file);
+    expect([result.width,result.height]).toEqual([100,300]);
+  });
+  it('resizes large images proportionally to the inline upload budget', async () => {
+    const result = await prepareTrainingImage(await png(3000,1500));
+    expect([result.width,result.height]).toEqual([2000,1000]);
+    expect(result.file.size).toBeLessThanOrEqual(2*1024*1024);
+    expect(result.file.type).toBe('image/jpeg');
+  });
+  it('rejects unsupported, empty, oversized, or corrupt files', async () => {
+    await expectAsync(prepareTrainingImage(new File(['svg'],'x.svg',{type:'image/svg+xml'}))).toBeRejectedWithError(/仅支持/);
+    await expectAsync(prepareTrainingImage(new File([],'x.png',{type:'image/png'}))).toBeRejectedWithError(/不能为空/);
+    await expectAsync(prepareTrainingImage(new File([new Uint8Array(10*1024*1024+1)],'x.png',{type:'image/png'}))).toBeRejectedWithError(/10 MB/);
+    await expectAsync(prepareTrainingImage(new File(['corrupt'],'x.png',{type:'image/png'}))).toBeRejected();
+  });
+});

+ 31 - 0
projects/xiaoshu-admin/src/app/shared/training-image.ts

@@ -0,0 +1,31 @@
+const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
+const INLINE_LIMIT = 2 * 1024 * 1024;
+
+/** Use the existing inline image endpoint; do not depend on optional S3 configuration. */
+export async function prepareTrainingImage(file: File): Promise<{ file: File; width: number; height: number }> {
+  if (!IMAGE_TYPES.includes(file.type)) throw new Error('仅支持 JPG、PNG、WebP 或 GIF 图片');
+  if (!file.size || file.size > 10 * 1024 * 1024) throw new Error('图片不能为空且不能超过 10 MB');
+  const bitmap = await createImageBitmap(file);
+  try {
+    if (file.type === 'image/gif') {
+      if (file.size > INLINE_LIMIT) throw new Error('GIF 请压缩至 2 MB 内,以保留动画');
+      return { file, width: bitmap.width, height: bitmap.height };
+    }
+    if (file.size <= INLINE_LIMIT && Math.max(bitmap.width, bitmap.height) <= 2000) {
+      return { file, width: bitmap.width, height: bitmap.height };
+    }
+    const scale = Math.min(1, 2000 / Math.max(bitmap.width, bitmap.height));
+    const width = Math.max(1, Math.round(bitmap.width * scale)), height = Math.max(1, Math.round(bitmap.height * scale));
+    const canvas = document.createElement('canvas');
+    canvas.width = width; canvas.height = height;
+    const context = canvas.getContext('2d');
+    if (!context) throw new Error('浏览器无法处理图片');
+    context.fillStyle = '#fff'; context.fillRect(0, 0, width, height);
+    context.drawImage(bitmap, 0, 0, width, height);
+    for (const quality of [.9, .75, .6]) {
+      const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality));
+      if (blob && blob.size <= INLINE_LIMIT) return { file: new File([blob], `${file.name.replace(/\.[^.]+$/, '')}.jpg`, { type: 'image/jpeg' }), width, height };
+    }
+    throw new Error('图片压缩后仍过大,请缩小后重试');
+  } finally { bitmap.close(); }
+}

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