DELIVERY_MESSAGE_DESIGN.md 21 KB

交付执行阶段 - 消息发送功能详细设计

1. 功能概述

组员上传交付物后,可以选择性地发送消息到客户群,支持:

  • 预设消息模板快速选择
  • 自定义编辑消息内容
  • 单独发送图片(无需文字)
  • 查看已发送的消息历史

2. UI设计

2.1 布局结构

左右分栏布局:

  • 左侧:消息发送面板(30%宽度)
  • 右侧:交付物上传区域(70%宽度)

2.2 消息面板 HTML 结构

<!-- 消息发送面板 - 固定在左侧 -->
<div class="message-panel-container">
  <div class="message-panel">
    <!-- 面板头部 -->
    <div class="panel-header">
      <h3>发送消息到客户群</h3>
      <span class="stage-badge">{{ getCurrentStageName() }}</span>
    </div>

    <!-- 消息模板选择 -->
    <div class="template-section">
      <label>快速回复:</label>
      <div class="template-list">
        @for (template of getCurrentStageTemplates(); track template.id) {
          <div class="template-item" 
               [class.selected]="selectedTemplateId === template.id"
               (click)="selectTemplate(template.id)">
            <input type="radio" 
                   name="template"
                   [value]="template.id" 
                   [checked]="selectedTemplateId === template.id">
            <span class="template-text">{{ template.text }}</span>
          </div>
        }
      </div>
    </div>

    <!-- 消息编辑区 -->
    <div class="message-editor">
      <label>消息内容:</label>
      <textarea 
        [(ngModel)]="messageContent" 
        placeholder="输入消息内容,或选择上方快速回复..."
        rows="4">
      </textarea>
      <div class="char-count">{{ messageContent.length }} / 500</div>
    </div>

    <!-- 待发送图片预览 -->
    @if (pendingImages.length > 0) {
      <div class="pending-images">
        <label>待发送图片({{ pendingImages.length }}):</label>
        <div class="image-grid">
          @for (img of pendingImages; track img.id) {
            <div class="image-item">
              <img [src]="img.url" [alt]="img.name">
              <div class="image-overlay">
                <button class="btn-send-single" 
                        (click)="sendSingleImage(img)"
                        title="单独发送此图">
                  <svg width="16" height="16">...</svg>
                  单发
                </button>
              </div>
              <button class="btn-remove" 
                      (click)="removePendingImage(img.id)">
                ×
              </button>
            </div>
          }
        </div>
      </div>
    }

    <!-- 发送选项 -->
    <div class="send-options">
      <label class="checkbox-label">
        <input type="checkbox" 
               [(ngModel)]="includeImages"
               [disabled]="pendingImages.length === 0">
        <span>同时发送所有图片({{ pendingImages.length }})</span>
      </label>
    </div>

    <!-- 操作按钮 -->
    <div class="panel-actions">
      <button class="btn-send-message" 
              (click)="sendMessage()"
              [disabled]="!canSendMessage()">
        <svg>📤</svg>
        发送消息
      </button>
      <button class="btn-send-images" 
              (click)="sendImagesOnly()"
              [disabled]="pendingImages.length === 0">
        <svg>🖼️</svg>
        仅发送图片
      </button>
    </div>

    <!-- 消息历史 -->
    <div class="message-history">
      <div class="history-header">
        <h4>消息历史</h4>
        <button class="btn-refresh" (click)="loadMessageHistory()">
          <svg>🔄</svg>
        </button>
      </div>
      <div class="history-list">
        @if (messageHistory.length === 0) {
          <div class="empty-state">暂无消息记录</div>
        }
        @for (msg of messageHistory; track msg.id) {
          <div class="history-item">
            <div class="msg-meta">
              <span class="sender">{{ msg.senderName }}</span>
              <span class="time">{{ formatTime(msg.sentAt) }}</span>
            </div>
            @if (msg.content) {
              <div class="msg-content">{{ msg.content }}</div>
            }
            @if (msg.images && msg.images.length > 0) {
              <div class="msg-images">
                <svg>🖼️</svg>
                <span>{{ msg.images.length }} 张图片</span>
              </div>
            }
          </div>
        }
      </div>
    </div>
  </div>
</div>

2.3 样式设计 (SCSS)

.message-panel-container {
  position: sticky;
  top: 20px;
  height: calc(100vh - 120px);
  overflow: hidden;
}

.message-panel {
  display: flex;
  flex-direction: column;
  height: 100%;
  background: #fff;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
  overflow: hidden;

  .panel-header {
    padding: 16px;
    border-bottom: 1px solid #e5e7eb;
    display: flex;
    align-items: center;
    justify-content: space-between;
    background: linear-gradient(to bottom, #f9fafb, #ffffff);

    h3 {
      margin: 0;
      font-size: 16px;
      font-weight: 600;
      color: #1f2937;
    }

    .stage-badge {
      padding: 4px 12px;
      background: #4f46e5;
      color: white;
      border-radius: 12px;
      font-size: 12px;
      font-weight: 500;
    }
  }

  .template-section {
    padding: 16px;
    border-bottom: 1px solid #f3f4f6;

    label {
      display: block;
      margin-bottom: 8px;
      font-size: 13px;
      font-weight: 500;
      color: #6b7280;
    }

    .template-list {
      display: flex;
      flex-direction: column;
      gap: 8px;
    }

    .template-item {
      display: flex;
      align-items: flex-start;
      gap: 8px;
      padding: 10px;
      background: #f9fafb;
      border: 1px solid #e5e7eb;
      border-radius: 8px;
      cursor: pointer;
      transition: all 0.2s;

      &:hover {
        background: #f3f4f6;
        border-color: #d1d5db;
      }

      &.selected {
        background: #ede9fe;
        border-color: #8b5cf6;
      }

      input[type="radio"] {
        margin-top: 2px;
        flex-shrink: 0;
      }

      .template-text {
        flex: 1;
        font-size: 13px;
        line-height: 1.5;
        color: #374151;
      }
    }
  }

  .message-editor {
    padding: 16px;
    border-bottom: 1px solid #f3f4f6;

    label {
      display: block;
      margin-bottom: 8px;
      font-size: 13px;
      font-weight: 500;
      color: #6b7280;
    }

    textarea {
      width: 100%;
      padding: 10px;
      border: 1px solid #e5e7eb;
      border-radius: 8px;
      font-size: 13px;
      line-height: 1.6;
      resize: vertical;
      transition: border-color 0.2s;

      &:focus {
        outline: none;
        border-color: #8b5cf6;
        box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
      }
    }

    .char-count {
      margin-top: 4px;
      text-align: right;
      font-size: 11px;
      color: #9ca3af;
    }
  }

  .pending-images {
    padding: 16px;
    border-bottom: 1px solid #f3f4f6;

    label {
      display: block;
      margin-bottom: 8px;
      font-size: 13px;
      font-weight: 500;
      color: #6b7280;
    }

    .image-grid {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 8px;
    }

    .image-item {
      position: relative;
      aspect-ratio: 1;
      border-radius: 8px;
      overflow: hidden;
      border: 1px solid #e5e7eb;

      img {
        width: 100%;
        height: 100%;
        object-fit: cover;
      }

      .image-overlay {
        position: absolute;
        inset: 0;
        background: rgba(0, 0, 0, 0.6);
        display: flex;
        align-items: center;
        justify-content: center;
        opacity: 0;
        transition: opacity 0.2s;
      }

      &:hover .image-overlay {
        opacity: 1;
      }

      .btn-send-single {
        padding: 6px 12px;
        background: white;
        color: #4f46e5;
        border: none;
        border-radius: 6px;
        font-size: 11px;
        cursor: pointer;
      }

      .btn-remove {
        position: absolute;
        top: 4px;
        right: 4px;
        width: 20px;
        height: 20px;
        background: rgba(0, 0, 0, 0.7);
        color: white;
        border: none;
        border-radius: 50%;
        cursor: pointer;
        font-size: 14px;
        line-height: 1;
      }
    }
  }

  .send-options {
    padding: 12px 16px;
    background: #f9fafb;

    .checkbox-label {
      display: flex;
      align-items: center;
      gap: 8px;
      font-size: 13px;
      color: #374151;
      cursor: pointer;

      input[type="checkbox"] {
        width: 16px;
        height: 16px;
      }
    }
  }

  .panel-actions {
    padding: 16px;
    display: flex;
    gap: 8px;
    border-bottom: 1px solid #e5e7eb;

    button {
      flex: 1;
      padding: 10px 16px;
      border: none;
      border-radius: 8px;
      font-size: 13px;
      font-weight: 500;
      cursor: pointer;
      transition: all 0.2s;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 6px;

      &:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }
    }

    .btn-send-message {
      background: #4f46e5;
      color: white;

      &:hover:not(:disabled) {
        background: #4338ca;
      }
    }

    .btn-send-images {
      background: #10b981;
      color: white;

      &:hover:not(:disabled) {
        background: #059669;
      }
    }
  }

  .message-history {
    flex: 1;
    display: flex;
    flex-direction: column;
    overflow: hidden;

    .history-header {
      padding: 12px 16px;
      background: #f9fafb;
      display: flex;
      align-items: center;
      justify-content: space-between;

      h4 {
        margin: 0;
        font-size: 13px;
        font-weight: 600;
        color: #6b7280;
      }

      .btn-refresh {
        padding: 4px;
        background: transparent;
        border: none;
        cursor: pointer;
        color: #6b7280;

        &:hover {
          color: #4f46e5;
        }
      }
    }

    .history-list {
      flex: 1;
      overflow-y: auto;
      padding: 12px;

      .empty-state {
        text-align: center;
        padding: 40px 20px;
        color: #9ca3af;
        font-size: 13px;
      }

      .history-item {
        padding: 12px;
        background: #f9fafb;
        border-radius: 8px;
        margin-bottom: 8px;

        .msg-meta {
          display: flex;
          justify-content: space-between;
          margin-bottom: 6px;
          font-size: 11px;

          .sender {
            font-weight: 600;
            color: #4f46e5;
          }

          .time {
            color: #9ca3af;
          }
        }

        .msg-content {
          font-size: 13px;
          line-height: 1.5;
          color: #374151;
          margin-bottom: 6px;
        }

        .msg-images {
          display: flex;
          align-items: center;
          gap: 4px;
          font-size: 12px;
          color: #6b7280;
        }
      }
    }
  }
}

3. 消息模板配置

// src/modules/project/constants/message-templates.ts

export interface MessageTemplate {
  id: string;
  text: string;
  isDefault?: boolean;
}

export const MESSAGE_TEMPLATES: Record<string, MessageTemplate[]> = {
  // 白模阶段
  white_model: [
    {
      id: 'wm_default',
      text: '老师我这里硬装模型做好了,看下是否有问题,如果没有,我去做渲染',
      isDefault: true
    },
    {
      id: 'wm_2',
      text: '白模阶段已完成,请查看空间结构是否合理'
    },
    {
      id: 'wm_3',
      text: '硬装建模已完成,等待您的反馈'
    }
  ],
  
  // 软装阶段
  soft_decor: [
    {
      id: 'sd_default',
      text: '软装好了,准备渲染,有问题可以留言',
      isDefault: true
    },
    {
      id: 'sd_2',
      text: '软装配置已完成,请查看家具和材质选择'
    },
    {
      id: 'sd_3',
      text: '软装方案已完成,期待您的意见'
    }
  ],
  
  // 渲染阶段
  rendering: [
    {
      id: 'rd_default',
      text: '小图已完成,请查看整体效果',
      isDefault: true
    },
    {
      id: 'rd_2',
      text: '渲染图已完成,请查看光影和氛围效果'
    },
    {
      id: 'rd_3',
      text: '效果图已出,请确认是否需要调整'
    }
  ],
  
  // 后期阶段
  post_process: [
    {
      id: 'pp_default',
      text: '后期处理已完成,请查看最终成品',
      isDefault: true
    },
    {
      id: 'pp_2',
      text: '大图交付完成,请查收'
    },
    {
      id: 'pp_3',
      text: '最终成品已完成,如有需要修改的地方请及时反馈'
    }
  ]
};

4. MessageService 实现

// src/app/pages/services/message.service.ts

import { Injectable } from '@angular/core';
import Parse from 'parse';

@Injectable({
  providedIn: 'root'
})
export class MessageService {
  
  /**
   * 发送消息到客户群
   */
  async sendMessage(params: {
    projectId: string;
    stage: string;
    content?: string;
    images?: string[];
    messageType: 'text' | 'image' | 'text_with_images';
  }): Promise<void> {
    try {
      const ProjectMessage = Parse.Object.extend('ProjectMessage');
      const message = new ProjectMessage();
      
      // 设置字段
      message.set('project', {
        __type: 'Pointer',
        className: 'Project',
        objectId: params.projectId
      });
      message.set('stage', params.stage);
      message.set('messageType', params.messageType);
      if (params.content) message.set('content', params.content);
      if (params.images) message.set('images', params.images);
      message.set('sentBy', Parse.User.current());
      message.set('sentAt', new Date());
      message.set('status', 'sent');
      
      await message.save();
      
      // TODO: 调用企业微信API发送消息到客户群
      console.log('✅ 消息已发送到客户群');
      
    } catch (error) {
      console.error('❌ 发送消息失败:', error);
      throw error;
    }
  }
  
  /**
   * 获取项目的消息历史
   */
  async getProjectMessages(projectId: string, stage?: string): Promise<any[]> {
    try {
      const query = new Parse.Query('ProjectMessage');
      query.equalTo('project', {
        __type: 'Pointer',
        className: 'Project',
        objectId: projectId
      });
      if (stage) {
        query.equalTo('stage', stage);
      }
      query.include('sentBy');
      query.descending('createdAt');
      query.limit(50);
      
      const messages = await query.find();
      return messages.map(msg => ({
        id: msg.id,
        content: msg.get('content'),
        images: msg.get('images') || [],
        messageType: msg.get('messageType'),
        stage: msg.get('stage'),
        senderName: msg.get('sentBy')?.get('name') || '未知',
        sentAt: msg.get('sentAt') || msg.createdAt,
        status: msg.get('status')
      }));
      
    } catch (error) {
      console.error('❌ 获取消息历史失败:', error);
      return [];
    }
  }
}

5. 组件方法实现

// stage-delivery.component.ts 中新增的方法

// 消息相关属性
pendingImages: any[] = [];
messageContent: string = '';
selectedTemplateId: string = '';
includeImages: boolean = true;
messageHistory: any[] = [];

constructor(
  // ... 现有依赖
  private messageService: MessageService
) {}

/**
 * 上传成功后,添加到待发送图片列表
 */
onUploadSuccess(images: any[]): void {
  this.pendingImages = [...this.pendingImages, ...images];
  this.cdr.markForCheck();
}

/**
 * 获取当前阶段的消息模板
 */
getCurrentStageTemplates(): MessageTemplate[] {
  return MESSAGE_TEMPLATES[this.activeDeliveryType] || [];
}

/**
 * 选择消息模板
 */
selectTemplate(templateId: string): void {
  this.selectedTemplateId = templateId;
  const template = this.getCurrentStageTemplates().find(t => t.id === templateId);
  if (template) {
    this.messageContent = template.text;
    this.cdr.markForCheck();
  }
}

/**
 * 检查是否可以发送消息
 */
canSendMessage(): boolean {
  return this.messageContent.trim().length > 0 || this.pendingImages.length > 0;
}

/**
 * 发送消息(带图片)
 */
async sendMessage(): Promise<void> {
  if (!this.canSendMessage()) return;
  
  try {
    const images = this.includeImages ? this.pendingImages.map(img => img.url) : [];
    const messageType = this.messageContent && images.length > 0 
      ? 'text_with_images' 
      : this.messageContent 
        ? 'text' 
        : 'image';
    
    await this.messageService.sendMessage({
      projectId: this.project!.id!,
      stage: this.activeDeliveryType,
      content: this.messageContent,
      images,
      messageType
    });
    
    window?.fmode?.toast?.success?.('消息已发送到客户群');
    
    // 清空表单
    this.messageContent = '';
    this.selectedTemplateId = '';
    if (this.includeImages) {
      this.pendingImages = [];
    }
    
    // 重新加载消息历史
    await this.loadMessageHistory();
    this.cdr.markForCheck();
    
  } catch (error) {
    console.error('发送消息失败:', error);
    window?.fmode?.alert?.('发送消息失败,请重试');
  }
}

/**
 * 仅发送图片
 */
async sendImagesOnly(): Promise<void> {
  if (this.pendingImages.length === 0) return;
  
  try {
    await this.messageService.sendMessage({
      projectId: this.project!.id!,
      stage: this.activeDeliveryType,
      images: this.pendingImages.map(img => img.url),
      messageType: 'image'
    });
    
    window?.fmode?.toast?.success?.('图片已发送');
    
    this.pendingImages = [];
    await this.loadMessageHistory();
    this.cdr.markForCheck();
    
  } catch (error) {
    console.error('发送图片失败:', error);
    window?.fmode?.alert?.('发送图片失败,请重试');
  }
}

/**
 * 单独发送一张图片
 */
async sendSingleImage(image: any): Promise<void> {
  try {
    await this.messageService.sendMessage({
      projectId: this.project!.id!,
      stage: this.activeDeliveryType,
      images: [image.url],
      messageType: 'image'
    });
    
    window?.fmode?.toast?.success?.('图片已发送');
    
    // 从待发送列表移除
    this.pendingImages = this.pendingImages.filter(img => img.id !== image.id);
    await this.loadMessageHistory();
    this.cdr.markForCheck();
    
  } catch (error) {
    console.error('发送图片失败:', error);
    window?.fmode?.alert?.('发送图片失败,请重试');
  }
}

/**
 * 移除待发送图片
 */
removePendingImage(imageId: string): void {
  this.pendingImages = this.pendingImages.filter(img => img.id !== imageId);
  this.cdr.markForCheck();
}

/**
 * 加载消息历史
 */
async loadMessageHistory(): Promise<void> {
  try {
    this.messageHistory = await this.messageService.getProjectMessages(
      this.project!.id!,
      this.activeDeliveryType
    );
    this.cdr.markForCheck();
  } catch (error) {
    console.error('加载消息历史失败:', error);
  }
}

/**
 * 格式化时间
 */
formatTime(date: Date): string {
  const now = new Date();
  const diff = now.getTime() - new Date(date).getTime();
  const minutes = Math.floor(diff / 60000);
  
  if (minutes < 1) return '刚刚';
  if (minutes < 60) return `${minutes}分钟前`;
  if (minutes < 1440) return `${Math.floor(minutes / 60)}小时前`;
  
  const d = new Date(date);
  return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
}

6. 数据表设计

ProjectMessage 表结构

字段 类型 说明
project Pointer 关联的项目
stage String 阶段:white_model/soft_decor/rendering/post_process
messageType String 消息类型:text/image/text_with_images
content String 文本内容(可选)
images Array 图片URL数组(可选)
sentBy Pointer 发送人(User)
sentAt Date 发送时间
status String 状态:sent/failed

7. 集成到现有组件

修改上传文件方法

async uploadDeliveryFile(event: any, productId: string, deliveryType: string): Promise<void> {
  // ... 现有上传逻辑 ...
  
  // 上传成功后,添加到待发送列表
  const uploadedImages = results.map(file => ({
    id: file.id,
    url: file.fileUrl,
    name: file.fileName
  }));
  
  this.onUploadSuccess(uploadedImages);
  
  // 自动选择默认模板
  const templates = this.getCurrentStageTemplates();
  const defaultTemplate = templates.find(t => t.isDefault);
  if (defaultTemplate) {
    this.selectTemplate(defaultTemplate.id);
  }
}

8. 测试场景

功能测试

  1. ✅ 上传文件后自动添加到待发送列表
  2. ✅ 选择模板后自动填充到编辑器
  3. ✅ 可以修改模板内容
  4. ✅ 发送消息成功
  5. ✅ 单独发送图片成功
  6. ✅ 消息历史正确显示
  7. ✅ 切换阶段后清空表单

边界测试

  1. ✅ 消息内容为空但有图片:可以发送
  2. ✅ 消息内容不为空但无图片:可以发送
  3. ✅ 消息和图片都为空:不可发送
  4. ✅ 发送失败时的错误提示

完整!