diff --git a/src/main/ipc/report-handler.ts b/src/main/ipc/report-handler.ts index ccc7d6a..7b1acc5 100644 --- a/src/main/ipc/report-handler.ts +++ b/src/main/ipc/report-handler.ts @@ -6,7 +6,11 @@ import { ConfigManager } from '../services/config/config-manager' import { RustfsService } from '../services/rustfs' import { ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3' import { SessionManager } from '../services/user/session-manager' -import { ReportAnalyzer, type ParsedReportData } from '../services/report/report-analyzer' +import { + ReportAnalyzer, + type ParsedReportData, + type AggregatedDailyData +} from '../services/report/report-analyzer' const log = createLogger('ReportHandler') @@ -182,7 +186,7 @@ export function registerReportHandlers(): void { ipcMain.handle( IPC_CHANNELS.REPORT_ANALYZE_ALL, - async (): Promise> => { + async (_event, selectedUsernames?: string[]): Promise> => { return withErrorHandling(async () => { // Check Admin permission const sessionManager = SessionManager.getInstance() @@ -247,17 +251,91 @@ export function registerReportHandlers(): void { } } - // Analyze all reports + // Analyze reports const analyzer = new ReportAnalyzer() - const analyzedData = analyzer.analyzeReports(reports) + + // If specific users are selected, filter by users; otherwise, aggregate all + let analyzedData: AggregatedDailyData[] + if (selectedUsernames && selectedUsernames.length > 0) { + log.info('Analyzing reports for selected users', { usernames: selectedUsernames }) + analyzedData = analyzer.analyzeReportsByUsers(reports, selectedUsernames) + } else { + log.info('Analyzing all reports without user filter') + analyzedData = analyzer.analyzeReports(reports) + } log.info('Report analysis completed', { totalReports: reports.length, - successfulAnalyses: analyzedData.length + aggregatedDays: analyzedData.length }) return analyzedData }, 'report:analyzeAll') } ) + + // Get all unique usernames from reports + ipcMain.handle(IPC_CHANNELS.REPORT_GET_USERNAMES, async (): Promise> => { + return withErrorHandling(async () => { + // Check Admin permission + const sessionManager = SessionManager.getInstance() + if (!sessionManager.isAdmin()) { + log.warn('Non-Admin user attempted to access report usernames') + throw new Error('Unauthorized: Admin access required') + } + + const rustfs = getRustfsService() + if (!rustfs) { + throw new Error('RustFS is not configured or enabled') + } + + const configManager = ConfigManager.getInstance() + const config = configManager.getConfig() + + // Create S3Client to list objects + const client = new S3Client({ + region: config.rustfs?.region || 'us-east-1', + endpoint: config.rustfs?.endpoint || '', + credentials: { + accessKeyId: config.rustfs?.accessKey || '', + secretAccessKey: config.rustfs?.secretKey || '' + }, + forcePathStyle: true + }) + + log.info('Fetching usernames from reports') + const input = { + Bucket: config.rustfs?.bucket || '', + Prefix: 'reports/cleaner/' + } + + const command = new ListObjectsV2Command(input) + const response = await client.send(command) + + const reports: Array<{ content: string; filename?: string }> = [] + + if (response.Contents) { + for (const item of response.Contents) { + if (item.Key && item.Key.endsWith('.md')) { + log.debug('Downloading report for username extraction', { key: item.Key }) + const downloadResult = await rustfs.downloadFile(item.Key) + + if (downloadResult.success) { + reports.push({ + content: downloadResult.content.toString('utf-8'), + filename: item.Key + }) + } + } + } + } + + const analyzer = new ReportAnalyzer() + const usernames = analyzer.getAllUsernames(reports) + + log.info('Username extraction completed', { count: usernames.length }) + + return usernames + }, 'report:getUsernames') + }) } diff --git a/src/main/services/report/__tests__/report-analyzer.test.ts b/src/main/services/report/__tests__/report-analyzer.test.ts index 0eedeb9..c9e8f24 100644 --- a/src/main/services/report/__tests__/report-analyzer.test.ts +++ b/src/main/services/report/__tests__/report-analyzer.test.ts @@ -196,10 +196,12 @@ No execution summary section here` const results = analyzer.analyzeReports(reports) expect(results.length).toBe(2) - expect(results[0].username).toBe('user1') expect(results[0].date).toBe('2026-03-25') - expect(results[1].username).toBe('user2') + expect(results[0].ordersProcessed).toBe(100) + expect(results[0].executionCount).toBe(1) expect(results[1].date).toBe('2026-03-26') + expect(results[1].ordersProcessed).toBe(200) + expect(results[1].executionCount).toBe(1) }) it('should skip invalid reports in batch', () => { @@ -228,7 +230,51 @@ No execution summary section here` const results = analyzer.analyzeReports(reports) expect(results.length).toBe(1) - expect(results[0].username).toBe('valid') + expect(results[0].ordersProcessed).toBe(50) + }) + + it('should aggregate data by date', () => { + const reports = [ + { + content: `## 执行摘要 + +| **操作用户** | \`user1\` | +| **处理订单数** | \`100\` | +| **删除物料数** | \`20\` | +| **跳过物料数** | \`10\` | +| **错误数量** | \`1\` | +| **重试订单数** | \`5\` | +| **成功重试数** | \`4\` | +| **执行耗时** | \`1 分\` | +| **执行时间** | \`2026-03-25 08:00:00\` | +`, + filename: 'report1-2026-03-25.md' + }, + { + content: `## 执行摘要 + +| **操作用户** | \`user2\` | +| **处理订单数** | \`150\` | +| **删除物料数** | \`30\` | +| **跳过物料数** | \`15\` | +| **错误数量** | \`2\` | +| **重试订单数** | \`8\` | +| **成功重试数** | \`6\` | +| **执行耗时** | \`2 分\` | +| **执行时间** | \`2026-03-25 14:00:00\` | +`, + filename: 'report2-2026-03-25.md' + } + ] + + const results = analyzer.analyzeReports(reports) + + expect(results.length).toBe(1) + expect(results[0].date).toBe('2026-03-25') + expect(results[0].ordersProcessed).toBe(250) // 100 + 150 + expect(results[0].materialsDeleted).toBe(50) // 20 + 30 + expect(results[0].executionCount).toBe(2) + expect(results[0].avgDurationSeconds).toBe(90) // (60 + 120) / 2 }) it('should use filename date to override content date', () => { diff --git a/src/main/services/report/report-analyzer.ts b/src/main/services/report/report-analyzer.ts index c7b9c7a..396d8b1 100644 --- a/src/main/services/report/report-analyzer.ts +++ b/src/main/services/report/report-analyzer.ts @@ -3,7 +3,7 @@ import { createLogger } from '../logger' const log = createLogger('ReportAnalyzer') /** - * Parsed report data structure + * Parsed report data structure - 单条报告记录 */ export interface ParsedReportData { /** 报告日期 (从文件名或内容提取) */ @@ -26,6 +26,30 @@ export interface ParsedReportData { durationSeconds: number } +/** + * Aggregated daily data - 按日期聚合后的数据 + */ +export interface AggregatedDailyData { + /** 日期 */ + date: string + /** 处理订单数总和 */ + ordersProcessed: number + /** 删除物料数总和 */ + materialsDeleted: number + /** 跳过物料数总和 */ + materialsSkipped: number + /** 错误数量总和 */ + errorCount: number + /** 重试订单数总和 */ + retriedOrders: number + /** 成功重试数总和 */ + successfulRetries: number + /** 执行耗时平均值 (秒) */ + avgDurationSeconds: number + /** 执行次数 */ + executionCount: number +} + /** * ReportAnalyzer - 解析和分析 ERP 物料清理执行报告 */ @@ -85,12 +109,13 @@ export class ReportAnalyzer { } /** - * 批量解析多个报告 + * 批量解析多个报告并按日期聚合 * @param reports 报告元数据和内容列表 - * @returns 解析后的数据列表 (自动跳过解析失败的报告) + * @returns 按日期聚合后的数据,按日期升序排序 */ - analyzeReports(reports: Array<{ content: string; filename?: string }>): ParsedReportData[] { - const results: ParsedReportData[] = [] + analyzeReports(reports: Array<{ content: string; filename?: string }>): AggregatedDailyData[] { + // 第一步:解析所有报告 + const parsedReports: ParsedReportData[] = [] for (const report of reports) { const parsed = this.parseMarkdownReport(report.content) @@ -102,13 +127,153 @@ export class ReportAnalyzer { parsed.date = dateFromFilename } } - results.push(parsed) + parsedReports.push(parsed) } else { log.warn('Skipping report due to parse failure', { filename: report.filename }) } } - return results + // 第二步:按日期聚合数据 + const dateMap = new Map() + for (const report of parsedReports) { + const existing = dateMap.get(report.date) || [] + existing.push(report) + dateMap.set(report.date, existing) + } + + // 第三步:计算每天的聚合数据 + const aggregated: AggregatedDailyData[] = [] + for (const [date, reportsOnDate] of dateMap.entries()) { + const totalOrdersProcessed = reportsOnDate.reduce((sum, r) => sum + r.ordersProcessed, 0) + const totalMaterialsDeleted = reportsOnDate.reduce((sum, r) => sum + r.materialsDeleted, 0) + const totalMaterialsSkipped = reportsOnDate.reduce((sum, r) => sum + r.materialsSkipped, 0) + const totalErrorCount = reportsOnDate.reduce((sum, r) => sum + r.errorCount, 0) + const totalRetriedOrders = reportsOnDate.reduce((sum, r) => sum + r.retriedOrders, 0) + const totalSuccessfulRetries = reportsOnDate.reduce((sum, r) => sum + r.successfulRetries, 0) + const totalDurationSeconds = reportsOnDate.reduce((sum, r) => sum + r.durationSeconds, 0) + const executionCount = reportsOnDate.length + + aggregated.push({ + date, + ordersProcessed: totalOrdersProcessed, + materialsDeleted: totalMaterialsDeleted, + materialsSkipped: totalMaterialsSkipped, + errorCount: totalErrorCount, + retriedOrders: totalRetriedOrders, + successfulRetries: totalSuccessfulRetries, + avgDurationSeconds: Math.round(totalDurationSeconds / executionCount), + executionCount + }) + } + + // 第四步:按日期升序排序(从早到晚),确保图表时间轴正确 + aggregated.sort((a, b) => { + const dateA = new Date(a.date) + const dateB = new Date(b.date) + return dateA.getTime() - dateB.getTime() + }) + + return aggregated + } + + /** + * 获取所有唯一的用户名列表 + * @param reports 报告元数据和内容列表 + * @returns 按字母顺序排序的用户名列表 + */ + getAllUsernames(reports: Array<{ content: string; filename?: string }>): string[] { + const usernames = new Set() + + for (const report of reports) { + const parsed = this.parseMarkdownReport(report.content) + if (parsed && parsed.username) { + usernames.add(parsed.username) + } + } + + return Array.from(usernames).sort() + } + + /** + * 按用户筛选报告并返回聚合数据 + * @param reports 报告元数据和内容列表 + * @param selectedUsernames 选中的用户名列表 + * @returns 按日期聚合后的数据,仅包含选中用户的报告 + */ + analyzeReportsByUsers( + reports: Array<{ content: string; filename?: string }>, + selectedUsernames: string[] + ): AggregatedDailyData[] { + // 如果未选择任何用户,返回空数组 + if (selectedUsernames.length === 0) { + return [] + } + + // 第一步:解析所有报告 + const parsedReports: ParsedReportData[] = [] + + for (const report of reports) { + const parsed = this.parseMarkdownReport(report.content) + if (parsed) { + // 如果有文件名,尝试从文件名提取日期覆盖 + if (report.filename) { + const dateFromFilename = this.extractDateFromFilename(report.filename) + if (dateFromFilename) { + parsed.date = dateFromFilename + } + } + parsedReports.push(parsed) + } else { + log.warn('Skipping report due to parse failure', { filename: report.filename }) + } + } + + // 第二步:按选中的用户过滤 + const filteredReports = parsedReports.filter((report) => + selectedUsernames.includes(report.username) + ) + + // 第三步:按日期聚合过滤后的数据 + const dateMap = new Map() + for (const report of filteredReports) { + const existing = dateMap.get(report.date) || [] + existing.push(report) + dateMap.set(report.date, existing) + } + + // 第四步:计算每天的聚合数据 + const aggregated: AggregatedDailyData[] = [] + for (const [date, reportsOnDate] of dateMap.entries()) { + const totalOrdersProcessed = reportsOnDate.reduce((sum, r) => sum + r.ordersProcessed, 0) + const totalMaterialsDeleted = reportsOnDate.reduce((sum, r) => sum + r.materialsDeleted, 0) + const totalMaterialsSkipped = reportsOnDate.reduce((sum, r) => sum + r.materialsSkipped, 0) + const totalErrorCount = reportsOnDate.reduce((sum, r) => sum + r.errorCount, 0) + const totalRetriedOrders = reportsOnDate.reduce((sum, r) => sum + r.retriedOrders, 0) + const totalSuccessfulRetries = reportsOnDate.reduce((sum, r) => sum + r.successfulRetries, 0) + const totalDurationSeconds = reportsOnDate.reduce((sum, r) => sum + r.durationSeconds, 0) + const executionCount = reportsOnDate.length + + aggregated.push({ + date, + ordersProcessed: totalOrdersProcessed, + materialsDeleted: totalMaterialsDeleted, + materialsSkipped: totalMaterialsSkipped, + errorCount: totalErrorCount, + retriedOrders: totalRetriedOrders, + successfulRetries: totalSuccessfulRetries, + avgDurationSeconds: Math.round(totalDurationSeconds / executionCount), + executionCount + }) + } + + // 第五步:按日期升序排序(从早到晚),确保图表时间轴正确 + aggregated.sort((a, b) => { + const dateA = new Date(a.date) + const dateB = new Date(b.date) + return dateA.getTime() - dateB.getTime() + }) + + return aggregated } /** diff --git a/src/main/types/ipc-api.types.ts b/src/main/types/ipc-api.types.ts index 17388d1..75b817f 100644 --- a/src/main/types/ipc-api.types.ts +++ b/src/main/types/ipc-api.types.ts @@ -201,21 +201,27 @@ export interface ReportAPI { download: (key: string) => Promise> /** - * Analyze all reports and return parsed data (Admin only) + * Analyze all reports and return aggregated daily data (Admin only) + * @param selectedUsernames - Optional array of usernames to filter by */ - analyzeAll: () => Promise< + analyzeAll: (selectedUsernames?: string[]) => Promise< IpcResult< { date: string - username: string ordersProcessed: number materialsDeleted: number materialsSkipped: number errorCount: number retriedOrders: number successfulRetries: number - durationSeconds: number + avgDurationSeconds: number + executionCount: number }[] > > + + /** + * Get all unique usernames from reports (Admin only) + */ + getUsernames: () => Promise> } diff --git a/src/preload/api/materials.ts b/src/preload/api/materials.ts index ccb60c4..bca11db 100644 --- a/src/preload/api/materials.ts +++ b/src/preload/api/materials.ts @@ -72,7 +72,9 @@ export const reportApi = { listAll: () => invokeIpc(IPC_CHANNELS.REPORT_LIST_ALL), listByUser: (username: string) => invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username), download: (key: string) => invokeIpc(IPC_CHANNELS.REPORT_DOWNLOAD, key), - analyzeAll: () => invokeIpc(IPC_CHANNELS.REPORT_ANALYZE_ALL) + analyzeAll: (selectedUsernames?: string[]) => + invokeIpc(IPC_CHANNELS.REPORT_ANALYZE_ALL, selectedUsernames), + getUsernames: () => invokeIpc(IPC_CHANNELS.REPORT_GET_USERNAMES) } as const export const updateApi = { diff --git a/src/renderer/src/components/ReportAnalysisDialog.tsx b/src/renderer/src/components/ReportAnalysisDialog.tsx index b4f5ae9..8620384 100644 --- a/src/renderer/src/components/ReportAnalysisDialog.tsx +++ b/src/renderer/src/components/ReportAnalysisDialog.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react' -import { X, FileText, Loader2, AlertCircle, RefreshCw } from 'lucide-react' +import { X, FileText, Loader2, AlertCircle, RefreshCw, Users } from 'lucide-react' import { LineChart, Line, @@ -11,16 +11,16 @@ import { ResponsiveContainer } from 'recharts' -interface ParsedReportData { +interface AggregatedDailyData { date: string - username: string ordersProcessed: number materialsDeleted: number materialsSkipped: number errorCount: number retriedOrders: number successfulRetries: number - durationSeconds: number + avgDurationSeconds: number + executionCount: number } interface ReportAnalysisDialogProps { @@ -42,7 +42,7 @@ export const ReportAnalysisDialog: React.FC = ({ { key: 'errorCount', label: '错误数量', color: '#ef4444' }, { key: 'retriedOrders', label: '重试订单数', color: '#8b5cf6' }, { key: 'successfulRetries', label: '成功重试数', color: '#06b6d4' }, - { key: 'durationSeconds', label: '执行耗时', color: '#ec4899' } + { key: 'avgDurationSeconds', label: '执行耗时', color: '#ec4899' } ] // State: selected metrics (default all) @@ -56,33 +56,92 @@ export const ReportAnalysisDialog: React.FC = ({ } // Data state - const [reportData, setReportData] = useState([]) + const [reportData, setReportData] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - // Fetch data when dialog opens + // User filter state + const [allUsernames, setAllUsernames] = useState([]) + const [selectedUsernames, setSelectedUsernames] = useState([]) + const [loadingUsers, setLoadingUsers] = useState(true) + + // Fetch usernames when dialog opens useEffect(() => { if (!isOpen || !isAdmin) return + const fetchUsernames = async () => { + setLoadingUsers(true) + try { + console.log('[ReportAnalysis] Fetching usernames...') + const result = await window.electron.report.getUsernames() + console.log('[ReportAnalysis] Received usernames:', result) + + // IpcResult shape: { success: boolean, data?: string[] } + if (result && Array.isArray(result.data)) { + const usernames = result.data + console.log('[ReportAnalysis] Setting usernames:', usernames) + setAllUsernames(usernames) + setSelectedUsernames(usernames) // Select all users by default + } else { + console.warn('[ReportAnalysis] Unexpected result format:', result) + } + } catch (err) { + console.error('Failed to fetch usernames', err) + } finally { + setLoadingUsers(false) + } + } + + fetchUsernames() + }, [isOpen, isAdmin]) + + // Fetch data when dialog opens or user selection changes + useEffect(() => { + if (!isOpen || !isAdmin || allUsernames.length === 0) return + const fetchData = async () => { setLoading(true) setError(null) try { - const result = await window.electron.report.analyzeAll() - if (result.success && result.data) { - setReportData(result.data) + console.log('[ReportAnalysis] Fetching data for users:', selectedUsernames) + const result = await window.electron.report.analyzeAll(selectedUsernames) + + // IpcResult shape: { success: boolean, data?: AggregatedDailyData[] } + if (result && Array.isArray(result.data)) { + const data = result.data + console.log('[ReportAnalysis] Received data:', data) + setReportData(data) } else { - setError(result.error || '加载失败') + console.warn('[ReportAnalysis] Unexpected result format:', result) + setError('数据格式错误') } } catch (err) { - setError(err instanceof Error ? err.message : '未知错误') + console.error('Failed to fetch analysis data', err) + setError(err instanceof Error ? err.message : '加载失败') } finally { setLoading(false) } } fetchData() - }, [isOpen, isAdmin]) + }, [isOpen, isAdmin, selectedUsernames, allUsernames.length]) + + // Toggle user selection + const toggleUser = (username: string) => { + setSelectedUsernames((prev) => + prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username] + ) + } + + // Select all users + const selectAllUsers = () => { + setSelectedUsernames(allUsernames) + } + + // Deselect all users + const deselectAllUsers = () => { + setSelectedUsernames([]) + } // Transform data for chart const chartData = reportData.map((item) => ({ @@ -117,7 +176,8 @@ export const ReportAnalysisDialog: React.FC = ({ {/* Controls */}
-
+ {/* Metrics Selection */} +
{numericMetrics.map((metric) => ( @@ -138,6 +198,64 @@ export const ReportAnalysisDialog: React.FC = ({ ))}
+ + {/* User Filter */} +
+ + {loadingUsers ? ( +
+ + 加载用户列表... +
+ ) : allUsernames.length === 0 ? ( +
+ + 暂无用户数据 + {reportData.length > 0 && ( + <> + | + 共有 {reportData.length} 天的聚合数据(无用户信息) + + )} +
+ ) : ( + <> +
+ {allUsernames.map((username) => ( + + ))} +
+
+ + | + +
+ + )} +
{/* Content */} @@ -162,12 +280,13 @@ export const ReportAnalysisDialog: React.FC = ({ setLoading(true) setError(null) window.electron.report - .analyzeAll() + .analyzeAll(selectedUsernames) .then((result) => { - if (result.success && result.data) { + // IpcResult shape: { success: boolean, data?: AggregatedDailyData[] } + if (result && Array.isArray(result.data)) { setReportData(result.data) } else { - setError(result.error || '加载失败') + setError('加载失败') } }) .catch((err) => { diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 6139ac8..c25e7d0 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -98,6 +98,7 @@ export const IPC_CHANNELS = { REPORT_LIST_BY_USER: 'report:listByUser', REPORT_DOWNLOAD: 'report:download', REPORT_ANALYZE_ALL: 'report:analyzeAll', + REPORT_GET_USERNAMES: 'report:getUsernames', // Update UPDATE_GET_STATUS: 'update:getStatus',