# 交付执行阶段 - 消息发送功能详细设计 ## 1. 功能概述 组员上传交付物后,可以选择性地发送消息到客户群,支持: - 预设消息模板快速选择 - 自定义编辑消息内容 - 单独发送图片(无需文字) - 查看已发送的消息历史 --- ## 2. UI设计 ### 2.1 布局结构 左右分栏布局: - **左侧**:消息发送面板(30%宽度) - **右侧**:交付物上传区域(70%宽度) ### 2.2 消息面板 HTML 结构 ```html

发送消息到客户群

{{ getCurrentStageName() }}
@for (template of getCurrentStageTemplates(); track template.id) {
{{ template.text }}
}
{{ messageContent.length }} / 500
@if (pendingImages.length > 0) {
@for (img of pendingImages; track img.id) {
}
}

消息历史

@if (messageHistory.length === 0) {
暂无消息记录
} @for (msg of messageHistory; track msg.id) {
{{ msg.senderName }} {{ formatTime(msg.sentAt) }}
@if (msg.content) {
{{ msg.content }}
} @if (msg.images && msg.images.length > 0) {
🖼️ {{ msg.images.length }} 张图片
}
}
``` ### 2.3 样式设计 (SCSS) ```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. 消息模板配置 ```typescript // src/modules/project/constants/message-templates.ts export interface MessageTemplate { id: string; text: string; isDefault?: boolean; } export const MESSAGE_TEMPLATES: Record = { // 白模阶段 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 实现 ```typescript // 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 { 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 { 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. 组件方法实现 ```typescript // 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 { 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 { 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 { 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 { 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. 集成到现有组件 ### 修改上传文件方法 ```typescript async uploadDeliveryFile(event: any, productId: string, deliveryType: string): Promise { // ... 现有上传逻辑 ... // 上传成功后,添加到待发送列表 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. ✅ 发送失败时的错误提示 --- 完整!