10 Commits

Author SHA1 Message Date
Misaka_Company
d4389933d8 fix(ui): replace absolute paths with @renderer alias
Fix import resolution errors in shadcn/ui components by replacing
absolute paths (src/renderer/src/...) with the configured @renderer alias.

- Fix lib/utils imports across all UI components
- Fix button import in alert-dialog.tsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 12:47:07 +08:00
google-labs-jules[bot]
9b41f851de feat(ui): refactor with shadcn/ui framework
- Initialize shadcn/ui configuration and dependencies
- Refactor Button component to wrap shadcn/ui button while maintaining backward compatibility
- Replace custom Modal implementation with shadcn/ui Dialog components
- Upgrade ConfirmDialog to use shadcn/ui AlertDialog for better accessibility
- Apply Card layout components to ExtractorPage and CleanerPage for a modern look

Co-authored-by: luwamgere15-crypto <255338376+luwamgere15-crypto@users.noreply.github.com>
2026-04-01 04:31:31 +00:00
Misaka_Company
d004f8e9f8 feat(history): add one-click copy for production IDs and order numbers
- Add copy buttons in table headers for "总排号" and "订单号" columns
- Copy all non-empty values as newline-separated text
- Show toast notification with copied data count
- Handle clipboard errors gracefully

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 10:00:31 +08:00
Misaka_Company
3cbe9eef12 1.7.1 2026-04-01 08:36:50 +08:00
Misaka_Company
5b310d944b docs: add release notes for version 1.7.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 08:36:09 +08:00
Misaka
6e04f21b10 fix(extractor): track per-order RecordCount in operation history
Previously updateBatchStatus wrote the batch-level total recordCount to
every row, causing the detail view to show misleading identical counts.
Now mergeFiles collects per-order material counts, the handler writes
each order's count individually via updateRecordStatus, and batch
aggregation uses SUM instead of MAX for accurate totals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 20:42:22 +08:00
Misaka
17fbd7d251 fix(extractor): resolve MySQL LIMIT placeholder error in operation history query
MySQL binary protocol prepared statements (connection.execute()) do not
support ? placeholders in LIMIT/OFFSET clauses, causing "Incorrect
arguments to mysqld_stmt_execute". Embed validated integer values directly
for MySQL while keeping parameterized queries for SQL Server.

Also apply React best practices to ExtractorOperationHistoryModal:
- Hoist formatDateTime to module level
- Wrap async handlers with useCallback for stable effect dependencies
- Import shared types instead of duplicating definitions
- Use ternary for conditional rendering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 19:42:44 +08:00
Misaka_Company
c6eb60ada7 1.7.0 2026-03-31 15:28:26 +08:00
Misaka_Company
571ec2325f docs: add release notes for version 1.7.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 15:28:08 +08:00
Misaka_Company
557ed174c3 feat(extractor): add operation history tracking
Add a new operation history feature for the extractor module that tracks
all extraction operations with persistent database storage.

Features:
- Records extraction operations with batch tracking (UUID-based)
- Preserves production ID to order number mapping
- Shows batch statistics (orders, records, success/failure counts)
- Expandable details for each batch showing individual order records
- User-based permission: Admin sees all records, User sees own records only
- Delete functionality with permission validation

Database:
- New ExtractorOperationHistory table schema
- Supports both SQL Server and MySQL
- Indexed on BatchId, UserId, and OperationTime

Files:
- Add DAO class for history operations
- Add IPC handler with permission checks
- Add preload API wrapper
- Add React modal component with expandable batch details
- Integrate history recording into extractor handler
- Add operation history button to ExtractorPage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 15:22:53 +08:00
43 changed files with 4054 additions and 232 deletions

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/renderer/src/assets/main.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "src/renderer/src/components",
"utils": "src/renderer/src/lib/utils",
"ui": "src/renderer/src/components/ui",
"lib": "src/renderer/src/lib",
"hooks": "src/renderer/src/hooks"
},
"iconLibrary": "lucide"
}

18
docs/releases/1.7.0.md Normal file
View File

@@ -0,0 +1,18 @@
# 1.7.0
## 核心功能
- 新增提取操作历史记录功能,每次执行提取后自动保存订单号和总排号。
- 支持查看历史批次详情,包含操作时间、订单数、记录数、成功/失败统计。
- 批次记录可展开查看,显示总排号与订单号的对应关系。
## 界面与交互
- 提取页面新增"操作历史"按钮,点击打开历史记录对话框。
- 管理员可查看所有用户的历史记录,普通用户仅查看自己的记录。
- 支持删除历史批次,管理员可删除任意批次,普通用户仅可删除自己的记录。
## 数据存储
- 新增数据库表 `ExtractorOperationHistory`,支持 SQL Server 和 MySQL。
- 需执行数据库脚本创建表结构(详见项目文档)。

6
docs/releases/1.7.1.md Normal file
View File

@@ -0,0 +1,6 @@
# 1.7.1
## 问题修复
- 修复 MySQL 数据库下操作历史查询报错问题。
- 优化历史记录数据结构,支持按订单统计记录数量。

1160
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "erpauto",
"version": "1.6.2",
"version": "1.7.1",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",
@@ -37,9 +37,22 @@
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@headlessui/react": "^2.2.9",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.2.1",
"@types/js-yaml": "^4.0.9",
"chromium-bidi": "^15.0.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"exceljs": "^4.4.0",
"github-markdown-css": "^5.9.0",
@@ -57,6 +70,7 @@
"rehype-highlight": "^7.0.2",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"typeorm": "^0.3.28",
"uuid": "^13.0.0",
"winston": "^3.19.0",

35
setup_shadcn.exp Executable file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/expect -f
set timeout -1
spawn npx shadcn@latest init
expect "Select a component library"
send "\r"
expect "Select a framework"
send "\r"
expect "Would you like to use TypeScript?"
send "y\r"
expect "Where is your global CSS file?"
send "src/renderer/src/assets/main.css\r"
expect "Would you like to use CSS variables for colors?"
send "y\r"
expect "Where is your tailwind.config.js located?"
send "tailwind.config.js\r"
expect "Configure the import alias for components:"
send "@/components\r"
expect "Configure the import alias for utils:"
send "@/lib/utils\r"
expect "Are you using React Server Components?"
send "n\r"
expect "Write configuration to components.json. Proceed?"
send "y\r"
expect eof

View File

@@ -3,6 +3,7 @@ import { ErpAuthService } from '../services/erp/erp-auth'
import { ExtractorService } from '../services/erp/extractor'
import { OrderNumberResolver } from '../services/erp/order-resolver'
import { create, type IDatabaseService } from '../services/database'
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger'
import { SessionManager } from '../services/user/session-manager'
@@ -12,6 +13,7 @@ import type { ExtractorInput, ExtractorResult, ExtractionProgress } from '../typ
import { UserErpConfigService } from '../services/user/user-erp-config-service'
import { ConfigManager } from '../services/config/config-manager'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { randomUUID } from 'crypto'
const log = createLogger('ExtractorHandler')
@@ -141,6 +143,29 @@ export function registerExtractorHandlers(): void {
log.info('Resolved order numbers', { count: validOrderNumbers.length })
// Initialize operation history recording
const currentUser = SessionManager.getInstance().getUserInfo()
const historyDao = new ExtractorOperationHistoryDAO()
const batchId = randomUUID()
// Save order records to history (preserve productionId -> orderNumber mapping)
if (currentUser) {
const orderRecords = mappings.map((m) => ({
productionId: m.productionId || null,
orderNumber: m.orderNumber || m.input
}))
await historyDao.insertBatchRecords(
batchId,
currentUser.id,
currentUser.username,
orderRecords
)
log.info('Operation history batch created', {
batchId,
recordCount: orderRecords.length
})
}
// Log deduplication summary
sendLog(sender, 'info', dedupReport.summary)
@@ -223,11 +248,29 @@ export function registerExtractorHandlers(): void {
})
}
// Update operation history batch status
if (currentUser) {
const status: 'success' | 'failed' | 'partial' =
result.errors.length > 0 && result.recordCount > 0
? 'partial'
: result.errors.length > 0
? 'failed'
: 'success'
// Write per-order record counts
for (const { orderNumber, recordCount } of result.orderRecordCounts) {
await historyDao.updateRecordStatus(batchId, orderNumber, status, undefined, recordCount)
}
// Update batch status without recordCount (per-order counts are set individually)
await historyDao.updateBatchStatus(batchId, status)
log.info('Operation history batch status updated', { batchId, status })
}
// Audit log: EXTRACT (non-blocking)
const os = await import('os')
const currentUser = SessionManager.getInstance().getUserInfo()
if (currentUser) {
const status: 'success' | 'failure' | 'partial' =
const auditStatus: 'success' | 'failure' | 'partial' =
result.errors.length > 0 && result.recordCount > 0
? 'partial'
: result.errors.length > 0
@@ -237,7 +280,7 @@ export function registerExtractorHandlers(): void {
username: currentUser.username,
computerName: os.hostname(),
resource: 'MATERIAL_PLAN',
status,
status: auditStatus,
metadata: {
orderCount: validOrderNumbers.length,
recordCount: result.recordCount,

View File

@@ -17,6 +17,7 @@ import { registerLoggerHandlers } from './logger-handler'
import { registerReportHandlers } from './report-handler'
import { registerUpdateHandlers } from './update-handler'
import { registerPlaywrightBrowserHandlers } from './playwright-browser'
import { registerOperationHistoryHandlers } from './operation-history-handler'
import { createLogger, logError } from '../services/logger'
import { serializeError, sanitizeError } from '../services/logger/error-utils'
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
@@ -107,5 +108,6 @@ export function registerIpcHandlers(): void {
registerReportHandlers()
registerUpdateHandlers()
registerPlaywrightBrowserHandlers()
registerOperationHistoryHandlers()
log.info('All IPC handlers registered')
}

View File

@@ -0,0 +1,124 @@
/**
* IPC Handler for Extractor Operation History
*
* Handles IPC requests for operation history management:
* - Get batch list (filtered by user for non-admin users)
* - Get batch details
* - Delete batches
*/
import { ipcMain } from 'electron'
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
import { SessionManager } from '../services/user/session-manager'
import { withErrorHandling, type IpcResult } from './index'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { createLogger } from '../services/logger'
import type {
BatchStats,
OperationHistoryRecord,
GetBatchesOptions
} from '../types/operation-history.types'
const log = createLogger('OperationHistoryHandler')
/**
* Register IPC handlers for operation history
*/
export function registerOperationHistoryHandlers(): void {
const dao = new ExtractorOperationHistoryDAO()
/**
* Get batches list
* Admin users get all batches, regular users get only their own
*/
ipcMain.handle(
IPC_CHANNELS.OPERATION_HISTORY_GET_BATCHES,
async (event, options?: GetBatchesOptions): Promise<IpcResult<BatchStats[]>> => {
return withErrorHandling(async () => {
const currentUser = SessionManager.getInstance().getUserInfo()
if (!currentUser) {
throw new Error('用户未登录')
}
// Admin gets all batches, User gets only their own
const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id
log.info('Getting operation history batches', {
userId: currentUser.id,
userType: currentUser.userType,
filtered: userId !== undefined
})
const batches = await dao.getBatches(userId, options)
return batches
}, 'operationHistory:getBatches')
}
)
/**
* Get batch details
* Users can only view their own batch details, admins can view all
*/
ipcMain.handle(
IPC_CHANNELS.OPERATION_HISTORY_GET_BATCH_DETAILS,
async (event, batchId: string): Promise<IpcResult<OperationHistoryRecord[]>> => {
return withErrorHandling(async () => {
const currentUser = SessionManager.getInstance().getUserInfo()
if (!currentUser) {
throw new Error('用户未登录')
}
log.info('Getting batch details', { batchId, userId: currentUser.id })
const details = await dao.getBatchDetails(batchId)
// For non-admin users, verify they own this batch
if (currentUser.userType !== 'Admin' && details.length > 0) {
const batchOwnerId = details[0].userId
if (batchOwnerId !== currentUser.id) {
throw new Error('没有权限查看此批次详情')
}
}
return details
}, 'operationHistory:getBatchDetails')
}
)
/**
* Delete a batch
* Users can only delete their own batches, admins can delete any
*/
ipcMain.handle(
IPC_CHANNELS.OPERATION_HISTORY_DELETE_BATCH,
async (event, batchId: string): Promise<IpcResult<{ deleted: boolean }>> => {
return withErrorHandling(async () => {
const currentUser = SessionManager.getInstance().getUserInfo()
if (!currentUser) {
throw new Error('用户未登录')
}
const isAdmin = currentUser.userType === 'Admin'
log.info('Deleting batch', {
batchId,
userId: currentUser.id,
isAdmin
})
const result = await dao.deleteBatch(batchId, currentUser.id, isAdmin)
if (!result.success) {
throw new Error(result.error || '删除批次失败')
}
return { deleted: true }
}, 'operationHistory:deleteBatch')
}
)
log.info('Operation history IPC handlers registered')
}

View File

@@ -0,0 +1,608 @@
/**
* Data Access Object for ExtractorOperationHistory table
*
* Handles database operations for tracking extraction operation history:
* - Batch record insertion
* - Batch status updates
* - Querying batches (with user filtering for non-admin users)
* - Getting batch details
* - Deleting batches
*/
import { create, type IDatabaseService } from './index'
import { createLogger } from '../logger'
import type {
OperationHistoryRecord,
BatchStats,
InsertBatchRecordInput,
UpdateBatchStatusResult,
GetBatchesOptions
} from '../../types/operation-history.types'
const log = createLogger('ExtractorOperationHistoryDAO')
/**
* Configuration for ExtractorOperationHistory table
*/
export const EXTRACTOR_OPERATION_HISTORY_CONFIG = {
TABLE_NAME_SQLSERVER: '[dbo].[ExtractorOperationHistory]',
TABLE_NAME_MYSQL: 'dbo_ExtractorOperationHistory',
COLUMNS: {
ID: 'ID',
BATCH_ID: 'BatchId',
USER_ID: 'UserId',
USERNAME: 'Username',
PRODUCTION_ID: 'ProductionId',
ORDER_NUMBER: 'OrderNumber',
OPERATION_TIME: 'OperationTime',
STATUS: 'Status',
RECORD_COUNT: 'RecordCount',
ERROR_MESSAGE: 'ErrorMessage'
}
} as const
/**
* ExtractorOperationHistory DAO Class
*/
export class ExtractorOperationHistoryDAO {
private dbService: IDatabaseService | null = null
/**
* Get the appropriate table name based on database type
*/
private getTableName(): string {
const isSqlServer = this.dbService?.type === 'sqlserver'
return isSqlServer
? EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_SQLSERVER
: EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_MYSQL
}
/**
* Get database service instance using DatabaseFactory
*/
private async getDatabaseService(): Promise<IDatabaseService> {
if (this.dbService && this.dbService.isConnected()) {
return this.dbService
}
this.dbService = await create()
return this.dbService
}
/**
* Build placeholders for IN clause based on database type
*/
private buildPlaceholders(count: number, isSqlServer: boolean): string {
return isSqlServer
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
: Array.from({ length: count }, () => '?').join(',')
}
// ==================== INSERT ====================
/**
* Insert batch records for a single extraction operation
* @param batchId - Unique batch identifier
* @param userId - User ID performing the operation
* @param username - Username performing the operation
* @param records - Array of order records to insert
* @returns True if successful
*/
async insertBatchRecords(
batchId: string,
userId: number,
username: string,
records: InsertBatchRecordInput[]
): Promise<boolean> {
if (!records || records.length === 0) {
log.warn('No records to insert')
return false
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
for (const record of records) {
try {
if (isSqlServer) {
const sqlString = `
INSERT INTO ${tableName}
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
VALUES
(@p0, @p1, @p2, @p3, @p4, GETDATE(), 'pending')
`
await dbService.query(sqlString, [
batchId,
userId,
username,
record.productionId || null,
record.orderNumber
])
} else {
const sqlString = `
INSERT INTO ${tableName}
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
VALUES
(?, ?, ?, ?, ?, NOW(), 'pending')
`
await dbService.query(sqlString, [
batchId,
userId,
username,
record.productionId || null,
record.orderNumber
])
}
} catch (error) {
log.error('Error inserting individual record', {
batchId,
orderNumber: record.orderNumber,
error: error instanceof Error ? error.message : String(error)
})
}
}
log.info('Batch records inserted', { batchId, count: records.length })
return true
} catch (error) {
log.error('Insert batch records error', {
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
// ==================== UPDATE ====================
/**
* Update the status of all records in a batch
* @param batchId - Batch identifier
* @param status - New status (success, failed, partial)
* @returns Update result
*/
async updateBatchStatus(
batchId: string,
status: string
): Promise<UpdateBatchStatusResult> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const sqlString = `
UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'}
WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
`
const params = [status, batchId]
await dbService.query(sqlString, params)
log.info('Batch status updated', { batchId, status })
return { success: true, updatedCount: 1 }
} catch (error) {
log.error('Update batch status error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return { success: false, updatedCount: 0 }
}
}
/**
* Update a single record's status, error message, and optional record count
* @param batchId - Batch identifier
* @param orderNumber - Order number
* @param status - New status
* @param errorMessage - Optional error message
* @param recordCount - Optional per-order record count
* @returns True if successful
*/
async updateRecordStatus(
batchId: string,
orderNumber: string,
status: string,
errorMessage?: string,
recordCount?: number
): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
let sqlString: string
let params: (string | number | null)[]
if (recordCount !== undefined) {
sqlString = `
UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'},
ErrorMessage = ${isSqlServer ? '@p1' : '?'},
RecordCount = ${isSqlServer ? '@p2' : '?'}
WHERE BatchId = ${isSqlServer ? '@p3' : '?'}
AND OrderNumber = ${isSqlServer ? '@p4' : '?'}
`
params = [status, errorMessage || null, recordCount, batchId, orderNumber]
} else {
sqlString = `
UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'},
ErrorMessage = ${isSqlServer ? '@p1' : '?'}
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
AND OrderNumber = ${isSqlServer ? '@p3' : '?'}
`
params = [status, errorMessage || null, batchId, orderNumber]
}
await dbService.query(sqlString, params)
return true
} catch (error) {
log.error('Update record status error', {
batchId,
orderNumber,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
// ==================== READ ====================
/**
* Get batch statistics with optional user filtering
* @param userId - Optional user ID for filtering (Admin gets all, User gets own)
* @param options - Query options (limit, offset)
* @returns Array of batch statistics
*/
async getBatches(userId?: number, options?: GetBatchesOptions): Promise<BatchStats[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
let sqlString = `
SELECT
BatchId,
UserId,
Username,
MIN(OperationTime) as OperationTime,
MAX(Status) as Status,
COUNT(*) as TotalOrders,
SUM(COALESCE(RecordCount, 0)) as TotalRecords,
SUM(CASE WHEN Status = 'success' THEN 1 ELSE 0 END) as SuccessCount,
SUM(CASE WHEN Status = 'failed' THEN 1 ELSE 0 END) as FailedCount
FROM ${tableName}
`
const params: (number | string)[] = []
if (userId !== undefined) {
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
params.push(userId)
}
sqlString += `
GROUP BY BatchId, UserId, Username
ORDER BY OperationTime DESC
`
if (options?.limit) {
const safeLimit = Math.floor(options.limit)
const safeOffset = options.offset !== undefined ? Math.floor(options.offset) : undefined
if (isSqlServer) {
// SQL Server: use parameterized OFFSET/FETCH
const offsetIndex = params.length
if (safeOffset !== undefined) {
params.push(safeOffset)
}
params.push(safeLimit)
if (safeOffset !== undefined) {
sqlString += ` OFFSET @p${offsetIndex} ROWS FETCH NEXT @p${offsetIndex + 1} ROWS ONLY`
} else {
sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY`
}
} else {
// MySQL: embed validated integer values directly.
// connection.execute() uses binary protocol prepared statements,
// which do not reliably support ? placeholders in LIMIT/OFFSET clauses.
if (safeOffset !== undefined) {
sqlString += ` LIMIT ${safeLimit} OFFSET ${safeOffset}`
} else {
sqlString += ` LIMIT ${safeLimit}`
}
}
}
const result = await dbService.query(sqlString, params)
return result.rows.map((row) => ({
batchId: row.BatchId as string,
userId: row.UserId as number,
username: row.Username as string,
operationTime: row.OperationTime
? new Date(row.OperationTime as string).toISOString()
: new Date().toISOString(),
status: row.Status as string,
totalOrders: row.TotalOrders as number,
totalRecords: (row.TotalRecords as number) || 0,
successCount: (row.SuccessCount as number) || 0,
failedCount: (row.FailedCount as number) || 0
}))
} catch (error) {
log.error('Get batches error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get detailed records for a specific batch
* @param batchId - Batch identifier
* @returns Array of operation records
*/
async getBatchDetails(batchId: string): Promise<OperationHistoryRecord[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT
ID,
BatchId,
UserId,
Username,
ProductionId,
OrderNumber,
OperationTime,
Status,
RecordCount,
ErrorMessage
FROM ${tableName}
WHERE BatchId = ${placeholder}
ORDER BY ID
`
const result = await dbService.query(sqlString, [batchId])
return result.rows.map((row) => ({
id: row.ID as number,
batchId: row.BatchId as string,
userId: row.UserId as number,
username: row.Username as string,
productionId: row.ProductionId as string | null,
orderNumber: row.OrderNumber as string,
operationTime: new Date(row.OperationTime as string),
status: row.Status as string,
recordCount: row.RecordCount as number | null,
errorMessage: row.ErrorMessage as string | null
}))
} catch (error) {
log.error('Get batch details error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get a single batch's statistics
* @param batchId - Batch identifier
* @returns Batch statistics or null
*/
async getBatchStats(batchId: string): Promise<BatchStats | null> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT
BatchId,
UserId,
Username,
MIN(OperationTime) as OperationTime,
MAX(Status) as Status,
COUNT(*) as TotalOrders,
SUM(COALESCE(RecordCount, 0)) as TotalRecords,
SUM(CASE WHEN Status = 'success' THEN 1 ELSE 0 END) as SuccessCount,
SUM(CASE WHEN Status = 'failed' THEN 1 ELSE 0 END) as FailedCount
FROM ${tableName}
WHERE BatchId = ${placeholder}
GROUP BY BatchId, UserId, Username
`
const result = await dbService.query(sqlString, [batchId])
if (result.rows.length === 0) {
return null
}
const row = result.rows[0]
return {
batchId: row.BatchId as string,
userId: row.UserId as number,
username: row.Username as string,
operationTime: row.OperationTime
? new Date(row.OperationTime as string).toISOString()
: new Date().toISOString(),
status: row.Status as string,
totalOrders: row.TotalOrders as number,
totalRecords: (row.TotalRecords as number) || 0,
successCount: (row.SuccessCount as number) || 0,
failedCount: (row.FailedCount as number) || 0
}
} catch (error) {
log.error('Get batch stats error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return null
}
}
// ==================== DELETE ====================
/**
* Delete a batch with permission checking
* @param batchId - Batch identifier
* @param requestingUserId - User ID requesting the deletion
* @param isAdmin - Whether the requesting user is an admin
* @returns True if successful
*/
async deleteBatch(
batchId: string,
requestingUserId: number,
isAdmin: boolean
): Promise<{ success: boolean; error?: string }> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
// First check if the batch exists and if the user has permission
const batchStats = await this.getBatchStats(batchId)
if (!batchStats) {
return { success: false, error: '批次不存在' }
}
// Non-admin users can only delete their own batches
if (!isAdmin && batchStats.userId !== requestingUserId) {
return { success: false, error: '没有权限删除此批次' }
}
// Delete the batch
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
DELETE FROM ${tableName}
WHERE BatchId = ${placeholder}
`
const result = await dbService.query(sqlString, [batchId])
log.info('Batch deleted', { batchId, rowCount: result.rowCount })
return { success: true }
} catch (error) {
log.error('Delete batch error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
/**
* Delete all batches for a specific user
* @param userId - User ID
* @returns Number of batches deleted
*/
async deleteByUser(userId: number): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
DELETE FROM ${tableName}
WHERE UserId = ${placeholder}
`
const result = await dbService.query(sqlString, [userId])
return result.rowCount
} catch (error) {
log.error('Delete by user error', {
userId,
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
// ==================== UTILITIES ====================
/**
* Check if a batch exists
* @param batchId - Batch identifier
* @returns True if batch exists
*/
async batchExists(batchId: string): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT COUNT(*) as count
FROM ${tableName}
WHERE BatchId = ${placeholder}
`
const result = await dbService.query(sqlString, [batchId])
return result.rows.length > 0 && (result.rows[0].count as number) > 0
} catch (error) {
log.error('Batch exists error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
/**
* Count total batches with optional user filtering
* @param userId - Optional user ID for filtering
* @returns Total number of batches
*/
async countBatches(userId?: number): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
let sqlString = `
SELECT COUNT(DISTINCT BatchId) as count
FROM ${tableName}
`
const params: number[] = []
if (userId !== undefined) {
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
params.push(userId)
}
const result = await dbService.query(sqlString, params)
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
log.error('Count batches error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Disconnect from database
*/
async disconnect(): Promise<void> {
if (this.dbService) {
await this.dbService.disconnect()
this.dbService = null
}
}
}

View File

@@ -46,7 +46,8 @@ export class ExtractorService {
downloadedFiles: [],
mergedFile: null,
recordCount: 0,
errors: []
errors: [],
orderRecordCounts: []
}
try {
@@ -79,6 +80,7 @@ export class ExtractorService {
const mergeResult = await this.mergeFiles(result.downloadedFiles)
result.mergedFile = mergeResult.mergedFile
result.recordCount = mergeResult.recordCount
result.orderRecordCounts = mergeResult.orderRecordCounts
// Add merge error to result if any
if (mergeResult.error) {
@@ -123,9 +125,14 @@ export class ExtractorService {
*/
private async mergeFiles(
filePaths: string[]
): Promise<{ mergedFile: string | null; recordCount: number; error?: string }> {
): Promise<{
mergedFile: string | null
recordCount: number
error?: string
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
}> {
if (filePaths.length === 0) {
return { mergedFile: null, recordCount: 0 }
return { mergedFile: null, recordCount: 0, orderRecordCounts: [] }
}
log.info('Starting merge', { fileCount: filePaths.length })
@@ -154,15 +161,21 @@ export class ExtractorService {
// Calculate total record count (total material rows)
let recordCount = 0
const orderRecordCounts: Array<{ orderNumber: string; recordCount: number }> = []
for (const order of allOrders) {
recordCount += order.materials.length
const count = order.materials.length
recordCount += count
orderRecordCounts.push({
orderNumber: order.orderInfo.productionOrder || '',
recordCount: count
})
}
log.info('Merge summary', { orderCount: allOrders.length, recordCount })
if (recordCount === 0) {
log.warn('No records found in any downloaded files')
return { mergedFile: null, recordCount: 0 }
return { mergedFile: null, recordCount: 0, orderRecordCounts }
}
// Generate output filename with timestamp
@@ -178,13 +191,13 @@ export class ExtractorService {
log.info('Saving merged file', { outputPath })
await this.saveMergedOrders(allOrders, outputPath)
log.info('Merged file saved successfully', { recordCount })
return { mergedFile: outputPath, recordCount }
return { mergedFile: outputPath, recordCount, orderRecordCounts }
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : ''
log.error('Failed to save merged file', { error: errorMsg, stack: errorStack })
// Return parsed record count and error info even if save fails
return { mergedFile: null, recordCount, error: `保存合并文件失败:${errorMsg}` }
return { mergedFile: null, recordCount, orderRecordCounts, error: `保存合并文件失败:${errorMsg}` }
}
}

View File

@@ -43,6 +43,8 @@ export interface ExtractorResult {
errors: string[]
/** Database import result (only populated if mergedFile was created) */
importResult?: ImportResult
/** Per-order material row counts */
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
}
export interface OrderInfo {

View File

@@ -0,0 +1,86 @@
/**
* Operation History Type Definitions
*
* Type definitions for the Extractor Operation History feature.
* Tracks extraction operations with batch and individual order record details.
*/
/**
* Individual operation history record
*/
export interface OperationHistoryRecord {
/** Auto-increment ID */
id?: number
/** Batch ID - shared among all orders in a single extraction operation */
batchId: string
/** User ID who performed the operation */
userId: number
/** Username who performed the operation */
username: string
/** Original input production ID (e.g., "22A1"), null if input was already an order number */
productionId: string | null
/** Resolved order number (e.g., "SC70202602120085") */
orderNumber: string
/** When the operation was performed */
operationTime: Date
/** Operation status: pending, success, failed, partial */
status: string
/** Number of records extracted for this order */
recordCount: number | null
/** Error message if operation failed */
errorMessage: string | null
}
/**
* Batch statistics - aggregated view of a batch operation
*/
export interface BatchStats {
/** Unique batch identifier */
batchId: string
/** User ID who performed the operation */
userId: number
/** Username who performed the operation */
username: string
/** When the operation started */
operationTime: string
/** Overall batch status: pending, success, failed, partial */
status: string
/** Total number of orders in the batch */
totalOrders: number
/** Total records extracted across all orders */
totalRecords: number
/** Number of orders that succeeded */
successCount: number
/** Number of orders that failed */
failedCount: number
}
/**
* Input for inserting batch records
*/
export interface InsertBatchRecordInput {
/** Original input production ID (e.g., "22A1") */
productionId: string | null
/** Resolved order number (e.g., "SC70202602120085") */
orderNumber: string
}
/**
* Result for batch status update
*/
export interface UpdateBatchStatusResult {
/** Whether the update was successful */
success: boolean
/** Number of records updated */
updatedCount: number
}
/**
* Options for querying batches
*/
export interface GetBatchesOptions {
/** Maximum number of batches to return */
limit?: number
/** Number of batches to skip (for pagination) */
offset?: number
}

View File

@@ -17,6 +17,7 @@ import {
} from './materials'
import { loggerApi } from './logger'
import { playwrightBrowserApi } from './browser-download'
import { operationHistoryApi } from './operation-history'
export const api = {
process: processApi,
@@ -35,7 +36,8 @@ export const api = {
logger: loggerApi,
report: reportApi,
update: updateApi,
playwrightBrowser: playwrightBrowserApi
playwrightBrowser: playwrightBrowserApi,
operationHistory: operationHistoryApi
} as const
export type ElectronApi = typeof api

View File

@@ -0,0 +1,30 @@
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { invokeIpc } from '../lib/ipc'
import type {
BatchStats,
OperationHistoryRecord,
GetBatchesOptions
} from '../../main/types/operation-history.types'
import type { IpcResult } from '../../main/types/ipc.types'
export const operationHistoryApi = {
/**
* Get list of operation batches
* Admin users receive all batches, regular users only their own
*/
getBatches: (options?: GetBatchesOptions): Promise<IpcResult<BatchStats[]>> =>
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_GET_BATCHES, options),
/**
* Get detailed records for a specific batch
*/
getBatchDetails: (batchId: string): Promise<IpcResult<OperationHistoryRecord[]>> =>
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_GET_BATCH_DETAILS, batchId),
/**
* Delete a batch
* Admin users can delete any batch, regular users only their own
*/
deleteBatch: (batchId: string): Promise<IpcResult<{ deleted: boolean }>> =>
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_DELETE_BATCH, batchId)
} as const

View File

@@ -157,6 +157,37 @@ export interface PlaywrightBrowserAPI {
onProgress: (callback: (data: DownloadProgress) => void) => () => void
}
export interface OperationHistoryAPI {
getBatches: (options?: { limit?: number; offset?: number }) => Promise<IpcResult<BatchStats[]>>
getBatchDetails: (batchId: string) => Promise<IpcResult<OperationHistoryRecord[]>>
deleteBatch: (batchId: string) => Promise<IpcResult<{ deleted: boolean }>>
}
export interface BatchStats {
batchId: string
userId: number
username: string
operationTime: string
status: string
totalOrders: number
totalRecords: number
successCount: number
failedCount: number
}
export interface OperationHistoryRecord {
id?: number
batchId: string
userId: number
username: string
productionId: string | null
orderNumber: string
operationTime: Date
status: string
recordCount: number | null
errorMessage: string | null
}
export interface ProcessAPI {
versions: {
electron: string
@@ -185,6 +216,7 @@ declare global {
report: ReportAPI
update: UpdateAPI
playwrightBrowser: PlaywrightBrowserAPI
operationHistory: OperationHistoryAPI
}
api: unknown
}

View File

@@ -0,0 +1,423 @@
/**
* Extractor Operation History Modal
*
* Displays extraction operation history with batch statistics and details.
* Admin users see all users' records, regular users see only their own.
*/
import React, { useState, useEffect, useCallback } from 'react'
import { Modal } from './ui/Modal'
import {
RefreshCw,
Trash2,
ChevronDown,
ChevronRight,
CheckCircle,
XCircle,
Clock,
Copy
} from 'lucide-react'
import type { UserInfo } from './UserSelectionDialog'
import type {
BatchStats,
OperationHistoryRecord
} from '../../../main/types/operation-history.types'
import { showSuccess, showError, showWarning } from '../stores/useAppStore'
interface ExtractorOperationHistoryModalProps {
isOpen: boolean
onClose: () => void
user?: UserInfo | null
}
const statusStyles: Record<string, string> = {
success: 'bg-green-100 text-green-700',
partial: 'bg-amber-100 text-amber-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-gray-100 text-gray-700'
}
const statusLabels: Record<string, string> = {
success: '成功',
partial: '部分成功',
failed: '失败',
pending: '进行中'
}
const statusIcons: Record<string, React.ReactNode> = {
success: <CheckCircle size={16} className="text-green-600" />,
partial: <Clock size={16} className="text-amber-600" />,
failed: <XCircle size={16} className="text-red-600" />,
pending: <Clock size={16} className="text-gray-500" />
}
const formatDateTime = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryModalProps> = ({
isOpen,
onClose,
user
}) => {
const [batches, setBatches] = useState<BatchStats[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [expandedBatches, setExpandedBatches] = useState<Set<string>>(new Set())
const [batchDetails, setBatchDetails] = useState<Map<string, OperationHistoryRecord[]>>(new Map())
const [deleting, setDeleting] = useState<Set<string>>(new Set())
const isAdmin = user?.userType === 'Admin'
const fetchBatches = useCallback(async () => {
setLoading(true)
setError(null)
try {
const result = await window.electron.operationHistory.getBatches({ limit: 100 })
if (result.success && result.data) {
setBatches(result.data)
} else {
setError(result.error || '获取历史记录失败')
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取历史记录失败')
} finally {
setLoading(false)
}
}, [])
const fetchBatchDetails = useCallback(
async (batchId: string) => {
// If already loaded, don't fetch again
if (batchDetails.has(batchId)) {
return
}
try {
const result = await window.electron.operationHistory.getBatchDetails(batchId)
if (result.success && result.data) {
setBatchDetails((prev) => new Map(prev).set(batchId, result.data!))
}
} catch (err) {
console.error('Failed to fetch batch details:', err)
}
},
[batchDetails]
)
// Fetch batches when modal opens
useEffect(() => {
if (isOpen) {
void fetchBatches()
}
}, [isOpen, fetchBatches])
const toggleBatchExpansion = (batchId: string) => {
setExpandedBatches((prev) => {
const newSet = new Set(prev)
if (newSet.has(batchId)) {
newSet.delete(batchId)
} else {
newSet.add(batchId)
void fetchBatchDetails(batchId)
}
return newSet
})
}
const handleDeleteBatch = async (batchId: string) => {
if (deleting.has(batchId)) return
const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。')
if (!confirmed) return
setDeleting((prev) => new Set(prev).add(batchId))
try {
const result = await window.electron.operationHistory.deleteBatch(batchId)
if (result.success) {
// Remove from local state
setBatches((prev) => prev.filter((b) => b.batchId !== batchId))
setBatchDetails((prev) => {
const newMap = new Map(prev)
newMap.delete(batchId)
return newMap
})
setExpandedBatches((prev) => {
const newSet = new Set(prev)
newSet.delete(batchId)
return newSet
})
} else {
alert(result.error || '删除失败')
}
} catch (err) {
alert(err instanceof Error ? err.message : '删除失败')
} finally {
setDeleting((prev) => {
const newSet = new Set(prev)
newSet.delete(batchId)
return newSet
})
}
}
const handleCopyColumn = async (field: 'productionId' | 'orderNumber', batchId: string) => {
const details = batchDetails.get(batchId) || []
const values = details
.map((d) => (field === 'productionId' ? d.productionId : d.orderNumber))
.filter(Boolean) // 移除空值
.join('\n') // 使用换行符分隔
if (!values) {
showWarning('没有可复制的数据')
return
}
try {
await navigator.clipboard.writeText(values)
showSuccess(`已复制 ${values.split('\n').length} 条数据`)
} catch {
showError('复制失败,请手动复制')
}
}
if (!isOpen) return null
return (
<Modal isOpen={isOpen} onClose={onClose} title="操作历史" size="3xl">
<div className="flex flex-col h-[70vh]">
{/* Toolbar */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{isAdmin ? (
<span className="text-amber-600 font-medium"></span>
) : (
<span></span>
)}
</span>
{batches.length > 0 && (
<span className="text-sm text-gray-500"> {batches.length} </span>
)}
</div>
<button
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
onClick={() => void fetchBatches()}
disabled={loading}
title="刷新"
>
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Error message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{/* Batch list */}
<div className="flex-1 overflow-y-auto">
{loading && batches.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-500">...</div>
) : batches.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-500"></div>
) : (
<div className="flex flex-col gap-3">
{batches.map((batch) => {
const isExpanded = expandedBatches.has(batch.batchId)
const details = batchDetails.get(batch.batchId) || []
const isDeleting = deleting.has(batch.batchId)
return (
<div
key={batch.batchId}
className="border border-gray-200 rounded-lg overflow-hidden"
>
{/* Batch summary */}
<div
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50'
}`}
onClick={() => toggleBatchExpansion(batch.batchId)}
>
<div className="flex items-center gap-4 flex-1">
<button className="p-1 hover:bg-gray-200 rounded">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div className="flex-1 grid grid-cols-6 gap-4 text-sm">
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">
{formatDateTime(batch.operationTime)}
</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">{batch.username}</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="flex items-center gap-1">
{statusIcons[batch.status] || statusIcons.pending}
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${
statusStyles[batch.status] || statusStyles.pending
}`}
>
{statusLabels[batch.status] || batch.status}
</span>
</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">{batch.totalOrders}</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">{batch.totalRecords}</div>
</div>
<div>
<div className="text-gray-500 text-xs">/</div>
<div className="font-medium text-gray-900">
<span className="text-green-600">{batch.successCount}</span>
{batch.failedCount > 0 && (
<>
{' / '}
<span className="text-red-600">{batch.failedCount}</span>
</>
)}
</div>
</div>
</div>
</div>
<button
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
onClick={(e) => {
e.stopPropagation()
void handleDeleteBatch(batch.batchId)
}}
disabled={isDeleting}
title="删除批次"
>
<Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} />
</button>
</div>
{/* Batch details */}
{isExpanded && details.length > 0 && (
<div className="border-t border-gray-200 bg-white">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-600">
<div className="flex items-center gap-2">
<button
className="p-1 hover:bg-gray-200 rounded transition-colors"
onClick={() =>
void handleCopyColumn('productionId', batch.batchId)
}
title="复制所有总排号"
>
<Copy
size={14}
className="text-gray-500 hover:text-gray-700"
/>
</button>
</div>
</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">
<div className="flex items-center gap-2">
<button
className="p-1 hover:bg-gray-200 rounded transition-colors"
onClick={() =>
void handleCopyColumn('orderNumber', batch.batchId)
}
title="复制所有订单号"
>
<Copy
size={14}
className="text-gray-500 hover:text-gray-700"
/>
</button>
</div>
</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">
</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">
</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{details.map((detail) => (
<tr key={detail.id} className="hover:bg-gray-50">
<td className="px-4 py-2 text-gray-900">
{detail.productionId || '-'}
</td>
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
{detail.orderNumber}
</td>
<td className="px-4 py-2">
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
statusStyles[detail.status] || statusStyles.pending
}`}
>
{statusIcons[detail.status]}
{statusLabels[detail.status] || detail.status}
</span>
</td>
<td className="px-4 py-2 text-gray-900">
{detail.recordCount ?? '-'}
</td>
<td className="px-4 py-2 text-red-600 text-xs max-w-xs truncate">
{detail.errorMessage || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Footer */}
<div className="pt-4 border-t border-gray-200 flex justify-end">
<button
className="px-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors"
onClick={onClose}
>
</button>
</div>
</div>
</Modal>
)
}
export default ExtractorOperationHistoryModal

View File

@@ -1,15 +1,10 @@
/**
* Button Component
*
* A reusable button component with variants and sizes.
*/
import React from 'react'
import * as React from 'react'
import { Button as ShadcnButton } from './button'
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
type ButtonSize = 'sm' | 'md' | 'lg'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
@@ -17,36 +12,28 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode
}
const variantStyles: Record<ButtonVariant, string> = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white border-transparent',
secondary: 'bg-gray-100 hover:bg-gray-200 text-gray-800 border-gray-300',
danger: 'bg-red-600 hover:bg-red-700 text-white border-transparent',
ghost: 'bg-transparent hover:bg-gray-100 text-gray-700 border-transparent'
const variantMap: Record<ButtonVariant, 'default' | 'secondary' | 'destructive' | 'ghost'> = {
primary: 'default',
secondary: 'secondary',
danger: 'destructive',
ghost: 'ghost'
}
const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
const sizeMap: Record<ButtonSize, 'default' | 'sm' | 'lg'> = {
sm: 'sm',
md: 'default',
lg: 'lg'
}
export function Button({
variant = 'primary',
size = 'md',
loading = false,
icon,
children,
className = '',
disabled,
...props
}: ButtonProps) {
const baseStyles =
'inline-flex items-center justify-center font-medium rounded-lg border transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed'
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', loading = false, icon, children, disabled, className, ...props }, ref) => {
return (
<button
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
<ShadcnButton
ref={ref}
variant={variantMap[variant]}
size={sizeMap[size]}
disabled={disabled || loading}
className={className}
{...props}
>
{loading && (
@@ -56,14 +43,7 @@ export function Button({
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
@@ -73,8 +53,11 @@ export function Button({
)}
{icon && !loading && <span className="mr-2">{icon}</span>}
{children}
</button>
</ShadcnButton>
)
}
)
Button.displayName = 'Button'
export default Button

View File

@@ -1,13 +1,21 @@
/**
* ConfirmDialog Component
*
* A confirmation dialog component for displaying confirmation prompts.
* Extends the Modal component with consistent styling and behavior.
* A confirmation dialog component for displaying confirmation prompts using shadcn/ui.
*/
import React, { useCallback, useEffect } from 'react'
import { AlertTriangle, Info, AlertCircle } from 'lucide-react'
import { Modal } from './Modal'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from './alert-dialog'
import { Button } from './Button'
export type ConfirmDialogVariant = 'danger' | 'warning' | 'info'
@@ -79,39 +87,33 @@ export function ConfirmDialog({
const styles = variantStyles[variant]
return (
<Modal
isOpen={isOpen}
onClose={onCancel}
title={title}
size="md"
showCloseButton={false}
isAlertDialog={true}
initialFocusSelector="[data-autofocus]"
>
<div className="flex items-start gap-4">
{/* Icon */}
<div className={`flex-shrink-0 ${styles.iconColor}`}>{styles.icon}</div>
{/* Message */}
<div className="flex-1">
<AlertDialog open={isOpen} onOpenChange={(open) => {
if (!open) onCancel()
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<span className={styles.iconColor}>{styles.icon}</span>
{title}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="pt-2">
{typeof message === 'string' ? (
<p className="text-gray-700 whitespace-pre-wrap">{message}</p>
) : (
message
)}
</div>
</div>
{/* Buttons */}
<div className="flex justify-end gap-3 mt-6">
<Button variant="secondary" onClick={onCancel}>
{cancelText}
</Button>
<Button data-autofocus="true" variant={styles.buttonVariant} onClick={onConfirm}>
{confirmText}
</Button>
</div>
</Modal>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>{cancelText}</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant={styles.buttonVariant} onClick={onConfirm}>{confirmText}</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -1,13 +1,17 @@
/**
* Modal Component
*
* A reusable modal dialog component.
* A reusable modal dialog component refactored to use shadcn/ui Dialog.
*/
import React, { useRef, useMemo, useState } from 'react'
import { X } from 'lucide-react'
import FocusLock from 'react-focus-lock'
import { useDialogFocus } from '../../hooks/useDialogFocus'
import React from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from './dialog'
interface ModalProps {
isOpen: boolean
@@ -33,12 +37,12 @@ interface ModalProps {
}
const sizeStyles: Record<string, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl'
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl'
}
export function Modal({
@@ -48,85 +52,48 @@ export function Modal({
children,
size = 'md',
showCloseButton = true,
triggerRef,
titleId,
initialFocusSelector,
ariaDescribedBy,
isAlertDialog = false,
disableEscapeKey = false,
disableBackdropClick = false
}: ModalProps): React.JSX.Element | null {
const dialogRef = useRef<HTMLDivElement>(null)
const [generatedId] = useState(
() => `modal-title-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
)
// Use provided titleId or generated one
const generatedTitleId = useMemo((): string => {
return titleId || generatedId
}, [titleId, generatedId])
// Setup focus management (includes Escape key handling)
const { focusLockProps } = useDialogFocus({
isOpen,
dialogRef,
onClose,
triggerRef,
initialFocusSelector,
shouldCloseOnEscape: !disableEscapeKey
})
if (!isOpen) return null
return (
<FocusLock {...focusLockProps}>
<div
className="fixed inset-0 z-50 overflow-y-auto"
role={isAlertDialog ? 'alertdialog' : 'dialog'}
aria-modal="true"
aria-labelledby={generatedTitleId}
aria-describedby={ariaDescribedBy}
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose()
}
}}
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={disableBackdropClick ? undefined : onClose}
aria-hidden="true"
/>
{/* Modal container */}
<div className="flex min-h-full items-center justify-center p-4">
<div
ref={dialogRef}
className={`relative w-full ${sizeStyles[size]} bg-white rounded-lg shadow-xl transform transition-all`}
onClick={(e) => e.stopPropagation()}
<DialogContent
className={`${sizeStyles[size]} !p-0 gap-0 overflow-hidden`}
hideCloseButton={!showCloseButton}
onEscapeKeyDown={(e) => {
if (disableEscapeKey) {
e.preventDefault()
}
}}
onPointerDownOutside={(e) => {
if (disableBackdropClick) {
e.preventDefault()
}
}}
onInteractOutside={(e) => {
if (disableBackdropClick) {
e.preventDefault()
}
}}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
{title && (
<h3 id={generatedTitleId} className="text-lg font-semibold text-gray-900">
{title}
</h3>
<DialogHeader className="px-6 py-4 border-b border-gray-200 m-0">
{title && <DialogTitle className="text-lg font-semibold text-gray-900 m-0">{title}</DialogTitle>}
{!title && <DialogTitle className="sr-only">Dialog</DialogTitle>}
{/* Accessibility requires a description or Title */}
<DialogDescription className="sr-only">Dialog content</DialogDescription>
</DialogHeader>
)}
{showCloseButton && (
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
aria-label="关闭对话框"
>
<X className="w-5 h-5" />
</button>
)}
</div>
)}
{/* Content */}
<div className="px-6 py-4">{children}</div>
</div>
</div>
</div>
</FocusLock>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@renderer/lib/utils"
import { buttonVariants } from "@renderer/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@renderer/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@renderer/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@renderer/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@renderer/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@renderer/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("grid place-content-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@renderer/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@renderer/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@renderer/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@renderer/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@renderer/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@renderer/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,157 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@renderer/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,138 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@renderer/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@renderer/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@renderer/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@renderer/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@renderer/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@renderer/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -5,6 +5,7 @@ import { CleanerSidebar } from '../components/cleaner/CleanerSidebar'
import { CleanerToolbar } from '../components/cleaner/CleanerToolbar'
import { ConfirmDialog } from '../components/ui/ConfirmDialog'
import { useCleaner } from '../hooks/useCleaner'
import { Card } from '../components/ui/card'
const MaterialTypeManagementDialog = React.lazy(
() => import('../components/MaterialTypeManagementDialog')
@@ -81,7 +82,7 @@ const CleanerPage: React.FC = () => {
/>
)}
<div className="flex-1 bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col overflow-hidden">
<Card className="flex-1 rounded-xl shadow-sm border-slate-200 flex flex-col overflow-hidden">
<CleanerToolbar
validationResults={validationResults}
filteredResults={filteredResults}
@@ -132,7 +133,7 @@ const CleanerPage: React.FC = () => {
isRunning={isRunning}
executeButtonRef={executeButtonRef}
/>
</div>
</Card>
<Suspense fallback={null}>
<MaterialTypeManagementDialog

View File

@@ -1,14 +1,20 @@
import React from 'react'
import { Download, Play, CheckCircle } from 'lucide-react'
import { Download, Play, CheckCircle, History } from 'lucide-react'
import OrderNumberInput from '../components/OrderNumberInput'
import { useExtractor } from '../hooks/useExtractor'
import { usePersistentTextState } from '../hooks/usePersistentTextState'
import { useSharedProductionIds } from '../hooks/useSharedProductionIds'
import LogPanel from '../components/ui/LogPanel'
import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar'
import ExtractorOperationHistoryModal from '../components/ExtractorOperationHistoryModal'
import { useUserStore } from '../stores/useUserStore'
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'
import { Button } from '../components/ui/Button'
const ExtractorPage: React.FC = () => {
const [orderNumbers, setOrderNumbers] = usePersistentTextState('extractor_orderNumbers')
const [showHistoryModal, setShowHistoryModal] = React.useState(false)
const user = useUserStore((state) => state.user)
const {
isRunning,
@@ -38,11 +44,11 @@ const ExtractorPage: React.FC = () => {
return (
<div className="flex h-full gap-4 relative">
<aside className="w-80 flex-shrink-0 bg-white border border-slate-200 flex flex-col shadow-sm rounded-xl overflow-hidden h-full">
<div className="p-4 border-b border-slate-100">
<h3 className="text-sm font-semibold text-slate-800"></h3>
</div>
<div className="flex-1 flex flex-col p-4 min-h-0">
<Card className="w-80 flex-shrink-0 flex flex-col shadow-sm rounded-xl overflow-hidden h-full border-slate-200">
<CardHeader className="p-4 border-b border-slate-100 bg-white pb-3">
<CardTitle className="text-sm font-semibold text-slate-800"></CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-4 min-h-0">
<OrderNumberInput
value={orderNumbers}
onChange={setOrderNumbers}
@@ -52,37 +58,59 @@ const ExtractorPage: React.FC = () => {
showReset={true}
onReset={handleReset}
/>
</div>
</aside>
</CardContent>
</Card>
<div className="flex-1 min-w-0 flex flex-col gap-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5 flex items-center justify-between">
<div className="flex-1">
<Card className="shadow-sm border-slate-200 rounded-xl p-2">
<div className="p-3 flex items-center justify-between">
<div className="flex-1 px-2">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-800 mb-1">
<Download size={20} className="text-blue-600" />
</h2>
<p className="text-sm text-slate-500"></p>
{error && <p className="text-sm text-red-500 mt-2">{error}</p>}
{error && <p className="text-sm text-destructive mt-2">{error}</p>}
</div>
<div className="flex items-center gap-4">
<button
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white px-6 py-2.5 rounded-lg flex items-center gap-2 font-medium shadow-sm transition-colors"
<div className="flex items-center gap-4 px-2">
<Button
variant="secondary"
className="flex items-center gap-2"
onClick={() => setShowHistoryModal(true)}
disabled={isRunning}
>
<History size={18} />
</Button>
<Button
variant="primary"
className="flex items-center gap-2"
onClick={handleExtract}
disabled={isRunning || !orderNumbers.trim()}
>
<Play size={18} fill="currentColor" />
{isRunning ? '提取中...' : '开始提取'}
</button>
</Button>
</div>
</div>
</Card>
{showHistoryModal ? (
<ExtractorOperationHistoryModal
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
user={user}
/>
) : null}
{!isRunning && isComplete && (
<div className="bg-green-50 rounded-xl p-8 flex items-center justify-center gap-4 shadow-md">
<Card className="bg-green-50 border-green-100 shadow-md">
<CardContent className="p-8 flex items-center justify-center gap-4">
<CheckCircle className="text-green-600" size={35} />
<p className="text-4xl font-bold text-green-600"></p>
</div>
</CardContent>
</Card>
)}
{isRunning && progress && (

View File

@@ -111,7 +111,12 @@ export const IPC_CHANNELS = {
PLAYWRIGHT_BROWSER_DOWNLOAD: 'playwright-browser:download',
PLAYWRIGHT_BROWSER_CANCEL: 'playwright-browser:cancel',
PLAYWRIGHT_BROWSER_PROGRESS: 'playwright-browser:progress',
PLAYWRIGHT_BROWSER_CHECK: 'playwright-browser:check'
PLAYWRIGHT_BROWSER_CHECK: 'playwright-browser:check',
// Operation History
OPERATION_HISTORY_GET_BATCHES: 'operationHistory:getBatches',
OPERATION_HISTORY_GET_BATCH_DETAILS: 'operationHistory:getBatchDetails',
OPERATION_HISTORY_DELETE_BATCH: 'operationHistory:deleteBatch'
} as const
/**