feat: report analysis with date aggregation and user filtering
- Implement date aggregation logic, sum multiple reports on same day - Add user filtering with multi-select Chip components - Fix IpcResult data unwrapping bug in frontend - Add loading state and empty state indicators - Update test cases to verify aggregation logic Fixes: - Timeline confusion: Now sorted by date ascending - Data duplication: Same-day data automatically aggregated - No filtering: Added user selector for filtering by users
This commit is contained in:
@@ -6,7 +6,11 @@ import { ConfigManager } from '../services/config/config-manager'
|
|||||||
import { RustfsService } from '../services/rustfs'
|
import { RustfsService } from '../services/rustfs'
|
||||||
import { ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'
|
import { ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'
|
||||||
import { SessionManager } from '../services/user/session-manager'
|
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')
|
const log = createLogger('ReportHandler')
|
||||||
|
|
||||||
@@ -182,7 +186,7 @@ export function registerReportHandlers(): void {
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.REPORT_ANALYZE_ALL,
|
IPC_CHANNELS.REPORT_ANALYZE_ALL,
|
||||||
async (): Promise<IpcResult<ParsedReportData[]>> => {
|
async (_event, selectedUsernames?: string[]): Promise<IpcResult<AggregatedDailyData[]>> => {
|
||||||
return withErrorHandling(async () => {
|
return withErrorHandling(async () => {
|
||||||
// Check Admin permission
|
// Check Admin permission
|
||||||
const sessionManager = SessionManager.getInstance()
|
const sessionManager = SessionManager.getInstance()
|
||||||
@@ -247,17 +251,91 @@ export function registerReportHandlers(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Analyze all reports
|
// Analyze reports
|
||||||
const analyzer = new ReportAnalyzer()
|
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', {
|
log.info('Report analysis completed', {
|
||||||
totalReports: reports.length,
|
totalReports: reports.length,
|
||||||
successfulAnalyses: analyzedData.length
|
aggregatedDays: analyzedData.length
|
||||||
})
|
})
|
||||||
|
|
||||||
return analyzedData
|
return analyzedData
|
||||||
}, 'report:analyzeAll')
|
}, 'report:analyzeAll')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Get all unique usernames from reports
|
||||||
|
ipcMain.handle(IPC_CHANNELS.REPORT_GET_USERNAMES, async (): Promise<IpcResult<string[]>> => {
|
||||||
|
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')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,10 +196,12 @@ No execution summary section here`
|
|||||||
const results = analyzer.analyzeReports(reports)
|
const results = analyzer.analyzeReports(reports)
|
||||||
|
|
||||||
expect(results.length).toBe(2)
|
expect(results.length).toBe(2)
|
||||||
expect(results[0].username).toBe('user1')
|
|
||||||
expect(results[0].date).toBe('2026-03-25')
|
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].date).toBe('2026-03-26')
|
||||||
|
expect(results[1].ordersProcessed).toBe(200)
|
||||||
|
expect(results[1].executionCount).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should skip invalid reports in batch', () => {
|
it('should skip invalid reports in batch', () => {
|
||||||
@@ -228,7 +230,51 @@ No execution summary section here`
|
|||||||
const results = analyzer.analyzeReports(reports)
|
const results = analyzer.analyzeReports(reports)
|
||||||
|
|
||||||
expect(results.length).toBe(1)
|
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', () => {
|
it('should use filename date to override content date', () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createLogger } from '../logger'
|
|||||||
const log = createLogger('ReportAnalyzer')
|
const log = createLogger('ReportAnalyzer')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parsed report data structure
|
* Parsed report data structure - 单条报告记录
|
||||||
*/
|
*/
|
||||||
export interface ParsedReportData {
|
export interface ParsedReportData {
|
||||||
/** 报告日期 (从文件名或内容提取) */
|
/** 报告日期 (从文件名或内容提取) */
|
||||||
@@ -26,6 +26,30 @@ export interface ParsedReportData {
|
|||||||
durationSeconds: number
|
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 物料清理执行报告
|
* ReportAnalyzer - 解析和分析 ERP 物料清理执行报告
|
||||||
*/
|
*/
|
||||||
@@ -85,12 +109,13 @@ export class ReportAnalyzer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量解析多个报告
|
* 批量解析多个报告并按日期聚合
|
||||||
* @param reports 报告元数据和内容列表
|
* @param reports 报告元数据和内容列表
|
||||||
* @returns 解析后的数据列表 (自动跳过解析失败的报告)
|
* @returns 按日期聚合后的数据,按日期升序排序
|
||||||
*/
|
*/
|
||||||
analyzeReports(reports: Array<{ content: string; filename?: string }>): ParsedReportData[] {
|
analyzeReports(reports: Array<{ content: string; filename?: string }>): AggregatedDailyData[] {
|
||||||
const results: ParsedReportData[] = []
|
// 第一步:解析所有报告
|
||||||
|
const parsedReports: ParsedReportData[] = []
|
||||||
|
|
||||||
for (const report of reports) {
|
for (const report of reports) {
|
||||||
const parsed = this.parseMarkdownReport(report.content)
|
const parsed = this.parseMarkdownReport(report.content)
|
||||||
@@ -102,13 +127,153 @@ export class ReportAnalyzer {
|
|||||||
parsed.date = dateFromFilename
|
parsed.date = dateFromFilename
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
results.push(parsed)
|
parsedReports.push(parsed)
|
||||||
} else {
|
} else {
|
||||||
log.warn('Skipping report due to parse failure', { filename: report.filename })
|
log.warn('Skipping report due to parse failure', { filename: report.filename })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
// 第二步:按日期聚合数据
|
||||||
|
const dateMap = new Map<string, ParsedReportData[]>()
|
||||||
|
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<string>()
|
||||||
|
|
||||||
|
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<string, ParsedReportData[]>()
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -201,21 +201,27 @@ export interface ReportAPI {
|
|||||||
download: (key: string) => Promise<IpcResult<string>>
|
download: (key: string) => Promise<IpcResult<string>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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<
|
IpcResult<
|
||||||
{
|
{
|
||||||
date: string
|
date: string
|
||||||
username: string
|
|
||||||
ordersProcessed: number
|
ordersProcessed: number
|
||||||
materialsDeleted: number
|
materialsDeleted: number
|
||||||
materialsSkipped: number
|
materialsSkipped: number
|
||||||
errorCount: number
|
errorCount: number
|
||||||
retriedOrders: number
|
retriedOrders: number
|
||||||
successfulRetries: number
|
successfulRetries: number
|
||||||
durationSeconds: number
|
avgDurationSeconds: number
|
||||||
|
executionCount: number
|
||||||
}[]
|
}[]
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all unique usernames from reports (Admin only)
|
||||||
|
*/
|
||||||
|
getUsernames: () => Promise<IpcResult<string[]>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ export const reportApi = {
|
|||||||
listAll: () => invokeIpc(IPC_CHANNELS.REPORT_LIST_ALL),
|
listAll: () => invokeIpc(IPC_CHANNELS.REPORT_LIST_ALL),
|
||||||
listByUser: (username: string) => invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username),
|
listByUser: (username: string) => invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username),
|
||||||
download: (key: string) => invokeIpc(IPC_CHANNELS.REPORT_DOWNLOAD, key),
|
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<string[]>(IPC_CHANNELS.REPORT_GET_USERNAMES)
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const updateApi = {
|
export const updateApi = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
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 {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -11,16 +11,16 @@ import {
|
|||||||
ResponsiveContainer
|
ResponsiveContainer
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
|
|
||||||
interface ParsedReportData {
|
interface AggregatedDailyData {
|
||||||
date: string
|
date: string
|
||||||
username: string
|
|
||||||
ordersProcessed: number
|
ordersProcessed: number
|
||||||
materialsDeleted: number
|
materialsDeleted: number
|
||||||
materialsSkipped: number
|
materialsSkipped: number
|
||||||
errorCount: number
|
errorCount: number
|
||||||
retriedOrders: number
|
retriedOrders: number
|
||||||
successfulRetries: number
|
successfulRetries: number
|
||||||
durationSeconds: number
|
avgDurationSeconds: number
|
||||||
|
executionCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReportAnalysisDialogProps {
|
interface ReportAnalysisDialogProps {
|
||||||
@@ -42,7 +42,7 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
|
|||||||
{ key: 'errorCount', label: '错误数量', color: '#ef4444' },
|
{ key: 'errorCount', label: '错误数量', color: '#ef4444' },
|
||||||
{ key: 'retriedOrders', label: '重试订单数', color: '#8b5cf6' },
|
{ key: 'retriedOrders', label: '重试订单数', color: '#8b5cf6' },
|
||||||
{ key: 'successfulRetries', label: '成功重试数', color: '#06b6d4' },
|
{ key: 'successfulRetries', label: '成功重试数', color: '#06b6d4' },
|
||||||
{ key: 'durationSeconds', label: '执行耗时', color: '#ec4899' }
|
{ key: 'avgDurationSeconds', label: '执行耗时', color: '#ec4899' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// State: selected metrics (default all)
|
// State: selected metrics (default all)
|
||||||
@@ -56,33 +56,92 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
const [reportData, setReportData] = useState<ParsedReportData[]>([])
|
const [reportData, setReportData] = useState<AggregatedDailyData[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Fetch data when dialog opens
|
// User filter state
|
||||||
|
const [allUsernames, setAllUsernames] = useState<string[]>([])
|
||||||
|
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
|
||||||
|
const [loadingUsers, setLoadingUsers] = useState(true)
|
||||||
|
|
||||||
|
// Fetch usernames when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || !isAdmin) return
|
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 () => {
|
const fetchData = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.report.analyzeAll()
|
console.log('[ReportAnalysis] Fetching data for users:', selectedUsernames)
|
||||||
if (result.success && result.data) {
|
const result = await window.electron.report.analyzeAll(selectedUsernames)
|
||||||
setReportData(result.data)
|
|
||||||
|
// 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 {
|
} else {
|
||||||
setError(result.error || '加载失败')
|
console.warn('[ReportAnalysis] Unexpected result format:', result)
|
||||||
|
setError('数据格式错误')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : '未知错误')
|
console.error('Failed to fetch analysis data', err)
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchData()
|
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
|
// Transform data for chart
|
||||||
const chartData = reportData.map((item) => ({
|
const chartData = reportData.map((item) => ({
|
||||||
@@ -117,7 +176,8 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
|
|||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="px-6 py-4 border-b border-slate-200 bg-white flex-shrink-0">
|
<div className="px-6 py-4 border-b border-slate-200 bg-white flex-shrink-0">
|
||||||
<div className="flex items-center gap-4">
|
{/* Metrics Selection */}
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<label className="text-sm font-medium text-slate-700 flex-shrink-0">选择指标:</label>
|
<label className="text-sm font-medium text-slate-700 flex-shrink-0">选择指标:</label>
|
||||||
<div className="flex flex-wrap gap-2 flex-1">
|
<div className="flex flex-wrap gap-2 flex-1">
|
||||||
{numericMetrics.map((metric) => (
|
{numericMetrics.map((metric) => (
|
||||||
@@ -138,6 +198,64 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* User Filter */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="text-sm font-medium text-slate-700 flex-shrink-0 flex items-center gap-1">
|
||||||
|
<Users size={16} />
|
||||||
|
筛选用户:
|
||||||
|
</label>
|
||||||
|
{loadingUsers ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
<span>加载用户列表...</span>
|
||||||
|
</div>
|
||||||
|
) : allUsernames.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
<span>暂无用户数据</span>
|
||||||
|
{reportData.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-400">|</span>
|
||||||
|
<span>共有 {reportData.length} 天的聚合数据(无用户信息)</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-2 flex-1">
|
||||||
|
{allUsernames.map((username) => (
|
||||||
|
<button
|
||||||
|
key={username}
|
||||||
|
onClick={() => toggleUser(username)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all border ${
|
||||||
|
selectedUsernames.includes(username)
|
||||||
|
? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700'
|
||||||
|
: 'bg-white text-slate-600 border-slate-300 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{username}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={selectAllUsers}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-800 font-medium px-2 py-1 rounded hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<span className="text-slate-300">|</span>
|
||||||
|
<button
|
||||||
|
onClick={deselectAllUsers}
|
||||||
|
className="text-xs text-slate-600 hover:text-slate-800 font-medium px-2 py-1 rounded hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -162,12 +280,13 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
window.electron.report
|
window.electron.report
|
||||||
.analyzeAll()
|
.analyzeAll(selectedUsernames)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result.success && result.data) {
|
// IpcResult shape: { success: boolean, data?: AggregatedDailyData[] }
|
||||||
|
if (result && Array.isArray(result.data)) {
|
||||||
setReportData(result.data)
|
setReportData(result.data)
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || '加载失败')
|
setError('加载失败')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export const IPC_CHANNELS = {
|
|||||||
REPORT_LIST_BY_USER: 'report:listByUser',
|
REPORT_LIST_BY_USER: 'report:listByUser',
|
||||||
REPORT_DOWNLOAD: 'report:download',
|
REPORT_DOWNLOAD: 'report:download',
|
||||||
REPORT_ANALYZE_ALL: 'report:analyzeAll',
|
REPORT_ANALYZE_ALL: 'report:analyzeAll',
|
||||||
|
REPORT_GET_USERNAMES: 'report:getUsernames',
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
UPDATE_GET_STATUS: 'update:getStatus',
|
UPDATE_GET_STATUS: 'update:getStatus',
|
||||||
|
|||||||
Reference in New Issue
Block a user