project-issue.service.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import { Injectable } from '@angular/core';
  2. import { v4 as uuidv4 } from 'uuid';
  3. import { FmodeObject, FmodeParse } from 'fmode-ng/parse';
  4. const Parse: any = FmodeParse.with('nova');
  5. export type IssueStatus = 'open' | 'in_progress' | 'resolved' | 'closed';
  6. export type IssuePriority = 'low' | 'medium' | 'high' | 'critical' | 'urgent';
  7. export type IssueType = 'bug' | 'task' | 'feedback' | 'risk' | 'feature';
  8. export interface IssueComment {
  9. id: string;
  10. authorId: string;
  11. content: string;
  12. createdAt: Date;
  13. }
  14. export interface ProjectIssue {
  15. id: string;
  16. projectId: string;
  17. title: string;
  18. status: IssueStatus;
  19. priority: IssuePriority;
  20. type: IssueType;
  21. creatorId: string;
  22. assignee?: FmodeObject;
  23. assigneeId?: string;
  24. createdAt: Date;
  25. updatedAt: Date;
  26. dueDate?: Date;
  27. tags?: string[];
  28. description?: string;
  29. comments?: IssueComment[];
  30. // 新增:关联信息
  31. relatedSpace?: string;
  32. relatedStage?: string;
  33. productId?: string;
  34. }
  35. export interface IssueCounts {
  36. total: number;
  37. open: number;
  38. in_progress: number;
  39. resolved: number;
  40. closed: number;
  41. }
  42. @Injectable({ providedIn: 'root' })
  43. export class ProjectIssueService {
  44. private store = new Map<string, ProjectIssue[]>();
  45. // 状态中英文映射(兼容后台中文状态)
  46. private zh2en(status: string): IssueStatus {
  47. const map: any = {
  48. '待处理': 'open',
  49. '处理中': 'in_progress',
  50. '已解决': 'resolved',
  51. '已关闭': 'closed'
  52. };
  53. return (map[status] || status) as IssueStatus;
  54. }
  55. private en2zh(status: IssueStatus): string {
  56. const map: any = {
  57. open: '待处理',
  58. in_progress: '处理中',
  59. resolved: '已解决',
  60. closed: '已关闭'
  61. };
  62. return map[status] || status;
  63. }
  64. // 将 Parse.Object 转换为本地模型
  65. private parseToModel(obj: any): ProjectIssue {
  66. const data = obj.get('data') || {};
  67. const tags: string[] = data.tags || [];
  68. const comments: IssueComment[] = (data.comments || []).map((c: any) => ({
  69. id: c.id || uuidv4(),
  70. authorId: c.authorId,
  71. content: c.content,
  72. createdAt: c.createdAt ? new Date(c.createdAt) : new Date()
  73. }));
  74. const statusRaw = obj.get('status');
  75. const status: IssueStatus = statusRaw ? this.zh2en(statusRaw) : 'open';
  76. return {
  77. id: obj.id,
  78. projectId: obj.get('project')?.id || '',
  79. title: obj.get('title') || (obj.get('description') || '').slice(0, 40) || '未命名问题',
  80. description: obj.get('description') || '',
  81. priority: (obj.get('priority') || 'medium') as IssuePriority,
  82. type: (obj.get('issueType') || 'task') as IssueType,
  83. status,
  84. creatorId: obj.get('creator')?.id || obj.get('reportedBy')?.id || '',
  85. assignee: obj.get('assignee'),
  86. assigneeId: obj.get('assignee')?.id,
  87. createdAt: obj.createdAt || new Date(),
  88. updatedAt: obj.updatedAt || new Date(),
  89. dueDate: obj.get('dueDate') || undefined,
  90. tags,
  91. comments,
  92. relatedSpace: obj.get('relatedSpace') || data.relatedSpace || undefined,
  93. relatedStage: obj.get('relatedStage') || data.relatedStage || undefined,
  94. productId: obj.get('product')?.id || undefined
  95. };
  96. }
  97. // 同步缓存筛选
  98. listIssues(projectId: string, opts?: { status?: IssueStatus[]; text?: string }): ProjectIssue[] {
  99. const list = this.ensure(projectId);
  100. let result: ProjectIssue[] = [...list];
  101. if (opts?.status && opts.status.length > 0) {
  102. result = result.filter((issue: ProjectIssue) => opts.status!.includes(issue.status));
  103. }
  104. if (opts?.text && opts.text.trim()) {
  105. const q = opts.text.trim().toLowerCase();
  106. result = result.filter((issue: ProjectIssue) =>
  107. (issue.title || '').toLowerCase().includes(q) ||
  108. (issue.description || '').toLowerCase().includes(q) ||
  109. (issue.tags || []).some((t: string) => t.toLowerCase().includes(q))
  110. );
  111. }
  112. return result.sort((a: ProjectIssue, b: ProjectIssue) => +new Date(b.updatedAt) - +new Date(a.updatedAt));
  113. }
  114. // 从后端刷新到缓存
  115. async refreshFromServer(projectId: string, opts?: { status?: IssueStatus[]; text?: string }): Promise<void> {
  116. const query = new Parse.Query('ProjectIssue');
  117. query.equalTo('project', { __type: 'Pointer', className: 'Project', objectId: projectId });
  118. query.notEqualTo('isDeleted', true);
  119. query.include(['creator', 'assignee', 'product']);
  120. // 服务器端粗过滤(仅文本)
  121. if (opts?.text && opts.text.trim()) {
  122. const kw = opts.text.trim();
  123. query.matches('title', new RegExp(kw, 'i'));
  124. }
  125. const results = await query.find();
  126. const list: ProjectIssue[] = results.map((obj: any) => this.parseToModel(obj));
  127. // 客户端状态过滤
  128. const filtered: ProjectIssue[] = opts?.status && opts.status.length > 0 ? list.filter((issue: ProjectIssue) => opts!.status!.includes(issue.status)) : list;
  129. this.store.set(projectId, filtered);
  130. }
  131. // 列出负责人候选:基于 ProjectTeam
  132. async listAssignees(projectId: string): Promise<{ id: string; name: string }[]> {
  133. try {
  134. const teamQuery = new Parse.Query('ProjectTeam');
  135. teamQuery.equalTo('project', { __type: 'Pointer', className: 'Project', objectId: projectId });
  136. teamQuery.include(['profile']);
  137. teamQuery.notEqualTo('isDeleted', true);
  138. const teams = await teamQuery.find();
  139. const seen = new Set<string>();
  140. const list: { id: string; name: string }[] = [];
  141. for (const t of teams) {
  142. const p = t.get('profile');
  143. const pid = p?.id;
  144. const name = p?.get ? (p.get('name') || '未命名') : '未命名';
  145. if (pid && !seen.has(pid)) {
  146. seen.add(pid);
  147. list.push({ id: pid, name });
  148. }
  149. }
  150. // 兜底:加入项目负责人(owner)
  151. if (list.length === 0) {
  152. const pQuery = new Parse.Query('Project');
  153. pQuery.include(['owner']);
  154. const project = await pQuery.get(projectId);
  155. const owner = project.get('owner');
  156. if (owner?.id && !seen.has(owner.id)) {
  157. list.unshift({ id: owner.id, name: owner.get('name') || '项目负责人' });
  158. seen.add(owner.id);
  159. }
  160. }
  161. return list;
  162. } catch (e) {
  163. console.warn('listAssignees failed', e);
  164. return [];
  165. }
  166. }
  167. // 创建问题(持久化)
  168. async createIssue(
  169. projectId: string,
  170. payload: { title: string; description?: string; priority?: IssuePriority; type?: IssueType; dueDate?: Date; tags?: string[]; creatorId: string; assigneeId?: string; relatedSpace?: string; relatedStage?: string; productId?: string }
  171. ): Promise<ProjectIssue> {
  172. const Issue = Parse.Object.extend('ProjectIssue');
  173. const obj = new Issue();
  174. obj.set('project', { __type: 'Pointer', className: 'Project', objectId: projectId });
  175. obj.set('title', payload.title);
  176. obj.set('description', payload.description || '');
  177. obj.set('priority', payload.priority || 'medium');
  178. obj.set('issueType', payload.type || 'task');
  179. obj.set('status', this.en2zh('open'));
  180. obj.set('creator', { __type: 'Pointer', className: 'Profile', objectId: payload.creatorId });
  181. // 负责人:优先使用传入;否则尝试项目负责人;最后创建人
  182. let assigneeId = payload.assigneeId;
  183. if (!assigneeId) {
  184. try {
  185. const pQuery = new Parse.Query('Project');
  186. pQuery.include(['owner']);
  187. const project = await pQuery.get(projectId);
  188. assigneeId = project.get('owner')?.id || payload.creatorId;
  189. } catch {}
  190. }
  191. if (assigneeId) {
  192. obj.set('assignee', { __type: 'Pointer', className: 'Profile', objectId: assigneeId });
  193. }
  194. if (payload.dueDate) obj.set('dueDate', payload.dueDate);
  195. if (payload.relatedSpace) obj.set('relatedSpace', payload.relatedSpace);
  196. if (payload.relatedStage) obj.set('relatedStage', payload.relatedStage);
  197. if (payload.productId) obj.set('product', { __type: 'Pointer', className: 'Product', objectId: payload.productId });
  198. obj.set('isDeleted', false);
  199. obj.set('data', { tags: payload.tags || [], comments: [], relatedSpace: payload.relatedSpace, relatedStage: payload.relatedStage });
  200. const saved = await obj.save();
  201. const model = this.parseToModel(saved);
  202. const list = this.ensure(projectId);
  203. list.push(model);
  204. this.store.set(projectId, list);
  205. return model;
  206. }
  207. // 更新问题(持久化)
  208. async updateIssue(projectId: string, issueId: string, updates: Partial<ProjectIssue>): Promise<ProjectIssue | null> {
  209. const query = new Parse.Query('ProjectIssue');
  210. const obj = await query.get(issueId).catch(() => null);
  211. if (!obj) return null;
  212. if (updates.title !== undefined) obj.set('title', updates.title);
  213. if (updates.description !== undefined) obj.set('description', updates.description);
  214. if (updates.priority !== undefined) obj.set('priority', updates.priority);
  215. if (updates.type !== undefined) obj.set('issueType', updates.type);
  216. if (updates.dueDate !== undefined) obj.set('dueDate', updates.dueDate);
  217. if (updates.assigneeId !== undefined) {
  218. obj.set('assignee', updates.assigneeId ? { __type: 'Pointer', className: 'Profile', objectId: updates.assigneeId } : undefined);
  219. }
  220. if (updates.relatedSpace !== undefined) obj.set('relatedSpace', updates.relatedSpace || undefined);
  221. if (updates.relatedStage !== undefined) obj.set('relatedStage', updates.relatedStage || undefined);
  222. if (updates.productId !== undefined) obj.set('product', updates.productId ? { __type: 'Pointer', className: 'Product', objectId: updates.productId } : undefined);
  223. if (updates.status !== undefined) obj.set('status', this.en2zh(updates.status));
  224. if (updates.tags !== undefined) {
  225. const data = obj.get('data') || {};
  226. data.tags = updates.tags || [];
  227. obj.set('data', data);
  228. }
  229. const saved = await obj.save();
  230. // 更新缓存
  231. const list = this.ensure(projectId);
  232. const idx = list.findIndex((issue: ProjectIssue) => issue.id === issueId);
  233. const updated = this.parseToModel(saved);
  234. if (idx >= 0) list[idx] = updated; else list.push(updated);
  235. this.store.set(projectId, list);
  236. return updated;
  237. }
  238. async deleteIssue(projectId: string, issueId: string): Promise<boolean> {
  239. const query = new Parse.Query('ProjectIssue');
  240. const obj = await query.get(issueId).catch(() => null);
  241. if (!obj) return false;
  242. obj.set('isDeleted', true);
  243. await obj.save();
  244. const list = this.ensure(projectId).filter((issue: ProjectIssue) => issue.id !== issueId);
  245. this.store.set(projectId, list);
  246. return true;
  247. }
  248. // 添加评论(持久化至 data.comments)
  249. async addComment(projectId: string, issueId: string, authorId: string, content: string): Promise<IssueComment | null> {
  250. const query = new Parse.Query('ProjectIssue');
  251. const obj = await query.get(issueId).catch(() => null);
  252. if (!obj) return null;
  253. const comment: IssueComment = { id: uuidv4(), authorId, content, createdAt: new Date() };
  254. const data = obj.get('data') || {};
  255. data.comments = Array.isArray(data.comments) ? data.comments : [];
  256. data.comments.push({ ...comment });
  257. obj.set('data', data);
  258. await obj.save();
  259. // 更新缓存
  260. const list = this.ensure(projectId);
  261. const issue = list.find((i: ProjectIssue) => i.id === issueId);
  262. if (issue) {
  263. issue.comments = issue.comments || [];
  264. issue.comments.push(comment);
  265. issue.updatedAt = new Date();
  266. this.store.set(projectId, list);
  267. }
  268. return comment;
  269. }
  270. // 快速修改状态(持久化)
  271. async setStatus(projectId: string, issueId: string, status: IssueStatus): Promise<ProjectIssue | null> {
  272. return await this.updateIssue(projectId, issueId, { status });
  273. }
  274. // 统计汇总(缓存)
  275. getCounts(projectId: string): IssueCounts {
  276. const list = this.ensure(projectId);
  277. const counts: IssueCounts = { total: list.length, open: 0, in_progress: 0, resolved: 0, closed: 0 };
  278. for (const issue of list) {
  279. if (issue.status === 'open') counts.open++;
  280. else if (issue.status === 'in_progress') counts.in_progress++;
  281. else if (issue.status === 'resolved') counts.resolved++;
  282. else if (issue.status === 'closed') counts.closed++;
  283. }
  284. return counts;
  285. }
  286. // 首次访问种子数据(仍保留本地演示,不写入后端)
  287. seed(projectId: string) {
  288. const list = this.ensure(projectId);
  289. if (list.length > 0) return;
  290. const now = new Date();
  291. const creator = 'seed-user';
  292. // list.push({
  293. // 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: []
  294. // });
  295. // list.push({
  296. // id: uuidv4(), projectId, title: '主卧效果图灯光偏暗', description: '客户反馈主卧效果图灯光偏暗,需要调整光源与明暗对比。', priority: 'medium', type: 'feedback', status: 'open', creatorId: creator, createdAt: now, updatedAt: now, tags: ['灯光', '效果图'], comments: []
  297. // });
  298. // const secondId = uuidv4();
  299. // list.push({
  300. // id: secondId, projectId, title: '厨房柜体尺寸与现场不符', description: '现场复尺发现图纸尺寸与实际有偏差,需要同步调整。', priority: 'critical', type: 'bug', status: 'in_progress', creatorId: creator, createdAt: now, updatedAt: now, tags: ['复尺', '尺寸'], comments: []
  301. // });
  302. this.store.set(projectId, list);
  303. }
  304. // 内部:确保项目列表存在
  305. private ensure(projectId: string): ProjectIssue[] {
  306. if (!this.store.has(projectId)) {
  307. this.store.set(projectId, []);
  308. }
  309. return this.store.get(projectId)!;
  310. }
  311. }