Download client/src/lib/projectUtils.ts from zuocong12/README: direct link, hf CLI and curl.
- Browser
- Download file 15.2 kB
-
https://huggingface.co/spaces/zuocong12/README/resolve/main/client/src/lib/projectUtils.ts
- Command line
-
hf download hf://spaces/zuocong12/README/client/src/lib/projectUtils.ts
-
curl -L -o projectUtils.ts https://huggingface.co/spaces/zuocong12/README/resolve/main/client/src/lib/projectUtils.ts
15.2 kB
| /** | |
| * Project utilities for parsing and processing Excel data | |
| * Three-section data model support | |
| */ | |
| import * as XLSX from 'xlsx'; | |
| import { ProjectData, ProjectStats, DEFAULT_COLUMN_MAPPING } from '@/types/project'; | |
| /** | |
| * Set nested property value using dot notation path | |
| */ | |
| function setNestedValue(obj: any, path: string, value: any) { | |
| const keys = path.split('.'); | |
| let current = obj; | |
| for (let i = 0; i < keys.length - 1; i++) { | |
| const key = keys[i]; | |
| if (!current[key]) { | |
| current[key] = {}; | |
| } | |
| current = current[key]; | |
| } | |
| current[keys[keys.length - 1]] = value; | |
| } | |
| /** | |
| * Get nested property value using dot notation path | |
| */ | |
| function getNestedValue(obj: any, path: string): any { | |
| const keys = path.split('.'); | |
| let current = obj; | |
| for (const key of keys) { | |
| if (current === undefined || current === null) { | |
| return undefined; | |
| } | |
| current = current[key]; | |
| } | |
| return current; | |
| } | |
| /** | |
| * Parse Excel serial date number to ISO date string | |
| */ | |
| function parseExcelDate(value: any): string { | |
| if (!value) return ''; | |
| // If it's a number, treat as Excel serial date | |
| if (typeof value === 'number') { | |
| try { | |
| const date = XLSX.SSF.parse_date_code(value); | |
| return `${date.y}-${String(date.m).padStart(2, '0')}-${String(date.d).padStart(2, '0')}`; | |
| } catch { | |
| return String(value); | |
| } | |
| } | |
| // If it's already a string, try to parse as date | |
| if (typeof value === 'string') { | |
| const dateTest = new Date(value); | |
| if (!isNaN(dateTest.getTime())) { | |
| return dateTest.toISOString().split('T')[0]; | |
| } | |
| } | |
| return String(value); | |
| } | |
| /** | |
| * Format date value for display in "MM月DD日" format | |
| * Handles Excel serial numbers, text strings, and empty values | |
| * | |
| * @param value - Raw value from Excel cell (number, string, or undefined) | |
| * @returns Formatted date string like "12月24日" or "暂无日期" | |
| */ | |
| export function formatExcelDate(value: any): string { | |
| // Handle empty/undefined first | |
| if (!value || value === '') { | |
| return '暂无日期'; | |
| } | |
| // PRIORITY 1: If it's already a clean string like "12月24日", just return it | |
| if (typeof value === 'string' && value.includes('月')) { | |
| return value; | |
| } | |
| // PRIORITY 2: If it's a number (e.g., 45325), convert it strictly | |
| if (!isNaN(value) && typeof value === 'number') { | |
| try { | |
| // Excel serial date formula: (serial - 25569) * 86400 * 1000 | |
| const date = new Date((value - 25569) * 86400 * 1000); | |
| const month = date.getMonth() + 1; | |
| const day = date.getDate(); | |
| return `${month}月${day}日`; | |
| } catch { | |
| return String(value); | |
| } | |
| } | |
| // PRIORITY 3: Try to parse as ISO date (YYYY-MM-DD) | |
| if (typeof value === 'string') { | |
| const dateMatch = value.match(/(\d{4})-(\d{1,2})-(\d{1,2})/); | |
| if (dateMatch) { | |
| const month = parseInt(dateMatch[2]); | |
| const day = parseInt(dateMatch[3]); | |
| return `${month}月${day}日`; | |
| } | |
| } | |
| // Fallback: return as string | |
| return String(value); | |
| } | |
| /** | |
| * Parse Excel file and extract project data | |
| */ | |
| export function parseExcelFile(file: File): Promise<ProjectData[]> { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| try { | |
| const data = e.target?.result; | |
| const workbook = XLSX.read(data, { type: 'binary' }); | |
| const firstSheet = workbook.Sheets[workbook.SheetNames[0]]; | |
| const jsonData = XLSX.utils.sheet_to_json(firstSheet, { | |
| header: 1, | |
| raw: false, // Force reading formatted strings instead of raw numbers | |
| dateNF: 'mm"月"dd"日"' // Hint for date format | |
| }) as any[][]; | |
| // Find header rows (row 0 and 1 are merged headers, row 2 is the actual header) | |
| const headerRowIndex = 2; | |
| const headers = jsonData[headerRowIndex] as string[]; | |
| // Parse data rows (starting from row 3) | |
| const projects: ProjectData[] = []; | |
| for (let i = headerRowIndex + 1; i < jsonData.length; i++) { | |
| const row = jsonData[i]; | |
| // Skip empty rows or rows that are just instructions | |
| if (!row || !row[0] || String(row[0]).includes('列')) { | |
| continue; | |
| } | |
| // Initialize project with nested structure | |
| const project: any = { | |
| no: '', | |
| identity: {}, | |
| milestones: {}, | |
| details: {} | |
| }; | |
| // First column (index 0) is NO., even though header is undefined/empty | |
| project.no = row[0] !== undefined && row[0] !== null ? String(row[0]) : '-'; | |
| // Map other columns starting from index 1 | |
| headers.forEach((header, index) => { | |
| if (index === 0) return; // Skip first column as we already handled it | |
| const mappedPath = DEFAULT_COLUMN_MAPPING[header]; | |
| if (mappedPath) { | |
| const value = row[index]; | |
| let processedValue = value !== undefined && value !== null ? String(value) : '-'; | |
| // Special handling for date fields | |
| if (mappedPath.includes('milestones.') && | |
| (mappedPath.includes('projectStart') || mappedPath.includes('t1') || | |
| mappedPath.includes('glTime') || mappedPath.includes('vmp') || | |
| mappedPath.includes('mp') || mappedPath.includes('detailDate'))) { | |
| processedValue = parseExcelDate(value) || '-'; | |
| } | |
| setNestedValue(project, mappedPath, processedValue); | |
| } | |
| }); | |
| // Only add if it has a project name | |
| if (project.identity?.projectName && project.identity.projectName !== '-') { | |
| projects.push(project as ProjectData); | |
| } | |
| } | |
| resolve(projects); | |
| } catch (error) { | |
| reject(error); | |
| } | |
| }; | |
| reader.onerror = () => reject(new Error('文件读取失败')); | |
| reader.readAsBinaryString(file); | |
| }); | |
| } | |
| /** | |
| * Get risk level color based on risk level text | |
| */ | |
| export function getRiskColor(riskLevel: string): string { | |
| const level = riskLevel?.trim() || ''; | |
| if (level === '高' || level.includes('高')) { | |
| return 'rgb(220, 38, 38)'; // Red for high risk | |
| } else if (level === '中' || level.includes('中')) { | |
| return 'rgb(234, 179, 8)'; // Yellow for medium risk | |
| } else { | |
| return 'rgb(34, 197, 94)'; // Green for low/normal risk | |
| } | |
| } | |
| /** | |
| * Get risk level badge variant | |
| */ | |
| export function getRiskBadgeClass(riskLevel: string): string { | |
| const level = riskLevel?.trim() || ''; | |
| if (level === '高' || level.includes('高')) { | |
| return 'bg-red-100 text-red-700 border-red-300'; | |
| } else if (level === '中' || level.includes('中')) { | |
| return 'bg-yellow-100 text-yellow-700 border-yellow-300'; | |
| } else { | |
| return 'bg-green-100 text-green-700 border-green-300'; | |
| } | |
| } | |
| /** | |
| * Get status badge class | |
| */ | |
| export function getStatusBadgeClass(status: string): string { | |
| const statusText = status?.trim() || ''; | |
| if (statusText === '已完成' || statusText.includes('完成')) { | |
| return 'bg-green-100 text-green-700 border-green-300'; | |
| } else if (statusText === '进行中' || statusText.includes('进行')) { | |
| return 'bg-yellow-100 text-yellow-700 border-yellow-300'; | |
| } else if (statusText === '已超时' || statusText.includes('超时')) { | |
| return 'bg-red-100 text-red-700 border-red-300'; | |
| } else { | |
| return 'bg-gray-100 text-gray-700 border-gray-300'; | |
| } | |
| } | |
| /** | |
| * Calculate project statistics based EXCLUSIVELY on 当前节点 (Current Node) | |
| * SINGLE SOURCE OF TRUTH - Ignore all other columns for status determination | |
| * | |
| * Strict Mapping: | |
| * - "已完成" (Completed) → Green Theme | |
| * - "已超时" (Overdue) → Red Theme | |
| * - "进行中" (Ongoing) → Blue Theme | |
| * - Anything else or empty → Do NOT count, treat as null | |
| */ | |
| export function calculateStats(projects: ProjectData[]): ProjectStats { | |
| const stats: ProjectStats = { | |
| total: projects.length, | |
| highRisk: 0, // Now represents "已超时" (Overdue) ONLY | |
| inProgress: 0, | |
| completed: 0 | |
| }; | |
| projects.forEach(project => { | |
| const currentNode = project.milestones?.currentNode?.trim() || ''; // No default, treat empty as null | |
| // Strict matching - only count exact matches | |
| if (currentNode === '已完成') { | |
| stats.completed++; | |
| } else if (currentNode === '已超时') { | |
| stats.highRisk++; | |
| } else if (currentNode === '进行中') { | |
| stats.inProgress++; | |
| } | |
| // Anything else: do NOT count in any category | |
| }); | |
| return stats; | |
| } | |
| /** | |
| * Format date string for display | |
| */ | |
| export function formatDate(dateStr: string): string { | |
| if (!dateStr || dateStr === '-' || dateStr === 'N/A') { | |
| return '-'; | |
| } | |
| try { | |
| // If already in YYYY-MM-DD format, return as is | |
| if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { | |
| return dateStr; | |
| } | |
| // Try to parse as date | |
| const date = new Date(dateStr); | |
| if (!isNaN(date.getTime())) { | |
| return date.toISOString().split('T')[0]; | |
| } | |
| return dateStr; | |
| } catch { | |
| return dateStr; | |
| } | |
| } | |
| /** | |
| * Get display value with fallback | |
| */ | |
| export function getDisplayValue(value: string | undefined | null, fallback: string = '-'): string { | |
| if (!value || value === 'undefined' || value === 'null' || value === '-') { | |
| return fallback; | |
| } | |
| return value; | |
| } | |
| /** | |
| * Check if project is high risk (for red border highlighting) | |
| * @deprecated Use getCardVisualState instead | |
| */ | |
| export function isHighRisk(riskLevel: string): boolean { | |
| const level = riskLevel?.trim() || ''; | |
| return level === '高' || level.includes('高'); | |
| } | |
| /** | |
| * Get card visual state based EXCLUSIVELY on 当前节点 (Current Node) | |
| * SINGLE SOURCE OF TRUTH - Returns visual styling configuration | |
| * | |
| * Strict Mapping: | |
| * - "已完成" → Green border + Light green background | |
| * - "已超时" → Red border + Light red background | |
| * - "进行中" → Blue border + White background | |
| * - Anything else → Neutral style, no badge | |
| */ | |
| export function getCardVisualState(currentNode: string | undefined) { | |
| const node = currentNode?.trim() || ''; // No default, treat empty as null | |
| // Case A: 已完成 (Completed) - Green Theme | |
| if (node === '已完成') { | |
| return { | |
| type: 'completed' as const, | |
| borderClass: 'border-2 border-emerald-500', | |
| bgClass: 'bg-emerald-50', | |
| badgeClass: 'bg-emerald-100 text-emerald-700 border-emerald-300', | |
| badgeLabel: '已完成', | |
| showBadge: true | |
| }; | |
| } | |
| // Case B: 已超时 (Overdue) - Red Theme | |
| if (node === '已超时') { | |
| return { | |
| type: 'overdue' as const, | |
| borderClass: 'border-2 border-red-600', | |
| bgClass: 'bg-red-50', | |
| badgeClass: 'bg-red-100 text-red-700 border-red-300', | |
| badgeLabel: '已超时', | |
| showBadge: true | |
| }; | |
| } | |
| // Case C: 进行中 (Ongoing) - Blue Theme | |
| if (node === '进行中') { | |
| return { | |
| type: 'ongoing' as const, | |
| borderClass: 'border-2 border-blue-500', | |
| bgClass: 'bg-white', | |
| badgeClass: 'bg-blue-100 text-blue-700 border-blue-300', | |
| badgeLabel: '进行中', | |
| showBadge: true | |
| }; | |
| } | |
| // Default: Null/Unknown - Neutral style, no badge | |
| return { | |
| type: 'unknown' as const, | |
| borderClass: 'border border-slate-200', | |
| bgClass: 'bg-white', | |
| badgeClass: 'bg-slate-100 text-slate-500 border-slate-300', | |
| badgeLabel: 'N/A', | |
| showBadge: false | |
| }; | |
| } | |
| /** | |
| * Parse FAI value to percentage format | |
| * Handles both decimal (0.9) and percentage (90%) inputs | |
| * Returns formatted string like "90%" | |
| */ | |
| export function parseFAIValue(value: string | undefined | null): string { | |
| if (!value || value === '-' || value === 'N/A') { | |
| return '-'; | |
| } | |
| const strValue = String(value).trim(); | |
| // If already has %, just return it | |
| if (strValue.includes('%')) { | |
| return strValue; | |
| } | |
| // Try to parse as number | |
| const numValue = parseFloat(strValue); | |
| if (isNaN(numValue)) { | |
| return strValue; // Return as-is if not a number | |
| } | |
| // If value is between 0 and 1, treat as decimal (e.g., 0.9 = 90%) | |
| if (numValue > 0 && numValue <= 1) { | |
| return `${Math.round(numValue * 100)}%`; | |
| } | |
| // If value is 1, treat as 100% | |
| if (numValue === 1) { | |
| return '100%'; | |
| } | |
| // If value is greater than 1, assume it's already a percentage | |
| return `${Math.round(numValue)}%`; | |
| } | |
| /** | |
| * Get FAI color class based on value (traffic light system) | |
| * < 100% = Red (alert) | |
| * = 100% = Green (pass) | |
| */ | |
| export function getFAIColorClass(value: string | undefined | null): string { | |
| const parsed = parseFAIValue(value); | |
| if (parsed === '-' || parsed === 'N/A') { | |
| return 'text-gray-500'; | |
| } | |
| // Extract numeric value from percentage string | |
| const numericMatch = parsed.match(/\d+/); | |
| if (!numericMatch) { | |
| return 'text-gray-500'; | |
| } | |
| const numValue = parseInt(numericMatch[0], 10); | |
| if (numValue >= 100) { | |
| return 'text-emerald-600 font-bold'; // Green for 100% | |
| } else { | |
| return 'text-red-600 font-bold'; // Red for < 100% | |
| } | |
| } | |
| /** | |
| * Module 2 field definitions for completeness calculation | |
| */ | |
| const MODULE_2_FIELDS = [ | |
| 'milestones.projectStart', | |
| 'milestones.t1', | |
| 'milestones.glTime', | |
| 'milestones.vmp', | |
| 'milestones.mp', | |
| 'milestones.currentStage', | |
| 'milestones.trialCount', | |
| 'milestones.toolingFAI', | |
| 'milestones.partFAI', | |
| 'milestones.t1SizeQualified', | |
| ] as const; | |
| /** | |
| * Check if a field value is considered empty | |
| */ | |
| function isFieldEmpty(value: any): boolean { | |
| if (value === null || value === undefined) return true; | |
| if (typeof value === 'string') { | |
| const trimmed = value.trim(); | |
| return trimmed === '' || trimmed === '-' || trimmed === 'N/A'; | |
| } | |
| return false; | |
| } | |
| /** | |
| * Calculate Module 2 completeness percentage | |
| */ | |
| export function calculateCompleteness(project: ProjectData): number { | |
| const totalFields = MODULE_2_FIELDS.length; | |
| let filledFields = 0; | |
| for (const fieldPath of MODULE_2_FIELDS) { | |
| const value = getNestedValue(project, fieldPath); | |
| if (!isFieldEmpty(value)) { | |
| filledFields++; | |
| } | |
| } | |
| return Math.round((filledFields / totalFields) * 100); | |
| } | |
| /** | |
| * Get list of missing fields in Module 2 | |
| */ | |
| export function getMissingFields(project: ProjectData): string[] { | |
| const fieldLabels: Record<string, string> = { | |
| 'milestones.projectStart': '项目启动时间', | |
| 'milestones.t1': 'T1时间', | |
| 'milestones.glTime': 'G/L时间', | |
| 'milestones.vmp': 'VMP时间', | |
| 'milestones.mp': 'MP时间', | |
| 'milestones.currentStage': '当前阶段', | |
| 'milestones.trialCount': '试模次数', | |
| 'milestones.toolingFAI': 'Tooling FAI', | |
| 'milestones.partFAI': 'Part FAI', | |
| 'milestones.t1SizeQualified': 'T1尺寸是否达标', | |
| }; | |
| const missingFields: string[] = []; | |
| for (const fieldPath of MODULE_2_FIELDS) { | |
| const value = getNestedValue(project, fieldPath); | |
| if (isFieldEmpty(value)) { | |
| missingFields.push(fieldLabels[fieldPath] || fieldPath); | |
| } | |
| } | |
| return missingFields; | |
| } | |