2 Komitmen 2b92eabab9 ... 877245e2b2

Pembuat SHA1 Pesan Tanggal
  Future 877245e2b2 Merge branch 'master' of http://git.fmode.cn:3000/nkkj/yss-project 17 jam lalu
  Future 528146c291 feat: project issue 17 jam lalu

+ 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: '问题追踪'
           }
         ]
       }

+ 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') || '项目详情';
   }

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

@@ -0,0 +1,125 @@
+<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>
+          <input type="date" [(ngModel)]="newDueDate" />
+        </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>
+            </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;
+  }
+}

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

@@ -0,0 +1,144 @@
+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';
+
+@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 = '';
+
+  loading: boolean = false;
+  error: string | null = null;
+
+  constructor(private issueService: ProjectIssueService) {}
+
+  ngOnInit() {
+    this.refresh();
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes['isVisible'] && this.isVisible) {
+      this.refresh();
+    }
+    if (changes['project'] && this.project) {
+      this.refresh();
+    }
+  }
+
+  refresh() {
+    if (!this.project?.id) return;
+    try {
+      this.loading = true;
+      // 首次种子数据(仅内存,无副作用)
+      this.issueService.seed(this.project.id);
+      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();
+  }
+
+  startCreate() {
+    this.creating = true;
+    this.newTitle = '';
+    this.newDescription = '';
+    this.newPriority = 'medium';
+    this.newType = 'task';
+    this.newDueDate = undefined;
+    this.newTagsText = '';
+  }
+
+  cancelCreate() {
+    this.creating = false;
+  }
+
+  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;
+
+    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: undefined,
+      dueDate: due,
+      tags
+    });
+
+    this.creating = false;
+    this.refresh();
+  }
+
+  setStatus(issue: ProjectIssue, status: IssueStatus) {
+    if (!this.project?.id) return;
+    this.issueService.setStatus(this.project.id, issue.id, status);
+    this.refresh();
+  }
+
+  deleteIssue(issue: ProjectIssue) {
+    if (!this.project?.id) return;
+    this.issueService.deleteIssue(this.project.id, issue.id);
+    this.refresh();
+  }
+
+  addComment(issue: ProjectIssue, text: string) {
+    if (!this.project?.id || !this.currentUser?.id) return;
+    const content = text.trim();
+    if (!content) return;
+    this.issueService.addComment(this.project.id, issue.id, this.currentUser.id, content);
+    this.refresh();
+  }
+
+  onClose() {
+    this.close.emit();
+  }
+}

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

+ 35 - 2
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() {
@@ -180,6 +187,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 +422,11 @@ export class ProjectDetailComponent implements OnInit {
     this.showMembersModal = true;
   }
 
+  /** 显示问题模态框 */
+  showIssues() {
+    this.showIssuesModal = true;
+  }
+
   /**
    * 关闭文件模态框
    */
@@ -417,4 +440,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();
+  }
+}

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

@@ -0,0 +1,182 @@
+import { Injectable } from '@angular/core';
+import { v4 as uuidv4 } from 'uuid';
+
+export type IssueStatus = 'open' | 'in_progress' | 'resolved' | 'closed';
+export type IssuePriority = 'low' | 'medium' | 'high' | 'critical';
+export type IssueType = 'bug' | 'task' | 'feedback' | 'risk';
+
+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;
+  assigneeId?: string;
+  createdAt: Date;
+  updatedAt: Date;
+  dueDate?: Date;
+  tags?: string[];
+  description?: string;
+  comments?: IssueComment[];
+}
+
+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[]>();
+
+  /** 列表查询(支持状态过滤与文本搜索) */
+  listIssues(projectId: string, opts?: { status?: IssueStatus[]; text?: string }): ProjectIssue[] {
+    const list = this.ensure(projectId);
+    let result = [...list];
+
+    if (opts?.status && opts.status.length > 0) {
+      result = result.filter(i => opts.status!.includes(i.status));
+    }
+
+    if (opts?.text && opts.text.trim()) {
+      const q = opts.text.trim().toLowerCase();
+      result = result.filter(i =>
+        (i.title || '').toLowerCase().includes(q) ||
+        (i.description || '').toLowerCase().includes(q) ||
+        (i.tags || []).some(t => t.toLowerCase().includes(q))
+      );
+    }
+
+    return result.sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt));
+  }
+
+  /** 创建问题 */
+  createIssue(projectId: string, payload: { title: string; description?: string; priority?: IssuePriority; type?: IssueType; dueDate?: Date; tags?: string[]; creatorId: string; assigneeId?: string }): ProjectIssue {
+    const now = new Date();
+    const issue: ProjectIssue = {
+      id: uuidv4(),
+      projectId,
+      title: payload.title,
+      description: payload.description || '',
+      priority: payload.priority || 'medium',
+      type: payload.type || 'task',
+      status: 'open',
+      creatorId: payload.creatorId,
+      assigneeId: payload.assigneeId,
+      createdAt: now,
+      updatedAt: now,
+      dueDate: payload.dueDate,
+      tags: payload.tags || [],
+      comments: []
+    };
+
+    const list = this.ensure(projectId);
+    list.push(issue);
+    this.store.set(projectId, list);
+    return issue;
+  }
+
+  /** 更新问题 */
+  updateIssue(projectId: string, issueId: string, updates: Partial<ProjectIssue>): ProjectIssue | null {
+    const list = this.ensure(projectId);
+    const idx = list.findIndex(i => i.id === issueId);
+    if (idx === -1) return null;
+
+    const now = new Date();
+    const updated: ProjectIssue = { ...list[idx], ...updates, updatedAt: now };
+    list[idx] = updated;
+    this.store.set(projectId, list);
+    return updated;
+  }
+
+  /** 删除问题 */
+  deleteIssue(projectId: string, issueId: string): boolean {
+    const list = this.ensure(projectId);
+    const lenBefore = list.length;
+    const filtered = list.filter(i => i.id !== issueId);
+    this.store.set(projectId, filtered);
+    return filtered.length < lenBefore;
+  }
+
+  /** 添加评论(用于催办或讨论) */
+  addComment(projectId: string, issueId: string, authorId: string, content: string): IssueComment | null {
+    const list = this.ensure(projectId);
+    const issue = list.find(i => i.id === issueId);
+    if (!issue) return null;
+
+    const comment: IssueComment = { id: uuidv4(), authorId, content, createdAt: new Date() };
+    issue.comments = issue.comments || [];
+    issue.comments.push(comment);
+    issue.updatedAt = new Date();
+    this.store.set(projectId, list);
+    return comment;
+  }
+
+  /** 快速修改状态 */
+  setStatus(projectId: string, issueId: string, status: IssueStatus): ProjectIssue | null {
+    return 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 i of list) counts[i.status]++;
+    return counts;
+  }
+
+  /** 首次访问种子数据 */
+  seed(projectId: string) {
+    const list = this.ensure(projectId);
+    if (list.length > 0) return;
+
+    const now = new Date();
+    const creator = 'seed-user';
+    this.createIssue(projectId, {
+      title: '确认客厅配色与材质样板',
+      description: '需要确认客厅主色调与地面材质,影响方案深化。',
+      priority: 'high',
+      type: 'task',
+      creatorId: creator,
+      dueDate: new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000),
+      tags: ['配色', '材质']
+    });
+    this.createIssue(projectId, {
+      title: '主卧效果图灯光偏暗',
+      description: '客户反馈主卧效果图灯光偏暗,需要调整光源与明暗对比。',
+      priority: 'medium',
+      type: 'feedback',
+      creatorId: creator,
+      tags: ['灯光', '效果图']
+    });
+    const second = this.createIssue(projectId, {
+      title: '厨房柜体尺寸与现场不符',
+      description: '现场复尺发现图纸尺寸与实际有偏差,需要同步调整。',
+      priority: 'critical',
+      type: 'bug',
+      creatorId: creator,
+      tags: ['复尺', '尺寸']
+    });
+    this.setStatus(projectId, second.id, 'in_progress');
+  }
+
+  /** 内部:确保项目列表存在 */
+  private ensure(projectId: string): ProjectIssue[] {
+    if (!this.store.has(projectId)) {
+      this.store.set(projectId, []);
+    }
+    return this.store.get(projectId)!;
+  }
+}