gongxiangp2/lib/performance-survey-dashboard-export.ts
2026-08-06 14:28:26 +08:00

269 lines
9.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, PerformanceSurveyExportCell | null | undefined>
}
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<void>
close: () => Promise<void>
}>
}
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<string | number> = [
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<ExcelJS.Borders> = {
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<PerformanceSurveyExportDestination> {
if (typeof window === 'undefined') return { kind: 'download' }
const picker = (window as unknown as {
showSaveFilePicker?: (options: unknown) => Promise<WritableFileHandle>
}).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)
}
}