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(); // 状态中英文映射(兼容后台中文状态) 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 { 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(); 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 { 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): Promise { 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 { 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 { 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 { 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)!; } }