项目-问题追踪.md 40 KB

项目问题追踪系统产品设计

概述

组件名称: app-project-issue 功能定位: 项目问题创建、管理和追踪系统 应用场景: 项目执行过程中出现问题时,用于快速创建问题记录、分配责任人、追踪问题解决进度,并通过企微消息进行催办提醒

数据结构分析

1. ProjectIssue 表结构

基于现有的ProjectIssue表,扩展为完整的问题追踪系统:

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. 与现有系统的关联

// 与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 中的调用:

<!-- 项目底部卡片 -->
<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. 组件输入输出接口

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 问题创建流程

graph TD
    A[点击问题按钮] --> B[打开问题创建模态框]
    B --> C[填写问题信息]
    C --> D[选择责任人]
    D --> E[设置优先级和截止时间]
    E --> F[创建问题记录]
    F --> G[发送企微通知给责任人]
    G --> H[更新问题列表]

1.2 催单流程

graph TD
    A[点击催单按钮] --> B[检查催单间隔限制]
    B --> C{可以催单?}
    C -->|是| D[构建催单消息]
    C -->|否| E[显示催单限制提示]
    D --> F[调用企微API发送消息]
    F --> G[更新最后催单时间]
    G --> H[记录催单次数]
    H --> I[显示催单成功提示]

2. 组件状态管理

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. 模态框主体结构

<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. 样式设计

.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. 主组件实现

@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:

<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:

@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:

@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. 消息类型和格式

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. 消息模板

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. 消息发送服务

@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 催单频率限制

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 权限验证

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 离线支持

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. 数据加载优化

// 分页加载问题列表
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优化

// 虚拟滚动优化大列表
.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. 单元测试

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. 集成测试

describe('Project Issue Integration', () => {
  it('should complete full issue lifecycle', async () => {
    // 测试从创建到解决的完整流程
    // 1. 创建问题
    // 2. 发送通知
    // 3. 催单提醒
    // 4. 标记解决
    // 5. 发送解决通知
  });
});

总结

项目问题追踪系统提供了完整的问题管理解决方案:

完整的问题生命周期管理: 创建、分配、处理、解决、归档 ✅ 智能催单机制: 频率限制、消息模板、企微集成 ✅ 灵活的过滤和搜索: 多维度筛选、实时搜索 ✅ 权限控制: 项目成员验证、操作权限管理 ✅ 离线支持: 网络异常时的降级处理 ✅ 性能优化: 分页加载、防抖搜索、虚拟滚动 ✅ 用户体验: 直观的界面设计、清晰的状态展示

该系统能有效提升项目问题处理效率,确保问题及时解决,提升项目交付质量和客户满意度。