订单分配阶段是项目管理流程的第一个环节,主要负责将客户咨询转化为正式项目订单,并完成设计师团队的初步分配。该阶段是连接客服端和设计师端的关键桥梁。
class ProductIdentificationService {
// 基于客户需求描述自动识别空间设计产品
async identifyProductsFromDescription(description: string): Promise<ProductIdentificationResult> {
const result: ProductIdentificationResult = {
identifiedProducts: [],
confidence: 0,
reasoning: '',
suggestedQuestions: []
};
// 产品类型关键词映射
const productKeywords = {
[ProductType.LIVING_ROOM]: ['客厅', '起居室', '会客厅', '茶室', '待客区'],
[ProductType.BEDROOM]: ['卧室', '主卧', '次卧', '儿童房', '老人房', '客房', '主人房'],
[ProductType.KITCHEN]: ['厨房', '开放式厨房', '中西厨', '餐厨一体'],
[ProductType.BATHROOM]: ['卫生间', '浴室', '洗手间', '盥洗室', '主卫', '次卫'],
[ProductType.DINING_ROOM]: ['餐厅', '餐厅区', '用餐区', '就餐空间'],
[ProductType.STUDY]: ['书房', '工作室', '办公室', '学习区', '阅读区'],
[ProductType.BALCONY]: ['阳台', '露台', '花园阳台', '休闲阳台'],
[ProductType.CORRIDOR]: ['走廊', '过道', '玄关', '门厅', '入户'],
[ProductType.STORAGE]: ['储物间', '衣帽间', '杂物间', '收纳空间']
};
// 分析描述中的空间关键词
const foundProducts: Array<{ type: ProductType; keywords: string[]; confidence: number }> = [];
for (const [productType, keywords] of Object.entries(productKeywords)) {
const matchedKeywords = keywords.filter(keyword =>
description.toLowerCase().includes(keyword.toLowerCase())
);
if (matchedKeywords.length > 0) {
foundProducts.push({
type: productType as ProductType,
keywords: matchedKeywords,
confidence: matchedKeywords.length / keywords.length
});
}
}
// 按置信度排序
foundProducts.sort((a, b) => b.confidence - a.confidence);
// 构建识别结果
result.identifiedProducts = foundProducts.map(fp => ({
type: fp.type,
productName: this.getDefaultProductName(fp.type),
priority: this.calculateProductPriority(fp.type, fp.confidence),
confidence: fp.confidence,
identifiedKeywords: fp.keywords
}));
// 计算整体置信度
result.confidence = foundProducts.length > 0
? foundProducts.reduce((sum, fp) => sum + fp.confidence, 0) / foundProducts.length
: 0;
// 生成推理说明
result.reasoning = this.generateIdentificationReasoning(foundProducts);
// 生成建议问题
result.suggestedQuestions = this.generateClarifyingQuestions(foundProducts);
return result;
}
// 基于面积和预算推断产品设计数量
estimateProductCount(totalArea: number, budget: number): ProductEstimationResult {
const result: ProductEstimationResult = {
estimatedProductCount: 1,
confidence: 0.5,
reasoning: '',
possibleProductTypes: []
};
// 基于面积的产品数量估算
const areaBasedCount = Math.max(1, Math.floor(totalArea / 20)); // 每20平米一个主要空间
// 基于预算的产品数量估算
const budgetBasedCount = Math.max(1, Math.floor(budget / 30000)); // 每3万一个空间
// 综合判断
const finalCount = Math.min(areaBasedCount, budgetBasedCount);
result.estimatedProductCount = finalCount;
// 推断可能的空间类型
result.possibleProductTypes = this.inferPossibleProductTypes(totalArea, budget);
// 生成推理
result.reasoning = `基于面积${totalArea}平米和预算${budget}元,估算需要${finalCount}个主要空间设计产品`;
return result;
}
// 生成产品设计配置建议
generateProductConfiguration(
identifiedProducts: IdentifiedProduct[],
totalArea: number,
budget: number
): ProductConfiguration {
const configuration: ProductConfiguration = {
products: [],
totalEstimatedBudget: 0,
budgetAllocation: {},
recommendations: []
};
// 为识别出的产品创建配置
for (const product of identifiedProducts) {
const productConfig = this.createProductConfiguration(product, totalArea, budget);
configuration.products.push(productConfig);
configuration.budgetAllocation[product.type] = productConfig.estimatedBudget;
}
// 如果没有识别出产品,创建默认配置
if (configuration.products.length === 0) {
const defaultProduct = this.createDefaultProductConfiguration(totalArea, budget);
configuration.products.push(defaultProduct);
configuration.budgetAllocation[defaultProduct.type] = defaultProduct.estimatedBudget;
}
// 计算总预算
configuration.totalEstimatedBudget = Object.values(configuration.budgetAllocation)
.reduce((sum, budget) => sum + budget, 0);
// 生成建议
configuration.recommendations = this.generateConfigurationRecommendations(configuration);
return configuration;
}
}
interface ProductIdentificationResult {
identifiedProducts: IdentifiedProduct[];
confidence: number;
reasoning: string;
suggestedQuestions: string[];
}
interface IdentifiedProduct {
type: ProductType;
productName: string;
priority: number;
confidence: number;
identifiedKeywords: string[];
}
interface ProductEstimationResult {
estimatedProductCount: number;
confidence: number;
reasoning: string;
possibleProductTypes: ProductType[];
}
interface ProductConfiguration {
products: ProductConfig[];
totalEstimatedBudget: number;
budgetAllocation: Record<ProductType, number>;
recommendations: string[];
}
interface ProductConfig {
type: ProductType;
productName: string;
estimatedArea: number;
estimatedBudget: number;
priority: number;
complexity: 'simple' | 'medium' | 'complex';
}
<!-- 空间产品设计管理面板 -->
<div class="space-management-panel">
<div class="panel-header">
<h3>空间配置</h3>
<div class="space-type-indicator">
<span class="indicator-label">项目类型:</span>
<span class="indicator-value"
[class.single-space]="isSingleSpaceProject"
[class.multi-space]="!isSingleSpaceProject">
{{ isSingleSpaceProject ? '单空间项目' : '多空间项目' }}
</span>
</div>
</div>
<!-- 空间识别结果 -->
<div class="space-identification-result" *ngIf="spaceIdentificationResult">
<div class="identification-summary">
<h4>识别到 {{ spaceIdentificationResult.identifiedSpaces.length }} 个空间</h4>
<div class="confidence-indicator">
<span class="confidence-label">置信度:</span>
<div class="confidence-bar">
<div class="confidence-fill"
[style.width.%]="spaceIdentificationResult.confidence * 100"
[class.high]="spaceIdentificationResult.confidence >= 0.8"
[class.medium]="spaceIdentificationResult.confidence >= 0.5 && spaceIdentificationResult.confidence < 0.8"
[class.low]="spaceIdentificationResult.confidence < 0.5">
</div>
</div>
<span class="confidence-value">{{ Math.round(spaceIdentificationResult.confidence * 100) }}%</span>
</div>
</div>
<div class="identification-reasoning">
<p>{{ spaceIdentificationResult.reasoning }}</p>
</div>
<!-- 建议问题 -->
<div class="suggested-questions" *ngIf="spaceIdentificationResult.suggestedQuestions.length > 0">
<h5>建议确认的问题:</h5>
<ul>
@for (question of spaceIdentificationResult.suggestedQuestions; track question) {
<li>{{ question }}</li>
}
</ul>
</div>
</div>
<!-- 空间列表 -->
<div class="spaces-list">
<div class="list-header">
<h4>空间列表</h4>
<button class="btn-add-space" (click)="showAddSpaceDialog()">
<i class="icon-plus"></i> 添加空间
</button>
</div>
<div class="spaces-grid">
@for (space of projectSpaces; track space.id) {
<div class="space-card"
[class.high-priority]="space.priority >= 8"
[class.medium-priority]="space.priority >= 5 && space.priority < 8"
[class.low-priority]="space.priority < 5">
<div class="space-header">
<div class="space-icon">
<i class="icon-{{ getSpaceIcon(space.type) }}"></i>
</div>
<div class="space-info">
<input type="text"
[(ngModel)]="space.name"
class="space-name-input"
placeholder="空间名称">
<select [(ngModel)]="space.type" class="space-type-select">
<option value="{{ SpaceType.LIVING_ROOM }}">客厅</option>
<option value="{{ SpaceType.BEDROOM }}">卧室</option>
<option value="{{ SpaceType.KITCHEN }}">厨房</option>
<option value="{{ SpaceType.BATHROOM }}">卫生间</option>
<option value="{{ SpaceType.DINING_ROOM }}">餐厅</option>
<option value="{{ SpaceType.STUDY }}">书房</option>
<option value="{{ SpaceType.BALCONY }}">阳台</option>
<option value="{{ SpaceType.CORRIDOR }}">走廊</option>
<option value="{{ SpaceType.STORAGE }}">储物间</option>
<option value="{{ SpaceType.OTHER }}">其他</option>
</select>
</div>
<div class="space-actions">
<button class="btn-icon" (click)="editSpace(space.id)" title="编辑">
<i class="icon-edit"></i>
</button>
<button class="btn-icon danger" (click)="removeSpace(space.id)" title="删除">
<i class="icon-delete"></i>
</button>
</div>
</div>
<div class="space-details">
<div class="detail-row">
<label>面积:</label>
<input type="number"
[(ngModel)]="space.area"
min="1"
class="detail-input">
<span class="unit">m²</span>
</div>
<div class="detail-row">
<label>优先级:</label>
<select [(ngModel)]="space.priority" class="priority-select">
<option [ngValue]="10">最高</option>
<option [ngValue]="8">高</option>
<option [ngValue]="5">中</option>
<option [ngValue]="3">低</option>
<option [ngValue]="1">最低</option>
</select>
</div>
<div class="detail-row">
<label>复杂度:</label>
<select [(ngModel)]="space.complexity" class="complexity-select">
<option value="simple">简单</option>
<option value="medium">中等</option>
<option value="complex">复杂</option>
</select>
</div>
</div>
<div class="space-budget">
<label>预估预算:</label>
<div class="budget-input-group">
<input type="number"
[(ngModel)]="space.estimatedBudget"
min="0"
class="budget-input">
<span class="currency">元</span>
</div>
</div>
</div>
}
</div>
</div>
<!-- 空间统计信息 -->
<div class="space-statistics">
<div class="stat-item">
<span class="stat-label">总空间数:</span>
<span class="stat-value">{{ projectSpaces.length }}</span>
</div>
<div class="stat-item">
<span class="stat-label">总面积:</span>
<span class="stat-value">{{ totalSpaceArea }}m²</span>
</div>
<div class="stat-item">
<span class="stat-label">总预算:</span>
<span class="stat-value">¥{{ totalSpaceBudget.toLocaleString() }}</span>
</div>
</div>
</div>
class MultiSpaceQuotationService {
// 计算多空间项目报价
calculateMultiSpaceQuotation(
spaces: ProjectSpace[],
globalOptions: QuotationOptions
): MultiSpaceQuotation {
const quotation: MultiSpaceQuotation = {
projectId: this.getProjectId(),
quotationDate: new Date(),
currency: 'CNY',
// 空间报价明细
spaceQuotations: [],
// 全局折扣和优惠
globalDiscounts: [],
// 汇总信息
summary: {
totalBaseAmount: 0,
totalDiscountAmount: 0,
finalAmount: 0,
averagePricePerSqm: 0
}
};
// 计算各空间报价
for (const space of spaces) {
const spaceQuotation = this.calculateSpaceQuotation(space, globalOptions);
quotation.spaceQuotations.push(spaceQuotation);
}
// 计算基础总额
quotation.summary.totalBaseAmount = quotation.spaceQuotations
.reduce((sum, sq) => sum + sq.totalAmount, 0);
// 应用多空间折扣
quotation.globalDiscounts = this.calculateMultiSpaceDiscounts(
spaces,
quotation.summary.totalBaseAmount
);
// 计算折扣总额
quotation.summary.totalDiscountAmount = quotation.globalDiscounts
.reduce((sum, discount) => sum + discount.value, 0);
// 计算最终金额
quotation.summary.finalAmount = quotation.summary.totalBaseAmount - quotation.summary.totalDiscountAmount;
// 计算平均单价
const totalArea = spaces.reduce((sum, space) => sum + (space.area || 0), 0);
quotation.summary.averagePricePerSqm = totalArea > 0 ? quotation.summary.finalAmount / totalArea : 0;
return quotation;
}
// 计算单个空间报价
private calculateSpaceQuotation(
space: ProjectSpace,
options: QuotationOptions
): SpaceQuotation {
// 基础价格计算
const basePrice = this.calculateBasePrice(space, options);
// 复杂度调整
const complexityMultiplier = this.getComplexityMultiplier(space.complexity);
// 优先级调整
const priorityMultiplier = this.getPriorityMultiplier(space.priority);
// 面积系数
const areaCoefficient = this.getAreaCoefficient(space.area || 0);
// 计算最终价格
const finalPrice = basePrice * complexityMultiplier * priorityMultiplier * areaCoefficient;
const spaceQuotation: SpaceQuotation = {
spaceId: space.id,
spaceName: space.name,
spaceType: space.type,
area: space.area || 0,
// 价格明细
priceBreakdown: {
basePrice: basePrice,
complexityAdjustment: basePrice * (complexityMultiplier - 1),
priorityAdjustment: basePrice * (priorityMultiplier - 1),
areaAdjustment: basePrice * (areaCoefficient - 1),
},
// 总价
totalAmount: finalPrice,
// 单价
unitPrice: space.area ? finalPrice / space.area : 0,
// 时间预估
estimatedDays: this.calculateEstimatedDays(space, finalPrice),
// 设计师配置
designerRequirements: this.getDesignerRequirements(space)
};
return spaceQuotation;
}
// 计算多空间折扣
private calculateMultiSpaceDiscounts(
spaces: ProjectSpace[],
baseAmount: number
): QuotationDiscount[] {
const discounts: QuotationDiscount[] = [];
// 1. 空间数量折扣
if (spaces.length >= 5) {
discounts.push({
type: 'space_count',
name: '5空间及以上项目折扣',
description: '5个及以上空间项目享受10%折扣',
value: baseAmount * 0.1,
isApplicable: true
});
} else if (spaces.length >= 3) {
discounts.push({
type: 'space_count',
name: '3-4空间项目折扣',
description: '3-4个空间项目享受5%折扣',
value: baseAmount * 0.05,
isApplicable: true
});
}
// 2. 总额折扣
if (baseAmount >= 200000) {
discounts.push({
type: 'total_amount',
name: '高额度项目折扣',
description: '项目总额超过20万享受额外5%折扣',
value: baseAmount * 0.05,
isApplicable: true
});
}
// 3. 复杂度折扣(针对全高优先级空间)
const allHighPriority = spaces.every(space => space.priority >= 8);
if (allHighPriority) {
discounts.push({
type: 'priority_bonus',
name: '高优先级项目折扣',
description: '全高优先级空间项目享受3%折扣',
value: baseAmount * 0.03,
isApplicable: true
});
}
return discounts;
}
// 生成报价单
async generateQuotationDocument(quotation: MultiSpaceQuotation): Promise<QuotationDocument> {
const document: QuotationDocument = {
id: `quotation_${Date.now()}`,
projectId: quotation.projectId,
documentNumber: this.generateQuotationNumber(),
issueDate: new Date(),
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30天有效期
currency: quotation.currency,
quotation: quotation,
// 客户信息
customerInfo: await this.getCustomerInfo(),
// 项目信息
projectInfo: await this.getProjectInfo(),
// 支付条款
paymentTerms: this.generatePaymentTerms(quotation),
// 服务内容
serviceScope: this.generateServiceScope(quotation),
// 注意事项
notes: this.generateQuotationNotes(quotation)
};
return document;
}
}
interface MultiSpaceQuotation {
projectId: string;
quotationDate: Date;
currency: string;
spaceQuotations: SpaceQuotation[];
globalDiscounts: QuotationDiscount[];
summary: {
totalBaseAmount: number;
totalDiscountAmount: number;
finalAmount: number;
averagePricePerSqm: number;
};
}
interface SpaceQuotation {
spaceId: string;
spaceName: string;
spaceType: SpaceType;
area: number;
priceBreakdown: {
basePrice: number;
complexityAdjustment: number;
priorityAdjustment: number;
areaAdjustment: number;
};
totalAmount: number;
unitPrice: number;
estimatedDays: number;
designerRequirements: string[];
}
interface QuotationDiscount {
type: 'space_count' | 'total_amount' | 'priority_bonus' | 'special_offer';
name: string;
description: string;
value: number;
isApplicable: boolean;
}
interface QuotationDocument {
id: string;
projectId: string;
documentNumber: string;
issueDate: Date;
validUntil: Date;
currency: string;
quotation: MultiSpaceQuotation;
customerInfo: any;
projectInfo: any;
paymentTerms: any;
serviceScope: any;
notes: string[];
}
位置:订单分配阶段左侧面板
展示内容:
interface CustomerInfoDisplay {
// 基础信息
name: string; // 客户姓名
phone: string; // 联系电话
wechat?: string; // 微信号
customerType: string; // 客户类型:新客户/老客户/VIP客户
source: string; // 来源:小程序/官网咨询/推荐介绍
remark?: string; // 备注信息
// 状态信息
syncStatus: 'syncing' | 'synced' | 'error'; // 同步状态
lastSyncTime?: Date; // 最后同步时间
// 需求标签
demandType?: string; // 需求类型:价格敏感/质量敏感/综合要求
followUpStatus?: string; // 跟进状态:待报价/待确认需求/已失联
preferenceTags?: string[]; // 偏好标签数组
}
数据来源:
客服端同步:通过路由查询参数 syncData 传递客户信息
// 客服端跳转示例
router.navigate(['/designer/project-detail', projectId], {
queryParams: {
syncData: JSON.stringify({
customerInfo: {...},
requirementInfo: {...},
preferenceTags: [...]
})
}
});
实时同步机制:
交互特性:
isCustomerInfoExpanded)适用场景:手动创建订单时快速选择已有客户
搜索逻辑:
searchCustomer(): void {
// 至少输入2个字符才触发搜索
if (this.customerSearchKeyword.trim().length >= 2) {
// 模糊搜索客户姓名、手机号、微信号
this.customerSearchResults = this.customerService.search({
keyword: this.customerSearchKeyword,
fields: ['name', 'phone', 'wechat']
});
}
}
搜索结果展示:
表单定义:
orderCreationForm = this.fb.group({
orderAmount: ['', [Validators.required, Validators.min(0)]],
smallImageDeliveryTime: ['', Validators.required],
decorationType: ['', Validators.required],
requirementReason: ['', Validators.required],
isMultiDesigner: [false]
});
字段详解:
| 字段名 | 类型 | 验证规则 | 说明 | UI组件 |
|---|---|---|---|---|
orderAmount |
number | required, min(0) | 订单金额,单位:元 | 数字输入框,支持千分位格式化 |
smallImageDeliveryTime |
Date | required | 小图交付时间 | 日期选择器,限制最早日期为今天 |
decorationType |
string | required | 装修类型:全包/半包/清包/软装 | 下拉选择框 |
requirementReason |
string | required | 需求原因:新房装修/旧房改造/局部翻新 | 单选框组 |
isMultiDesigner |
boolean | - | 是否需要多设计师协作 | 复选框 |
表单验证提示:
markAllAsTouched() 显示所有错误折叠面板设计:
<div class="optional-info-section">
<div class="section-header" (click)="isOptionalFormExpanded = !isOptionalFormExpanded">
<span>可选信息</span>
<span class="toggle-icon">{{ isOptionalFormExpanded ? '▼' : '▶' }}</span>
</div>
@if (isOptionalFormExpanded) {
<div class="section-content">
<!-- 可选字段表单 -->
</div>
}
</div>
可选字段:
optionalForm = this.fb.group({
largeImageDeliveryTime: [''], // 大图交付时间
spaceRequirements: [''], // 空间需求描述
designAngles: [''], // 设计角度要求
specialAreaHandling: [''], // 特殊区域处理说明
materialRequirements: [''], // 材质要求
lightingRequirements: [''] // 光照需求
});
组件标签:
<app-quotation-details
[initialData]="quotationData"
[readonly]="isReadOnly()"
(dataChange)="onQuotationDataChange($event)">
</app-quotation-details>
数据结构:
interface QuotationData {
items: Array<{
id: string;
room: string; // 空间名称:客餐厅/主卧/次卧/厨房/卫生间
amount: number; // 金额
description?: string; // 描述
}>;
totalAmount: number; // 总金额
materialCost: number; // 材料费
laborCost: number; // 人工费
designFee: number; // 设计费
managementFee: number; // 管理费
}
报价项管理:
AI辅助生成(可选功能):
generateQuotationDetails(): void {
// 基于项目信息自动生成报价明细
const rooms = ['客餐厅', '主卧', '次卧', '厨房', '卫生间'];
this.quotationDetails = rooms.map((room, index) => ({
id: `quote_${index + 1}`,
room: room,
amount: Math.floor(Math.random() * 1000) + 300,
description: `${room}装修设计费用`
}));
this.orderAmount = this.quotationDetails.reduce((total, item) => total + item.amount, 0);
}
金额自动汇总:
组件标签:
<app-designer-assignment
[projectData]="projectData"
[readonly]="isReadOnly()"
(assignmentChange)="onDesignerAssignmentChange($event)"
(designerClick)="onDesignerClick($event)">
</app-designer-assignment>
数据结构:
interface DesignerAssignmentData {
selectedDesigners: Designer[];
teamId: string;
teamName: string;
leaderId: string;
assignmentDate: Date;
expectedStartDate: Date;
}
interface Designer {
id: string;
name: string;
avatar: string;
teamId: string;
teamName: string;
isTeamLeader: boolean;
status: 'idle' | 'busy' | 'unavailable';
currentProjects: number;
skillMatch: number; // 技能匹配度 0-100
recentOrders: number; // 近期订单数
idleDays: number; // 闲置天数
workload: number; // 工作负荷 0-100
reviewDates: string[]; // 对图评审日期列表
}
智能推荐算法:
calculateDesignerScore(designer: Designer): number {
let score = 0;
// 1. 技能匹配度(权重40%)
score += designer.skillMatch * 0.4;
// 2. 工作负荷(权重30%,负荷越低分数越高)
score += (100 - designer.workload) * 0.3;
// 3. 闲置时间(权重20%,闲置越久分数越高)
score += Math.min(designer.idleDays * 2, 100) * 0.2;
// 4. 近期接单数(权重10%,接单越少分数越高)
score += Math.max(0, 100 - designer.recentOrders * 10) * 0.1;
return score;
}
设计师列表展示:
idle 空闲 - 绿色busy 繁忙 - 橙色unavailable 不可用 - 灰色触发条件:点击设计师卡片时
弹窗内容:
<app-designer-calendar
[designers]="[selectedDesigner]"
[groups]="calendarGroups"
[selectedDate]="selectedCalendarDate"
(designerSelected)="onCalendarDesignerSelected($event)"
(assignmentRequested)="onCalendarAssignmentRequested($event)">
</app-designer-calendar>
日历功能:
实现逻辑:
ngOnInit(): void {
// 自动生成下单时间
this.orderTime = new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
显示格式:2025-10-16 14:30:25
用途:
sequenceDiagram
participant CS as 客服端
participant Route as 路由
participant PD as 项目详情页
participant Form as 订单表单
CS->>Route: navigate with syncData
Route->>PD: queryParams.syncData
PD->>PD: parseJSON(syncData)
PD->>Form: patchValue(customerInfo)
PD->>PD: syncRequirementKeyInfo(requirementInfo)
PD->>PD: 更新 projectData
PD->>PD: 触发 cdr.detectChanges()
PD-->>CS: 同步完成
关键代码实现:
// project-detail.ts lines 741-816
this.route.queryParamMap.subscribe({
next: (qp) => {
const syncDataParam = qp.get('syncData');
if (syncDataParam) {
try {
const syncData = JSON.parse(syncDataParam);
// 设置同步状态
this.isSyncingCustomerInfo = true;
// 存储订单分配数据用于显示
this.orderCreationData = syncData;
// 同步客户信息到表单
if (syncData.customerInfo) {
this.customerForm.patchValue({
name: syncData.customerInfo.name || '',
phone: syncData.customerInfo.phone || '',
wechat: syncData.customerInfo.wechat || '',
customerType: syncData.customerInfo.customerType || '新客户',
source: syncData.customerInfo.source || '小程序',
remark: syncData.customerInfo.remark || ''
});
}
// 同步需求信息
if (syncData.requirementInfo) {
this.syncRequirementKeyInfo(syncData.requirementInfo);
}
// 同步偏好标签
if (syncData.preferenceTags) {
this.project.customerTags = syncData.preferenceTags;
}
// 模拟同步完成
setTimeout(() => {
this.isSyncingCustomerInfo = false;
this.lastSyncTime = new Date();
this.cdr.detectChanges();
}, 1500);
} catch (error) {
console.error('解析同步数据失败:', error);
this.isSyncingCustomerInfo = false;
}
}
}
});
flowchart TD
A[客服/设计师填写表单] --> B{表单验证}
B -->|验证失败| C[显示错误提示]
C --> A
B -->|验证成功| D[调用 createOrder]
D --> E[整合表单数据]
E --> F[整合报价数据]
F --> G[整合设计师分配数据]
G --> H[调用 ProjectService.createProject]
H --> I{API响应}
I -->|成功| J[显示成功提示]
J --> K[推进到需求沟通阶段]
K --> L[展开需求沟通面板]
L --> M[滚动到需求沟通区域]
I -->|失败| N[显示错误信息]
关键方法实现:
// project-detail.ts lines 4783-4808
createOrder(): void {
if (!this.canCreateOrder()) {
// 标记所有字段为已触摸,以显示验证错误
this.orderCreationForm.markAllAsTouched();
return;
}
const orderData = {
...this.orderCreationForm.value,
...this.optionalForm.value,
customerInfo: this.orderCreationData?.customerInfo,
quotationData: this.quotationData,
designerAssignment: this.designerAssignmentData
};
console.log('分配订单:', orderData);
// 调用 ProjectService 创建项目
this.projectService.createProject(orderData).subscribe({
next: (result) => {
if (result.success) {
alert('订单分配成功!');
// 订单分配成功后自动切换到下一环节
this.advanceToNextStage('订单分配');
}
},
error: (error) => {
console.error('订单分配失败:', error);
alert('订单分配失败,请重试');
}
});
}
推进触发条件:
onProjectCreated 事件自动推进推进逻辑:
// project-detail.ts lines 1391-1423
advanceToNextStage(afterStage: ProjectStage): void {
const idx = this.stageOrder.indexOf(afterStage);
if (idx >= 0 && idx < this.stageOrder.length - 1) {
const next = this.stageOrder[idx + 1];
// 更新项目阶段
this.updateProjectStage(next);
// 更新展开状态,折叠当前、展开下一阶段
this.expandedStages[afterStage] = false;
this.expandedStages[next] = true;
// 更新板块展开状态
const nextSection = this.getSectionKeyForStage(next);
this.expandedSection = nextSection;
// 触发变更检测以更新导航栏颜色
this.cdr.detectChanges();
}
}
// project-detail.ts lines 3038-3069
onProjectCreated(projectData: any): void {
console.log('项目创建完成:', projectData);
this.projectData = projectData;
// 团队分配已在子组件中完成并触发该事件:推进到需求沟通阶段
this.updateProjectStage('需求沟通');
// 更新项目对象的当前阶段,确保四大板块状态正确显示
if (this.project) {
this.project.currentStage = '需求沟通';
}
// 展开需求沟通阶段,收起订单分配阶段
this.expandedStages['需求沟通'] = true;
this.expandedStages['订单分配'] = false;
// 自动展开确认需求板块
this.expandedSection = 'requirements';
// 强制触发变更检测,确保UI更新
this.cdr.detectChanges();
// 延迟滚动到需求沟通阶段,确保DOM更新完成
setTimeout(() => {
this.scrollToStage('需求沟通');
this.cdr.detectChanges();
}, 100);
}
| 操作 | 客服 | 设计师 | 组长 | 技术 |
|---|---|---|---|---|
| 查看订单分配阶段 | ✅ | ✅ | ✅ | ✅ |
| 创建订单 | ✅ | ❌ | ✅ | ❌ |
| 编辑客户信息 | ✅ | ❌ | ✅ | ❌ |
| 填写报价明细 | ✅ | ❌ | ✅ | ❌ |
| 选择设计师 | ✅ | ❌ | ✅ | ❌ |
| 接收订单通知 | ❌ | ✅ | ✅ | ❌ |
| 查看设计师日历 | ✅ | ✅ | ✅ | ❌ |
| 推进到下一阶段 | ✅ | ❌ | ✅ | ❌ |
// project-detail.ts lines 890-936
private detectRoleContextFromUrl(): 'customer-service' | 'designer' | 'team-leader' | 'technical' {
const url = this.router.url || '';
// 首先检查查询参数中的role
const queryParams = this.route.snapshot.queryParamMap;
const roleParam = queryParams.get('roleName');
if (roleParam === 'customer-service') {
return 'customer-service';
}
if (roleParam === 'technical') {
return 'technical';
}
// 如果没有role查询参数,则根据URL路径判断
if (url.includes('/customer-service/')) return 'customer-service';
if (url.includes('/team-leader/')) return 'team-leader';
if (url.includes('/technical/')) return 'technical';
return 'designer';
}
isDesignerView(): boolean { return this.roleContext === 'designer'; }
isTeamLeaderView(): boolean { return this.roleContext === 'team-leader'; }
isCustomerServiceView(): boolean { return this.roleContext === 'customer-service'; }
isTechnicalView(): boolean { return this.roleContext === 'technical'; }
isReadOnly(): boolean { return this.isCustomerServiceView(); }
canEditSection(sectionKey: SectionKey): boolean {
if (this.isCustomerServiceView()) {
return sectionKey === 'order' || sectionKey === 'requirements' || sectionKey === 'aftercare';
}
return true; // 设计师和组长可以编辑所有板块
}
canEditStage(stage: ProjectStage): boolean {
if (this.isCustomerServiceView()) {
const editableStages: ProjectStage[] = [
'订单分配', '需求沟通', '方案确认', // 订单分配和确认需求板块
'尾款结算', '客户评价', '投诉处理' // 售后板块
];
return editableStages.includes(stage);
}
return true; // 设计师和组长可以编辑所有阶段
}
模板权限指令:
<!-- 只读模式:客服查看设计师视角 -->
@if (isReadOnly()) {
<div class="readonly-banner">
当前为只读模式,您可以查看项目信息但无法编辑
</div>
}
<!-- 编辑权限检查:表单禁用状态 -->
<app-quotation-details
[readonly]="!canEditSection('order')"
...>
</app-quotation-details>
<!-- 按钮显示控制:只有客服和组长可以看到分配订单按钮 -->
@if (canEditSection('order')) {
<button
class="btn-primary"
[disabled]="!canCreateOrder()"
(click)="createOrder()">
分配订单
</button>
}
实时验证:
// 失焦时触发验证
<input
formControlName="orderAmount"
(blur)="orderCreationForm.get('orderAmount')?.markAsTouched()"
...>
// 错误信息显示
@if (orderCreationForm.get('orderAmount')?.invalid &&
orderCreationForm.get('orderAmount')?.touched) {
<div class="error-message">
@if (orderCreationForm.get('orderAmount')?.errors?.['required']) {
订单金额不能为空
}
@if (orderCreationForm.get('orderAmount')?.errors?.['min']) {
订单金额不能小于0
}
</div>
}
提交验证:
createOrder(): void {
if (!this.canCreateOrder()) {
// 标记所有字段为已触摸,显示所有验证错误
this.orderCreationForm.markAllAsTouched();
// 滚动到第一个错误字段
const firstError = document.querySelector('.error-message');
if (firstError) {
firstError.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return;
}
// ... 继续提交逻辑
}
分配订单按钮状态:
canCreateOrder(): boolean {
// 检查必填表单是否有效
const formValid = this.orderCreationForm?.valid;
// 检查是否已选择设计师
const designerAssigned = this.designerAssignmentData?.selectedDesigners?.length > 0;
// 检查是否已填写报价明细
const quotationFilled = this.quotationData?.items?.length > 0 && this.quotationData?.totalAmount > 0;
return formValid && designerAssigned && quotationFilled;
}
按钮样式:
<button
class="btn-primary"
[class.disabled]="!canCreateOrder()"
[disabled]="!canCreateOrder()"
(click)="createOrder()">
@if (isSubmitting) {
<span class="spinner"></span>
分配中...
} @else {
分配订单
}
</button>
滚动动画:
// project-detail.ts lines 2253-2260
scrollToStage(stage: ProjectStage): void {
const anchor = this.stageToAnchor(stage);
const el = document.getElementById(anchor);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
展开动画:
.stage-content {
transition: max-height 0.3s ease-in-out, opacity 0.2s ease-in-out;
&.collapsed {
max-height: 0;
opacity: 0;
overflow: hidden;
}
&.expanded {
max-height: 5000px;
opacity: 1;
}
}
graph TD
A[ProjectDetail 父组件] --> B[QuotationDetailsComponent]
A --> C[DesignerAssignmentComponent]
A --> D[DesignerCalendarComponent]
B -->|dataChange| A
C -->|assignmentChange| A
C -->|designerClick| A
D -->|designerSelected| A
D -->|assignmentRequested| A
QuotationDetailsComponent:
@Component({
selector: 'app-quotation-details',
...
})
export class QuotationDetailsComponent {
@Input() initialData?: QuotationData;
@Input() readonly: boolean = false;
@Output() dataChange = new EventEmitter<QuotationData>();
// 当报价数据变化时触发
onDataChange(): void {
this.dataChange.emit(this.quotationData);
}
}
DesignerAssignmentComponent:
@Component({
selector: 'app-designer-assignment',
...
})
export class DesignerAssignmentComponent {
@Input() projectData?: any;
@Input() readonly: boolean = false;
@Output() assignmentChange = new EventEmitter<DesignerAssignmentData>();
@Output() designerClick = new EventEmitter<Designer>();
// 当设计师分配变化时触发
onAssignmentChange(): void {
this.assignmentChange.emit(this.assignmentData);
}
// 当点击设计师卡片时触发
onDesignerCardClick(designer: Designer): void {
this.designerClick.emit(designer);
}
}
父组件处理:
// project-detail.ts lines 2843-2873
onQuotationDataChange(data: QuotationData): void {
this.quotationData = { ...data };
this.orderAmount = data.totalAmount || 0;
}
onDesignerAssignmentChange(data: DesignerAssignmentData): void {
this.designerAssignmentData = { ...data };
}
onDesignerClick(designer: AssignmentDesigner): void {
// 映射为日历组件需要的数据格式
const mapped = this.mapAssignmentDesignerToCalendar(designer);
this.calendarDesigners = [mapped];
this.calendarGroups = [{
id: designer.teamId,
name: designer.teamName,
leaderId: designer.id,
memberIds: [designer.id]
}];
this.selectedCalendarDate = new Date();
this.showDesignerCalendar = true;
}
接口地址:POST /api/projects
请求参数:
interface CreateProjectRequest {
// 客户信息
customerId: string;
customerName: string;
customerPhone: string;
customerWechat?: string;
customerType: string;
customerSource: string;
customerRemark?: string;
// 订单信息
orderAmount: number;
smallImageDeliveryTime: Date;
largeImageDeliveryTime?: Date;
decorationType: string;
requirementReason: string;
isMultiDesigner: boolean;
// 空间需求(可选)
spaceRequirements?: string;
designAngles?: string;
specialAreaHandling?: string;
materialRequirements?: string;
lightingRequirements?: string;
// 报价明细
quotation: {
items: Array<{
room: string;
amount: number;
description?: string;
}>;
totalAmount: number;
materialCost: number;
laborCost: number;
designFee: number;
managementFee: number;
};
// 设计师分配
assignment: {
designerIds: string[];
teamId: string;
leaderId: string;
assignmentDate: Date;
expectedStartDate: Date;
};
// 偏好标签
preferenceTags?: string[];
}
响应数据:
interface CreateProjectResponse {
success: boolean;
message: string;
projectId: string;
project: {
id: string;
name: string;
currentStage: ProjectStage;
createdAt: Date;
assignedDesigners: string[];
};
}
接口地址:GET /api/designers/available
查询参数:
interface DesignerQueryParams {
projectType?: string; // 项目类型,用于技能匹配
requiredSkills?: string[]; // 必需技能
startDate?: Date; // 项目预计开始日期
teamId?: string; // 指定团队ID
sortBy?: 'skillMatch' | 'workload' | 'idleDays'; // 排序方式
}
响应数据:
interface DesignerListResponse {
success: boolean;
designers: Designer[];
recommendedDesignerId?: string; // AI推荐的最佳设计师
}
createOrder(): void {
if (!this.canCreateOrder()) {
// 1. 标记所有字段为已触摸
this.orderCreationForm.markAllAsTouched();
// 2. 收集所有错误信息
const errors: string[] = [];
Object.keys(this.orderCreationForm.controls).forEach(key => {
const control = this.orderCreationForm.get(key);
if (control?.invalid) {
errors.push(this.getFieldLabel(key));
}
});
// 3. 显示错误提示
alert(`请完善以下必填项:\n${errors.join('\n')}`);
// 4. 滚动到第一个错误字段
const firstError = document.querySelector('.error-message');
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
// ... 继续提交
}
this.projectService.createProject(orderData).subscribe({
next: (result) => {
if (result.success) {
alert('订单分配成功!');
this.advanceToNextStage('订单分配');
} else {
// 服务端返回失败
alert(`订单分配失败:${result.message || '未知错误'}`);
}
},
error: (error) => {
// 网络错误或服务端异常
console.error('订单分配失败:', error);
let errorMessage = '订单分配失败,请重试';
if (error.status === 400) {
errorMessage = '请求参数有误,请检查表单填写';
} else if (error.status === 401) {
errorMessage = '未登录或登录已过期,请重新登录';
} else if (error.status === 403) {
errorMessage = '没有权限执行此操作';
} else if (error.status === 500) {
errorMessage = '服务器错误,请稍后重试';
}
alert(errorMessage);
}
});
onDesignerAssignmentChange(data: DesignerAssignmentData): void {
if (!data.selectedDesigners || data.selectedDesigners.length === 0) {
// 没有选择设计师
this.showWarning('请至少选择一位设计师');
return;
}
// 检查设计师是否可用
const unavailableDesigners = data.selectedDesigners.filter(d => d.status === 'unavailable');
if (unavailableDesigners.length > 0) {
const names = unavailableDesigners.map(d => d.name).join('、');
this.showWarning(`以下设计师当前不可用:${names}\n请重新选择`);
return;
}
// 检查工作负荷
const overloadedDesigners = data.selectedDesigners.filter(d => d.workload > 90);
if (overloadedDesigners.length > 0) {
const names = overloadedDesigners.map(d => d.name).join('、');
const confirmed = confirm(`以下设计师工作负荷较高(>90%):${names}\n确定要继续分配吗?`);
if (!confirmed) {
return;
}
}
this.designerAssignmentData = { ...data };
}
// 客服端同步数据解析失败
this.route.queryParamMap.subscribe({
next: (qp) => {
const syncDataParam = qp.get('syncData');
if (syncDataParam) {
try {
const syncData = JSON.parse(syncDataParam);
// ... 同步逻辑
} catch (error) {
console.error('解析同步数据失败:', error);
this.isSyncingCustomerInfo = false;
// 显示错误提示
alert('客户信息同步失败,请手动填写表单');
// 回退到手动模式
this.orderCreationMethod = 'manual';
}
}
}
});
// 使用 OnPush 策略的子组件
@Component({
selector: 'app-quotation-details',
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
// 父组件手动触发变更检测
onQuotationDataChange(data: QuotationData): void {
this.quotationData = { ...data }; // 不可变更新
this.orderAmount = data.totalAmount || 0;
this.cdr.markForCheck(); // 标记需要检查
}
// 只有展开订单分配阶段时才加载子组件
@if (expandedSection === 'order' || getSectionStatus('order') === 'active') {
<div class="order-assignment-section">
<app-quotation-details ...></app-quotation-details>
<app-designer-assignment ...></app-designer-assignment>
</div>
}
// 客户搜索防抖
private searchDebounce = new Subject<string>();
ngOnInit(): void {
this.searchDebounce
.pipe(debounceTime(300), distinctUntilChanged())
.subscribe(keyword => {
this.performSearch(keyword);
});
}
onSearchInputChange(keyword: string): void {
this.searchDebounce.next(keyword);
}
表单验证测试:
describe('OrderCreationForm Validation', () => {
it('should require orderAmount', () => {
const control = component.orderCreationForm.get('orderAmount');
control?.setValue('');
expect(control?.valid).toBeFalsy();
expect(control?.errors?.['required']).toBeTruthy();
});
it('should reject negative orderAmount', () => {
const control = component.orderCreationForm.get('orderAmount');
control?.setValue(-100);
expect(control?.valid).toBeFalsy();
expect(control?.errors?.['min']).toBeTruthy();
});
it('should accept valid orderAmount', () => {
const control = component.orderCreationForm.get('orderAmount');
control?.setValue(5000);
expect(control?.valid).toBeTruthy();
});
});
权限控制测试:
describe('Permission Control', () => {
it('should allow customer-service to edit order section', () => {
component.roleContext = 'customer-service';
expect(component.canEditSection('order')).toBeTruthy();
});
it('should not allow designer to edit order section', () => {
component.roleContext = 'designer';
expect(component.canEditSection('order')).toBeFalsy();
});
it('should allow team-leader to edit all sections', () => {
component.roleContext = 'team-leader';
expect(component.canEditSection('order')).toBeTruthy();
expect(component.canEditSection('requirements')).toBeTruthy();
expect(component.canEditSection('delivery')).toBeTruthy();
expect(component.canEditSection('aftercare')).toBeTruthy();
});
});
订单创建流程测试:
describe('Order Creation Flow', () => {
it('should complete full order creation flow', async () => {
// 1. 填写客户信息
component.customerForm.patchValue({
name: '测试客户',
phone: '13800138000',
customerType: '新客户'
});
// 2. 填写必填项
component.orderCreationForm.patchValue({
orderAmount: 10000,
smallImageDeliveryTime: new Date(),
decorationType: '全包',
requirementReason: '新房装修'
});
// 3. 添加报价明细
component.quotationData = {
items: [{ id: '1', room: '客厅', amount: 5000 }],
totalAmount: 10000,
materialCost: 6000,
laborCost: 3000,
designFee: 1000,
managementFee: 0
};
// 4. 分配设计师
component.designerAssignmentData = {
selectedDesigners: [mockDesigner],
teamId: 'team1',
teamName: '设计一组',
leaderId: 'designer1',
assignmentDate: new Date(),
expectedStartDate: new Date()
};
// 5. 验证可以提交
expect(component.canCreateOrder()).toBeTruthy();
// 6. 提交订单
spyOn(component.projectService, 'createProject').and.returnValue(
of({ success: true, projectId: 'proj-001' })
);
component.createOrder();
expect(component.projectService.createProject).toHaveBeenCalled();
});
});
完整用户流程测试:
describe('Order Assignment E2E', () => {
it('should complete order assignment as customer-service', async () => {
// 1. 客服登录
await page.goto('/customer-service/login');
await page.fill('#username', 'cs_user');
await page.fill('#password', 'password');
await page.click('button[type="submit"]');
// 2. 创建咨询订单
await page.goto('/customer-service/consultation-order/create');
await page.fill('#customerName', '张三');
await page.fill('#customerPhone', '13800138000');
await page.click('button[text="创建订单"]');
// 3. 跳转到项目详情页
await page.waitForURL(/\/designer\/project-detail/);
// 4. 填写订单分配表单
await page.fill('#orderAmount', '10000');
await page.fill('#smallImageDeliveryTime', '2025-11-01');
await page.selectOption('#decorationType', '全包');
await page.check('#requirementReason[value="新房装修"]');
// 5. 添加报价明细
await page.click('button[text="添加报价项"]');
await page.fill('.quotation-item:last-child #room', '客厅');
await page.fill('.quotation-item:last-child #amount', '5000');
// 6. 选择设计师
await page.click('.designer-card:first-child');
// 7. 提交订单
await page.click('button[text="分配订单"]');
// 8. 验证成功跳转到需求沟通阶段
await expect(page.locator('.stage-nav-item.active')).toContainText('确认需求');
});
});
private fieldLabelMap: Record<string, string> = {
'orderAmount': '订单金额',
'smallImageDeliveryTime': '小图交付时间',
'decorationType': '装修类型',
'requirementReason': '需求原因',
'isMultiDesigner': '多设计师协作',
'largeImageDeliveryTime': '大图交付时间',
'spaceRequirements': '空间需求',
'designAngles': '设计角度',
'specialAreaHandling': '特殊区域处理',
'materialRequirements': '材质要求',
'lightingRequirements': '光照需求'
};
private getFieldLabel(fieldName: string): string {
return this.fieldLabelMap[fieldName] || fieldName;
}
// project-detail.ts lines 2237-2251
stageToAnchor(stage: ProjectStage): string {
const map: Record<ProjectStage, string> = {
'订单分配': 'order',
'需求沟通': 'requirements-talk',
'方案确认': 'proposal-confirm',
'建模': 'modeling',
'软装': 'softdecor',
'渲染': 'render',
'后期': 'postprocess',
'尾款结算': 'settlement',
'客户评价': 'customer-review',
'投诉处理': 'complaint'
};
return `stage-${map[stage] || 'unknown'}`;
}
.designer-card {
&.status-idle {
border-left: 4px solid #10b981; // 绿色 - 空闲
}
&.status-busy {
border-left: 4px solid #f59e0b; // 橙色 - 繁忙
}
&.status-unavailable {
border-left: 4px solid #9ca3af; // 灰色 - 不可用
opacity: 0.6;
cursor: not-allowed;
}
}
文档版本:v1.0.0 创建日期:2025-10-16 最后更新:2025-10-16 维护人:产品团队