|
@@ -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)!;
|
|
|
|
+ }
|
|
|
|
+}
|