tickets.component.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import { AfterViewInit, Component, OnInit } from '@angular/core';
  2. import { FormsModule } from '@angular/forms';
  3. import { RouterLink } from '@angular/router';
  4. import { gsap } from 'gsap';
  5. import { ParseApiService, ParseTicket } from '../../core/parse-api.service';
  6. import { ImagePreviewWrapperComponent } from '../../react-wrappers/image-preview-wrapper.component';
  7. import { RatingWrapperComponent } from '../../react-wrappers/rating-wrapper.component';
  8. import { PreviewImage } from '../../../react-components/ImagePreview';
  9. import { MetricCardComponent } from '../../shared/metric-card.component';
  10. import { StatusBadgeComponent } from '../../shared/status-badge.component';
  11. type TicketStatus = 'pending' | 'processing' | 'confirming' | 'resolved' | 'closed';
  12. @Component({
  13. selector: 'app-tickets',
  14. standalone: true,
  15. imports: [FormsModule, RouterLink, ImagePreviewWrapperComponent, RatingWrapperComponent, MetricCardComponent, StatusBadgeComponent],
  16. templateUrl: './tickets.component.html',
  17. styleUrl: './tickets.component.scss'
  18. })
  19. export class TicketsComponent implements OnInit, AfterViewInit {
  20. tickets: ParseTicket[] = [];
  21. newTicket = {
  22. title: '',
  23. category: '软件问题',
  24. priority: 'high',
  25. impactScope: '部门',
  26. description: ''
  27. };
  28. selected: ParseTicket | null = null;
  29. editingTitle = '';
  30. searchKeyword = '';
  31. statusFilter = 'all';
  32. priorityFilter = 'all';
  33. loading = false;
  34. message = '';
  35. score = 4;
  36. previewVisible = false;
  37. images: PreviewImage[] = [
  38. { id: 'img-1', name: '客户报错截图.png', url: 'https://dummyimage.com/960x540/e5e7eb/111827&text=Ticket+Screenshot' },
  39. { id: 'img-2', name: '控制台日志.png', url: 'https://dummyimage.com/960x540/dbeafe/1d4ed8&text=Console+Log' }
  40. ];
  41. readonly lifecycle: TicketStatus[] = ['pending', 'processing', 'confirming', 'resolved'];
  42. constructor(readonly parseApi: ParseApiService) {}
  43. get visibleTicketCount(): string {
  44. return String(this.tickets.length);
  45. }
  46. get overdueTicketCount(): string {
  47. return String(this.tickets.filter((ticket) => ticket.isOverdue).length);
  48. }
  49. get resolvedCount(): string {
  50. return String(this.tickets.filter((ticket) => ticket.status === 'resolved').length);
  51. }
  52. get activeRiskCount(): string {
  53. return String(this.tickets.filter((ticket) => ticket.isOverdue || ticket.priority === 'urgent').length);
  54. }
  55. get filteredTickets(): ParseTicket[] {
  56. const keyword = this.searchKeyword.trim().toLowerCase();
  57. return this.tickets.filter((ticket) => {
  58. const matchesKeyword = !keyword
  59. || ticket.title.toLowerCase().includes(keyword)
  60. || ticket.ticketNo.toLowerCase().includes(keyword)
  61. || ticket.companyName.toLowerCase().includes(keyword);
  62. const matchesStatus = this.statusFilter === 'all' || ticket.status === this.statusFilter;
  63. const matchesPriority = this.priorityFilter === 'all' || ticket.priority === this.priorityFilter;
  64. return matchesKeyword && matchesStatus && matchesPriority;
  65. });
  66. }
  67. ngOnInit(): void {
  68. void this.loadTickets();
  69. }
  70. ngAfterViewInit(): void {
  71. this.animateWorkbench();
  72. }
  73. async loadTickets(): Promise<void> {
  74. if (!this.parseApi.isLoggedIn) {
  75. this.message = '请先登录测试账号后查看按 ACL 隔离后的工单数据。';
  76. return;
  77. }
  78. this.loading = true;
  79. this.message = '正在从 Parse 数据库读取当前用户可见工单...';
  80. try {
  81. this.tickets = await this.parseApi.listTickets();
  82. this.selected = this.tickets[0] ?? null;
  83. this.editingTitle = this.selected?.title ?? '';
  84. this.message = `已加载 ${this.tickets.length} 条当前用户可见工单,筛选和状态流转均写回数据库。`;
  85. this.animateWorkbench();
  86. } catch (error) {
  87. this.message = error instanceof Error ? error.message : '工单读取失败。';
  88. } finally {
  89. this.loading = false;
  90. }
  91. }
  92. async createTicket(): Promise<void> {
  93. if (!this.newTicket.title.trim()) {
  94. this.message = '工单标题不能为空。';
  95. return;
  96. }
  97. try {
  98. await this.parseApi.createTicket({
  99. title: this.newTicket.title,
  100. category: this.newTicket.category,
  101. priority: this.newTicket.priority,
  102. impactScope: this.newTicket.impactScope,
  103. description: this.newTicket.description || '由前端页面创建的测试工单。'
  104. });
  105. this.newTicket = { title: '', category: '软件问题', priority: 'high', impactScope: '部门', description: '' };
  106. await this.loadTickets();
  107. this.message = '工单创建成功,已按当前用户 ACL 隔离。';
  108. } catch (error) {
  109. this.message = error instanceof Error ? error.message : '工单创建失败。';
  110. }
  111. }
  112. selectTicket(ticket: ParseTicket): void {
  113. this.selected = ticket;
  114. this.editingTitle = ticket.title;
  115. this.animateWorkbench();
  116. }
  117. async updateSelected(): Promise<void> {
  118. if (!this.selected) {
  119. return;
  120. }
  121. await this.parseApi.updateTicket(this.selected, { title: this.editingTitle });
  122. await this.loadTickets();
  123. this.message = '工单标题已更新。';
  124. }
  125. async transitionSelected(status: TicketStatus): Promise<void> {
  126. if (!this.selected) {
  127. return;
  128. }
  129. await this.parseApi.updateTicket(this.selected, { status });
  130. await this.loadTickets();
  131. this.message = `工单状态已更新为:${this.statusLabel(status)}。`;
  132. }
  133. async deleteSelected(): Promise<void> {
  134. if (!this.selected) {
  135. return;
  136. }
  137. await this.parseApi.deleteTicket(this.selected);
  138. await this.loadTickets();
  139. this.message = '工单已删除。';
  140. }
  141. slaText(ticket: ParseTicket): string {
  142. return ticket.isOverdue ? '已超时' : 'SLA 正常';
  143. }
  144. statusLabel(status: string): string {
  145. const labels: Record<string, string> = {
  146. pending: '待分派',
  147. processing: '处理中',
  148. confirming: '待客户确认',
  149. resolved: '已解决',
  150. closed: '已关闭'
  151. };
  152. return labels[status] ?? status;
  153. }
  154. priorityLabel(priority: string): string {
  155. const labels: Record<string, string> = {
  156. low: '低',
  157. medium: '中',
  158. high: '高',
  159. urgent: '紧急'
  160. };
  161. return labels[priority] ?? priority;
  162. }
  163. statusTone(status: string): 'default' | 'success' | 'warning' {
  164. if (status === 'resolved' || status === 'closed') {
  165. return 'success';
  166. }
  167. if (status === 'confirming') {
  168. return 'warning';
  169. }
  170. return 'default';
  171. }
  172. priorityTone(priority: string): 'default' | 'warning' | 'danger' {
  173. if (priority === 'urgent') {
  174. return 'danger';
  175. }
  176. if (priority === 'high') {
  177. return 'warning';
  178. }
  179. return 'default';
  180. }
  181. nextActions(status: string): Array<{ label: string; status: TicketStatus }> {
  182. if (status === 'pending') {
  183. return [{ label: '分派处理', status: 'processing' }];
  184. }
  185. if (status === 'processing') {
  186. return [{ label: '提交客户确认', status: 'confirming' }];
  187. }
  188. if (status === 'confirming') {
  189. return [
  190. { label: '确认解决', status: 'resolved' },
  191. { label: '退回处理', status: 'processing' }
  192. ];
  193. }
  194. return [{ label: '重新打开', status: 'processing' }];
  195. }
  196. isDoneStep(step: TicketStatus, currentStatus: string): boolean {
  197. const currentIndex = this.lifecycle.indexOf(currentStatus as TicketStatus);
  198. return currentIndex >= 0 && this.lifecycle.indexOf(step) < currentIndex;
  199. }
  200. private animateWorkbench(): void {
  201. window.setTimeout(() => {
  202. gsap.fromTo('.ticket-row', { y: 8, opacity: 0 }, { y: 0, opacity: 1, duration: 0.24, stagger: 0.025, ease: 'power2.out' });
  203. gsap.fromTo('.timeline article', { scale: 0.96, opacity: 0.45 }, { scale: 1, opacity: 1, duration: 0.32, stagger: 0.06, ease: 'back.out(1.4)' });
  204. gsap.fromTo('.detail-panel, .test-panel', { y: 10, opacity: 0.72 }, { y: 0, opacity: 1, duration: 0.28, ease: 'power2.out' });
  205. });
  206. }
  207. }