2 Commit-ok a4f49bba4f ... 6594b484e8

Szerző SHA1 Üzenet Dátum
  ryanemax 6594b484e8 Merge branch 'master' of http://git.fmode.cn:3000/nkkj/yss-project 1 napja
  ryanemax 153d8fdc9f fix: project issue comp 1 napja
2 módosított fájl, 1546 hozzáadás és 8 törlés
  1. 1529 0
      docs/prd/项目-问题追踪.md
  2. 17 8
      rules/schemas.md

+ 1529 - 0
docs/prd/项目-问题追踪.md

@@ -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. 发送解决通知
+  });
+});
+```
+
+## 总结
+
+项目问题追踪系统提供了完整的问题管理解决方案:
+
+✅ **完整的问题生命周期管理**: 创建、分配、处理、解决、归档
+✅ **智能催单机制**: 频率限制、消息模板、企微集成
+✅ **灵活的过滤和搜索**: 多维度筛选、实时搜索
+✅ **权限控制**: 项目成员验证、操作权限管理
+✅ **离线支持**: 网络异常时的降级处理
+✅ **性能优化**: 分页加载、防抖搜索、虚拟滚动
+✅ **用户体验**: 直观的界面设计、清晰的状态展示
+
+该系统能有效提升项目问题处理效率,确保问题及时解决,提升项目交付质量和客户满意度。

+ 17 - 8
rules/schemas.md

@@ -222,17 +222,26 @@ TABLE(ProjectFeedback, "ProjectFeedback\n客户反馈表") {
     FIELD(isDeleted, Boolean)
 }
 
-TABLE(ProjectIssue, "ProjectIssue\n异常记录表") {
+TABLE(ProjectIssue, "ProjectIssue\n项目问题追踪表") {
     FIELD(objectId, String)
     FIELD(project, Pointer→Project)
     FIELD(product, Pointer→Product)
-    FIELD(reportedBy, Pointer→Profile)
-    FIELD(exceptionType, String)
-    FIELD(severity, String)
-    FIELD(description, String)
-    FIELD(status, String)
-    FIELD(resolution, String)
-    FIELD(data, Object)
+    FIELD(creator, Pointer→Profile)                  // 创建人
+    FIELD(assignee, Pointer→Profile)                 // 责任人
+    FIELD(title, String)                              // 问题标题
+    FIELD(description, String)                       // 问题描述
+    FIELD(relatedSpace, String)                       // 相关空间
+    FIELD(relatedStage, String)                       // 相关阶段
+    FIELD(relatedContentType, String)                 // 相关内容类型 (白模/软装/渲染/后期)
+    FIELD(relatedFiles, Array)                        // 相关项目文件
+    FIELD(priority, String)                           // 优先程度 (低/中/高/紧急)
+    FIELD(issueType, String)                          // 问题类型 (投诉/建议/改图)
+    FIELD(dueDate, Date)                              // 截止时间
+    FIELD(status, String)                             // 状态 (待处理/处理中/已解决/已关闭)
+    FIELD(resolution, String)                         // 解决方案
+    FIELD(lastReminderAt, Date)                       // 最后催单时间
+    FIELD(reminderCount, Number)                      // 催单次数
+    FIELD(data, Object)                              // 扩展数据
     FIELD(isDeleted, Boolean)
 }