269 lines
9.0 KiB
TypeScript
269 lines
9.0 KiB
TypeScript
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)
|
||
}
|
||
}
|