import type ExcelJS from 'exceljs' const XLSX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' type PerformanceSurveyExportCell = { score?: number | null surveyCount?: number | null positionGroupCode?: string | null } type PerformanceSurveyExportRow = { month: string employee: { displayName?: string | null jobNumber?: string | null departmentName?: string | null positionLevel?: string | null levelType?: string | null } cells?: Record } export type PerformanceSurveyDashboardExportData = { month: string filters?: { positionGroupCode?: string | null } selectedPositionGroup?: { code?: string | null; name?: string | null } | null positionGroups?: Array<{ code: string; name: string }> templates: Array<{ code: string name?: string | null fullName?: string | null maxTotal?: number | null }> public: { rows: PerformanceSurveyExportRow[] } } type WritableFileHandle = { createWritable: () => Promise<{ write: (data: Blob) => Promise close: () => Promise }> } export type PerformanceSurveyExportDestination = | { kind: 'file-system'; handle: WritableFileHandle } | { kind: 'download' } | { kind: 'cancelled' } function safeText(value: unknown, fallback = '-') { const normalized = String(value ?? '').trim() return normalized || fallback } export function sanitizePerformanceSurveyExportFileName(value: string) { const normalized = value .replace(/[\\/:*?"<>|]/g, '_') .replace(/\s+/g, ' ') .trim() return `${normalized || '考核调研公共看板'}.xlsx` } function getTemplateName(template: PerformanceSurveyDashboardExportData['templates'][number]) { return safeText(template.fullName || template.name, template.code) } function getRowPositionName( data: PerformanceSurveyDashboardExportData, row: PerformanceSurveyExportRow, ) { if (data.selectedPositionGroup?.name) return data.selectedPositionGroup.name const positionNameByCode = new Map( (data.positionGroups || []).map((group) => [group.code, group.name]), ) const names = Array.from(new Set( Object.values(row.cells || {}) .map((cell) => cell?.positionGroupCode ? positionNameByCode.get(cell.positionGroupCode) : null) .filter((name): name is string => Boolean(name)), )) return names.length > 0 ? names.join('、') : '全部岗位' } export function buildPerformanceSurveyExportTable(data: PerformanceSurveyDashboardExportData) { const fixedHeaders = ['序号', '岗位', '考核月份', '姓名', '工号', '组织', '职级'] const scoreHeaders = data.templates.map((template) => { const maxTotal = Number(template.maxTotal) return Number.isFinite(maxTotal) && maxTotal > 0 ? `${getTemplateName(template)}(满分${maxTotal})` : getTemplateName(template) }) const rows = data.public.rows.map((row, index) => { const values: Array = [ index + 1, getRowPositionName(data, row), safeText(row.month || data.month), safeText(row.employee.displayName), safeText(row.employee.jobNumber), safeText(row.employee.departmentName), safeText(row.employee.positionLevel || row.employee.levelType), ] for (const template of data.templates) { const cell = row.cells?.[template.code] const score = Number(cell?.score) if (cell?.score !== null && cell?.score !== undefined && Number.isFinite(score)) { values.push(score) } else if (Number(cell?.surveyCount || 0) > 0) { values.push('待评分') } else { values.push('-') } } return values }) return { headers: [...fixedHeaders, ...scoreHeaders], rows } } export async function buildPerformanceSurveyDashboardWorkbook( data: PerformanceSurveyDashboardExportData, ) { const excelJsModule = await import('exceljs') const ExcelJS = excelJsModule.default const workbook = new ExcelJS.Workbook() workbook.creator = '考核调研' workbook.created = new Date() const selectedPositionName = data.selectedPositionGroup?.name || (data.filters?.positionGroupCode === 'all' ? '全部岗位' : '考核岗位') const sheetName = `${safeText(data.month)}_${selectedPositionName}`.slice(0, 31) const worksheet = workbook.addWorksheet(sheetName) const table = buildPerformanceSurveyExportTable(data) const lastColumn = Math.max(table.headers.length, 1) worksheet.mergeCells(1, 1, 1, lastColumn) const titleCell = worksheet.getCell(1, 1) titleCell.value = `${safeText(data.month)} · ${selectedPositionName}考核调研得分` titleCell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 16 } titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1F2937' } } titleCell.alignment = { vertical: 'middle', horizontal: 'left' } worksheet.getRow(1).height = 30 worksheet.mergeCells(2, 1, 2, lastColumn) const summaryCell = worksheet.getCell(2, 1) summaryCell.value = `岗位:${selectedPositionName} 月份:${safeText(data.month)} 人员:${data.public.rows.length} 人 考核项:${data.templates.length} 项` summaryCell.font = { color: { argb: 'FF475467' }, size: 10 } summaryCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF4F6F8' } } summaryCell.alignment = { vertical: 'middle', horizontal: 'left' } worksheet.getRow(2).height = 24 worksheet.addRow([]) const headerRow = worksheet.addRow(table.headers) headerRow.height = 34 headerRow.font = { bold: true, color: { argb: 'FF1F2937' }, size: 10 } headerRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDCE8F8' } } headerRow.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true } for (const values of table.rows) { const row = worksheet.addRow(values) row.height = 26 row.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true } row.eachCell((cell, columnNumber) => { if (columnNumber >= 8 && typeof cell.value === 'number') cell.numFmt = '0.##' }) } worksheet.columns = table.headers.map((header, index) => ({ width: index === 0 ? 8 : index === 1 ? 18 : index === 2 ? 13 : index === 3 ? 14 : index === 4 ? 16 : index === 5 ? 22 : index === 6 ? 12 : Math.min(Math.max(header.length * 1.2, 18), 30), })) worksheet.views = [{ state: 'frozen', xSplit: 7, ySplit: 4 }] worksheet.autoFilter = { from: { row: 4, column: 1 }, to: { row: 4, column: lastColumn } } const border: Partial = { top: { style: 'thin', color: { argb: 'FFD0D5DD' } }, left: { style: 'thin', color: { argb: 'FFD0D5DD' } }, bottom: { style: 'thin', color: { argb: 'FFD0D5DD' } }, right: { style: 'thin', color: { argb: 'FFD0D5DD' } }, } for (let rowNumber = 4; rowNumber <= worksheet.rowCount; rowNumber += 1) { worksheet.getRow(rowNumber).eachCell({ includeEmpty: true }, (cell) => { cell.border = border }) } return workbook } export async function choosePerformanceSurveyExportDestination( suggestedName: string, ): Promise { if (typeof window === 'undefined') return { kind: 'download' } const picker = (window as unknown as { showSaveFilePicker?: (options: unknown) => Promise }).showSaveFilePicker if (!picker) return { kind: 'download' } try { const handle = await picker.call(window, { suggestedName, types: [{ description: 'WPS/Excel 工作簿', accept: { [XLSX_MIME_TYPE]: ['.xlsx'] }, }], excludeAcceptAllOption: true, }) return { kind: 'file-system', handle } } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return { kind: 'cancelled' } throw error } } export async function savePerformanceSurveyDashboardWorkbook( data: PerformanceSurveyDashboardExportData, fileName: string, destination: PerformanceSurveyExportDestination, ) { if (destination.kind === 'cancelled') return const workbook = await buildPerformanceSurveyDashboardWorkbook(data) const buffer = await workbook.xlsx.writeBuffer() const blob = new Blob([buffer], { type: XLSX_MIME_TYPE }) if (destination.kind === 'file-system') { const writable = await destination.handle.createWritable() await writable.write(blob) await writable.close() return } if ( typeof document === 'undefined' || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function' ) { throw new Error('当前浏览器无法创建下载文件,请更换 Chrome 或 Edge 后重试。') } const objectUrl = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = objectUrl anchor.download = fileName anchor.rel = 'noopener' anchor.style.display = 'none' document.body.appendChild(anchor) try { anchor.click() } finally { anchor.remove() window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0) } }