|
@@ -0,0 +1,1529 @@
|
|
|
|
+# 项目问题追踪系统产品设计
|
|
|
|
+
|
|
|
|
+## 概述
|
|
|
|
+
|
|
|
|
+**组件名称**: `app-project-issue`
|
|
|
|
+**功能定位**: 项目问题创建、管理和追踪系统
|
|
|
|
+**应用场景**: 项目执行过程中出现问题时,用于快速创建问题记录、分配责任人、追踪问题解决进度,并通过企微消息进行催办提醒
|
|
|
|
+
|
|
|
|
+## 数据结构分析
|
|
|
|
+
|
|
|
|
+### 1. ProjectIssue 表结构
|
|
|
|
+
|
|
|
|
+基于现有的ProjectIssue表,扩展为完整的问题追踪系统:
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+interface ProjectIssue {
|
|
|
|
+ objectId: string;
|
|
|
|
+ project: Pointer<Project>; // 所属项目
|
|
|
|
+ product?: Pointer<Product>; // 相关产品 (可选)
|
|
|
|
+ creator: Pointer<Profile>; // 创建人
|
|
|
|
+ assignee: Pointer<Profile>; // 责任人
|
|
|
|
+ title: string; // 问题标题
|
|
|
|
+ description: string; // 问题描述
|
|
|
|
+ relatedSpace?: string; // 相关空间 (如"客厅"、"主卧")
|
|
|
|
+ relatedStage?: string; // 相关阶段 (如"深化设计"、"施工图")
|
|
|
|
+ relatedContentType?: string; // 相关内容类型 (白模/软装/渲染/后期)
|
|
|
|
+ relatedFiles?: Array<Pointer<ProjectFile>>; // 相关项目文件
|
|
|
|
+ priority: '低' | '中' | '高' | '紧急'; // 优先程度
|
|
|
|
+ issueType: '投诉' | '建议' | '改图'; // 问题类型
|
|
|
|
+ dueDate?: Date; // 截止时间
|
|
|
|
+ status: '待处理' | '处理中' | '已解决' | '已关闭'; // 状态
|
|
|
|
+ resolution?: string; // 解决方案
|
|
|
|
+ lastReminderAt?: Date; // 最后催单时间
|
|
|
|
+ reminderCount: number; // 催单次数
|
|
|
|
+ data?: Object; // 扩展数据
|
|
|
|
+ isDeleted: boolean;
|
|
|
|
+ createdAt: Date;
|
|
|
|
+ updatedAt: Date;
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 2. 与现有系统的关联
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+// 与Project表的关联
|
|
|
|
+interface Project {
|
|
|
|
+ objectId: string;
|
|
|
|
+ title: string;
|
|
|
|
+ // ... 其他字段
|
|
|
|
+ issues?: Pointer<ProjectIssue>[]; // 项目问题列表
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// 与Product表的关联
|
|
|
|
+interface Product {
|
|
|
|
+ objectId: string;
|
|
|
|
+ name: string;
|
|
|
|
+ productType: string; // 白模/软装/渲染/后期
|
|
|
|
+ // ... 其他字段
|
|
|
|
+ issues?: Pointer<ProjectIssue>[]; // 产品相关问题
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## 组件接口设计
|
|
|
|
+
|
|
|
|
+### 1. 组件位置和调用方式
|
|
|
|
+
|
|
|
|
+**入口位置**: 项目底部卡片成员区域右侧,问题按钮
|
|
|
|
+
|
|
|
|
+**在 project-detail.component.html 中的调用**:
|
|
|
|
+```html
|
|
|
|
+<!-- 项目底部卡片 -->
|
|
|
|
+<div class="project-bottom-card">
|
|
|
|
+ <div class="action-buttons">
|
|
|
|
+ <!-- 现有文件按钮 -->
|
|
|
|
+ <button class="action-button files-button" (click)="onShowFiles()">
|
|
|
|
+ <!-- ... -->
|
|
|
|
+ </button>
|
|
|
|
+
|
|
|
|
+ <!-- 现有成员按钮 -->
|
|
|
|
+ <button class="action-button members-button" (click)="onShowMembers()">
|
|
|
|
+ <!-- ... -->
|
|
|
|
+ </button>
|
|
|
|
+
|
|
|
|
+ <!-- 新增问题按钮 -->
|
|
|
|
+ <button class="action-button issues-button" (click)="onShowIssues()">
|
|
|
|
+ <div class="button-content">
|
|
|
|
+ <svg class="button-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
|
+ <circle cx="12" cy="12" r="10"></circle>
|
|
|
|
+ <path d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
|
|
|
+ </svg>
|
|
|
|
+ <span class="button-text">问题</span>
|
|
|
|
+ @if (issueCount > 0) {
|
|
|
|
+ <span class="button-badge danger">{{ issueCount }}</span>
|
|
|
|
+ }
|
|
|
|
+ </div>
|
|
|
|
+ </button>
|
|
|
|
+ </div>
|
|
|
|
+</div>
|
|
|
|
+
|
|
|
|
+<!-- 问题追踪模态框 -->
|
|
|
|
+<app-project-issue-modal
|
|
|
|
+ [project]="project"
|
|
|
|
+ [currentUser]="currentUser"
|
|
|
|
+ [cid]="cid"
|
|
|
|
+ [isVisible]="showIssuesModal"
|
|
|
|
+ (close)="closeIssuesModal()">
|
|
|
|
+</app-project-issue-modal>
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 2. 组件输入输出接口
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+interface ProjectIssueModalInputs {
|
|
|
|
+ // 必填属性
|
|
|
|
+ project: Parse.Object; // 项目对象
|
|
|
|
+ currentUser: Parse.Object; // 当前用户
|
|
|
|
+ cid: string; // 企业微信CorpID
|
|
|
|
+
|
|
|
|
+ // 可选属性
|
|
|
|
+ isVisible?: boolean; // 是否显示模态框,默认false
|
|
|
|
+ initialIssueType?: string; // 初始问题类型
|
|
|
|
+ initialAssignee?: Parse.Object; // 初始责任人
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+interface ProjectIssueModalOutputs {
|
|
|
|
+ // 问题创建/更新事件
|
|
|
|
+ issueChanged: EventEmitter<{
|
|
|
|
+ issue: Parse.Object;
|
|
|
|
+ action: 'created' | 'updated' | 'resolved' | 'closed';
|
|
|
|
+ }>;
|
|
|
|
+
|
|
|
|
+ // 关闭事件
|
|
|
|
+ close: EventEmitter<void>;
|
|
|
|
+
|
|
|
|
+ // 催单事件
|
|
|
|
+ reminderSent: EventEmitter<{
|
|
|
|
+ issue: Parse.Object;
|
|
|
|
+ recipient: Parse.Object;
|
|
|
|
+ }>;
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## 组件功能设计
|
|
|
|
+
|
|
|
|
+### 1. 核心功能流程
|
|
|
|
+
|
|
|
|
+#### 1.1 问题创建流程
|
|
|
|
+```mermaid
|
|
|
|
+graph TD
|
|
|
|
+ A[点击问题按钮] --> B[打开问题创建模态框]
|
|
|
|
+ B --> C[填写问题信息]
|
|
|
|
+ C --> D[选择责任人]
|
|
|
|
+ D --> E[设置优先级和截止时间]
|
|
|
|
+ E --> F[创建问题记录]
|
|
|
|
+ F --> G[发送企微通知给责任人]
|
|
|
|
+ G --> H[更新问题列表]
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+#### 1.2 催单流程
|
|
|
|
+```mermaid
|
|
|
|
+graph TD
|
|
|
|
+ A[点击催单按钮] --> B[检查催单间隔限制]
|
|
|
|
+ B --> C{可以催单?}
|
|
|
|
+ C -->|是| D[构建催单消息]
|
|
|
|
+ C -->|否| E[显示催单限制提示]
|
|
|
|
+ D --> F[调用企微API发送消息]
|
|
|
|
+ F --> G[更新最后催单时间]
|
|
|
|
+ G --> H[记录催单次数]
|
|
|
|
+ H --> I[显示催单成功提示]
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 2. 组件状态管理
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+enum IssueStatus {
|
|
|
|
+ PENDING = '待处理',
|
|
|
|
+ IN_PROGRESS = '处理中',
|
|
|
|
+ RESOLVED = '已解决',
|
|
|
|
+ CLOSED = '已关闭'
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+enum IssuePriority {
|
|
|
|
+ LOW = '低',
|
|
|
|
+ MEDIUM = '中',
|
|
|
|
+ HIGH = '高',
|
|
|
|
+ URGENT = '紧急'
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+enum IssueType {
|
|
|
|
+ COMPLAINT = '投诉',
|
|
|
|
+ SUGGESTION = '建议',
|
|
|
|
+ REVISION = '改图'
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+interface ComponentState {
|
|
|
|
+ mode: 'create' | 'list' | 'detail'; // 界面模式
|
|
|
|
+ issues: Parse.Object[]; // 问题列表
|
|
|
|
+ currentIssue?: Parse.Object; // 当前操作的问题
|
|
|
|
+ loading: boolean; // 加载状态
|
|
|
|
+ submitting: boolean; // 提交状态
|
|
|
|
+ searchKeyword: string; // 搜索关键词
|
|
|
|
+ statusFilter: IssueStatus | 'all'; // 状态过滤器
|
|
|
|
+ priorityFilter: IssuePriority | 'all'; // 优先级过滤器
|
|
|
|
+ error?: string; // 错误信息
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## 用户界面设计
|
|
|
|
+
|
|
|
|
+### 1. 模态框主体结构
|
|
|
|
+
|
|
|
|
+```html
|
|
|
|
+<div class="project-issue-modal">
|
|
|
|
+ <!-- 模态框头部 -->
|
|
|
|
+ <div class="modal-header">
|
|
|
|
+ <h2 class="modal-title">
|
|
|
|
+ <svg class="title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
|
|
+ <circle cx="12" cy="12" r="10"></circle>
|
|
|
|
+ <path d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
|
|
|
+ </svg>
|
|
|
|
+ 项目问题追踪
|
|
|
|
+ </h2>
|
|
|
|
+ <div class="header-actions">
|
|
|
|
+ <button class="btn btn-primary" (click)="createNewIssue()">
|
|
|
|
+ <svg class="btn-icon" viewBox="0 0 24 24">
|
|
|
|
+ <path d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
|
|
|
+ </svg>
|
|
|
|
+ 新建问题
|
|
|
|
+ </button>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <!-- 模态框内容区 -->
|
|
|
|
+ <div class="modal-content">
|
|
|
|
+ <!-- 搜索和过滤区域 -->
|
|
|
|
+ <div class="filters-section">
|
|
|
|
+ <div class="search-box">
|
|
|
|
+ <ion-searchbar
|
|
|
|
+ [(ngModel)]="searchKeyword"
|
|
|
|
+ placeholder="搜索问题标题或描述"
|
|
|
|
+ (ionInput)="onSearchChange($event)">
|
|
|
|
+ </ion-searchbar>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <div class="filter-buttons">
|
|
|
|
+ <button
|
|
|
|
+ class="filter-btn"
|
|
|
|
+ [class.active]="statusFilter === 'all'"
|
|
|
|
+ (click)="statusFilter = 'all'">
|
|
|
|
+ 全部
|
|
|
|
+ </button>
|
|
|
|
+ <button
|
|
|
|
+ class="filter-btn"
|
|
|
|
+ [class.active]="statusFilter === IssueStatus.PENDING"
|
|
|
|
+ (click)="statusFilter = IssueStatus.PENDING">
|
|
|
|
+ 待处理
|
|
|
|
+ </button>
|
|
|
|
+ <button
|
|
|
|
+ class="filter-btn"
|
|
|
|
+ [class.active]="statusFilter === IssueStatus.IN_PROGRESS"
|
|
|
|
+ (click)="statusFilter = IssueStatus.IN_PROGRESS">
|
|
|
|
+ 处理中
|
|
|
|
+ </button>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <!-- 问题列表 -->
|
|
|
|
+ <div class="issues-list" *ngIf="mode === 'list'">
|
|
|
|
+ @for (issue of filteredIssues; track issue.id) {
|
|
|
|
+ <div class="issue-card" [class]="getPriorityClass(issue.get('priority'))">
|
|
|
|
+ <div class="issue-header">
|
|
|
|
+ <div class="issue-title">{{ issue.get('title') }}</div>
|
|
|
|
+ <div class="issue-badges">
|
|
|
|
+ <span class="badge priority-{{ issue.get('priority') }}">
|
|
|
|
+ {{ issue.get('priority') }}
|
|
|
|
+ </span>
|
|
|
|
+ <span class="badge type-{{ issue.get('issueType') }}">
|
|
|
|
+ {{ issue.get('issueType') }}
|
|
|
|
+ </span>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <div class="issue-description">
|
|
|
|
+ {{ issue.get('description') }}
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <div class="issue-meta">
|
|
|
|
+ <div class="assignee-info">
|
|
|
|
+ <img [src]="issue.get('assignee')?.get('data')?.avatar" class="assignee-avatar">
|
|
|
|
+ <span>责任人: {{ issue.get('assignee')?.get('name') }}</span>
|
|
|
|
+ </div>
|
|
|
|
+ <div class="due-date">
|
|
|
|
+ @if (issue.get('dueDate')) {
|
|
|
|
+ <span>截止: {{ issue.get('dueDate') | date:'MM-dd' }}</span>
|
|
|
|
+ }
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <div class="issue-actions">
|
|
|
|
+ <button class="btn btn-sm btn-outline" (click)="viewIssueDetail(issue)">
|
|
|
|
+ 查看详情
|
|
|
|
+ </button>
|
|
|
|
+ <button
|
|
|
|
+ class="btn btn-sm btn-primary"
|
|
|
|
+ (click)="sendReminder(issue)"
|
|
|
|
+ [disabled]="canSendReminder(issue)">
|
|
|
|
+ 催单
|
|
|
|
+ </button>
|
|
|
|
+ @if (issue.get('status') !== IssueStatus.RESOLVED && issue.get('status') !== IssueStatus.CLOSED) {
|
|
|
|
+ <button
|
|
|
|
+ class="btn btn-sm btn-success"
|
|
|
|
+ (click)="resolveIssue(issue)">
|
|
|
|
+ 标记解决
|
|
|
|
+ </button>
|
|
|
|
+ }
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+ }
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <!-- 问题创建表单 -->
|
|
|
|
+ <div class="issue-form" *ngIf="mode === 'create'">
|
|
|
|
+ <form #issueForm="ngForm" (ngSubmit)="onSubmit()">
|
|
|
|
+ <!-- 基本信息 -->
|
|
|
|
+ <div class="form-section">
|
|
|
|
+ <h3>基本信息</h3>
|
|
|
|
+
|
|
|
|
+ <div class="form-group">
|
|
|
|
+ <label>问题标题 *</label>
|
|
|
|
+ <ion-input
|
|
|
|
+ name="title"
|
|
|
|
+ [(ngModel)]="issueData.title"
|
|
|
|
+ required
|
|
|
|
+ placeholder="请简要描述问题">
|
|
|
|
+ </ion-input>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <div class="form-group">
|
|
|
|
+ <label>问题类型 *</label>
|
|
|
|
+ <ion-select
|
|
|
|
+ name="issueType"
|
|
|
|
+ [(ngModel)]="issueData.issueType"
|
|
|
|
+ required
|
|
|
|
+ placeholder="请选择问题类型">
|
|
|
|
+ <ion-select-option value="投诉">投诉</ion-select-option>
|
|
|
|
+ <ion-select-option value="建议">建议</ion-select-option>
|
|
|
|
+ <ion-select-option value="改图">改图</ion-select-option>
|
|
|
|
+ </ion-select>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <div class="form-group">
|
|
|
|
+ <label>优先程度 *</label>
|
|
|
|
+ <ion-select
|
|
|
|
+ name="priority"
|
|
|
|
+ [(ngModel)]="issueData.priority"
|
|
|
|
+ required
|
|
|
|
+ placeholder="请选择优先程度">
|
|
|
|
+ <ion-select-option value="低">低</ion-select-option>
|
|
|
|
+ <ion-select-option value="中">中</ion-select-option>
|
|
|
|
+ <ion-select-option value="高">高</ion-select-option>
|
|
|
|
+ <ion-select-option value="紧急">紧急</ion-select-option>
|
|
|
|
+ </ion-select>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <!-- 详细描述 -->
|
|
|
|
+ <div class="form-section">
|
|
|
|
+ <h3>详细描述</h3>
|
|
|
|
+
|
|
|
|
+ <div class="form-group">
|
|
|
|
+ <label>问题描述 *</label>
|
|
|
|
+ <ion-textarea
|
|
|
|
+ name="description"
|
|
|
|
+ [(ngModel)]="issueData.description"
|
|
|
|
+ required
|
|
|
|
+ rows="4"
|
|
|
|
+ placeholder="请详细描述问题情况">
|
|
|
|
+ </ion-textarea>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <!-- 关联信息 -->
|
|
|
|
+ <div class="form-section">
|
|
|
|
+ <h3>关联信息</h3>
|
|
|
|
+
|
|
|
|
+ <div class="form-group">
|
|
|
|
+ <label>责任人 *</label>
|
|
|
|
+ <ion-select
|
|
|
|
+ name="assignee"
|
|
|
|
+ [(ngModel)]="issueData.assignee"
|
|
|
|
+ required
|
|
|
|
+ placeholder="请选择责任人">
|
|
|
|
+ @for (member of projectMembers; track member.id) {
|
|
|
|
+ <ion-select-option [value]="member.profileId">
|
|
|
|
+ {{ member.name }} - {{ member.role }}
|
|
|
|
+ </ion-select-option>
|
|
|
|
+ }
|
|
|
|
+ </ion-select>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <div class="form-group">
|
|
|
|
+ <label>相关空间</label>
|
|
|
|
+ <ion-input
|
|
|
|
+ name="relatedSpace"
|
|
|
|
+ [(ngModel)]="issueData.relatedSpace"
|
|
|
|
+ placeholder="如:客厅、主卧、厨房">
|
|
|
|
+ </ion-input>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <div class="form-group">
|
|
|
|
+ <label>相关阶段</label>
|
|
|
|
+ <ion-select
|
|
|
|
+ name="relatedStage"
|
|
|
|
+ [(ngModel)]="issueData.relatedStage"
|
|
|
|
+ placeholder="请选择相关阶段">
|
|
|
|
+ <ion-select-option value="方案设计">方案设计</ion-select-option>
|
|
|
|
+ <ion-select-option value="深化设计">深化设计</ion-select-option>
|
|
|
|
+ <ion-select-option value="施工图设计">施工图设计</ion-select-option>
|
|
|
|
+ <ion-select-option value="施工配合">施工配合</ion-select-option>
|
|
|
|
+ </ion-select>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <div class="form-group">
|
|
|
|
+ <label>相关内容</label>
|
|
|
|
+ <ion-select
|
|
|
|
+ name="relatedContentType"
|
|
|
|
+ [(ngModel)]="issueData.relatedContentType"
|
|
|
|
+ placeholder="请选择相关内容类型">
|
|
|
|
+ <ion-select-option value="白模">白模</ion-select-option>
|
|
|
|
+ <ion-select-option value="软装">软装</ion-select-option>
|
|
|
|
+ <ion-select-option value="渲染">渲染</ion-select-option>
|
|
|
|
+ <ion-select-option value="后期">后期</ion-select-option>
|
|
|
|
+ </ion-select>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <div class="form-group">
|
|
|
|
+ <label>截止时间</label>
|
|
|
|
+ <ion-datetime
|
|
|
|
+ name="dueDate"
|
|
|
|
+ [(ngModel)]="issueData.dueDate"
|
|
|
|
+ presentation="date"
|
|
|
|
+ placeholder="请选择截止时间">
|
|
|
|
+ </ion-datetime>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <!-- 表单操作 -->
|
|
|
|
+ <div class="form-actions">
|
|
|
|
+ <button type="button" class="btn btn-outline" (click)="cancelCreate()">
|
|
|
|
+ 取消
|
|
|
|
+ </button>
|
|
|
|
+ <button
|
|
|
|
+ type="submit"
|
|
|
|
+ class="btn btn-primary"
|
|
|
|
+ [disabled]="!issueForm.valid || submitting">
|
|
|
|
+ <ion-spinner *ngIf="submitting" name="dots"></ion-spinner>
|
|
|
|
+ 创建问题
|
|
|
|
+ </button>
|
|
|
|
+ </div>
|
|
|
|
+ </form>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+</div>
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 2. 样式设计
|
|
|
|
+
|
|
|
|
+```scss
|
|
|
|
+.project-issue-modal {
|
|
|
|
+ .modal-header {
|
|
|
|
+ display: flex;
|
|
|
|
+ justify-content: space-between;
|
|
|
|
+ align-items: center;
|
|
|
|
+ padding: 20px 24px;
|
|
|
|
+ border-bottom: 1px solid var(--ion-color-light);
|
|
|
|
+
|
|
|
|
+ .modal-title {
|
|
|
|
+ display: flex;
|
|
|
|
+ align-items: center;
|
|
|
|
+ gap: 8px;
|
|
|
|
+ margin: 0;
|
|
|
|
+ font-size: 20px;
|
|
|
|
+ font-weight: 600;
|
|
|
|
+
|
|
|
|
+ .title-icon {
|
|
|
|
+ width: 24px;
|
|
|
|
+ height: 24px;
|
|
|
|
+ color: var(--ion-color-primary);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .filters-section {
|
|
|
|
+ padding: 16px 24px;
|
|
|
|
+ border-bottom: 1px solid var(--ion-color-light);
|
|
|
|
+
|
|
|
|
+ .search-box {
|
|
|
|
+ margin-bottom: 16px;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .filter-buttons {
|
|
|
|
+ display: flex;
|
|
|
|
+ gap: 8px;
|
|
|
|
+
|
|
|
|
+ .filter-btn {
|
|
|
|
+ padding: 8px 16px;
|
|
|
|
+ border: 1px solid var(--ion-color-light);
|
|
|
|
+ border-radius: 20px;
|
|
|
|
+ background: var(--ion-background-color);
|
|
|
|
+ font-size: 14px;
|
|
|
|
+ cursor: pointer;
|
|
|
|
+ transition: all 0.2s;
|
|
|
|
+
|
|
|
|
+ &.active {
|
|
|
|
+ background: var(--ion-color-primary);
|
|
|
|
+ color: white;
|
|
|
|
+ border-color: var(--ion-color-primary);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .issues-list {
|
|
|
|
+ padding: 16px 24px;
|
|
|
|
+ max-height: 500px;
|
|
|
|
+ overflow-y: auto;
|
|
|
|
+
|
|
|
|
+ .issue-card {
|
|
|
|
+ border: 1px solid var(--ion-color-light);
|
|
|
|
+ border-radius: 12px;
|
|
|
|
+ padding: 16px;
|
|
|
|
+ margin-bottom: 12px;
|
|
|
|
+ background: var(--ion-background-color);
|
|
|
|
+ transition: all 0.2s;
|
|
|
|
+
|
|
|
|
+ &:hover {
|
|
|
|
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ &.priority-紧急 {
|
|
|
|
+ border-left: 4px solid var(--ion-color-danger);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ &.priority-高 {
|
|
|
|
+ border-left: 4px solid var(--ion-color-warning);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ &.priority-中 {
|
|
|
|
+ border-left: 4px solid var(--ion-color-secondary);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .issue-header {
|
|
|
|
+ display: flex;
|
|
|
|
+ justify-content: space-between;
|
|
|
|
+ align-items: flex-start;
|
|
|
|
+ margin-bottom: 8px;
|
|
|
|
+
|
|
|
|
+ .issue-title {
|
|
|
|
+ font-size: 16px;
|
|
|
|
+ font-weight: 600;
|
|
|
|
+ color: var(--ion-color-dark);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .issue-badges {
|
|
|
|
+ display: flex;
|
|
|
|
+ gap: 6px;
|
|
|
|
+
|
|
|
|
+ .badge {
|
|
|
|
+ padding: 4px 8px;
|
|
|
|
+ border-radius: 12px;
|
|
|
|
+ font-size: 12px;
|
|
|
|
+ font-weight: 500;
|
|
|
|
+
|
|
|
|
+ &.priority-紧急 {
|
|
|
|
+ background: var(--ion-color-danger);
|
|
|
|
+ color: white;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ &.priority-高 {
|
|
|
|
+ background: var(--ion-color-warning);
|
|
|
|
+ color: white;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ &.type-投诉 {
|
|
|
|
+ background: var(--ion-color-danger);
|
|
|
|
+ color: white;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ &.type-建议 {
|
|
|
|
+ background: var(--ion-color-success);
|
|
|
|
+ color: white;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ &.type-改图 {
|
|
|
|
+ background: var(--ion-color-primary);
|
|
|
|
+ color: white;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .issue-description {
|
|
|
|
+ color: var(--ion-color-medium);
|
|
|
|
+ font-size: 14px;
|
|
|
|
+ margin-bottom: 12px;
|
|
|
|
+ line-height: 1.4;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .issue-meta {
|
|
|
|
+ display: flex;
|
|
|
|
+ justify-content: space-between;
|
|
|
|
+ align-items: center;
|
|
|
|
+ margin-bottom: 12px;
|
|
|
|
+
|
|
|
|
+ .assignee-info {
|
|
|
|
+ display: flex;
|
|
|
|
+ align-items: center;
|
|
|
|
+ gap: 6px;
|
|
|
|
+ font-size: 13px;
|
|
|
|
+
|
|
|
|
+ .assignee-avatar {
|
|
|
|
+ width: 20px;
|
|
|
|
+ height: 20px;
|
|
|
|
+ border-radius: 50%;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .due-date {
|
|
|
|
+ font-size: 13px;
|
|
|
|
+ color: var(--ion-color-medium);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .issue-actions {
|
|
|
|
+ display: flex;
|
|
|
|
+ gap: 8px;
|
|
|
|
+ justify-content: flex-end;
|
|
|
|
+
|
|
|
|
+ .btn {
|
|
|
|
+ padding: 6px 12px;
|
|
|
|
+ font-size: 13px;
|
|
|
|
+ border-radius: 6px;
|
|
|
|
+ border: none;
|
|
|
|
+ cursor: pointer;
|
|
|
|
+ transition: all 0.2s;
|
|
|
|
+
|
|
|
|
+ &:disabled {
|
|
|
|
+ opacity: 0.5;
|
|
|
|
+ cursor: not-allowed;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .issue-form {
|
|
|
|
+ padding: 24px;
|
|
|
|
+ max-height: 600px;
|
|
|
|
+ overflow-y: auto;
|
|
|
|
+
|
|
|
|
+ .form-section {
|
|
|
|
+ margin-bottom: 32px;
|
|
|
|
+
|
|
|
|
+ h3 {
|
|
|
|
+ margin: 0 0 16px;
|
|
|
|
+ font-size: 16px;
|
|
|
|
+ font-weight: 600;
|
|
|
|
+ color: var(--ion-color-dark);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .form-group {
|
|
|
|
+ margin-bottom: 20px;
|
|
|
|
+
|
|
|
|
+ label {
|
|
|
|
+ display: block;
|
|
|
|
+ margin-bottom: 8px;
|
|
|
|
+ font-size: 14px;
|
|
|
|
+ font-weight: 500;
|
|
|
|
+ color: var(--ion-color-dark);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .form-actions {
|
|
|
|
+ display: flex;
|
|
|
|
+ justify-content: flex-end;
|
|
|
|
+ gap: 12px;
|
|
|
|
+ padding-top: 20px;
|
|
|
|
+ border-top: 1px solid var(--ion-color-light);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## 技术实现方案
|
|
|
|
+
|
|
|
|
+### 1. 主组件实现
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+@Component({
|
|
|
|
+ selector: 'app-project-issue-modal',
|
|
|
|
+ standalone: true,
|
|
|
|
+ imports: [
|
|
|
|
+ CommonModule,
|
|
|
|
+ FormsModule,
|
|
|
|
+ IonicModule,
|
|
|
|
+ // 其他依赖
|
|
|
|
+ ],
|
|
|
|
+ templateUrl: './project-issue-modal.component.html',
|
|
|
|
+ styleUrls: ['./project-issue-modal.component.scss']
|
|
|
|
+})
|
|
|
|
+export class ProjectIssueModalComponent implements OnInit {
|
|
|
|
+ // 输入输出属性
|
|
|
|
+ @Input() project!: Parse.Object;
|
|
|
|
+ @Input() currentUser!: Parse.Object;
|
|
|
|
+ @Input() cid!: string;
|
|
|
|
+ @Input() isVisible: boolean = false;
|
|
|
|
+
|
|
|
|
+ @Output() close = new EventEmitter<void>();
|
|
|
|
+ @Output() issueChanged = new EventEmitter<IssueChangedEvent>();
|
|
|
|
+ @Output() reminderSent = new EventEmitter<ReminderSentEvent>();
|
|
|
|
+
|
|
|
|
+ // 组件状态
|
|
|
|
+ mode: 'create' | 'list' | 'detail' = 'list';
|
|
|
|
+ issues: Parse.Object[] = [];
|
|
|
|
+ projectMembers: ProjectMember[] = [];
|
|
|
|
+ loading: boolean = false;
|
|
|
|
+ submitting: boolean = false;
|
|
|
|
+ searchKeyword: string = '';
|
|
|
|
+ statusFilter: string = 'all';
|
|
|
|
+ priorityFilter: string = 'all';
|
|
|
|
+
|
|
|
|
+ // 问题表单数据
|
|
|
|
+ issueData = {
|
|
|
|
+ title: '',
|
|
|
|
+ description: '',
|
|
|
|
+ issueType: '',
|
|
|
|
+ priority: '中',
|
|
|
|
+ assignee: '',
|
|
|
|
+ relatedSpace: '',
|
|
|
|
+ relatedStage: '',
|
|
|
|
+ relatedContentType: '',
|
|
|
|
+ dueDate: null as Date | null
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ // 企业微信API
|
|
|
|
+ private wecorp: WxworkCorp | null = null;
|
|
|
|
+ private wwsdk: WxworkSDK | null = null;
|
|
|
|
+
|
|
|
|
+ constructor(
|
|
|
|
+ private parseService: ParseService,
|
|
|
|
+ private modalController: ModalController
|
|
|
|
+ ) {
|
|
|
|
+ this.initializeWxwork();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ ngOnInit() {
|
|
|
|
+ if (this.isVisible) {
|
|
|
|
+ this.loadData();
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ ngOnChanges() {
|
|
|
|
+ if (this.isVisible) {
|
|
|
|
+ this.loadData();
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private initializeWxwork(): void {
|
|
|
|
+ this.wecorp = new WxworkCorp(this.cid);
|
|
|
|
+ this.wwsdk = new WxworkSDK({cid: this.cid, appId: 'crm'});
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private async loadData(): Promise<void> {
|
|
|
|
+ try {
|
|
|
|
+ this.loading = true;
|
|
|
|
+ await Promise.all([
|
|
|
|
+ this.loadIssues(),
|
|
|
|
+ this.loadProjectMembers()
|
|
|
|
+ ]);
|
|
|
|
+ } catch (error) {
|
|
|
|
+ console.error('加载数据失败:', error);
|
|
|
|
+ } finally {
|
|
|
|
+ this.loading = false;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private async loadIssues(): Promise<void> {
|
|
|
|
+ const query = new Parse.Query('ProjectIssue');
|
|
|
|
+ query.equalTo('project', this.project);
|
|
|
|
+ query.notEqualTo('isDeleted', true);
|
|
|
|
+ query.descending('createdAt');
|
|
|
|
+ query.include('creator', 'assignee');
|
|
|
|
+
|
|
|
|
+ this.issues = await query.find();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private async loadProjectMembers(): Promise<void> {
|
|
|
|
+ const query = new Parse.Query('ProjectTeam');
|
|
|
|
+ query.equalTo('project', this.project);
|
|
|
|
+ query.include('profile', 'department');
|
|
|
|
+ query.notEqualTo('isDeleted', true);
|
|
|
|
+
|
|
|
|
+ const projectTeams = await query.find();
|
|
|
|
+ this.projectMembers = projectTeams.map(team => ({
|
|
|
|
+ id: team.id,
|
|
|
|
+ profileId: team.get('profile')?.id,
|
|
|
|
+ name: team.get('profile')?.get('name') || '未知',
|
|
|
|
+ userid: team.get('profile')?.get('userid') || '',
|
|
|
|
+ role: team.get('profile')?.get('roleName') || '未知'
|
|
|
|
+ }));
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ get filteredIssues(): Parse.Object[] {
|
|
|
|
+ let filtered = this.issues;
|
|
|
|
+
|
|
|
|
+ // 搜索过滤
|
|
|
|
+ if (this.searchKeyword) {
|
|
|
|
+ const keyword = this.searchKeyword.toLowerCase();
|
|
|
|
+ filtered = filtered.filter(issue => {
|
|
|
|
+ const title = (issue.get('title') || '').toLowerCase();
|
|
|
|
+ const description = (issue.get('description') || '').toLowerCase();
|
|
|
|
+ return title.includes(keyword) || description.includes(keyword);
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 状态过滤
|
|
|
|
+ if (this.statusFilter !== 'all') {
|
|
|
|
+ filtered = filtered.filter(issue => issue.get('status') === this.statusFilter);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 优先级过滤
|
|
|
|
+ if (this.priorityFilter !== 'all') {
|
|
|
|
+ filtered = filtered.filter(issue => issue.get('priority') === this.priorityFilter);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return filtered;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ createNewIssue(): void {
|
|
|
|
+ this.mode = 'create';
|
|
|
|
+ this.resetForm();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private resetForm(): void {
|
|
|
|
+ this.issueData = {
|
|
|
|
+ title: '',
|
|
|
|
+ description: '',
|
|
|
|
+ issueType: '',
|
|
|
|
+ priority: '中',
|
|
|
|
+ assignee: '',
|
|
|
|
+ relatedSpace: '',
|
|
|
|
+ relatedStage: '',
|
|
|
|
+ relatedContentType: '',
|
|
|
|
+ dueDate: null
|
|
|
|
+ };
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ async onSubmit(): Promise<void> {
|
|
|
|
+ if (this.submitting) return;
|
|
|
|
+
|
|
|
|
+ this.submitting = true;
|
|
|
|
+
|
|
|
|
+ try {
|
|
|
|
+ // 创建问题记录
|
|
|
|
+ const ProjectIssue = Parse.Object.extend('ProjectIssue');
|
|
|
|
+ const issue = new ProjectIssue();
|
|
|
|
+
|
|
|
|
+ issue.set('project', this.project);
|
|
|
|
+ issue.set('creator', this.currentUser);
|
|
|
|
+ issue.set('title', this.issueData.title);
|
|
|
|
+ issue.set('description', this.issueData.description);
|
|
|
|
+ issue.set('issueType', this.issueData.issueType);
|
|
|
|
+ issue.set('priority', this.issueData.priority);
|
|
|
|
+ issue.set('status', '待处理');
|
|
|
|
+ issue.set('reminderCount', 0);
|
|
|
|
+
|
|
|
|
+ if (this.issueData.assignee) {
|
|
|
|
+ const assigneeProfile = new Parse.Object('Profile', { id: this.issueData.assignee });
|
|
|
|
+ issue.set('assignee', assigneeProfile);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if (this.issueData.relatedSpace) {
|
|
|
|
+ issue.set('relatedSpace', this.issueData.relatedSpace);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if (this.issueData.relatedStage) {
|
|
|
|
+ issue.set('relatedStage', this.issueData.relatedStage);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if (this.issueData.relatedContentType) {
|
|
|
|
+ issue.set('relatedContentType', this.issueData.relatedContentType);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if (this.issueData.dueDate) {
|
|
|
|
+ issue.set('dueDate', this.issueData.dueDate);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ await issue.save();
|
|
|
|
+
|
|
|
|
+ // 发送企微通知
|
|
|
|
+ await this.sendNotificationToAssignee(issue);
|
|
|
|
+
|
|
|
|
+ // 更新状态
|
|
|
|
+ this.issues.unshift(issue);
|
|
|
|
+ this.mode = 'list';
|
|
|
|
+
|
|
|
|
+ // 触发事件
|
|
|
|
+ this.issueChanged.emit({
|
|
|
|
+ issue,
|
|
|
|
+ action: 'created'
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ console.log('✅ 问题创建成功:', issue.get('title'));
|
|
|
|
+
|
|
|
|
+ } catch (error) {
|
|
|
|
+ console.error('❌ 创建问题失败:', error);
|
|
|
|
+ } finally {
|
|
|
|
+ this.submitting = false;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ async sendReminder(issue: Parse.Object): Promise<void> {
|
|
|
|
+ if (!this.canSendReminder(issue)) return;
|
|
|
|
+
|
|
|
|
+ try {
|
|
|
|
+ const assignee = issue.get('assignee');
|
|
|
|
+ if (!assignee) return;
|
|
|
|
+
|
|
|
|
+ // 构建催单消息
|
|
|
|
+ const reminderMessage = this.buildReminderMessage(issue);
|
|
|
|
+
|
|
|
|
+ // 发送企微消息
|
|
|
|
+ await this.sendWxworkMessage(assignee.get('userid'), reminderMessage);
|
|
|
|
+
|
|
|
|
+ // 更新催单记录
|
|
|
|
+ issue.set('lastReminderAt', new Date());
|
|
|
|
+ issue.set('reminderCount', (issue.get('reminderCount') || 0) + 1);
|
|
|
|
+ await issue.save();
|
|
|
|
+
|
|
|
|
+ // 触发催单事件
|
|
|
|
+ this.reminderSent.emit({
|
|
|
|
+ issue,
|
|
|
|
+ recipient: assignee
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ console.log('✅ 催单消息发送成功');
|
|
|
|
+
|
|
|
|
+ } catch (error) {
|
|
|
|
+ console.error('❌ 发送催单失败:', error);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private buildReminderMessage(issue: Parse.Object): string {
|
|
|
|
+ const projectTitle = this.project.get('title');
|
|
|
|
+ const issueTitle = issue.get('title');
|
|
|
|
+ const priority = issue.get('priority');
|
|
|
|
+ const dueDate = issue.get('dueDate');
|
|
|
|
+ const reminderCount = issue.get('reminderCount') + 1;
|
|
|
|
+
|
|
|
|
+ let message = `【问题催办提醒】\n\n`;
|
|
|
|
+ message += `项目:${projectTitle}\n`;
|
|
|
|
+ message += `问题:${issueTitle}\n`;
|
|
|
|
+ message += `优先级:${priority}\n`;
|
|
|
|
+
|
|
|
|
+ if (dueDate) {
|
|
|
|
+ message += `截止时间:${new Date(dueDate).toLocaleString('zh-CN')}\n`;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ message += `催办次数:第${reminderCount}次\n\n`;
|
|
|
|
+ message += `请及时处理该问题,谢谢!`;
|
|
|
|
+
|
|
|
|
+ return message;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private async sendWxworkMessage(userId: string, content: string): Promise<void> {
|
|
|
|
+ if (!this.wwsdk) {
|
|
|
|
+ throw new Error('企业微信SDK未初始化');
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 获取群聊ID
|
|
|
|
+ const groupChatQuery = new Parse.Query('GroupChat');
|
|
|
|
+ groupChatQuery.equalTo('project', this.project);
|
|
|
|
+ const groupChat = await groupChatQuery.first();
|
|
|
|
+
|
|
|
|
+ if (!groupChat || !groupChat.get('chat_id')) {
|
|
|
|
+ throw new Error('项目群聊不存在');
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 发送企业微信消息
|
|
|
|
+ await this.wwsdk.ww.sendChatMessage({
|
|
|
|
+ chatId: groupChat.get('chat_id'),
|
|
|
|
+ msgType: 'text',
|
|
|
|
+ content: content,
|
|
|
|
+ userIds: [userId]
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ canSendReminder(issue: Parse.Object): boolean {
|
|
|
|
+ const lastReminderAt = issue.get('lastReminderAt');
|
|
|
|
+ const reminderCount = issue.get('reminderCount') || 0;
|
|
|
|
+
|
|
|
|
+ // 检查催单间隔(至少间隔30分钟)
|
|
|
|
+ if (lastReminderAt) {
|
|
|
|
+ const timeDiff = Date.now() - new Date(lastReminderAt).getTime();
|
|
|
|
+ if (timeDiff < 30 * 60 * 1000) {
|
|
|
|
+ return false;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 检查催单次数限制(每日最多3次)
|
|
|
|
+ if (reminderCount >= 3) {
|
|
|
|
+ return false;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return true;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ getPriorityClass(priority: string): string {
|
|
|
|
+ return `priority-${priority}`;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ cancelCreate(): void {
|
|
|
|
+ this.mode = 'list';
|
|
|
|
+ this.resetForm();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ onClose(): void {
|
|
|
|
+ this.close.emit();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ onBackdropClick(event: MouseEvent): void {
|
|
|
|
+ if (event.target === event.currentTarget) {
|
|
|
|
+ this.onClose();
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 2. 底部卡片更新
|
|
|
|
+
|
|
|
|
+**更新 project-bottom-card.component.html**:
|
|
|
|
+```html
|
|
|
|
+<div class="action-buttons">
|
|
|
|
+ <!-- 现有文件和成员按钮 -->
|
|
|
|
+ <button class="action-button files-button" (click)="onShowFiles()">
|
|
|
|
+ <!-- ... -->
|
|
|
|
+ </button>
|
|
|
|
+
|
|
|
|
+ <button class="action-button members-button" (click)="onShowMembers()">
|
|
|
|
+ <!-- ... -->
|
|
|
|
+ </button>
|
|
|
|
+
|
|
|
|
+ <!-- 新增问题按钮 -->
|
|
|
|
+ <button
|
|
|
|
+ class="action-button issues-button"
|
|
|
|
+ (click)="onShowIssues()"
|
|
|
|
+ [disabled]="loading">
|
|
|
|
+ <div class="button-content">
|
|
|
|
+ <svg class="button-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
|
+ <circle cx="12" cy="12" r="10"></circle>
|
|
|
|
+ <path d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
|
|
|
+ </svg>
|
|
|
|
+ <span class="button-text">问题</span>
|
|
|
|
+ @if (issueCount > 0) {
|
|
|
|
+ <span class="button-badge danger" [class]="getIssueBadgeClass()">
|
|
|
|
+ {{ issueCount }}
|
|
|
|
+ </span>
|
|
|
|
+ }
|
|
|
|
+ </div>
|
|
|
|
+ </button>
|
|
|
|
+</div>
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+**更新 project-bottom-card.component.ts**:
|
|
|
|
+```typescript
|
|
|
|
+@Component({
|
|
|
|
+ selector: 'app-project-bottom-card',
|
|
|
|
+ standalone: true,
|
|
|
|
+ // ...
|
|
|
|
+})
|
|
|
|
+export class ProjectBottomCardComponent implements OnInit {
|
|
|
|
+ @Input() project: Parse.Object;
|
|
|
|
+ @Input() groupChat: Parse.Object;
|
|
|
|
+ @Input() currentUser: Parse.Object;
|
|
|
|
+ @Input() cid: string;
|
|
|
|
+
|
|
|
|
+ @Output() showFiles = new EventEmitter<void>();
|
|
|
|
+ @Output() showMembers = new EventEmitter<void>();
|
|
|
|
+ @Output() showIssues = new EventEmitter<void>(); // 新增输出事件
|
|
|
|
+
|
|
|
|
+ issueCount: number = 0;
|
|
|
|
+ urgentIssueCount: number = 0;
|
|
|
|
+
|
|
|
|
+ // 现有代码...
|
|
|
|
+
|
|
|
|
+ ngOnInit() {
|
|
|
|
+ this.loadIssueCount();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ async loadIssueCount(): Promise<void> {
|
|
|
|
+ try {
|
|
|
|
+ const query = new Parse.Query('ProjectIssue');
|
|
|
|
+ query.equalTo('project', this.project);
|
|
|
|
+ query.notEqualTo('isDeleted', true);
|
|
|
|
+ query.notEqualTo('status', '已解决');
|
|
|
|
+ query.notEqualTo('status', '已关闭');
|
|
|
|
+
|
|
|
|
+ this.issueCount = await query.count();
|
|
|
|
+
|
|
|
|
+ // 统计紧急问题数量
|
|
|
|
+ const urgentQuery = new Parse.Query('ProjectIssue');
|
|
|
|
+ urgentQuery.equalTo('project', this.project);
|
|
|
|
+ urgentQuery.equalTo('priority', '紧急');
|
|
|
|
+ urgentQuery.notEqualTo('isDeleted', true);
|
|
|
|
+ urgentQuery.notEqualTo('status', '已解决');
|
|
|
|
+ urgentQuery.notEqualTo('status', '已关闭');
|
|
|
|
+
|
|
|
|
+ this.urgentIssueCount = await urgentQuery.count();
|
|
|
|
+ } catch (error) {
|
|
|
|
+ console.error('加载问题数量失败:', error);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ onShowIssues(): void {
|
|
|
|
+ this.showIssues.emit();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ getIssueBadgeClass(): string {
|
|
|
|
+ if (this.urgentIssueCount > 0) {
|
|
|
|
+ return 'badge-urgent';
|
|
|
|
+ }
|
|
|
|
+ return 'badge-normal';
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 3. 项目详情页面集成
|
|
|
|
+
|
|
|
|
+**更新 project-detail.component.ts**:
|
|
|
|
+```typescript
|
|
|
|
+@Component({
|
|
|
|
+ selector: 'app-project-detail',
|
|
|
|
+ standalone: true,
|
|
|
|
+ // ...
|
|
|
|
+})
|
|
|
|
+export class ProjectDetailComponent implements OnInit {
|
|
|
|
+ // 现有属性...
|
|
|
|
+ showIssuesModal: boolean = false;
|
|
|
|
+
|
|
|
|
+ // 现有方法...
|
|
|
|
+
|
|
|
|
+ showIssues(): void {
|
|
|
|
+ this.showIssuesModal = true;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ closeIssuesModal(): void {
|
|
|
|
+ this.showIssuesModal = false;
|
|
|
|
+ // 可以在这里刷新问题统计
|
|
|
|
+ this.refreshProjectStats();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ async refreshProjectStats(): Promise<void> {
|
|
|
|
+ // 触发底部卡片刷新问题数量
|
|
|
|
+ // 实现方式取决于具体架构
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## 企业微信消息发送机制
|
|
|
|
+
|
|
|
|
+### 1. 消息类型和格式
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+interface WxworkMessage {
|
|
|
|
+ chatId: string;
|
|
|
|
+ msgType: 'text' | 'markdown' | 'image' | 'file';
|
|
|
|
+ content?: string;
|
|
|
|
+ markdown?: {
|
|
|
|
+ content: string;
|
|
|
|
+ };
|
|
|
|
+ mediaId?: string;
|
|
|
|
+ userIds?: string[];
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+interface ReminderMessage {
|
|
|
|
+ type: 'new_issue' | 'reminder' | 'resolved';
|
|
|
|
+ recipientUserId: string;
|
|
|
|
+ chatId: string;
|
|
|
|
+ content: string;
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 2. 消息模板
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+class MessageTemplates {
|
|
|
|
+ // 新问题创建通知
|
|
|
|
+ static newIssueNotification(issue: Parse.Object, project: Parse.Object): string {
|
|
|
|
+ return `【新问题创建】
|
|
|
|
+
|
|
|
|
+项目:${project.get('title')}
|
|
|
|
+问题:${issue.get('title')}
|
|
|
|
+类型:${issue.get('issueType')}
|
|
|
|
+优先级:${issue.get('priority')}
|
|
|
|
+创建人:${issue.get('creator')?.get('name')}
|
|
|
|
+
|
|
|
|
+请及时查看并处理该问题。`;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 催单通知
|
|
|
|
+ static reminderNotification(issue: Parse.Object, project: Parse.Object, reminderCount: number): string {
|
|
|
|
+ const dueDate = issue.get('dueDate');
|
|
|
|
+ let dueDateText = '';
|
|
|
|
+ if (dueDate) {
|
|
|
|
+ const due = new Date(dueDate);
|
|
|
|
+ const now = new Date();
|
|
|
|
+ const daysLeft = Math.ceil((due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
+ dueDateText = `(剩余${daysLeft}天)`;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return `【问题催办提醒 - 第${reminderCount}次】
|
|
|
|
+
|
|
|
|
+项目:${project.get('title')}
|
|
|
|
+问题:${issue.get('title')}
|
|
|
|
+优先级:${issue.get('priority')}
|
|
|
|
+截止时间:${dueDate ? new Date(dueDate).toLocaleDateString('zh-CN') : '未设置'}${dueDateText}
|
|
|
|
+
|
|
|
|
+请尽快处理该问题,谢谢!`;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 问题解决通知
|
|
|
|
+ static resolvedNotification(issue: Parse.Object, project: Parse.Object): string {
|
|
|
|
+ return `【问题已解决】
|
|
|
|
+
|
|
|
|
+项目:${project.get('title')}
|
|
|
|
+问题:${issue.get('title')}
|
|
|
|
+解决方案:${issue.get('resolution') || '已处理完成'}
|
|
|
|
+完成人:${issue.get('assignee')?.get('name')}
|
|
|
|
+
|
|
|
|
+问题已成功解决,感谢配合!`;
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 3. 消息发送服务
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+@Injectable({
|
|
|
|
+ providedIn: 'root'
|
|
|
|
+})
|
|
|
|
+export class IssueNotificationService {
|
|
|
|
+ private wwsdk: WxworkSDK | null = null;
|
|
|
|
+
|
|
|
|
+ constructor() {
|
|
|
|
+ // 初始化企业微信SDK
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ async sendNewIssueNotification(issue: Parse.Object, project: Parse.Object): Promise<void> {
|
|
|
|
+ try {
|
|
|
|
+ const assignee = issue.get('assignee');
|
|
|
|
+ if (!assignee) return;
|
|
|
|
+
|
|
|
|
+ const message = MessageTemplates.newIssueNotification(issue, project);
|
|
|
|
+ await this.sendMessage(assignee.get('userid'), message);
|
|
|
|
+
|
|
|
|
+ } catch (error) {
|
|
|
|
+ console.error('发送新问题通知失败:', error);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ async sendReminderNotification(
|
|
|
|
+ issue: Parse.Object,
|
|
|
|
+ project: Parse.Object,
|
|
|
|
+ reminderCount: number
|
|
|
|
+ ): Promise<void> {
|
|
|
|
+ try {
|
|
|
|
+ const assignee = issue.get('assignee');
|
|
|
|
+ if (!assignee) return;
|
|
|
|
+
|
|
|
|
+ const message = MessageTemplates.reminderNotification(issue, project, reminderCount);
|
|
|
|
+ await this.sendMessage(assignee.get('userid'), message);
|
|
|
|
+
|
|
|
|
+ } catch (error) {
|
|
|
|
+ console.error('发送催单通知失败:', error);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ async sendResolvedNotification(issue: Parse.Object, project: Parse.Object): Promise<void> {
|
|
|
|
+ try {
|
|
|
|
+ const creator = issue.get('creator');
|
|
|
|
+ if (!creator) return;
|
|
|
|
+
|
|
|
|
+ const message = MessageTemplates.resolvedNotification(issue, project);
|
|
|
|
+ await this.sendMessage(creator.get('userid'), message);
|
|
|
|
+
|
|
|
|
+ } catch (error) {
|
|
|
|
+ console.error('发送解决通知失败:', error);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private async sendMessage(userId: string, content: string): Promise<void> {
|
|
|
|
+ if (!this.wwsdk) {
|
|
|
|
+ throw new Error('企业微信SDK未初始化');
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 获取项目群聊ID
|
|
|
|
+ // 这里需要根据实际情况获取对应的群聊ID
|
|
|
|
+
|
|
|
|
+ // 发送消息
|
|
|
|
+ await this.wwsdk.ww.sendChatMessage({
|
|
|
|
+ chatId: 'project-group-chat-id',
|
|
|
|
+ msgType: 'text',
|
|
|
|
+ content: content,
|
|
|
|
+ userIds: [userId]
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## 错误处理与边界情况
|
|
|
|
+
|
|
|
|
+### 1. 常见错误场景
|
|
|
|
+
|
|
|
|
+#### 1.1 催单频率限制
|
|
|
|
+```typescript
|
|
|
|
+private canSendReminder(issue: Parse.Object): { canSend: boolean; reason?: string } {
|
|
|
|
+ const lastReminderAt = issue.get('lastReminderAt');
|
|
|
|
+ const reminderCount = issue.get('reminderCount') || 0;
|
|
|
|
+ const now = Date.now();
|
|
|
|
+
|
|
|
|
+ // 检查催单间隔(至少间隔30分钟)
|
|
|
|
+ if (lastReminderAt) {
|
|
|
|
+ const timeDiff = now - new Date(lastReminderAt).getTime();
|
|
|
|
+ if (timeDiff < 30 * 60 * 1000) {
|
|
|
|
+ const remainingMinutes = Math.ceil((30 * 60 * 1000 - timeDiff) / (60 * 1000));
|
|
|
|
+ return {
|
|
|
|
+ canSend: false,
|
|
|
|
+ reason: `催单过于频繁,请等待${remainingMinutes}分钟后再试`
|
|
|
|
+ };
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 检查每日催单次数限制
|
|
|
|
+ if (reminderCount >= 3) {
|
|
|
|
+ return {
|
|
|
|
+ canSend: false,
|
|
|
|
+ reason: '今日催单次数已达上限(3次)'
|
|
|
|
+ };
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return { canSend: true };
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+#### 1.2 权限验证
|
|
|
|
+```typescript
|
|
|
|
+private validateUserPermission(userId: string): boolean {
|
|
|
|
+ // 验证用户是否有权限创建/处理问题
|
|
|
|
+ const isProjectMember = this.projectMembers.some(member => member.userid === userId);
|
|
|
|
+ const isCreator = this.currentUser.id === this.project.get('createdBy')?.id;
|
|
|
|
+
|
|
|
|
+ return isProjectMember || isCreator;
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 2. 降级方案
|
|
|
|
+
|
|
|
|
+#### 2.1 离线支持
|
|
|
|
+```typescript
|
|
|
|
+private offlineQueue: Array<{
|
|
|
|
+ type: 'reminder' | 'notification';
|
|
|
|
+ data: any;
|
|
|
|
+ timestamp: number;
|
|
|
|
+}> = [];
|
|
|
|
+
|
|
|
|
+private async handleOfflineOperation(operation: any): Promise<void> {
|
|
|
|
+ if (navigator.onLine) {
|
|
|
|
+ // 在线时直接执行
|
|
|
|
+ await this.executeOperation(operation);
|
|
|
|
+ } else {
|
|
|
|
+ // 离线时加入队列
|
|
|
|
+ this.offlineQueue.push({
|
|
|
|
+ ...operation,
|
|
|
|
+ timestamp: Date.now()
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+private async syncOfflineOperations(): Promise<void> {
|
|
|
|
+ while (this.offlineQueue.length > 0) {
|
|
|
|
+ const operation = this.offlineQueue.shift();
|
|
|
|
+ try {
|
|
|
|
+ await this.executeOperation(operation);
|
|
|
|
+ } catch (error) {
|
|
|
|
+ // 失败时重新加入队列
|
|
|
|
+ this.offlineQueue.unshift(operation);
|
|
|
|
+ break;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## 性能优化
|
|
|
|
+
|
|
|
|
+### 1. 数据加载优化
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+// 分页加载问题列表
|
|
|
|
+private async loadIssues(page: number = 1, pageSize: number = 20): Promise<void> {
|
|
|
|
+ const query = new Parse.Query('ProjectIssue');
|
|
|
|
+ query.equalTo('project', this.project);
|
|
|
|
+ query.notEqualTo('isDeleted', true);
|
|
|
|
+ query.descending('createdAt');
|
|
|
|
+ query.include('creator', 'assignee');
|
|
|
|
+
|
|
|
|
+ query.skip((page - 1) * pageSize);
|
|
|
|
+ query.limit(pageSize);
|
|
|
|
+
|
|
|
|
+ const newIssues = await query.find();
|
|
|
|
+
|
|
|
|
+ if (page === 1) {
|
|
|
|
+ this.issues = newIssues;
|
|
|
|
+ } else {
|
|
|
|
+ this.issues.push(...newIssues);
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// 防抖搜索
|
|
|
|
+private searchSubject = new Subject<string>();
|
|
|
|
+
|
|
|
|
+ngOnInit() {
|
|
|
|
+ this.searchSubject.pipe(
|
|
|
|
+ debounceTime(300),
|
|
|
|
+ distinctUntilChanged()
|
|
|
|
+ ).subscribe(keyword => {
|
|
|
|
+ this.searchKeyword = keyword;
|
|
|
|
+ });
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 2. UI优化
|
|
|
|
+
|
|
|
|
+```scss
|
|
|
|
+// 虚拟滚动优化大列表
|
|
|
|
+.issues-list {
|
|
|
|
+ max-height: 500px;
|
|
|
|
+ overflow-y: auto;
|
|
|
|
+
|
|
|
|
+ // 使用CSS虚拟滚动优化性能
|
|
|
|
+ scroll-behavior: smooth;
|
|
|
|
+ -webkit-overflow-scrolling: touch;
|
|
|
|
+
|
|
|
|
+ .issue-card {
|
|
|
|
+ // 使用CSS containment优化渲染
|
|
|
|
+ contain: layout style paint;
|
|
|
|
+
|
|
|
|
+ // 添加骨架屏加载效果
|
|
|
|
+ &.skeleton {
|
|
|
|
+ .skeleton-text {
|
|
|
|
+ background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
|
|
+ background-size: 200% 100%;
|
|
|
|
+ animation: loading 1.5s infinite;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## 测试策略
|
|
|
|
+
|
|
|
|
+### 1. 单元测试
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+describe('ProjectIssueModalComponent', () => {
|
|
|
|
+ let component: ProjectIssueModalComponent;
|
|
|
|
+ let fixture: ComponentFixture<ProjectIssueModalComponent>;
|
|
|
|
+
|
|
|
|
+ beforeEach(async () => {
|
|
|
|
+ await TestBed.configureTestingModule({
|
|
|
|
+ imports: [ProjectIssueModalComponent]
|
|
|
|
+ }).compileComponents();
|
|
|
|
+
|
|
|
|
+ fixture = TestBed.createComponent(ProjectIssueModalComponent);
|
|
|
|
+ component = fixture.componentInstance;
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ it('should create new issue successfully', async () => {
|
|
|
|
+ // 测试问题创建功能
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ it('should send reminder with frequency limit', async () => {
|
|
|
|
+ // 测试催单频率限制
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ it('should filter issues correctly', () => {
|
|
|
|
+ // 测试问题过滤功能
|
|
|
|
+ });
|
|
|
|
+});
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 2. 集成测试
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+describe('Project Issue Integration', () => {
|
|
|
|
+ it('should complete full issue lifecycle', async () => {
|
|
|
|
+ // 测试从创建到解决的完整流程
|
|
|
|
+ // 1. 创建问题
|
|
|
|
+ // 2. 发送通知
|
|
|
|
+ // 3. 催单提醒
|
|
|
|
+ // 4. 标记解决
|
|
|
|
+ // 5. 发送解决通知
|
|
|
|
+ });
|
|
|
|
+});
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## 总结
|
|
|
|
+
|
|
|
|
+项目问题追踪系统提供了完整的问题管理解决方案:
|
|
|
|
+
|
|
|
|
+✅ **完整的问题生命周期管理**: 创建、分配、处理、解决、归档
|
|
|
|
+✅ **智能催单机制**: 频率限制、消息模板、企微集成
|
|
|
|
+✅ **灵活的过滤和搜索**: 多维度筛选、实时搜索
|
|
|
|
+✅ **权限控制**: 项目成员验证、操作权限管理
|
|
|
|
+✅ **离线支持**: 网络异常时的降级处理
|
|
|
|
+✅ **性能优化**: 分页加载、防抖搜索、虚拟滚动
|
|
|
|
+✅ **用户体验**: 直观的界面设计、清晰的状态展示
|
|
|
|
+
|
|
|
|
+该系统能有效提升项目问题处理效率,确保问题及时解决,提升项目交付质量和客户满意度。
|