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 { 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<IpcResult<ParsedReportData[]>> => {
|
||||
async (_event, selectedUsernames?: string[]): Promise<IpcResult<AggregatedDailyData[]>> => {
|
||||
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<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)
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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<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>>
|
||||
|
||||
/**
|
||||
* 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<IpcResult<string[]>>
|
||||
}
|
||||
|
||||
@@ -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<string[]>(IPC_CHANNELS.REPORT_GET_USERNAMES)
|
||||
} as const
|
||||
|
||||
export const updateApi = {
|
||||
|
||||
@@ -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<ReportAnalysisDialogProps> = ({
|
||||
{ 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<ReportAnalysisDialogProps> = ({
|
||||
}
|
||||
|
||||
// Data state
|
||||
const [reportData, setReportData] = useState<ParsedReportData[]>([])
|
||||
const [reportData, setReportData] = useState<AggregatedDailyData[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
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(() => {
|
||||
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<ReportAnalysisDialogProps> = ({
|
||||
|
||||
{/* Controls */}
|
||||
<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>
|
||||
<div className="flex flex-wrap gap-2 flex-1">
|
||||
{numericMetrics.map((metric) => (
|
||||
@@ -138,6 +198,64 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Content */}
|
||||
@@ -162,12 +280,13 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
|
||||
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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user