TASK_8.6_COMPLETION.md 9.2 KB

任务 8.6 完成报告:实现底部固定栏

任务概述

实现商品发布/编辑表单的底部固定操作栏,包含取消、保存草稿和发布按钮。

实现内容

1. HTML 结构

product-form.component.html 中实现了底部固定栏:

<div *ngIf="!loading" class="form-footer">
  <button
    mat-stroked-button
    type="button"
    (click)="cancel()"
    [disabled]="saving"
  >
    取消
  </button>
  <button
    mat-stroked-button
    color="primary"
    type="button"
    (click)="saveDraft()"
    [disabled]="saving"
  >
    保存草稿
  </button>
  <button
    mat-raised-button
    color="primary"
    type="button"
    (click)="publish()"
    [disabled]="saving"
  >
    <mat-spinner *ngIf="saving" diameter="20" class="button-spinner"></mat-spinner>
    <span *ngIf="!saving">{{ isEditMode ? '保存并发布' : '立即发布' }}</span>
    <span *ngIf="saving">保存中...</span>
  </button>
</div>

特性:

  • 仅在非加载状态下显示
  • 包含三个按钮:取消、保存草稿、发布
  • 保存中时所有按钮禁用
  • 发布按钮显示加载图标和状态文本
  • 根据编辑/创建模式显示不同的按钮文本

2. TypeScript 逻辑

product-form.component.ts 中实现了按钮功能:

/**
 * 保存草稿
 */
saveDraft(): void {
  if (this.productForm.invalid) {
    this.snackBar.open('请填写必填项', '关闭', {
      duration: 3000,
      horizontalPosition: 'center',
      verticalPosition: 'top'
    });
    return;
  }

  this.save(ProductStatus.OffShelf);
}

/**
 * 发布商品
 */
publish(): void {
  if (this.productForm.invalid) {
    this.snackBar.open('请填写必填项', '关闭', {
      duration: 3000,
      horizontalPosition: 'center',
      verticalPosition: 'top'
    });
    return;
  }

  this.save(ProductStatus.OnShelf);
}

/**
 * 取消编辑
 */
cancel(): void {
  this.router.navigate(['/products/list']);
}

/**
 * 保存商品
 */
private save(status: ProductStatus): void {
  this.saving = true;
  
  const imageUrls = this.imageFiles.map(img => img.url);
  
  const formValue = {
    ...this.productForm.value,
    images: imageUrls,
    status
  };

  const saveObservable = this.isEditMode
    ? this.productService.updateProduct(this.productId!, formValue)
    : this.productService.createProduct(formValue);

  saveObservable
    .pipe(takeUntil(this.destroy$))
    .subscribe({
      next: () => {
        const message = this.isEditMode ? '商品更新成功' : '商品创建成功';
        this.snackBar.open(message, '关闭', {
          duration: 3000,
          horizontalPosition: 'center',
          verticalPosition: 'top'
        });
        this.saving = false;
        this.router.navigate(['/products/list']);
      },
      error: (error) => {
        console.error('保存商品失败:', error);
        this.snackBar.open('保存失败,请重试', '关闭', {
          duration: 3000,
          horizontalPosition: 'center',
          verticalPosition: 'top'
        });
        this.saving = false;
      }
    });
}

功能:

  • cancel(): 返回商品列表页
  • saveDraft(): 以下架状态保存商品
  • publish(): 以上架状态保存商品
  • save(): 统一的保存逻辑,处理创建和更新

3. 样式实现

product-form.component.scss 中实现了固定栏样式:

.form-footer {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  background-color: #fff;
  border-top: 1px solid #e0e0e0;
  padding: 16px 24px;
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  z-index: 100;
  box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);

  button {
    min-width: 120px;
  }

  .button-spinner {
    display: inline-block;
    margin-right: 8px;
    vertical-align: middle;
  }
}

// 为底部固定栏留出空间
.product-form-container {
  padding: 24px;
  padding-bottom: 100px;
  max-width: 1200px;
  margin: 0 auto;
}

// 响应式适配
@media (max-width: 768px) {
  .form-footer {
    padding: 12px 16px;
    flex-wrap: wrap;

    button {
      flex: 1;
      min-width: auto;
    }
  }
}

样式特性:

  • 固定在页面底部
  • 白色背景,顶部边框和阴影
  • 按钮右对齐,间距 12px
  • 按钮最小宽度 120px
  • 加载图标内联显示
  • 移动端响应式布局

4. 测试覆盖

product-form.component.spec.ts 中添加了完整的测试:

describe('底部固定栏', () => {
  it('应该在非加载状态下显示底部固定栏', () => {
    fixture.detectChanges();
    component.loading = false;
    fixture.detectChanges();
    
    const footer = fixture.nativeElement.querySelector('.form-footer');
    expect(footer).toBeTruthy();
  });

  it('应该在加载状态下隐藏底部固定栏', () => {
    fixture.detectChanges();
    component.loading = true;
    fixture.detectChanges();
    
    const footer = fixture.nativeElement.querySelector('.form-footer');
    expect(footer).toBeFalsy();
  });

  it('底部固定栏应该包含三个按钮', () => {
    fixture.detectChanges();
    component.loading = false;
    fixture.detectChanges();
    
    const buttons = fixture.nativeElement.querySelectorAll('.form-footer button');
    expect(buttons.length).toBe(3);
  });

  it('取消按钮应该返回列表页', () => {
    fixture.detectChanges();
    component.cancel();
    expect(mockRouter.navigate).toHaveBeenCalledWith(['/products/list']);
  });

  it('保存中时应该禁用所有按钮', () => {
    fixture.detectChanges();
    component.saving = true;
    fixture.detectChanges();
    
    const buttons = fixture.nativeElement.querySelectorAll('.form-footer button');
    buttons.forEach((button: HTMLButtonElement) => {
      expect(button.disabled).toBe(true);
    });
  });

  it('保存中时发布按钮应该显示加载图标', () => {
    fixture.detectChanges();
    component.saving = true;
    fixture.detectChanges();
    
    const spinner = fixture.nativeElement.querySelector('.button-spinner');
    expect(spinner).toBeTruthy();
  });

  it('保存中时发布按钮应该显示"保存中..."文本', () => {
    fixture.detectChanges();
    component.saving = true;
    fixture.detectChanges();
    
    const publishButton = fixture.nativeElement.querySelector('.form-footer button:last-child');
    expect(publishButton.textContent).toContain('保存中...');
  });

  it('非保存状态时发布按钮应该显示正确文本(创建模式)', () => {
    fixture.detectChanges();
    component.isEditMode = false;
    component.saving = false;
    fixture.detectChanges();
    
    const publishButton = fixture.nativeElement.querySelector('.form-footer button:last-child');
    expect(publishButton.textContent).toContain('立即发布');
  });

  it('非保存状态时发布按钮应该显示正确文本(编辑模式)', (done) => {
    mockActivatedRoute.params = of({ id: 'P1001' });
    mockProductService.getProduct.and.returnValue(of(mockProduct));
    
    fixture.detectChanges();
    
    setTimeout(() => {
      component.saving = false;
      fixture.detectChanges();
      
      const publishButton = fixture.nativeElement.querySelector('.form-footer button:last-child');
      expect(publishButton.textContent).toContain('保存并发布');
      done();
    }, 100);
  });
});

测试覆盖:

  • 固定栏显示/隐藏逻辑
  • 按钮数量验证
  • 取消按钮功能
  • 保存状态下的按钮禁用
  • 加载图标显示
  • 按钮文本根据状态变化
  • 创建/编辑模式下的不同文本

需求验证

Requirement 9.6

✅ THE Merchant Portal SHALL display a sticky footer bar with cancel, save draft, and publish buttons

验证:

  • ✅ 创建了 Sticky Footer 容器(position: fixed
  • ✅ 实现了取消按钮(返回列表)
  • ✅ 实现了保存草稿按钮
  • ✅ 实现了提交发布按钮(带加载状态)
  • ✅ 所有按钮在保存时禁用
  • ✅ 发布按钮显示加载图标和状态文本

技术亮点

  1. 固定定位:使用 position: fixed 实现底部固定,不随页面滚动
  2. 状态管理:通过 saving 标志控制按钮禁用和加载状态
  3. 加载反馈:使用 Material Spinner 和文本提供清晰的加载反馈
  4. 响应式设计:移动端按钮自适应布局
  5. 用户体验
    • 保存中禁用所有按钮防止重复提交
    • 显示加载图标和文本提供即时反馈
    • 根据模式显示不同的按钮文本
    • 表单验证失败时显示友好提示

文件清单

修改的文件

  1. src/app/pages/products/product-form/product-form.component.html - 添加底部固定栏 HTML
  2. src/app/pages/products/product-form/product-form.component.ts - 实现按钮逻辑
  3. src/app/pages/products/product-form/product-form.component.scss - 添加固定栏样式
  4. src/app/pages/products/product-form/product-form.component.spec.ts - 添加测试用例

新增的文件

总结

任务 8.6 已成功完成。实现了功能完整的底部固定操作栏,包含取消、保存草稿和发布三个按钮,具有完善的加载状态反馈和响应式布局。所有功能都经过了单元测试验证,符合需求规范。

底部固定栏为商品发布/编辑表单提供了便捷的操作入口,用户无需滚动到页面底部即可执行保存操作,提升了用户体验。