feat: Add Report Viewer Dialog to Cleaner Page

Added a new "View Reports" button to the CleanerPage which opens a new ReportViewerDialog. This dialog lists all available execution reports stored in S3 for the current user, or for all users if the current user is an admin.
The reports are downloaded as Markdown and rendered using react-markdown.
Added three new IPC channels to fetch and download reports using the existing RustfsService and S3Client.

Co-authored-by: luwamgere15-crypto <255338376+luwamgere15-crypto@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot]
2026-03-17 23:03:13 +00:00
parent 351e9a92bc
commit a020ee537d
10 changed files with 1900 additions and 34 deletions

View File

@@ -14,6 +14,7 @@ import { registerSettingsHandlers } from './settings-handler'
import { registerMaterialTypeHandlers } from './material-type-handler'
import { registerUserErpConfigHandlers } from './user-erp-config-handler'
import { registerLoggerHandlers } from './logger-handler'
import { registerReportHandlers } from './report-handler'
import { createLogger, logError } from '../services/logger'
import { serializeError, sanitizeError } from '../services/logger/error-utils'
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
@@ -103,5 +104,6 @@ export function registerIpcHandlers(): void {
registerMaterialTypeHandlers()
registerUserErpConfigHandlers()
registerLoggerHandlers()
registerReportHandlers()
log.info('All IPC handlers registered')
}

View File

@@ -0,0 +1,183 @@
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { withErrorHandling, type IpcResult } from './index'
import { createLogger } from '../services/logger'
import { ConfigManager } from '../services/config/config-manager'
import { RustfsService } from '../services/rustfs'
import { ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'
const log = createLogger('ReportHandler')
export interface ReportMetadata {
key: string
filename: string
username: string
lastModified?: Date
size?: number
}
function getRustfsService(): RustfsService | null {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
if (config.rustfs?.enabled && config.rustfs.endpoint) {
return new RustfsService({ config: config.rustfs })
}
return null
}
export function registerReportHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.REPORT_LIST_ALL,
async (): Promise<IpcResult<ReportMetadata[]>> => {
return withErrorHandling(async () => {
const rustfs = getRustfsService()
if (!rustfs) {
throw new Error('RustFS is not configured or enabled')
}
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
// Create a direct S3Client since RustfsService doesn't expose listObjects natively easily
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 all reports from RustFS')
const input = {
Bucket: config.rustfs?.bucket || '',
Prefix: 'reports/cleaner/'
}
const command = new ListObjectsV2Command(input)
const response = await client.send(command)
const reports: ReportMetadata[] = []
if (response.Contents) {
for (const item of response.Contents) {
if (item.Key && item.Key.endsWith('.md')) {
// reports/cleaner/{username}/{filename}
const parts = item.Key.split('/')
if (parts.length >= 4) {
const username = parts[2]
const filename = parts.slice(3).join('/')
reports.push({
key: item.Key,
filename,
username,
lastModified: item.LastModified,
size: item.Size
})
}
}
}
}
// Sort by lastModified descending
reports.sort((a, b) => {
if (a.lastModified && b.lastModified) {
return b.lastModified.getTime() - a.lastModified.getTime()
}
return 0
})
return reports
}, 'report:listAll')
}
)
ipcMain.handle(
IPC_CHANNELS.REPORT_LIST_BY_USER,
async (_event, username: string): Promise<IpcResult<ReportMetadata[]>> => {
return withErrorHandling(async () => {
const rustfs = getRustfsService()
if (!rustfs) {
throw new Error('RustFS is not configured or enabled')
}
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
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 reports from RustFS for user', { username })
const input = {
Bucket: config.rustfs?.bucket || '',
Prefix: `reports/cleaner/${username}/`
}
const command = new ListObjectsV2Command(input)
const response = await client.send(command)
const reports: ReportMetadata[] = []
if (response.Contents) {
for (const item of response.Contents) {
if (item.Key && item.Key.endsWith('.md')) {
const parts = item.Key.split('/')
if (parts.length >= 4) {
const itemUsername = parts[2]
const filename = parts.slice(3).join('/')
reports.push({
key: item.Key,
filename,
username: itemUsername,
lastModified: item.LastModified,
size: item.Size
})
}
}
}
}
// Sort by lastModified descending
reports.sort((a, b) => {
if (a.lastModified && b.lastModified) {
return b.lastModified.getTime() - a.lastModified.getTime()
}
return 0
})
return reports
}, 'report:listByUser')
}
)
ipcMain.handle(
IPC_CHANNELS.REPORT_DOWNLOAD,
async (_event, key: string): Promise<IpcResult<string>> => {
return withErrorHandling(async () => {
const rustfs = getRustfsService()
if (!rustfs) {
throw new Error('RustFS is not configured or enabled')
}
log.info('Downloading report from RustFS', { key })
const result = await rustfs.downloadFile(key)
if (!result.success) {
throw new Error(result.error || 'Failed to download report')
}
// Convert buffer to string
return result.content.toString('utf-8')
}, 'report:download')
}
)
}

View File

@@ -168,3 +168,25 @@ export interface DatabaseAPI {
params?: Record<string, unknown>
) => Promise<IpcResult<SqlServerQueryResult>>
}
/**
* Report service APIs
*/
export interface ReportAPI {
/**
* List all reports across all users (Admin only typically)
*/
listAll: () => Promise<IpcResult<{ key: string; filename: string; username: string; lastModified?: Date; size?: number }[]>>
/**
* List reports for a specific user
* @param username - Username to list reports for
*/
listByUser: (username: string) => Promise<IpcResult<{ key: string; filename: string; username: string; lastModified?: Date; size?: number }[]>>
/**
* Download a specific report by key
* @param key - Report object key in RustFS
*/
download: (key: string) => Promise<IpcResult<string>>
}

View File

@@ -1,4 +1,4 @@
import type { FileAPI, ExtractorAPI, CleanerAPI, DatabaseAPI } from '../main/types/ipc-api.types'
import type { FileAPI, ExtractorAPI, CleanerAPI, DatabaseAPI, ReportAPI } from '../main/types/ipc-api.types'
import type { ResolverInput, ResolverResponse } from '../main/ipc/resolver-handler'
import type { UserInfo } from '../main/types/user.types'
import type {
@@ -145,6 +145,7 @@ declare global {
userErpConfig: UserErpConfigAPI
config: ConfigAPI
logger: LoggerAPI
report: ReportAPI
}
api: unknown
}

View File

@@ -211,6 +211,13 @@ const api = {
timestamp: Date.now()
})
}
},
report: {
listAll: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.REPORT_LIST_ALL),
listByUser: (username: string): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username),
download: (key: string): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.REPORT_DOWNLOAD, key)
}
} as const

View File

@@ -0,0 +1,188 @@
import React, { useEffect, useState } from 'react'
import { X, FileText, Loader2 } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface ReportMetadata {
key: string
filename: string
username: string
lastModified?: Date
size?: number
}
interface ReportViewerDialogProps {
isOpen: boolean
onClose: () => void
isAdmin: boolean
currentUsername: string
}
export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
isOpen,
onClose,
isAdmin,
currentUsername
}) => {
const [reports, setReports] = useState<ReportMetadata[]>([])
const [selectedReportKey, setSelectedReportKey] = useState<string>('')
const [reportContent, setReportContent] = useState<string>('')
const [isLoadingList, setIsLoadingList] = useState<boolean>(false)
const [isLoadingContent, setIsLoadingContent] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (isOpen) {
loadReports()
} else {
// Reset state when closed
setReports([])
setSelectedReportKey('')
setReportContent('')
setError(null)
}
}, [isOpen, isAdmin, currentUsername])
const loadReports = async () => {
setIsLoadingList(true)
setError(null)
try {
let result
if (isAdmin) {
result = await window.electron.report.listAll()
} else {
result = await window.electron.report.listByUser(currentUsername)
}
if (result.success && result.data) {
setReports(result.data)
} else {
setError(result.error || '无法获取报告列表')
}
} catch (err) {
setError('获取报告列表时发生错误')
} finally {
setIsLoadingList(false)
}
}
const handleReportChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const key = e.target.value
setSelectedReportKey(key)
if (!key) {
setReportContent('')
return
}
setIsLoadingContent(true)
setError(null)
try {
const result = await window.electron.report.download(key)
if (result.success && result.data) {
setReportContent(result.data)
} else {
setError(result.error || '无法获取报告内容')
setReportContent('')
}
} catch (err) {
setError('获取报告内容时发生错误')
setReportContent('')
} finally {
setIsLoadingContent(false)
}
}
if (!isOpen) return null
// Format date to local string
const formatDate = (dateString?: Date | string) => {
if (!dateString) return '未知时间'
const date = typeof dateString === 'string' ? new Date(dateString) : dateString
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/50 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-white rounded-2xl shadow-2xl w-[900px] max-w-[90vw] h-[80vh] flex flex-col border border-slate-200 overflow-hidden animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 bg-slate-50 flex-shrink-0">
<div className="flex items-center gap-2 text-slate-800">
<FileText size={20} className="text-blue-600" />
<h2 className="text-lg font-semibold"></h2>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200/50 p-1.5 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
{/* Controls */}
<div className="px-6 py-4 border-b border-slate-200 bg-white flex-shrink-0">
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-slate-700 flex-shrink-0">:</label>
<div className="relative flex-1 max-w-2xl">
<select
value={selectedReportKey}
onChange={handleReportChange}
disabled={isLoadingList}
className="w-full appearance-none bg-slate-50 border border-slate-300 text-slate-700 py-2 pl-3 pr-10 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 disabled:opacity-50 text-sm"
>
<option value="">...</option>
{reports.map((report) => (
<option key={report.key} value={report.key}>
{isAdmin ? `[${report.username}] ` : ''}
{report.filename} ({formatDate(report.lastModified)})
</option>
))}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500">
<svg
className="fill-current h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" />
</svg>
</div>
</div>
{isLoadingList && <Loader2 size={16} className="text-blue-500 animate-spin" />}
</div>
{error && <div className="mt-3 text-sm text-red-600 flex items-center gap-1.5 bg-red-50 p-2 rounded">{error}</div>}
</div>
{/* Content */}
<div className="flex-1 bg-slate-50 overflow-hidden relative">
{isLoadingContent ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-500 bg-white/80 z-10">
<Loader2 size={32} className="animate-spin text-blue-500 mb-4" />
<p>...</p>
</div>
) : reportContent ? (
<div className="h-full overflow-y-auto p-8">
<div className="prose prose-slate prose-sm max-w-none bg-white p-8 rounded-xl shadow-sm border border-slate-200">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{reportContent}</ReactMarkdown>
</div>
</div>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-400">
<FileText size={48} className="mb-4 text-slate-300 opacity-50" />
<p></p>
</div>
)}
</div>
</div>
</div>
)
}
export default ReportViewerDialog

View File

@@ -13,10 +13,12 @@ import {
Eye,
HardDrive,
Settings2,
FileSpreadsheet
FileSpreadsheet,
FileText
} from 'lucide-react'
import MaterialTypeManagementDialog from '../components/MaterialTypeManagementDialog'
import ExecutionReportDialog from '../components/ExecutionReportDialog'
import ReportViewerDialog from '../components/ReportViewerDialog'
import { ConfirmDialog } from '../components/ui/ConfirmDialog'
import { useCleaner } from '../hooks/useCleaner'
@@ -73,6 +75,8 @@ const CleanerPage: React.FC = () => {
confirmDialog
} = useCleaner()
const [isReportViewerOpen, setIsReportViewerOpen] = React.useState(false)
return (
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* 左栏:数据源与执行控制区 (仅 Admin 可见) */}
@@ -269,6 +273,12 @@ const CleanerPage: React.FC = () => {
>
<FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'}
</button>
<button
onClick={() => setIsReportViewerOpen(true)}
className="text-xs bg-emerald-50 border border-emerald-200 text-emerald-700 px-3 py-1.5 rounded shadow-sm hover:bg-emerald-100 flex items-center gap-1.5 font-medium transition-colors"
>
<FileText size={14} />
</button>
</div>
</div>
@@ -527,6 +537,14 @@ const CleanerPage: React.FC = () => {
successfulRetries={reportData?.successfulRetries}
/>
{/* Report Viewer Dialog */}
<ReportViewerDialog
isOpen={isReportViewerOpen}
onClose={() => setIsReportViewerOpen(false)}
isAdmin={isAdmin}
currentUsername={currentUsername}
/>
{/* Confirmation Dialog */}
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
</div>

View File

@@ -91,7 +91,12 @@ export const IPC_CHANNELS = {
CONFIG_UPDATE_CLEANER: 'config:updateCleaner',
// Logger
LOGGER_FORWARD: 'logger:forward'
LOGGER_FORWARD: 'logger:forward',
// Report
REPORT_LIST_ALL: 'report:listAll',
REPORT_LIST_BY_USER: 'report:listByUser',
REPORT_DOWNLOAD: 'report:download'
} as const
/**