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:
1496
package-lock.json
generated
1496
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@
|
|||||||
"test:rustfs": "tsx src/main/tools/rustfs-test.ts"
|
"test:rustfs": "tsx src/main/tools/rustfs-test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.929.0",
|
||||||
"@electron-toolkit/preload": "^3.0.2",
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
@@ -45,14 +46,15 @@
|
|||||||
"playwright": "^1.58.2",
|
"playwright": "^1.58.2",
|
||||||
"playwright-core": "^1.58.2",
|
"playwright-core": "^1.58.2",
|
||||||
"react-focus-lock": "^2.13.7",
|
"react-focus-lock": "^2.13.7",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"typeorm": "^0.3.28",
|
"typeorm": "^0.3.28",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
"winston-daily-rotate-file": "^5.0.0",
|
"winston-daily-rotate-file": "^5.0.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.11",
|
"zustand": "^5.0.11"
|
||||||
"@aws-sdk/client-s3": "^3.929.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { registerSettingsHandlers } from './settings-handler'
|
|||||||
import { registerMaterialTypeHandlers } from './material-type-handler'
|
import { registerMaterialTypeHandlers } from './material-type-handler'
|
||||||
import { registerUserErpConfigHandlers } from './user-erp-config-handler'
|
import { registerUserErpConfigHandlers } from './user-erp-config-handler'
|
||||||
import { registerLoggerHandlers } from './logger-handler'
|
import { registerLoggerHandlers } from './logger-handler'
|
||||||
|
import { registerReportHandlers } from './report-handler'
|
||||||
import { createLogger, logError } from '../services/logger'
|
import { createLogger, logError } from '../services/logger'
|
||||||
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
||||||
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
||||||
@@ -103,5 +104,6 @@ export function registerIpcHandlers(): void {
|
|||||||
registerMaterialTypeHandlers()
|
registerMaterialTypeHandlers()
|
||||||
registerUserErpConfigHandlers()
|
registerUserErpConfigHandlers()
|
||||||
registerLoggerHandlers()
|
registerLoggerHandlers()
|
||||||
|
registerReportHandlers()
|
||||||
log.info('All IPC handlers registered')
|
log.info('All IPC handlers registered')
|
||||||
}
|
}
|
||||||
|
|||||||
183
src/main/ipc/report-handler.ts
Normal file
183
src/main/ipc/report-handler.ts
Normal 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')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -168,3 +168,25 @@ export interface DatabaseAPI {
|
|||||||
params?: Record<string, unknown>
|
params?: Record<string, unknown>
|
||||||
) => Promise<IpcResult<SqlServerQueryResult>>
|
) => 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>>
|
||||||
|
}
|
||||||
|
|||||||
3
src/preload/index.d.ts
vendored
3
src/preload/index.d.ts
vendored
@@ -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 { ResolverInput, ResolverResponse } from '../main/ipc/resolver-handler'
|
||||||
import type { UserInfo } from '../main/types/user.types'
|
import type { UserInfo } from '../main/types/user.types'
|
||||||
import type {
|
import type {
|
||||||
@@ -145,6 +145,7 @@ declare global {
|
|||||||
userErpConfig: UserErpConfigAPI
|
userErpConfig: UserErpConfigAPI
|
||||||
config: ConfigAPI
|
config: ConfigAPI
|
||||||
logger: LoggerAPI
|
logger: LoggerAPI
|
||||||
|
report: ReportAPI
|
||||||
}
|
}
|
||||||
api: unknown
|
api: unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,13 @@ const api = {
|
|||||||
timestamp: Date.now()
|
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
|
} as const
|
||||||
|
|
||||||
|
|||||||
188
src/renderer/src/components/ReportViewerDialog.tsx
Normal file
188
src/renderer/src/components/ReportViewerDialog.tsx
Normal 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
|
||||||
@@ -13,10 +13,12 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Settings2,
|
Settings2,
|
||||||
FileSpreadsheet
|
FileSpreadsheet,
|
||||||
|
FileText
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import MaterialTypeManagementDialog from '../components/MaterialTypeManagementDialog'
|
import MaterialTypeManagementDialog from '../components/MaterialTypeManagementDialog'
|
||||||
import ExecutionReportDialog from '../components/ExecutionReportDialog'
|
import ExecutionReportDialog from '../components/ExecutionReportDialog'
|
||||||
|
import ReportViewerDialog from '../components/ReportViewerDialog'
|
||||||
import { ConfirmDialog } from '../components/ui/ConfirmDialog'
|
import { ConfirmDialog } from '../components/ui/ConfirmDialog'
|
||||||
import { useCleaner } from '../hooks/useCleaner'
|
import { useCleaner } from '../hooks/useCleaner'
|
||||||
|
|
||||||
@@ -73,6 +75,8 @@ const CleanerPage: React.FC = () => {
|
|||||||
confirmDialog
|
confirmDialog
|
||||||
} = useCleaner()
|
} = useCleaner()
|
||||||
|
|
||||||
|
const [isReportViewerOpen, setIsReportViewerOpen] = React.useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
{/* 左栏:数据源与执行控制区 (仅 Admin 可见) */}
|
{/* 左栏:数据源与执行控制区 (仅 Admin 可见) */}
|
||||||
@@ -269,6 +273,12 @@ const CleanerPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'}
|
<FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -527,6 +537,14 @@ const CleanerPage: React.FC = () => {
|
|||||||
successfulRetries={reportData?.successfulRetries}
|
successfulRetries={reportData?.successfulRetries}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Report Viewer Dialog */}
|
||||||
|
<ReportViewerDialog
|
||||||
|
isOpen={isReportViewerOpen}
|
||||||
|
onClose={() => setIsReportViewerOpen(false)}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
currentUsername={currentUsername}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
{/* Confirmation Dialog */}
|
||||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,7 +91,12 @@ export const IPC_CHANNELS = {
|
|||||||
CONFIG_UPDATE_CLEANER: 'config:updateCleaner',
|
CONFIG_UPDATE_CLEANER: 'config:updateCleaner',
|
||||||
|
|
||||||
// Logger
|
// 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
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user