Przeglądaj źródła

Merge branch 'master' of http://git.fmode.cn:3000/nkkj/yss-project

ryanemax 23 godzin temu
rodzic
commit
7384f44702

+ 1 - 1
package.json

@@ -68,7 +68,7 @@
     "echarts": "^6.0.0",
     "esdk-obs-browserjs": "^3.25.6",
     "eventemitter3": "^5.0.1",
-    "fmode-ng": "^0.0.222",
+    "fmode-ng": "^0.0.224",
     "highlight.js": "^11.11.1",
     "ionicons": "^8.0.13",
     "jquery": "^3.7.1",

+ 10 - 0
src/app/app.routes.ts

@@ -225,6 +225,11 @@ export const routes: Routes = [
             path: 'aftercare',
             loadComponent: () => import('../modules/project/pages/project-detail/stages/stage-aftercare.component').then(m => m.StageAftercareComponent),
             title: '售后归档'
+          },
+          {
+            path: 'issues',
+            loadComponent: () => import('../modules/project/pages/project-detail/stages/stage-issues.component').then(m => m.StageIssuesComponent),
+            title: '问题追踪'
           }
         ]
       },
@@ -360,6 +365,11 @@ export const routes: Routes = [
             path: 'aftercare',
             loadComponent: () => import('../modules/project/pages/project-detail/stages/stage-aftercare.component').then(m => m.StageAftercareComponent),
             title: '售后归档'
+          },
+          {
+            path: 'issues',
+            loadComponent: () => import('../modules/project/pages/project-detail/stages/stage-issues.component').then(m => m.StageIssuesComponent),
+            title: '问题追踪'
           }
         ]
       }

+ 37 - 0
src/modules/project/components/get-group-joinway.ts

@@ -0,0 +1,37 @@
+import { FmodeParse, WxworkCorp, WxworkSDK } from "fmode-ng/core"
+
+const Parse = FmodeParse.with("nova")
+export async function getGroupChat(gid:string){
+    let query = new Parse.Query("GroupChat")
+    let group = await query.get(gid);
+
+    let cid = group?.get("company")?.id || localStorage.getItem("company")
+    let wwcorp = new WxworkCorp(cid);
+    let needSave = false;
+    // group = await wwsdk.syncGroupChat(group.toJSON());
+    if(!group?.get("joinUrl")){
+        needSave = true;
+        let config_id1 = (await wwcorp.externalContact.groupChat.addJoinWay({
+          scene:1,
+          chat_id_list:[group.get("chat_id")],
+        }))?.config_id
+        let joinUrl = (await wwcorp.externalContact.groupChat.getJoinWay(config_id1))?.join_way
+        group.set("joinUrl",joinUrl)
+        needSave = true
+      }
+      if(!group?.get("joinQrcode")){
+        needSave = true;
+        let config_id2 = (await wwcorp.externalContact.groupChat.addJoinWay({
+          scene:2,
+          chat_id_list:[group.get("chat_id")],
+        }))?.config_id
+        let joinQrcode = (await wwcorp.externalContact.groupChat.getJoinWay(config_id2))?.join_way
+        group.set("joinQrcode",joinQrcode)
+        needSave = true
+      }
+    if(needSave){
+        group.save();
+    }
+    console.log("ggg",group)
+    return group
+}

+ 17 - 0
src/modules/project/components/project-bottom-card/project-bottom-card.component.html

@@ -64,6 +64,23 @@
             }
           </div>
         </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>
+              <line x1="12" y1="8" x2="12" y2="12"></line>
+              <circle cx="12" cy="16" r="1"></circle>
+            </svg>
+            <span class="button-text">问题</span>
+            @if (issueCount > 0) {
+              <span class="button-badge">{{ issueCount }}</span>
+            }
+          </div>
+        </button>
       </div>
     </div>
   }

+ 7 - 0
src/modules/project/components/project-bottom-card/project-bottom-card.component.scss

@@ -184,6 +184,13 @@
           color: #10b981;
         }
       }
+
+      &.issues-button:hover:not(:disabled) {
+        border-color: #f59e0b;
+        .button-text {
+          color: #f59e0b;
+        }
+      }
     }
   }
 }

+ 6 - 0
src/modules/project/components/project-bottom-card/project-bottom-card.component.ts

@@ -17,9 +17,11 @@ export class ProjectBottomCardComponent {
   @Input() loading: boolean = false;
   @Input() fileCount: number = 0;
   @Input() memberCount: number = 0;
+  @Input() issueCount: number = 0;
 
   @Output() showFiles = new EventEmitter<void>();
   @Output() showMembers = new EventEmitter<void>();
+  @Output() showIssues = new EventEmitter<void>();
 
   constructor() {}
 
@@ -31,6 +33,10 @@ export class ProjectBottomCardComponent {
     this.showMembers.emit();
   }
 
+  onShowIssues() {
+    this.showIssues.emit();
+  }
+
   getProjectTitle(): string {
     return this.project?.get('title') || '项目详情';
   }

+ 141 - 0
src/modules/project/components/project-issues-modal/project-issues-modal.component.html

@@ -0,0 +1,141 @@
+<div class="issues-modal" *ngIf="isVisible">
+  <div class="overlay" (click)="onClose()"></div>
+  <div class="modal">
+    <div class="header">
+      <div class="title-area">
+        <h3>问题追踪</h3>
+        <div class="counts">
+          <span class="count total">总计 {{ counts.total }}</span>
+          <span class="count open">未开始 {{ counts.open }}</span>
+          <span class="count in-progress">处理中 {{ counts.in_progress }}</span>
+          <span class="count resolved">已解决 {{ counts.resolved }}</span>
+          <span class="count closed">已关闭 {{ counts.closed }}</span>
+        </div>
+      </div>
+      <button class="close-btn" (click)="onClose()" aria-label="关闭">×</button>
+    </div>
+
+    <div class="tools">
+      <input
+        class="search-input"
+        type="text"
+        placeholder="搜索标题、标签或描述..."
+        [(ngModel)]="searchText"
+        (input)="onSearchChange()"
+      />
+      <div class="filters">
+        <button class="chip" [class.active]="filterStatus.includes('open')" (click)="toggleStatusFilter('open')">未开始</button>
+        <button class="chip" [class.active]="filterStatus.includes('in_progress')" (click)="toggleStatusFilter('in_progress')">处理中</button>
+        <button class="chip" [class.active]="filterStatus.includes('resolved')" (click)="toggleStatusFilter('resolved')">已解决</button>
+        <button class="chip" [class.active]="filterStatus.includes('closed')" (click)="toggleStatusFilter('closed')">已关闭</button>
+      </div>
+      <button class="primary" (click)="startCreate()" *ngIf="!creating">+ 新建问题</button>
+    </div>
+
+    <div class="create-form" *ngIf="creating">
+      <div class="form-grid">
+        <div class="form-item">
+          <label>标题</label>
+          <input type="text" [(ngModel)]="newTitle" placeholder="简短描述问题" />
+        </div>
+        <div class="form-item">
+          <label>优先级</label>
+          <select [(ngModel)]="newPriority">
+            <option value="low">低</option>
+            <option value="medium">中</option>
+            <option value="high">高</option>
+            <option value="critical">紧急</option>
+          </select>
+        </div>
+        <div class="form-item">
+          <label>类型</label>
+          <select [(ngModel)]="newType">
+            <option value="task">任务</option>
+            <option value="bug">问题</option>
+            <option value="feedback">反馈</option>
+            <option value="risk">风险</option>
+          </select>
+        </div>
+        <div class="form-item">
+          <label>负责人</label>
+          <select [(ngModel)]="selectedAssigneeId">
+            <option [ngValue]="''">自动选择</option>
+            <option *ngFor="let a of assignees" [ngValue]="a.id">{{ a.name }}</option>
+          </select>
+        </div>
+        <div class="form-item">
+          <label>截止日期</label>
+          <input type="date" [(ngModel)]="newDueDate" />
+        </div>
+        <div class="form-item">
+          <label>关联空间</label>
+          <input type="text" [(ngModel)]="newRelatedSpace" placeholder="如:客厅/主卧/厨房" />
+        </div>
+        <div class="form-item">
+          <label>相关阶段</label>
+          <input type="text" [(ngModel)]="newRelatedStage" placeholder="如:建模/渲染/后期" />
+        </div>
+        <div class="form-item full">
+          <label>描述</label>
+          <textarea rows="3" [(ngModel)]="newDescription" placeholder="可选:详细说明"></textarea>
+        </div>
+        <div class="form-item full">
+          <label>标签</label>
+          <input type="text" [(ngModel)]="newTagsText" placeholder="用逗号分隔,如:灯光,尺寸" />
+        </div>
+      </div>
+      <div class="form-actions">
+        <button class="secondary" (click)="cancelCreate()">取消</button>
+        <button class="primary" (click)="submitCreate()" [disabled]="!newTitle.trim()">创建</button>
+      </div>
+    </div>
+
+    <div class="body">
+      <div class="loading" *ngIf="loading">加载中...</div>
+      <div class="error" *ngIf="error">{{ error }}</div>
+      <div class="empty" *ngIf="!loading && !error && issues.length === 0">暂无问题,点击右上角“新建问题”开始吧。</div>
+
+      <div class="issue-list" *ngIf="!loading && !error && issues.length > 0">
+        <div class="issue-item" *ngFor="let issue of issues">
+          <div class="title-row">
+            <div class="left">
+              <span class="issue-title">{{ issue.title }}</span>
+              <span class="badge status" [class.open]="issue.status==='open'" [class.in-progress]="issue.status==='in_progress'" [class.resolved]="issue.status==='resolved'" [class.closed]="issue.status==='closed'">
+                {{ issue.status === 'open' ? '未开始' : issue.status === 'in_progress' ? '处理中' : issue.status === 'resolved' ? '已解决' : '已关闭' }}
+              </span>
+              <span class="badge priority" [class.low]="issue.priority==='low'" [class.medium]="issue.priority==='medium'" [class.high]="issue.priority==='high'" [class.critical]="issue.priority==='critical'">
+                {{ issue.priority === 'low' ? '低' : issue.priority === 'medium' ? '中' : issue.priority === 'high' ? '高' : '紧急' }}
+              </span>
+            </div>
+            <div class="right">
+              <button class="icon" title="删除" (click)="deleteIssue(issue)">🗑</button>
+            </div>
+          </div>
+          <div class="description" *ngIf="issue.description">{{ issue.description }}</div>
+          <div class="meta">
+            <span class="tag" *ngFor="let t of issue.tags">{{ t }}</span>
+            <span class="due" *ngIf="issue.dueDate">截止: {{ issue.dueDate | date:'MM-dd' }}</span>
+          </div>
+          <div class="actions">
+            <span class="action-label">状态切换:</span>
+            <button class="ghost" (click)="setStatus(issue, 'open')">未开始</button>
+            <button class="ghost" (click)="setStatus(issue, 'in_progress')">处理中</button>
+            <button class="ghost" (click)="setStatus(issue, 'resolved')">已解决</button>
+            <button class="ghost" (click)="setStatus(issue, 'closed')">关闭</button>
+          </div>
+          <div class="comments">
+            <div class="comment-item" *ngFor="let c of issue.comments">
+              <span class="time">{{ c.createdAt | date:'MM-dd HH:mm' }}</span>
+              <span class="content">{{ c.content }}</span>
+            </div>
+            <div class="comment-input">
+              <input type="text" placeholder="添加评论或催办..." #cInput>
+              <button class="primary" (click)="addComment(issue, cInput.value); cInput.value=''">发送</button>
+              <button class="primary" (click)="issueAlert(issue); cInput.value=''">催办</button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>

+ 232 - 0
src/modules/project/components/project-issues-modal/project-issues-modal.component.scss

@@ -0,0 +1,232 @@
+.issues-modal {
+  position: fixed;
+  inset: 0;
+  z-index: 1100;
+
+  .overlay {
+    position: absolute;
+    inset: 0;
+    background: rgba(0, 0, 0, 0.35);
+  }
+
+  .modal {
+    position: absolute;
+    left: 50%;
+    top: 52%;
+    transform: translate(-50%, -50%);
+    width: min(920px, 94vw);
+    max-height: 80vh;
+    background: #fff;
+    border-radius: 12px;
+    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+  }
+
+  .header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 12px 16px;
+    border-bottom: 1px solid #e5e7eb;
+
+    .title-area {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+
+      h3 {
+        margin: 0;
+        font-size: 16px;
+        font-weight: 600;
+        color: #111827;
+      }
+
+      .counts {
+        display: flex;
+        gap: 8px;
+        flex-wrap: wrap;
+
+        .count {
+          font-size: 12px;
+          color: #6b7280;
+        }
+
+        .open { color: #2563eb; }
+        .in-progress { color: #10b981; }
+        .resolved { color: #7c3aed; }
+        .closed { color: #ef4444; }
+      }
+    }
+
+    .close-btn {
+      border: none;
+      background: transparent;
+      font-size: 20px;
+      cursor: pointer;
+      color: #6b7280;
+    }
+  }
+
+  .tools {
+    display: flex;
+    gap: 12px;
+    align-items: center;
+    padding: 12px 16px;
+    border-bottom: 1px solid #e5e7eb;
+
+    .search-input {
+      flex: 1;
+      padding: 8px 10px;
+      border: 1px solid #e5e7eb;
+      border-radius: 8px;
+      font-size: 14px;
+    }
+
+    .filters {
+      display: flex;
+      gap: 8px;
+
+      .chip {
+        border: 1px solid #e5e7eb;
+        border-radius: 16px;
+        padding: 6px 10px;
+        font-size: 12px;
+        background: #fff;
+        cursor: pointer;
+      }
+
+      .chip.active {
+        border-color: #2563eb;
+        color: #2563eb;
+        background: #eff6ff;
+      }
+    }
+
+    .primary {
+      border: none;
+      background: #2563eb;
+      color: #fff;
+      padding: 8px 12px;
+      border-radius: 8px;
+      cursor: pointer;
+    }
+  }
+
+  .create-form {
+    padding: 12px 16px;
+    border-bottom: 1px solid #e5e7eb;
+
+    .form-grid {
+      display: grid;
+      grid-template-columns: 1fr 1fr 1fr 1fr;
+      gap: 12px;
+
+      .form-item {
+        display: flex;
+        flex-direction: column;
+        gap: 6px;
+
+        label { font-size: 12px; color: #6b7280; }
+        input, select, textarea {
+          border: 1px solid #e5e7eb;
+          border-radius: 8px;
+          padding: 8px 10px;
+          font-size: 14px;
+        }
+      }
+
+      .form-item.full { grid-column: span 4; }
+    }
+
+    .form-actions {
+      display: flex;
+      justify-content: flex-end;
+      gap: 12px;
+      margin-top: 8px;
+
+      .secondary {
+        background: #fff;
+        border: 1px solid #e5e7eb;
+        color: #374151;
+        padding: 8px 12px;
+        border-radius: 8px;
+        cursor: pointer;
+      }
+
+      .primary {
+        border: none;
+        background: #10b981;
+        color: #fff;
+        padding: 8px 12px;
+        border-radius: 8px;
+        cursor: pointer;
+      }
+    }
+  }
+
+  .body {
+    padding: 12px 16px;
+    overflow: auto;
+  }
+
+  .issue-list {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+
+    .issue-item {
+      border: 1px solid #e5e7eb;
+      border-radius: 10px;
+      padding: 10px;
+
+      .title-row {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+
+        .left { display: flex; align-items: center; gap: 8px; }
+        .issue-title { font-weight: 600; color: #111827; }
+
+        .badge {
+          font-size: 12px;
+          padding: 2px 8px;
+          border-radius: 12px;
+          border: 1px solid #e5e7eb;
+          color: #374151;
+        }
+
+        .status.open { color: #2563eb; border-color: #bfdbfe; background: #eff6ff; }
+        .status.in-progress { color: #10b981; border-color: #a7f3d0; background: #ecfdf5; }
+        .status.resolved { color: #7c3aed; border-color: #ddd6fe; background: #f5f3ff; }
+        .status.closed { color: #ef4444; border-color: #fecaca; background: #fef2f2; }
+
+        .priority.low { color: #6b7280; }
+        .priority.medium { color: #2563eb; }
+        .priority.high { color: #f59e0b; }
+        .priority.critical { color: #ef4444; }
+      }
+
+      .description { margin: 6px 0; color: #374151; }
+
+      .meta { display: flex; gap: 8px; flex-wrap: wrap; color: #6b7280; font-size: 12px; }
+      .meta .tag { background: #f3f4f6; border-radius: 10px; padding: 2px 6px; }
+
+      .actions { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
+      .actions .action-label { font-size: 12px; color: #6b7280; }
+      .actions .ghost { border: 1px dashed #e5e7eb; background: #fff; border-radius: 8px; padding: 4px 8px; font-size: 12px; cursor: pointer; }
+
+      .comments { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
+      .comments .comment-item { font-size: 12px; color: #374151; display: flex; gap: 8px; }
+      .comments .comment-input { display: flex; gap: 8px; }
+      .comments .comment-input input { flex: 1; border: 1px solid #e5e7eb; border-radius: 8px; padding: 6px 8px; }
+      .comments .comment-input .primary { border: none; background: #2563eb; color: #fff; padding: 6px 10px; border-radius: 8px; cursor: pointer; }
+    }
+  }
+
+  .loading, .error, .empty {
+    padding: 12px;
+    color: #6b7280;
+  }
+}

+ 233 - 0
src/modules/project/components/project-issues-modal/project-issues-modal.component.ts

@@ -0,0 +1,233 @@
+import { Component, EventEmitter, Input, Output, OnInit, OnChanges, SimpleChanges } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { FmodeObject } from 'fmode-ng/parse';
+import { ProjectIssueService, ProjectIssue, IssueStatus, IssuePriority, IssueType, IssueCounts } from '../../services/project-issue.service';
+import { WxworkCorp } from 'fmode-ng/core';
+
+@Component({
+  selector: 'app-project-issues-modal',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  templateUrl: './project-issues-modal.component.html',
+  styleUrls: ['./project-issues-modal.component.scss']
+})
+export class ProjectIssuesModalComponent implements OnInit, OnChanges {
+  @Input() project: FmodeObject | null = null;
+  @Input() currentUser: FmodeObject | null = null;
+  @Input() isVisible: boolean = false;
+  @Output() close = new EventEmitter<void>();
+
+  issues: ProjectIssue[] = [];
+  counts: IssueCounts = { total: 0, open: 0, in_progress: 0, resolved: 0, closed: 0 };
+
+  filterStatus: IssueStatus[] = [];
+  searchText: string = '';
+
+  // 创建表单
+  creating: boolean = false;
+  newTitle: string = '';
+  newDescription: string = '';
+  newPriority: IssuePriority = 'medium';
+  newType: IssueType = 'task';
+  newDueDate?: string;
+  newTagsText: string = '';
+  // 新增:负责人与关联信息
+  assignees: { id: string; name: string }[] = [];
+  selectedAssigneeId: string = '';
+  newRelatedSpace: string = '';
+  newRelatedStage: string = '';
+
+  loading: boolean = false;
+  error: string | null = null;
+  wwcorp:WxworkCorp | null = null;
+
+  constructor(private issueService: ProjectIssueService) {
+    let cid = localStorage.getItem('company') || this.project?.get("company")?.id || ""
+    this.wwcorp = new WxworkCorp(cid);
+  }
+
+  ngOnInit() {
+    this.refresh();
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes['isVisible'] && this.isVisible) {
+      this.refresh();
+    }
+    if (changes['project'] && this.project) {
+      this.refresh();
+    }
+  }
+
+  async refresh() {
+    if (!this.project?.id) return;
+    try {
+      this.loading = true;
+      // 从后端刷新缓存并应用筛选
+      await this.issueService.refreshFromServer(this.project.id, {
+        status: this.filterStatus,
+        text: this.searchText
+      });
+      this.issues = this.issueService.listIssues(this.project.id, {
+        status: this.filterStatus,
+        text: this.searchText
+      });
+      this.counts = this.issueService.getCounts(this.project.id);
+      this.loading = false;
+    } catch (err: any) {
+      this.error = err?.message || '加载问题列表失败';
+      this.loading = false;
+    }
+  }
+
+  toggleStatusFilter(status: IssueStatus) {
+    const idx = this.filterStatus.indexOf(status);
+    if (idx >= 0) this.filterStatus.splice(idx, 1); else this.filterStatus.push(status);
+    this.refresh();
+  }
+
+  onSearchChange() {
+    this.refresh();
+  }
+
+  async startCreate() {
+    this.creating = true;
+    this.newTitle = '';
+    this.newDescription = '';
+    this.newPriority = 'medium';
+    this.newType = 'task';
+    this.newDueDate = undefined;
+    this.newTagsText = '';
+    this.selectedAssigneeId = '';
+    this.newRelatedSpace = '';
+    this.newRelatedStage = '';
+    // 加载负责人候选
+    if (this.project?.id) {
+      this.assignees = await this.issueService.listAssignees(this.project.id);
+    }
+  }
+
+  cancelCreate() {
+    this.creating = false;
+  }
+
+  async submitCreate() {
+    if (!this.project?.id || !this.currentUser?.id) return;
+    if (!this.newTitle.trim()) return;
+
+    const tags = this.newTagsText
+      .split(',')
+      .map(t => t.trim())
+      .filter(Boolean);
+
+    const due = this.newDueDate ? new Date(this.newDueDate) : undefined;
+
+    await this.issueService.createIssue(this.project.id, {
+      title: this.newTitle.trim(),
+      description: this.newDescription.trim(),
+      priority: this.newPriority,
+      type: this.newType,
+      creatorId: this.currentUser.id,
+      assigneeId: this.selectedAssigneeId || undefined,
+      dueDate: due,
+      tags,
+      relatedSpace: this.newRelatedSpace.trim() || undefined,
+      relatedStage: this.newRelatedStage.trim() || undefined
+    });
+
+    this.creating = false;
+    this.refresh();
+  }
+
+  async setStatus(issue: ProjectIssue, status: IssueStatus) {
+    if (!this.project?.id) return;
+    await this.issueService.setStatus(this.project.id, issue.id, status);
+    this.refresh();
+  }
+
+  async deleteIssue(issue: ProjectIssue) {
+    if (!this.project?.id) return;
+    await this.issueService.deleteIssue(this.project.id, issue.id);
+    this.refresh();
+  }
+
+  async addComment(issue: ProjectIssue, text: string) {
+    if (!this.project?.id || !this.currentUser?.id) return;
+    const content = text.trim();
+    if (!content) return;
+
+    const md = `**项目:** ${this.project?.get('title')}
+**问题:** ${issue.title}
+**优先级:** ${this.priorityLabel(issue.priority)}
+**类型:** ${this.typeLabel(issue.type)}
+**状态:** ${this.statusLabel(issue.status)}
+**评论人:** ${this.currentUser?.get('name') || ''}
+<span style="color:red">**消息:** ${content}</span>`;
+
+    this.wwcorp?.message.sendMarkdown({
+      agentid:"1000017",
+      touser: issue.assignee?.get('userid') || '',
+      content: md
+    });
+
+    await this.issueService.addComment(this.project.id, issue.id, this.currentUser.id, content);
+    this.refresh();
+  }
+
+  issueAlert(issue: ProjectIssue){
+    const md = `**项目:** ${this.project?.get('title')}
+**问题:** ${issue.title}
+**优先级:** ${this.priorityLabel(issue.priority)}
+**类型:** ${this.typeLabel(issue.type)}
+**状态:** ${this.statusLabel(issue.status)}
+**描述:** ${issue.description || '暂无'}
+**截止日期:** ${issue.dueDate ? new Date(issue.dueDate).toLocaleString() : '未设置'}
+**标签:** ${issue.tags?.join(', ') || '无'}`;
+
+    this.wwcorp?.message.sendMarkdown({
+      agentid:"1000017",
+      touser: issue.assignee?.get('userid') || '',
+      content: md
+    });
+  }
+
+  onClose() {
+    this.close.emit();
+  }
+
+  // 辅助:状态中文标签
+  statusLabel(s: IssueStatus): string {
+    const map: Record<IssueStatus, string> = {
+      open: '待处理',
+      in_progress: '进行中',
+      resolved: '已解决',
+      closed: '已关闭'
+    };
+    return map[s] || s;
+  }
+
+  // 辅助:优先级中文标签
+  priorityLabel(p: IssuePriority): string {
+    const map: Record<IssuePriority, string> = {
+      low: '低',
+      medium: '中',
+      high: '高',
+      critical: '紧急',
+      urgent: '紧急'
+    };
+    return map[p] || p;
+  }
+
+  // 辅助:类型中文标签
+  typeLabel(t: IssueType): string {
+    const map: Record<IssueType, string> = {
+      task: '任务',
+      bug: '缺陷',
+      feature: '需求',
+      feedback:"反馈",
+      risk:"风险"
+    };
+    return map[t] || t;
+  }
+}

+ 30 - 6
src/modules/project/components/project-members-modal/project-members-modal.component.ts

@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { FmodeObject, FmodeQuery, FmodeParse } from 'fmode-ng/parse';
 import { WxworkCorp, WxworkSDK } from 'fmode-ng/core';
+import { getGroupChat } from '../get-group-joinway';
 
 const Parse = FmodeParse.with('nova');
 
@@ -81,8 +82,9 @@ export class ProjectMembersModalComponent implements OnInit {
 
   private checkWxworkEnvironment(): void {
     // 检查是否在企业微信环境中
-    this.wecorp = new WxworkCorp(this.cid);
-    this.wwsdk = new WxworkSDK({cid:this.cid,appId:'crm'});
+    let cid = this.project?.get("company")?.id || localStorage.getItem("company")
+    this.wecorp = new WxworkCorp(cid);
+    this.wwsdk = new WxworkSDK({cid:cid,appId:'crm'});
     console.log('✅ 企业微信环境检测成功');
   }
 
@@ -102,6 +104,10 @@ export class ProjectMembersModalComponent implements OnInit {
         gcQuery2.equalTo('project', this.project?.id);
         this.groupChat = await gcQuery2.first();
       }
+
+      if(this.groupChat?.id && !this.groupChat?.get("joinUrl")){
+        this.groupChat = await getGroupChat(this.groupChat?.id)
+      }
       const groupChatMembers = await this.loadGroupChatMembers();
 
       // 3. 合并成员数据
@@ -173,7 +179,7 @@ export class ProjectMembersModalComponent implements OnInit {
     groupChatMembers.forEach(groupMember => {
       // 查找是否已在项目团队中
       const existingMember = Array.from(memberMap.values()).find(
-        m => m.userid === groupMember.userid || m.name === groupMember.name
+        m => m.userid === groupMember.userid
       );
 
       if (existingMember) {
@@ -253,9 +259,6 @@ export class ProjectMembersModalComponent implements OnInit {
 
   async addMemberToGroupChat(member: ProjectMember): Promise<void> {
    
-    if(!this.isWechat){
-      alert("请在企业微信客户端添加")
-    }
     if (!member.userid) {
       alert('该成员没有用户ID,无法添加到群聊');
       return;
@@ -271,6 +274,27 @@ export class ProjectMembersModalComponent implements OnInit {
       console.log(`🚀 开始添加成员 ${member.name} (${member.userid}) 到群聊 ${chatId}`);
 
       // TODO: 实现正确的企业微信API调用
+        // console.log(this.groupChat?.get("joinUrl"),this.groupChat)
+
+          this.wecorp?.message.sendNews({
+            agentid:"1000017",
+            touser: member.userid || '',
+            articles:[
+              {
+                 title: this.project?.get('title'),
+                description: "点击加入项目群聊",
+                url: this.groupChat?.get("joinUrl")?.qr_code,
+                picurl: this.groupChat?.get("joinQrcode")?.qr_code,
+              }
+            ]
+          });
+
+          const md = `${this.project?.get('title')} 项目开始啦!\n就等你来啦!快点上面进群\n`;
+          this.wecorp?.message.sendMarkdown({
+            agentid:"1000017",
+            touser: member.userid || '',
+            content: md
+          });
       let result = await this.wwsdk?.ww.updateEnterpriseChat({
         chatId: chatId,
         userIdsToAdd: [member.userid]

+ 11 - 1
src/modules/project/pages/project-detail/project-detail.component.html

@@ -106,8 +106,10 @@
       [groupChat]="groupChat"
       [currentUser]="currentUser"
       [cid]="cid"
+      [issueCount]="issueCount"
       (showFiles)="showFiles()"
-      (showMembers)="showMembers()">
+      (showMembers)="showMembers()"
+      (showIssues)="showIssues()">
     </app-project-bottom-card>
   }
 
@@ -128,4 +130,12 @@
      [isVisible]="showMembersModal"
      (close)="closeMembersModal()">
     </app-project-members-modal>
+
+  <!-- 问题模态框 -->
+  <app-project-issues-modal
+    [project]="project"
+    [currentUser]="currentUser"
+    [isVisible]="showIssuesModal"
+    (close)="closeIssuesModal()">
+  </app-project-issues-modal>
 </div>

+ 39 - 5
src/modules/project/pages/project-detail/project-detail.component.ts

@@ -8,6 +8,8 @@ import { ProfileService } from '../../../../app/services/profile.service';
 import { ProjectBottomCardComponent } from '../../components/project-bottom-card/project-bottom-card.component';
 import { ProjectFilesModalComponent } from '../../components/project-files-modal/project-files-modal.component';
 import { ProjectMembersModalComponent } from '../../components/project-members-modal/project-members-modal.component';
+import { ProjectIssuesModalComponent } from '../../components/project-issues-modal/project-issues-modal.component';
+import { ProjectIssueService } from '../../services/project-issue.service';
 
 const Parse = FmodeParse.with('nova');
 
@@ -25,7 +27,7 @@ const Parse = FmodeParse.with('nova');
 @Component({
   selector: 'app-project-detail',
   standalone: true,
-  imports: [CommonModule, IonicModule, RouterModule, ProjectBottomCardComponent, ProjectFilesModalComponent, ProjectMembersModalComponent],
+  imports: [CommonModule, IonicModule, RouterModule, ProjectBottomCardComponent, ProjectFilesModalComponent, ProjectMembersModalComponent, ProjectIssuesModalComponent],
   templateUrl: './project-detail.component.html',
   styleUrls: ['./project-detail.component.scss']
 })
@@ -35,6 +37,9 @@ export class ProjectDetailComponent implements OnInit {
   @Input() groupChat: FmodeObject | null = null;
   @Input() currentUser: FmodeObject | null = null;
 
+  // 问题统计
+  issueCount: number = 0;
+
   // 路由参数
   cid: string = '';
   projectId: string = '';
@@ -72,11 +77,13 @@ export class ProjectDetailComponent implements OnInit {
   // 模态框状态
   showFilesModal: boolean = false;
   showMembersModal: boolean = false;
+  showIssuesModal: boolean = false;
 
   constructor(
     private router: Router,
     private route: ActivatedRoute,
-    private profileService: ProfileService
+    private profileService: ProfileService,
+    private issueService: ProjectIssueService
   ) {}
 
   async ngOnInit() {
@@ -121,10 +128,7 @@ export class ProjectDetailComponent implements OnInit {
       if (!this.currentUser?.id) {
         this.currentUser = await this.wxAuth?.currentProfile();
       }
-      console.log("777",this.currentUser)
-
       // 设置权限
-      console.log(this.currentUser)
       this.role = this.currentUser?.get('roleName') || '';
       this.canEdit = ['客服', '组员', '组长', '管理员'].includes(this.role);
       this.canViewCustomerPhone = ['客服', '组长', '管理员'].includes(this.role);
@@ -166,6 +170,8 @@ export class ProjectDetailComponent implements OnInit {
 
       }
 
+     
+
       if(!this.groupChat?.id){
         const gcQuery2 = new Parse.Query('GroupChat');
         gcQuery2.equalTo('project', this.projectId);
@@ -173,6 +179,8 @@ export class ProjectDetailComponent implements OnInit {
         this.groupChat = await gcQuery2.first();
       }
 
+      this.wxwork?.syncGroupChat(this.groupChat?.toJSON())
+
       if (!this.project) {
         throw new Error('无法加载项目信息');
       }
@@ -180,6 +188,17 @@ export class ProjectDetailComponent implements OnInit {
       this.contact = this.project.get('contact');
       this.assignee = this.project.get('assignee');
 
+      // 更新问题计数
+      try {
+        if (this.project?.id) {
+          this.issueService.seed(this.project.id!);
+          const counts = this.issueService.getCounts(this.project.id!);
+          this.issueCount = counts.total;
+        }
+      } catch (e) {
+        console.warn('统计问题数量失败:', e);
+      }
+
       // 4. 加载群聊(如果没有传入且有groupId)
       if (!this.groupChat && this.groupId) {
         try {
@@ -404,6 +423,11 @@ export class ProjectDetailComponent implements OnInit {
     this.showMembersModal = true;
   }
 
+  /** 显示问题模态框 */
+  showIssues() {
+    this.showIssuesModal = true;
+  }
+
   /**
    * 关闭文件模态框
    */
@@ -417,4 +441,14 @@ export class ProjectDetailComponent implements OnInit {
   closeMembersModal() {
     this.showMembersModal = false;
   }
+
+  /** 关闭问题模态框 */
+  closeIssuesModal() {
+    this.showIssuesModal = false;
+    // 关闭后更新计数(避免列表操作后的计数不一致)
+    if (this.project?.id) {
+      const counts = this.issueService.getCounts(this.project.id!);
+      this.issueCount = counts.total;
+    }
+  }
 }

+ 59 - 0
src/modules/project/pages/project-detail/stages/stage-issues.component.ts

@@ -0,0 +1,59 @@
+import { Component, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, RouterModule } from '@angular/router';
+import { FmodeParse, FmodeObject } from 'fmode-ng/parse';
+import { ProfileService } from '../../../../../app/services/profile.service';
+import { ProjectIssuesModalComponent } from '../../../components/project-issues-modal/project-issues-modal.component';
+
+const Parse = FmodeParse.with('nova');
+
+@Component({
+  selector: 'app-stage-issues',
+  standalone: true,
+  imports: [CommonModule, FormsModule, RouterModule, ProjectIssuesModalComponent],
+  template: `
+    <div class="stage-issues-page">
+      <h2 class="page-title">问题追踪</h2>
+      <div class="page-body">
+        <app-project-issues-modal
+          [project]="project"
+          [currentUser]="currentUser"
+          [isVisible]="true"
+          (close)="onClose()">
+        </app-project-issues-modal>
+      </div>
+    </div>
+  `,
+  styles: [
+    `.stage-issues-page { padding: 12px; }`,
+    `.page-title { font-size: 16px; font-weight: 600; color: #111827; margin: 8px 0 12px; }`
+  ]
+})
+export class StageIssuesComponent implements OnInit {
+  project: FmodeObject | null = null;
+  currentUser: FmodeObject | null = null;
+
+  constructor(private route: ActivatedRoute, private profileService: ProfileService) {}
+
+  async ngOnInit() {
+    const projectId = this.route.parent?.snapshot.paramMap.get('projectId') || '';
+    // 从根参数取 cid(可选,不强制)
+    const cid = this.route.parent?.parent?.snapshot.paramMap.get('cid') || '';
+
+    // 加载当前用户
+    this.currentUser = await this.profileService.getCurrentProfile(cid);
+
+    // 加载项目
+    if (projectId) {
+      const query = new Parse.Query('Project');
+      query.include('customer', 'assignee','department','department.leader');
+      this.project = await query.get(projectId);
+    }
+  }
+
+  onClose() {
+    // 关闭时返回到订单阶段(或上一页)
+    history.back();
+  }
+}

+ 354 - 0
src/modules/project/services/project-issue.service.ts

@@ -0,0 +1,354 @@
+import { Injectable } from '@angular/core';
+import { v4 as uuidv4 } from 'uuid';
+import { FmodeObject, FmodeParse } from 'fmode-ng/parse';
+
+const Parse: any = FmodeParse.with('nova');
+
+export type IssueStatus = 'open' | 'in_progress' | 'resolved' | 'closed';
+export type IssuePriority = 'low' | 'medium' | 'high' | 'critical' | 'urgent';
+export type IssueType = 'bug' | 'task' | 'feedback' | 'risk' | 'feature';
+
+export interface IssueComment {
+  id: string;
+  authorId: string;
+  content: string;
+  createdAt: Date;
+}
+
+export interface ProjectIssue {
+  id: string;
+  projectId: string;
+  title: string;
+  status: IssueStatus;
+  priority: IssuePriority;
+  type: IssueType;
+  creatorId: string;
+  assignee?: FmodeObject;
+  assigneeId?: string;
+  createdAt: Date;
+  updatedAt: Date;
+  dueDate?: Date;
+  tags?: string[];
+  description?: string;
+  comments?: IssueComment[];
+  // 新增:关联信息
+  relatedSpace?: string;
+  relatedStage?: string;
+  productId?: string;
+}
+
+export interface IssueCounts {
+  total: number;
+  open: number;
+  in_progress: number;
+  resolved: number;
+  closed: number;
+}
+
+@Injectable({ providedIn: 'root' })
+export class ProjectIssueService {
+  private store = new Map<string, ProjectIssue[]>();
+
+  // 状态中英文映射(兼容后台中文状态)
+  private zh2en(status: string): IssueStatus {
+    const map: any = {
+      '待处理': 'open',
+      '处理中': 'in_progress',
+      '已解决': 'resolved',
+      '已关闭': 'closed'
+    };
+    return (map[status] || status) as IssueStatus;
+  }
+  private en2zh(status: IssueStatus): string {
+    const map: any = {
+      open: '待处理',
+      in_progress: '处理中',
+      resolved: '已解决',
+      closed: '已关闭'
+    };
+    return map[status] || status;
+  }
+
+  // 将 Parse.Object 转换为本地模型
+  private parseToModel(obj: any): ProjectIssue {
+    const data = obj.get('data') || {};
+    const tags: string[] = data.tags || [];
+    const comments: IssueComment[] = (data.comments || []).map((c: any) => ({
+      id: c.id || uuidv4(),
+      authorId: c.authorId,
+      content: c.content,
+      createdAt: c.createdAt ? new Date(c.createdAt) : new Date()
+    }));
+
+    const statusRaw = obj.get('status');
+    const status: IssueStatus = statusRaw ? this.zh2en(statusRaw) : 'open';
+
+    return {
+      id: obj.id,
+      projectId: obj.get('project')?.id || '',
+      title: obj.get('title') || (obj.get('description') || '').slice(0, 40) || '未命名问题',
+      description: obj.get('description') || '',
+      priority: (obj.get('priority') || 'medium') as IssuePriority,
+      type: (obj.get('issueType') || 'task') as IssueType,
+      status,
+      creatorId: obj.get('creator')?.id || obj.get('reportedBy')?.id || '',
+      assignee: obj.get('assignee'),
+      assigneeId: obj.get('assignee')?.id,
+      createdAt: obj.createdAt || new Date(),
+      updatedAt: obj.updatedAt || new Date(),
+      dueDate: obj.get('dueDate') || undefined,
+      tags,
+      comments,
+      relatedSpace: obj.get('relatedSpace') || data.relatedSpace || undefined,
+      relatedStage: obj.get('relatedStage') || data.relatedStage || undefined,
+      productId: obj.get('product')?.id || undefined
+    };
+  }
+
+  // 同步缓存筛选
+  listIssues(projectId: string, opts?: { status?: IssueStatus[]; text?: string }): ProjectIssue[] {
+    const list = this.ensure(projectId);
+    let result: ProjectIssue[] = [...list];
+
+    if (opts?.status && opts.status.length > 0) {
+      result = result.filter((issue: ProjectIssue) => opts.status!.includes(issue.status));
+    }
+
+    if (opts?.text && opts.text.trim()) {
+      const q = opts.text.trim().toLowerCase();
+      result = result.filter((issue: ProjectIssue) =>
+        (issue.title || '').toLowerCase().includes(q) ||
+        (issue.description || '').toLowerCase().includes(q) ||
+        (issue.tags || []).some((t: string) => t.toLowerCase().includes(q))
+      );
+    }
+
+    return result.sort((a: ProjectIssue, b: ProjectIssue) => +new Date(b.updatedAt) - +new Date(a.updatedAt));
+  }
+
+  // 从后端刷新到缓存
+  async refreshFromServer(projectId: string, opts?: { status?: IssueStatus[]; text?: string }): Promise<void> {
+    const query = new Parse.Query('ProjectIssue');
+    query.equalTo('project', { __type: 'Pointer', className: 'Project', objectId: projectId });
+    query.notEqualTo('isDeleted', true);
+    query.include(['creator', 'assignee', 'product']);
+
+    // 服务器端粗过滤(仅文本)
+    if (opts?.text && opts.text.trim()) {
+      const kw = opts.text.trim();
+      query.matches('title', new RegExp(kw, 'i'));
+    }
+
+    const results = await query.find();
+    const list: ProjectIssue[] = results.map((obj: any) => this.parseToModel(obj));
+
+    // 客户端状态过滤
+    const filtered: ProjectIssue[] = opts?.status && opts.status.length > 0 ? list.filter((issue: ProjectIssue) => opts!.status!.includes(issue.status)) : list;
+
+    this.store.set(projectId, filtered);
+  }
+
+  // 列出负责人候选:基于 ProjectTeam
+  async listAssignees(projectId: string): Promise<{ id: string; name: string }[]> {
+    try {
+      const teamQuery = new Parse.Query('ProjectTeam');
+      teamQuery.equalTo('project', { __type: 'Pointer', className: 'Project', objectId: projectId });
+      teamQuery.include(['profile']);
+      teamQuery.notEqualTo('isDeleted', true);
+      const teams = await teamQuery.find();
+
+      const seen = new Set<string>();
+      const list: { id: string; name: string }[] = [];
+      for (const t of teams) {
+        const p = t.get('profile');
+        const pid = p?.id;
+        const name = p?.get ? (p.get('name') || '未命名') : '未命名';
+        if (pid && !seen.has(pid)) {
+          seen.add(pid);
+          list.push({ id: pid, name });
+        }
+      }
+
+      // 兜底:加入项目负责人(owner)
+      if (list.length === 0) {
+        const pQuery = new Parse.Query('Project');
+        pQuery.include(['owner']);
+        const project = await pQuery.get(projectId);
+        const owner = project.get('owner');
+        if (owner?.id && !seen.has(owner.id)) {
+          list.unshift({ id: owner.id, name: owner.get('name') || '项目负责人' });
+          seen.add(owner.id);
+        }
+      }
+      return list;
+    } catch (e) {
+      console.warn('listAssignees failed', e);
+      return [];
+    }
+  }
+
+  // 创建问题(持久化)
+  async createIssue(
+    projectId: string,
+    payload: { title: string; description?: string; priority?: IssuePriority; type?: IssueType; dueDate?: Date; tags?: string[]; creatorId: string; assigneeId?: string; relatedSpace?: string; relatedStage?: string; productId?: string }
+  ): Promise<ProjectIssue> {
+    const Issue = Parse.Object.extend('ProjectIssue');
+    const obj = new Issue();
+
+    obj.set('project', { __type: 'Pointer', className: 'Project', objectId: projectId });
+    obj.set('title', payload.title);
+    obj.set('description', payload.description || '');
+    obj.set('priority', payload.priority || 'medium');
+    obj.set('issueType', payload.type || 'task');
+    obj.set('status', this.en2zh('open'));
+    obj.set('creator', { __type: 'Pointer', className: 'Profile', objectId: payload.creatorId });
+
+    // 负责人:优先使用传入;否则尝试项目负责人;最后创建人
+    let assigneeId = payload.assigneeId;
+    if (!assigneeId) {
+      try {
+        const pQuery = new Parse.Query('Project');
+        pQuery.include(['owner']);
+        const project = await pQuery.get(projectId);
+        assigneeId = project.get('owner')?.id || payload.creatorId;
+      } catch {}
+    }
+    if (assigneeId) {
+      obj.set('assignee', { __type: 'Pointer', className: 'Profile', objectId: assigneeId });
+    }
+
+    if (payload.dueDate) obj.set('dueDate', payload.dueDate);
+    if (payload.relatedSpace) obj.set('relatedSpace', payload.relatedSpace);
+    if (payload.relatedStage) obj.set('relatedStage', payload.relatedStage);
+    if (payload.productId) obj.set('product', { __type: 'Pointer', className: 'Product', objectId: payload.productId });
+
+    obj.set('isDeleted', false);
+    obj.set('data', { tags: payload.tags || [], comments: [], relatedSpace: payload.relatedSpace, relatedStage: payload.relatedStage });
+
+    const saved = await obj.save();
+    const model = this.parseToModel(saved);
+
+    const list = this.ensure(projectId);
+    list.push(model);
+    this.store.set(projectId, list);
+
+    return model;
+  }
+
+  // 更新问题(持久化)
+  async updateIssue(projectId: string, issueId: string, updates: Partial<ProjectIssue>): Promise<ProjectIssue | null> {
+    const query = new Parse.Query('ProjectIssue');
+    const obj = await query.get(issueId).catch(() => null);
+    if (!obj) return null;
+
+    if (updates.title !== undefined) obj.set('title', updates.title);
+    if (updates.description !== undefined) obj.set('description', updates.description);
+    if (updates.priority !== undefined) obj.set('priority', updates.priority);
+    if (updates.type !== undefined) obj.set('issueType', updates.type);
+    if (updates.dueDate !== undefined) obj.set('dueDate', updates.dueDate);
+    if (updates.assigneeId !== undefined) {
+      obj.set('assignee', updates.assigneeId ? { __type: 'Pointer', className: 'Profile', objectId: updates.assigneeId } : undefined);
+    }
+    if (updates.relatedSpace !== undefined) obj.set('relatedSpace', updates.relatedSpace || undefined);
+    if (updates.relatedStage !== undefined) obj.set('relatedStage', updates.relatedStage || undefined);
+    if (updates.productId !== undefined) obj.set('product', updates.productId ? { __type: 'Pointer', className: 'Product', objectId: updates.productId } : undefined);
+    if (updates.status !== undefined) obj.set('status', this.en2zh(updates.status));
+    if (updates.tags !== undefined) {
+      const data = obj.get('data') || {};
+      data.tags = updates.tags || [];
+      obj.set('data', data);
+    }
+
+    const saved = await obj.save();
+
+    // 更新缓存
+    const list = this.ensure(projectId);
+    const idx = list.findIndex((issue: ProjectIssue) => issue.id === issueId);
+    const updated = this.parseToModel(saved);
+    if (idx >= 0) list[idx] = updated; else list.push(updated);
+    this.store.set(projectId, list);
+    return updated;
+  }
+
+  async deleteIssue(projectId: string, issueId: string): Promise<boolean> {
+    const query = new Parse.Query('ProjectIssue');
+    const obj = await query.get(issueId).catch(() => null);
+    if (!obj) return false;
+    obj.set('isDeleted', true);
+    await obj.save();
+
+    const list = this.ensure(projectId).filter((issue: ProjectIssue) => issue.id !== issueId);
+    this.store.set(projectId, list);
+    return true;
+  }
+
+  // 添加评论(持久化至 data.comments)
+  async addComment(projectId: string, issueId: string, authorId: string, content: string): Promise<IssueComment | null> {
+    const query = new Parse.Query('ProjectIssue');
+    const obj = await query.get(issueId).catch(() => null);
+    if (!obj) return null;
+
+    const comment: IssueComment = { id: uuidv4(), authorId, content, createdAt: new Date() };
+    const data = obj.get('data') || {};
+    data.comments = Array.isArray(data.comments) ? data.comments : [];
+    data.comments.push({ ...comment });
+    obj.set('data', data);
+    await obj.save();
+
+    // 更新缓存
+    const list = this.ensure(projectId);
+    const issue = list.find((i: ProjectIssue) => i.id === issueId);
+    if (issue) {
+      issue.comments = issue.comments || [];
+      issue.comments.push(comment);
+      issue.updatedAt = new Date();
+      this.store.set(projectId, list);
+    }
+    return comment;
+  }
+
+  // 快速修改状态(持久化)
+  async setStatus(projectId: string, issueId: string, status: IssueStatus): Promise<ProjectIssue | null> {
+    return await this.updateIssue(projectId, issueId, { status });
+  }
+
+  // 统计汇总(缓存)
+  getCounts(projectId: string): IssueCounts {
+    const list = this.ensure(projectId);
+    const counts: IssueCounts = { total: list.length, open: 0, in_progress: 0, resolved: 0, closed: 0 };
+    for (const issue of list) {
+      if (issue.status === 'open') counts.open++;
+      else if (issue.status === 'in_progress') counts.in_progress++;
+      else if (issue.status === 'resolved') counts.resolved++;
+      else if (issue.status === 'closed') counts.closed++;
+    }
+    return counts;
+  }
+
+  // 首次访问种子数据(仍保留本地演示,不写入后端)
+  seed(projectId: string) {
+    const list = this.ensure(projectId);
+    if (list.length > 0) return;
+    const now = new Date();
+    const creator = 'seed-user';
+    // list.push({
+    //   id: uuidv4(), projectId, title: '确认客厅配色与材质样板', description: '需要确认客厅主色调与地面材质,影响方案深化。', priority: 'high', type: 'task', status: 'open', creatorId: creator, createdAt: now, updatedAt: now, dueDate: new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000), tags: ['配色', '材质'], comments: []
+    // });
+    // list.push({
+    //   id: uuidv4(), projectId, title: '主卧效果图灯光偏暗', description: '客户反馈主卧效果图灯光偏暗,需要调整光源与明暗对比。', priority: 'medium', type: 'feedback', status: 'open', creatorId: creator, createdAt: now, updatedAt: now, tags: ['灯光', '效果图'], comments: []
+    // });
+    // const secondId = uuidv4();
+    // list.push({
+    //   id: secondId, projectId, title: '厨房柜体尺寸与现场不符', description: '现场复尺发现图纸尺寸与实际有偏差,需要同步调整。', priority: 'critical', type: 'bug', status: 'in_progress', creatorId: creator, createdAt: now, updatedAt: now, tags: ['复尺', '尺寸'], comments: []
+    // });
+    this.store.set(projectId, list);
+  }
+
+  // 内部:确保项目列表存在
+  private ensure(projectId: string): ProjectIssue[] {
+    if (!this.store.has(projectId)) {
+      this.store.set(projectId, []);
+    }
+    return this.store.get(projectId)!;
+  }
+}