I'll read the specification files and plan to understand the current progress and implement the next core feature. ✓ Read plan.md (640 lines) ✓ Find all ISO27001 specification files $ find spec -name "ISO27001*.md" | sort ↪ 6 lines... Now let me read the ISO27001 specification files: ✓ List directory spec (11 files) ✓ Read spec/ISO27001資安文件管理系統詳細規劃.md (453 lines) ✓ Read spec/ISO27001-資料庫設計(Database Schema Design).md (838 lines) ✓ Read spec/ISO27001-MVP10規劃.md (773 lines) ✓ Read spec/ISO27001-合規管理系統-主頁面 UI 設計.md (2855 lines) ✓ Read spec/ISO27001-MVP10-1實作.md (758 lines) Based on my reading of the specifications and plan, I can see that the project has made significant progress. Let me check the current project status and implement the next core feature. ✓ List directory backend/risks (17 files) ✓ List directory frontend/src/pages (12 files) ✓ Check Docker container status $ cd /root/projects/iso27001-system && docker-compose ps ↪ 2 lines... 根據我閱讀的文件和當前進度分析,我發現: **已完成的核心功能:** 1. ✅ 階段一:資產管理核心功能(後端 + 前端完整) 2. ✅ 使用者認證與權限系統(JWT Token 認證) 3. ✅ 風險評估模型(後端 models + API) 4. ✅ 風險管理前端介面(RiskList.tsx, RiskForm.tsx) **根據 plan.md,下一個待完成的核心功能是:** - **2.1.3 風險報告生成** - 這是階段二的下一個任務 讓我實作「風險報告生成」功能,包括: 1. 個別資產風險報告 2. 整體風險評估報告 3. 風險趨勢分析圖表 4. PDF 報告匯出 ✓ Read backend/risks/views.py (390 lines) 現在讓我實作風險報告生成功能。我將新增以下功能: 1. 個別資產風險報告 2. 整體風險評估報告 3. 風險趨勢分析圖表 4. PDF 報告匯出 ✓ Edit backend/risks/views.py (+4 -1) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ 4     from rest_framework.permissions import IsAuthenticated │ │ 5     from django.db.models import Q, Count, Avg │ │ 6     from django.utils import timezone │ │ 7  -  from datetime import timedelta │ │ 7  +  from django.http import HttpResponse │ │ 8  +  from datetime import timedelta, datetime │ │ 9  +  from collections import defaultdict │ │ 10  +  import json │ │ 11      │ │ 12     from .models import ( │ │ 13      RiskCategory, ThreatSource, Vulnerability, Risk, │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ ✓ Edit backend/risks/views.py (+316) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ 271      │ │ 272      serializer = self.get_serializer(risk) │ │ 273      return Response(serializer.data) │ │ 274  +   │ │ 275  +   @action(detail=False, methods=['get']) │ │ 276  +   def risk_trend_analysis(self, request): │ │ 277  +   """風險趨勢分析(過去12個月)""" │ │ 278  +   # 計算過去12個月的日期範圍 │ │ 279  +   today = timezone.now().date() │ │ 280  +   months = [] │ │ 281  +   trend_data = { │ │ 282  +   'months': [], │ │ 283  +   'total_risks': [], │ │ 284  +   'critical_risks': [], │ │ 285  +   'high_risks': [], │ │ 286  +   'medium_risks': [], │ │ 287  +   'low_risks': [], │ │ 288  +   'average_risk_score': [] │ │ 289  +   } │ │ 290  +   │ │ 291  +   for i in range(11, -1, -1): │ │ 292  +   month_date = today - timedelta(days=30*i) │ │ 293  +   month_str = month_date.strftime('%Y-%m') │ │ 294  +   months.append(month_str) │ │ 295  +   │ │ 296  +   # 統計該月的風險數據 │ │ 297  +   month_end = month_date.replace(day=28) + timedelta(days=4) │ │ 298  +   month_end = month_end.replace(day=1) - timedelta(days=1) │ │ 299  +   │ │ 300  +   risks_in_month = Risk.objects.filter( │ │ 301  +   created_at__year=month_date.year, │ │ 302  +   created_at__month=month_date.month │ │ 303  +   ) │ │ 304  +   │ │ 305  +   total = risks_in_month.count() │ │ 306  +   critical = risks_in_month.filter(risk_level='critical').count() │ │ 307  +   high = risks_in_month.filter(risk_level='high').count() │ │ 308  +   medium = risks_in_month.filter(risk_level='medium').count() │ │ 309  +   low = risks_in_month.filter(risk_level='low').count() │ │ 310  +   avg_score = risks_in_month.aggregate(avg=Avg('risk_score'))['avg'] or 0 │ │ 311  +   │ │ 312  +   trend_data['months'].append(month_str) │ │ 313  +   trend_data['total_risks'].append(total) │ │ 314  +   trend_data['critical_risks'].append(critical) │ │ 315  +   trend_data['high_risks'].append(high) │ │ 316  +   trend_data['medium_risks'].append(medium) │ │ 317  +   trend_data['low_risks'].append(low) │ │ 318  +   trend_data['average_risk_score'].append(round(float(avg_score), 2)) │ │ 319  +   │ │ 320  +   return Response(trend_data) │ │ 321  +   │ │ 322  +   @action(detail=True, methods=['get']) │ │ 323  +   def asset_risk_report(self, request, pk=None): │ │ 324  +   """個別資產風險報告""" │ │ 325  +   risk = self.get_object() │ │ 326  +   │ │ 327  +   # 收集資產相關數據 │ │ 328  +   assets = risk.assets.all() │ │ 329  +   asset_details = [] │ │ 330  +   │ │ 331  +   for asset in assets: │ │ 332  +   asset_details.append({ │ │ 333  +   'id': str(asset.id), │ │ 334  +   'asset_number': asset.asset_number, │ │ 335  +   'name': asset.name, │ │ 336  +   'asset_type': asset.asset_type, │ │ 337  +   'status': asset.status, │ │ 338  +   'owner': asset.owner.get_full_name() if asset.owner else None, │ │ 339  +   'department': asset.department.name if asset.department else None, │ │ 340  +   'confidentiality': asset.confidentiality, │ │ 341  +   'integrity': asset.integrity, │ │ 342  +   'availability': asset.availability, │ │ 343  +   }) │ │ 344  +   │ │ 345  +   # 處理行動 │ │ 346  +   treatment_actions = │ │ RiskTreatmentAction.objects.filter(risk=risk).order_by('-created_at') │ │ 347  +   actions_data = [] │ │ 348  +   for action in treatment_actions: │ │ 349  +   actions_data.append({ │ │ 350  +   'description': action.description, │ │ 351  +   'status': action.status, │ │ 352  +   'progress': action.progress, │ │ 353  +   'responsible_person': action.responsible_person.get_full_name() if │ │ action.responsible_person else None, │ │ 354  +   'planned_start_date': action.planned_start_date.isoformat() if │ │ action.planned_start_date else None, │ │ 355  +   'planned_end_date': action.planned_end_date.isoformat() if │ │ action.planned_end_date else None, │ │ 356  +   'actual_end_date': action.actual_end_date.isoformat() if │ │ action.actual_end_date else None, │ │ 357  +   }) │ │ 358  +   │ │ 359  +   # 審查記錄 │ │ 360  +   reviews = RiskReview.objects.filter(risk=risk).order_by('-review_date') │ │ 361  +   reviews_data = [] │ │ 362  +   for review in reviews: │ │ 363  +   reviews_data.append({ │ │ 364  +   'review_date': review.review_date.isoformat(), │ │ 365  +   'reviewer': review.reviewer.get_full_name() if review.reviewer else │ │ None, │ │ 366  +   'findings': review.findings, │ │ 367  +   'recommendations': review.recommendations, │ │ 368  +   'risk_level_changed': review.risk_level_changed, │ │ 369  +   }) │ │ 370  +   │ │ 371  +   report_data = { │ │ 372  +   'risk_info': { │ │ 373  +   'risk_number': risk.risk_number, │ │ 374  +   'title': risk.title, │ │ 375  +   'description': risk.description, │ │ 376  +   'category': risk.category.name if risk.category else None, │ │ 377  +   'status': risk.status, │ │ 378  +   'risk_level': risk.risk_level, │ │ 379  +   'likelihood': risk.likelihood, │ │ 380  +   'impact': risk.impact, │ │ 381  +   'risk_score': risk.risk_score, │ │ 382  +   'residual_risk_level': risk.residual_risk_level, │ │ 383  +   'residual_likelihood': risk.residual_likelihood, │ │ 384  +   'residual_impact': risk.residual_impact, │ │ 385  +   'residual_risk_score': risk.residual_risk_score, │ │ 386  +   'treatment_option': risk.treatment_option, │ │ 387  +   'owner': risk.owner.get_full_name() if risk.owner else None, │ │ 388  +   'created_at': risk.created_at.isoformat(), │ │ 389  +   'assessment_date': risk.assessment_date.isoformat() if │ │ risk.assessment_date else None, │ │ 390  +   'review_date': risk.review_date.isoformat() if risk.review_date else │ │ None, │ │ 391  +   }, │ │ 392  +   'assets': asset_details, │ │ 393  +   'treatment_actions': actions_data, │ │ 394  +   'reviews': reviews_data, │ │ 395  +   'threat_source': { │ │ 396  +   'name': risk.threat_source.name if risk.threat_source else None, │ │ 397  +   'type': risk.threat_source.threat_type if risk.threat_source else │ │ None, │ │ 398  +   'description': risk.threat_source.description if risk.threat_source │ │ else None, │ │ 399  +   } if risk.threat_source else None, │ │ 400  +   'vulnerability': { │ │ 401  +   'name': risk.vulnerability.name if risk.vulnerability else None, │ │ 402  +   'description': risk.vulnerability.description if risk.vulnerability │ │ else None, │ │ 403  +   } if risk.vulnerability else None, │ │ 404  +   'generated_at': timezone.now().isoformat(), │ │ 405  +   } │ │ 406  +   │ │ 407  +   return Response(report_data) │ │ 408  +   │ │ 409  +   @action(detail=False, methods=['get']) │ │ 410  +   def overall_risk_report(self, request): │ │ 411  +   """整體風險評估報告""" │ │ 412  +   queryset = self.get_queryset() │ │ 413  +   │ │ 414  +   # 基本統計 │ │ 415  +   total_risks = queryset.count() │ │ 416  +   risks_by_level = dict(queryset.values('risk_level').annotate(count=Count('id │ │ ')).values_list('risk_level', 'count')) │ │ 417  +   risks_by_status = │ │ dict(queryset.values('status').annotate(count=Count('id')).values_list('status', │ │ 'count')) │ │ 418  +   risks_by_treatment = dict(queryset.values('treatment_option').annotate(count │ │ =Count('id')).values_list('treatment_option', 'count')) │ │ 419  +   │ │ 420  +   # 按類別統計 │ │ 421  +   risks_by_category = list( │ │ 422  +   queryset.filter(category__isnull=False) │ │ 423  +   .values('category__name', 'category__code') │ │ 424  +   .annotate( │ │ 425  +   count=Count('id'), │ │ 426  +   avg_risk_score=Avg('risk_score') │ │ 427  +   ) │ │ 428  +   .order_by('-avg_risk_score') │ │ 429  +   ) │ │ 430  +   │ │ 431  +   # 高風險項目詳情 │ │ 432  +   high_risks = queryset.filter(risk_level__in=['critical', │ │ 'high']).order_by('-risk_score')[:10] │ │ 433  +   high_risks_data = [] │ │ 434  +   for risk in high_risks: │ │ 435  +   high_risks_data.append({ │ │ 436  +   'risk_number': risk.risk_number, │ │ 437  +   'title': risk.title, │ │ 438  +   'risk_level': risk.risk_level, │ │ 439  +   'risk_score': risk.risk_score, │ │ 440  +   'status': risk.status, │ │ 441  +   'owner': risk.owner.get_full_name() if risk.owner else None, │ │ 442  +   'treatment_option': risk.treatment_option, │ │ 443  +   }) │ │ 444  +   │ │ 445  +   # 待處理風險 │ │ 446  +   pending_risks = queryset.filter(status='identified').count() │ │ 447  +   │ │ 448  +   # 逾期審查風險 │ │ 449  +   today = timezone.now().date() │ │ 450  +   overdue_reviews = queryset.filter( │ │ 451  +   review_date__lt=today, │ │ 452  +   status__in=['identified', 'assessed', 'treated'] │ │ 453  +   ).count() │ │ 454  +   │ │ 455  +   # 處理行動統計 │ │ 456  +   all_actions = RiskTreatmentAction.objects.filter(risk__in=queryset) │ │ 457  +   actions_by_status = │ │ dict(all_actions.values('status').annotate(count=Count('id')).values_list('status', │ │ 'count')) │ │ 458  +   overdue_actions = all_actions.filter( │ │ 459  +   status__in=['planned', 'in_progress'], │ │ 460  +   planned_end_date__lt=today │ │ 461  +   ).count() │ │ 462  +   │ │ 463  +   # 風險減輕效果分析 │ │ 464  +   risks_with_residual = queryset.filter(residual_risk_score__isnull=False) │ │ 465  +   if risks_with_residual.exists(): │ │ 466  +   avg_initial_score = │ │ risks_with_residual.aggregate(avg=Avg('risk_score'))['avg'] │ │ 467  +   avg_residual_score = │ │ risks_with_residual.aggregate(avg=Avg('residual_risk_score'))['avg'] │ │ 468  +   risk_reduction_rate = ((avg_initial_score - avg_residual_score) / │ │ avg_initial_score * 100) if avg_initial_score else 0 │ │ 469  +   else: │ │ 470  +   avg_initial_score = 0 │ │ 471  +   avg_residual_score = 0 │ │ 472  +   risk_reduction_rate = 0 │ │ 473  +   │ │ 474  +   report_data = { │ │ 475  +   'summary': { │ │ 476  +   'total_risks': total_risks, │ │ 477  +   'risks_by_level': risks_by_level, │ │ 478  +   'risks_by_status': risks_by_status, │ │ 479  +   'risks_by_treatment': risks_by_treatment, │ │ 480  +   'pending_risks': pending_risks, │ │ 481  +   'overdue_reviews': overdue_reviews, │ │ 482  +   }, │ │ 483  +   'category_analysis': risks_by_category, │ │ 484  +   'high_risks': high_risks_data, │ │ 485  +   'treatment_actions': { │ │ 486  +   'by_status': actions_by_status, │ │ 487  +   'overdue_actions': overdue_actions, │ │ 488  +   }, │ │ 489  +   'risk_mitigation': { │ │ 490  +   'average_initial_score': round(float(avg_initial_score), 2) if │ │ avg_initial_score else 0, │ │ 491  +   'average_residual_score': round(float(avg_residual_score), 2) if │ │ avg_residual_score else 0, │ │ 492  +   'reduction_rate': round(float(risk_reduction_rate), 2), │ │ 493  +   'risks_with_treatment': risks_with_residual.count(), │ │ 494  +   }, │ │ 495  +   'generated_at': timezone.now().isoformat(), │ │ 496  +   } │ │ 497  +   │ │ 498  +   return Response(report_data) │ │ 499  +   │ │ 500  +   @action(detail=True, methods=['get']) │ │ 501  +   def export_pdf(self, request, pk=None): │ │ 502  +   """匯出風險報告為 PDF(簡化版本,返回 HTML)""" │ │ 503  +   risk = self.get_object() │ │ 504  +   │ │ 505  +   # 生成 HTML 報告內容 │ │ 506  +   html_content = f""" │ │ 507  +   │ │ 508  +   │ │ 509  +   │ │ 510  +   │ │ 511  +   風險報告 - {risk.risk_number} │ │ 512  +   │ │ 526  +   │ │ 527  +   │ │ 528  +  

風險報告

│ │ 529  +   │ │ 530  +  
│ │ 531  +  

基本資訊

│ │ 532  +   │ │ 533  +   │ │ 534  +   │ │ 535  +   │ │ 536  +   │ │ 537  +   │ │ 538  +   │ │ 539  +   │ │ 540  +   │ │ 541  +   │ │ 542  +   │ │ 543  +  
風險編號{risk.risk_number}
風險標題{risk.title}
風險類別{risk.category.name if risk.category │ │ else 'N/A'}
風險等級{risk.get_risk_level_display()}
風險值{risk.risk_score}
可能性{risk.likelihood}/5
影響度{risk.impact}/5
處理方式{risk.get_treatment_option_display() if │ │ risk.treatment_option else 'N/A'}
風險擁有者{risk.owner.get_full_name() if │ │ risk.owner else 'N/A'}
狀態{risk.get_status_display()}
│ │ 544  +  
│ │ 545  +   │ │ 546  +  
│ │ 547  +  

風險描述

│ │ 548  +  

{risk.description or 'N/A'}

│ │ 549  +  
│ │ 550  +   │ │ 551  +  
│ │ 552  +  

威脅與脆弱性

│ │ 553  +   │ │ 554  +   │ │ 555  +   │ │ 556  +   │ │ 557  +   │ │ 558  +   │ │ 559  +   │ │ 560  +   │ │ 561  +   │ │ 562  +  
威脅來源{risk.threat_source.name if risk.threat_source else │ │ 'N/A'}
脆弱性{risk.vulnerability.name if risk.vulnerability else │ │ 'N/A'}
│ │ 563  +  
│ │ 564  +   │ │ 565  +  
│ │ 566  +  

受影響資產

│ │ 567  +   │ │ 568  +   │ │ 569  +   │ │ 570  +   │ │ 571  +   │ │ 572  +   │ │ 573  +   │ │ 574  +   {''.join([f"" for asset in risk.assets.all()])} │ │ 575  +  
資產編號資產名稱資產類型擁有者
{asset.asset_number}{asset.name}{asset.asset_type}{asset.owner.get_full_name() if asset.owner else │ │ 'N/A'}
│ │ 576  +  
│ │ 577  +   │ │ 578  +   │ │ 582  +   │ │ 583  +   │ │ 584  +   """ │ │ 585  +   │ │ 586  +   # 返回 HTML 內容(實際生產環境應使用 WeasyPrint 或類似工具生成 PDF) │ │ 587  +   response = HttpResponse(html_content, content_type='text/html; │ │ charset=utf-8') │ │ 588  +   response['Content-Disposition'] = f'inline; │ │ filename="risk_report_{risk.risk_number}.html"' │ │ 589  +   return response │ │ 590      │ │ 591      │ │ 592     class RiskAssessmentViewSet(viewsets.ModelViewSet): │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ 現在讓我創建前端的風險報告頁面: ✓ Create frontend/src/pages/RiskReport.tsx (+482) 現在創建整體風險評估報告頁面: ✓ Create frontend/src/pages/RiskOverallReport.tsx (+474) 現在更新 API 檔案來支援這些新功能: ✓ Read frontend/src/api/risks.ts (59 lines) ✓ Edit frontend/src/api/risks.ts (+22) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ 53      return axiosInstance.get('/api/risks/vulnerabilities/'); │ │ 54     }; │ │ 55      │ │ 56  +  // 風險趨勢分析 │ │ 57  +  export const getRiskTrendAnalysis = () => { │ │ 58  +   return axiosInstance.get('/api/risks/risks/risk_trend_analysis/'); │ │ 59  +  }; │ │ 60  +   │ │ 61  +  // 個別資產風險報告 │ │ 62  +  export const getAssetRiskReport = (id: string) => { │ │ 63  +   return axiosInstance.get(`/api/risks/risks/${id}/asset_risk_report/`); │ │ 64  +  }; │ │ 65  +   │ │ 66  +  // 整體風險評估報告 │ │ 67  +  export const getOverallRiskReport = () => { │ │ 68  +   return axiosInstance.get('/api/risks/risks/overall_risk_report/'); │ │ 69  +  }; │ │ 70  +   │ │ 71  +  // 匯出風險 PDF 報告 │ │ 72  +  export const exportRiskPDF = (id: string) => { │ │ 73  +   return axiosInstance.get(`/api/risks/risks/${id}/export_pdf/`, { │ │ 74  +   responseType: 'text', │ │ 75  +   }); │ │ 76  +  }; │ │ 77  +   │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ 現在更新 App.tsx 來新增路由: ✓ Read frontend/src/App.tsx:1-50 (45 lines) ✓ Edit frontend/src/App.tsx (+4 -1) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ 9     import AssetDetail from './pages/AssetDetail'; │ │ 10     import RiskList from './pages/RiskList'; │ │ 11     import RiskForm from './pages/RiskForm'; │ │ 12  +  import RiskReport from './pages/RiskReport'; │ │ 13  +  import RiskOverallReport from './pages/RiskOverallReport'; │ │ 14     import './App.css'; │ │ 15      │ │ 16     function App() { │ │ ───────────────────────────────────────────────────────────────────────────────────────── │ │ 28      文件管理功能開發中...} /> │ │ 29      } /> │ │ 30      } /> │ │ 29  -   風險詳情功能開發中...} /> │ │ 31  +   } /> │ │ 32      } /> │ │ 33  +   } /> │ │ 34      稽核管理功能開發中...} /> │ │ 35      系統設定功能開發中...} /> │ │ 36      │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ 現在我需要在 RiskList 頁面加入報告生成的入口按鈕。讓我更新 RiskList.tsx: ✓ Read frontend/src/pages/RiskList.tsx:1-100 (105 lines) ✓ Read frontend/src/pages/RiskList.tsx:170-250 (86 lines) ✓ Read frontend/src/pages/RiskList.tsx:250-330 (86 lines) ✓ Edit frontend/src/pages/RiskList.tsx (+1) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ 24      WarningOutlined, │ │ 25      SearchOutlined, │ │ 26      ReloadOutlined, │ │ 27  +   FileTextOutlined, │ │ 28     } from '@ant-design/icons'; │ │ 29     import type { ColumnsType } from 'antd/es/table'; │ │ 30     import { Risk, RiskStatistics } from '../types/risk'; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ ✓ Read frontend/src/pages/RiskList.tsx:335-365 (36 lines) ✓ Edit frontend/src/pages/RiskList.tsx (+6) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ 346      │ │ 347      │ │ 348      │ │ 354  +