组员上传交付物后,可以选择性地发送消息到客户群,支持:
左右分栏布局:
<!-- 消息发送面板 - 固定在左侧 -->
<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>
.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;
}
}
}
}
}
// 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: '最终成品已完成,如有需要修改的地方请及时反馈'
}
]
};
// 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 [];
}
}
}
// 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')}`;
}
| 字段 | 类型 | 说明 |
|---|---|---|
| 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 |
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);
}
}
完整!