Compare commits
9 Commits
b94640ca81
...
1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baa7622954 | ||
|
|
851c2ce634 | ||
|
|
64349125ba | ||
|
|
a020ee537d | ||
|
|
351e9a92bc | ||
|
|
f5514dd721 | ||
|
|
569c8e8ecc | ||
|
|
21bb8ef79c | ||
|
|
fbcc656b99 |
@@ -3,6 +3,7 @@ import { defineConfig } from 'electron-vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { execSync } from 'child_process'
|
import { execSync } from 'child_process'
|
||||||
|
import { createRequire } from 'module'
|
||||||
|
|
||||||
// Get git hash (first 7 characters)
|
// Get git hash (first 7 characters)
|
||||||
const getGitHash = (): string => {
|
const getGitHash = (): string => {
|
||||||
@@ -14,6 +15,7 @@ const getGitHash = (): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get version from package.json
|
// Get version from package.json
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
const version = require('./package.json').version
|
const version = require('./package.json').version
|
||||||
const gitHash = getGitHash()
|
const gitHash = getGitHash()
|
||||||
|
|
||||||
@@ -30,6 +32,18 @@ export default defineConfig({
|
|||||||
'@renderer': resolve('src/renderer/src')
|
'@renderer': resolve('src/renderer/src')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [react(), tailwindcss()]
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
{
|
||||||
|
name: 'update-title',
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html.replace(
|
||||||
|
'<title>ERP Auto Tool</title>',
|
||||||
|
`<title>ERPAuto - v${version}(${gitHash})</title>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
1462
package-lock.json
generated
1462
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.0.1",
|
"version": "1.3.0",
|
||||||
"description": "An Electron application with React and TypeScript",
|
"description": "An Electron application with React and TypeScript",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "example.com",
|
"author": "example.com",
|
||||||
@@ -16,9 +16,10 @@
|
|||||||
"build": "chcp 65001 && npm run typecheck && electron-vite build",
|
"build": "chcp 65001 && npm run typecheck && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build && electron-builder --dir",
|
"build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build && electron-builder --dir",
|
||||||
"build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build && electron-builder --win",
|
"build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run prebuild && npm run build && electron-builder --win",
|
||||||
"build:mac": "chcp 65001 && electron-vite build && electron-builder --mac",
|
"build:mac": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --mac",
|
||||||
"build:linux": "chcp 65001 && electron-vite build && electron-builder --linux",
|
"build:linux": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --linux",
|
||||||
|
"prebuild": "node -e \"const fs=require('fs');['dist','out'].forEach(d=>{try{fs.rmSync(d,{recursive:true})}catch(e){}})\"",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
@@ -30,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",
|
||||||
@@ -44,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')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -132,10 +132,12 @@ export class OrderNumberResolver {
|
|||||||
let params: any[]
|
let params: any[]
|
||||||
|
|
||||||
if (this.dbService.type === 'sqlserver') {
|
if (this.dbService.type === 'sqlserver') {
|
||||||
sql = `SELECT TOP 1 [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] = @p0`
|
// 使用 COLLATE 指定不区分大小写的排序规则
|
||||||
|
sql = `SELECT TOP 1 [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS = @p0`
|
||||||
params = [productionId]
|
params = [productionId]
|
||||||
} else {
|
} else {
|
||||||
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE \`${dbConfig.FIELD_PRODUCTION_ID}\` = ? LIMIT 1`
|
// MySQL 默认不区分大小写,但显式使用 UPPER 确保一致性
|
||||||
|
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) = UPPER(?) LIMIT 1`
|
||||||
params = [productionId]
|
params = [productionId]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,11 +181,13 @@ export class OrderNumberResolver {
|
|||||||
let sql: string
|
let sql: string
|
||||||
if (this.dbService.type === 'sqlserver') {
|
if (this.dbService.type === 'sqlserver') {
|
||||||
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
||||||
sql = `SELECT DISTINCT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] IN (${placeholders})`
|
// 使用 COLLATE 指定不区分大小写的排序规则
|
||||||
|
sql = `SELECT DISTINCT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS IN (${placeholders})`
|
||||||
} else {
|
} else {
|
||||||
const idPlaceholders = uniqueProductionIds.map(() => '?').join(', ')
|
const idPlaceholders = uniqueProductionIds.map(() => 'UPPER(?)').join(', ')
|
||||||
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
||||||
sql = `SELECT DISTINCT \`${dbConfig.FIELD_PRODUCTION_ID}\`, \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE \`${dbConfig.FIELD_PRODUCTION_ID}\` IN (${idPlaceholders})`
|
// MySQL: 使用 UPPER 确保不区分大小写
|
||||||
|
sql = `SELECT DISTINCT \`${dbConfig.FIELD_PRODUCTION_ID}\`, \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) IN (${idPlaceholders})`
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.dbService.query(sql, params)
|
const result = await this.dbService.query(sql, params)
|
||||||
@@ -232,11 +236,12 @@ export class OrderNumberResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Batch query productionId to order number mappings
|
// Batch query productionId to order number mappings
|
||||||
|
// 使用小写 key 存储映射,以支持忽略大小写查找
|
||||||
const mappings = new Map<string, string>()
|
const mappings = new Map<string, string>()
|
||||||
if (productionIds.length > 0) {
|
if (productionIds.length > 0) {
|
||||||
const batchMappings = await this.mapProductionIdsToOrderNumbers(productionIds)
|
const batchMappings = await this.mapProductionIdsToOrderNumbers(productionIds)
|
||||||
batchMappings.forEach((orderNum, prodId) => {
|
batchMappings.forEach((orderNum, prodId) => {
|
||||||
mappings.set(prodId, orderNum)
|
mappings.set(prodId.toLowerCase(), orderNum)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,9 +263,9 @@ export class OrderNumberResolver {
|
|||||||
mapping.orderNumber = input
|
mapping.orderNumber = input
|
||||||
mapping.resolved = true
|
mapping.resolved = true
|
||||||
} else if (this.isProductionId(input)) {
|
} else if (this.isProductionId(input)) {
|
||||||
// Is a productionID, lookup from batch mappings
|
// Is a productionID, lookup from batch mappings (使用小写查找以忽略大小写)
|
||||||
mapping.productionId = input
|
mapping.productionId = input
|
||||||
const orderNumber = mappings.get(input)
|
const orderNumber = mappings.get(input.toLowerCase())
|
||||||
if (orderNumber) {
|
if (orderNumber) {
|
||||||
mapping.orderNumber = orderNumber
|
mapping.orderNumber = orderNumber
|
||||||
mapping.resolved = true
|
mapping.resolved = true
|
||||||
|
|||||||
@@ -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