import ProTable from '@ant-design/pro-table' import numeral from 'numeral' import './printBox.less' import { CurrentUser } from 'umi' import session from './session' import moment from 'moment' import { synchroBehaviorRecord } from '@/services/user' import ExcelJS from 'exceljs'; export type TreeSelectModel = { value: number | string, id: number | string, title: string, pId: number | string, disabled?: boolean } export type TreeNodeDto = { node: any; children: TreeNodeDto[]; } /** * @description: [{node:{},chidlren:[]}] => [{...node,children}] * @param {any} node 需要拼接的父节点 * @param {any} chidlren 需要遍历的平级节点 合并节点中的 * @return {*} 返回[node:{...,children:[]}] */ export function wrapTreeNode(data: TreeNodeDto[]) { const wrapData: any = data.map((item: TreeNodeDto) => { const node = { ...item.node }; if (item.children && item.children.length > 0) { node.children = wrapTreeNode(item.children); } return node }); return wrapData; } export function formatTreeForProTable(data: TreeNodeDto[]) { const wrapData = wrapTreeNode(data); return { data: wrapData, current: 1, pageSize: 100, total: 100, success: true, }; } export function tableList(list: any) { return { data: list.List || [], current: list.PageIndex || 1, pageSize: list.pageSize || 10, total: list.TotalCount || 0, otherData: list?.OtherData || '', success: true, }; } /** * @description:生产的数据仅提供 treeSelect组件treeDataSimpleMode模式使用的 数据, * @param fields 需要转化的数据, * @param treeKeys fields 与 TreeSelectModel 数据键值 如 fields as EnumItem ,treeKeys: { title: 'fieldEnumName', value: 'id', id:'id', pId:'parentId' } * @returns TreeSelectModel[] */ export const getTreeSelectOption = async (treeKeys: TreeSelectModel, fields: any[]) => { const option: TreeSelectModel[] = [] const { value, title, pId, id } = treeKeys fields.forEach(async (item: any) => { option.push({ value: item[value], title: item[title], pId: item[pId], id: item[id] }) if (item.children) { const children: TreeSelectModel[] = await getTreeSelectOption(treeKeys, item.children) option.push(...children) } }) return option } // 转换数据为option格式数据 export function formateOptions(list: [], rules: { name: string; value: string; other?: string }) { // let options: { label: string; value: number | string; other?: string | number }[] = []; const { name, value, other } = rules; if (list && other) { return list.map((n) => { return { label: n[name], value: n[value], other: n[other], }; }); } if (list) { return list.map((n) => { return { label: n[name], value: n[value], }; }); } return []; } // 转换options数据value类型为 number export function formateField(list: { label: string; value: string | number }[]) { const valueNumber: { label: string; value: number }[] = []; list.map((n: any) => { if (!isNaN(Number(n.value))) { valueNumber.push({ label: n.label, value: numeral(n.value).value(), }); } }); return valueNumber.length > 0 ? valueNumber : list; } // 转换树节点的value类型为string export function formateTreeField(list: TreeNodeDto[]) { const valueNumber: any[] = list.map((item: TreeNodeDto) => { const node = { label: item.node.label, value: item.node.value.toString(), type: item.node.type, ico: item.node.ico }; if (item.children && item.children.length > 0) { node.children = formateTreeField(item.children); } return node }); return valueNumber.length > 0 ? valueNumber : list; } // 把图片格式转化为 ui框架需要的数据格式 export const transferImg = (orgIamges: any[]) => { const newImages = orgIamges.map((n: any) => { if (typeof n === 'object') { return { uid: n.ImageId, // 注意,这个uid一定不能少,否则上传失败 name: n.ImageName, status: 'done', url: n.ImageUrl, // url 是展示在页面上的绝对链接 imgUrl: n.ImagePath, } } const [name] = [...n.split("/")].reverse() return { uid: new Date().getTime(), // 注意,这个uid一定不能少,否则上传失败 name, status: 'done', url: n, // url 是展示在页面上的绝对链接 imgUrl: n, } }) return newImages } // 合计方法 export const handleGetSumRow = (data: any[], fieldList: string[], sumTitle: string, avgFiled?: string) => { // data 表格数据 fieldList 要算合计的字段 sumTitle 显示合计两个字的字段 avgFiled 要算均值的字段(这个字段也必须在算合计的字段里面) if (data && data.length > 0) { if (data.length >= 2) { const res: any = {} if (fieldList && fieldList.length > 0) { fieldList.forEach((item: any) => { res[item] = 0 }) } data.forEach((item: any) => { if (res) { for (const key in res) { if (item[key]) { res[key] += item[key] } } } }) if (avgFiled) { res[avgFiled] = Number((res[avgFiled] / data.length).toFixed(2)) } console.log('res', res); res[sumTitle] = "合计" data.unshift(res) return data } // 一个片区的时候 判断是不是一个服务区 一个服务区的话 就单单显示一个服务区就好 if (data && data.length === 1) { const obj: any = data[0] if (obj.children && obj.children.length === 1) { return obj.children } return data } return data } return [] } // 自定义打印的内容的打印方法 export const handleNewPrint = (printName: string, title: string, neckBox?: any, styles?: any, tableDom?: any, footer?: any) => { // printName 打印出来文件的名称 // title 打印内容的标题 // neckBox 标题下面可能会要求的打印的内容 数组 {label,value}格式 样式已写死 // styles 获取页面的样式 // tableDom 要打印显示的表格 直接dom元素拿进来(处理好的) // footer 打印内容底部的自定义样式 需求不一样 样式也不一样 外面写好样式和标签直接传入 const printWindow = window.open('', '_blank', 'width=1400,height=800'); if (printWindow) { printWindow.document.open(); printWindow.document.write(` ${printName || ''}
${title}
${neckBox && neckBox.length > 0 ? neckBox .map((item: any) => { return `
${item.label ? item.label + ':' : ''} ${item.value}
`; }) .join('') // 连接成单一字符串,避免逗号 : ''}
单位:元
${tableDom}
${footer}
${Array(16) // 5x5 网格的水印内容 .fill('安徽省驿达高速公路服务区经营管理有限公司') .join('')}
`) printWindow.document.close(); printWindow.print(); // 使用定时器检测打印窗口是否关闭 const closeCheckInterval = setInterval(() => { if (printWindow.closed) { clearInterval(closeCheckInterval); } }, 500); // 监听窗口焦点事件,若打印窗口失去焦点,关闭窗口 printWindow.onfocus = () => { printWindow.close(); clearInterval(closeCheckInterval); }; printWindow.onafterprint = () => printWindow.close(); } } // 自定义打印的内容的打印方法 export const handleNewPrintAHJG = (printName: string, title: string, neckBox?: any, styles?: any, tableDom?: any, footer?: any) => { // printName 打印出来文件的名称 // title 打印内容的标题 // neckBox 标题下面可能会要求的打印的内容 数组 {label,value}格式 样式已写死 // styles 获取页面的样式 // tableDom 要打印显示的表格 直接dom元素拿进来(处理好的) // footer 打印内容底部的自定义样式 需求不一样 样式也不一样 外面写好样式和标签直接传入 const printWindow = window.open('', '_blank', 'width=1400,height=800'); if (printWindow) { printWindow.document.open(); printWindow.document.write(` ${printName || ''}
${title}
${neckBox && neckBox.length > 0 ? neckBox .map((item: any) => { return `
${item.label ? item.label + ':' : ''} ${item.value}
`; }) .join('') // 连接成单一字符串,避免逗号 : ''}
${tableDom}
${footer}
${Array(16) // 5x5 网格的水印内容 .fill('安徽建工集团投资运营管理有限公司') .join('')}
`) printWindow.document.close(); printWindow.print(); // 使用定时器检测打印窗口是否关闭 const closeCheckInterval = setInterval(() => { if (printWindow.closed) { clearInterval(closeCheckInterval); } }, 500); // 监听窗口焦点事件,若打印窗口失去焦点,关闭窗口 printWindow.onfocus = () => { printWindow.close(); clearInterval(closeCheckInterval); }; printWindow.onafterprint = () => printWindow.close(); } } // 打印图片 export const handlePrintImg = (url: any) => { const printWindow = window.open('', '_blank', 'width=1400,height=800'); if (printWindow) { printWindow.document.open(); printWindow.document.close(); printWindow.document.write(`
`) printWindow.print(); // 使用定时器检测打印窗口是否关闭 const closeCheckInterval = setInterval(() => { if (printWindow.closed) { clearInterval(closeCheckInterval); } }, 500); printWindow.onfocus = () => { printWindow.close(); clearInterval(closeCheckInterval); }; printWindow.onafterprint = () => printWindow.close(); } } // 记录每一个按钮的 调用了的方法 export const handleSetPublicLog = (obj: any) => { // desc 一个数组 入参内容说明 startTime 开始时间 endTime 结束时间 时间戳格式 buttonType 1 为点击了查询按钮 const currentUser: CurrentUser = session.get('currentUser'); let nowMenu = session.get("currentMenu") let basicInfo = session.get("basicInfo") let systemBasin = session.get("systemBasin") let browserVersion = session.get("browserVersion") if (obj.desc && obj.desc.length > 0) { obj.desc.forEach((item: any) => { item.url = 'https://pos.eshangtech.com' + item.url }) } const req: any = { USER_ID: currentUser.ID, USER_NAME: currentUser.Name, BEHAVIORRECORD_TYPE: "2000", // 1000 浏览页面 2000 行为记录 BEHAVIORRECORD_EXPLAIN: `在页面${nowMenu.name}${obj.buttonType === 1 ? '点击了查询按钮' : ''}`, // 操作行为说明 BEHAVIORRECORD_ROUT: nowMenu.pathname, BEHAVIORRECORD_ROUTNAME: nowMenu.name, REQUEST_INFO: JSON.stringify(obj.desc),// 存的是一个JSON 存完整点的内容 在什么页面点击了什么 调用了什么接口 入参是什么 多个接口的时候是数组格式 // BEHAVIORRECORD_DESC: obj.desc, // 入参 BEHAVIORRECORD_TIME: moment(new Date(obj.startTime)).format('YYYY-MM-DD HH:mm:ss'), BEHAVIORRECORD_LEAVETIME: moment(new Date(obj.endTime)).format('YYYY-MM-DD HH:mm:ss'), BEHAVIORRECORD_DURATION: (obj.endTime - obj.startTime) / 1000, OWNERUNIT_ID: currentUser.OwnerUnitId, OWNERUNIT_NAME: currentUser.OwnerUnitName, BUSINESSMAN_ID: currentUser.BusinessManID, BUSINESSMAN_NAME: currentUser.BusinessManName, SOURCE_PLATFORM: '驿商云平台', USER_LOGINIP: basicInfo.ip, USER_LOGINPLACE: `${basicInfo?.prov ? basicInfo?.prov : ''}${basicInfo?.prov && basicInfo?.city ? '-' : ''}${basicInfo?.city ? basicInfo?.city : ''}${basicInfo?.prov && basicInfo?.city && basicInfo?.district ? '-' : ''}${basicInfo?.district ? basicInfo?.district : ''}`, BROWSER_VERSION: browserVersion, OPERATING_SYSTEM: systemBasin } console.log('reqreqreqreqreq', req); synchroBehaviorRecord(req) } // 将一个对象的key值变为 中文字 export const handleChangeKeyTo = async (params: any, keyObj: any) => { // params 需要变的对象 keyObj 中文内容 let newObj: any = {} for (let key in params) { newObj[keyObj[key]] = params[key] } return newObj } // 传入秒 返回时分 export const secondsToHuman = (seconds: number) => { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const parts = []; if (hours > 0) parts.push(`${hours}小时`); if (minutes > 0) parts.push(`${minutes}分钟`); return parts.join('') || '0分钟'; } export async function getUserIP(): Promise { try { // 尝试多个第三方API服务 const apis = [ 'https://api.ipify.org?format=json', 'https://ipapi.co/json/', 'https://jsonip.com/', 'https://httpbin.org/ip' ]; for (const api of apis) { try { const response = await fetch(api, { method: 'GET', timeout: 5000, // 5秒超时 }); if (!response.ok) { continue; } const data = await response.json(); // 根据不同API返回格式获取IP if (data.ip) { return data.ip; } else if (data.origin) { // httpbin返回格式 return data.origin; } } catch (error) { console.warn(`IP API ${api} 请求失败:`, error); continue; } } throw new Error('所有IP服务都无法访问'); } catch (error) { console.error('获取IP地址失败:', error); return '获取失败'; } } /** * 根据IP地址获取地理位置信息 * @param ip IP地址 * @returns Promise 地理位置信息 */ export async function getLocationByIP(ip: string, ak: string): Promise<{ country?: string; province?: string; city?: string; district?: string; isp?: string; success: boolean; message?: string; }> { if (!ip || ip === '获取失败') { return { success: false, message: 'IP地址无效' }; } if (!ak) { return { success: false, message: '百度地图API密钥(ak)不能为空' }; } try { const response = await fetch(`/baidu-api/location/ip?ip=${ip}&ak=${ak}`, { method: 'GET', timeout: 8000, headers: { 'Accept': 'application/json' } }); if (!response.ok) { throw new Error(`HTTP错误: ${response.status}`); } const data = await response.json(); // 检查百度API返回状态 if (data.status !== 0) { return { success: false, message: `百度API错误: ${data.message || '未知错误'}` }; } // 解析百度地图返回的数据 const content = data.content; const addressDetail = content.address_detail; return { country: '中国', // 百度地图IP定位默认中国 province: addressDetail.province || '', city: addressDetail.city || '', district: addressDetail.district || '', isp: content.address || '', success: true }; } catch (error) { console.error('获取地理位置失败:', error); return { success: false, message: '获取地理位置失败' }; } } // 封装一个只要传入操作事项的就可以记录日志 export const handleSetlogSave = async (str?: string) => { const currentUser = session.get('currentUser') const basicInfo = session.get('basicInfo') const browserVersion = session.get('browserVersion') const systemBasin = session.get('systemBasin') let nowMenu = session.get("currentMenu") synchroBehaviorRecord({ USER_ID: currentUser?.ID, USER_NAME: currentUser?.Name, BEHAVIORRECORD_TYPE: "2000", BEHAVIORRECORD_EXPLAIN: str || "", BEHAVIORRECORD_ROUT: nowMenu.pathname, BEHAVIORRECORD_ROUTNAME: nowMenu.name, BEHAVIORRECORD_TIME: moment().format('YYYY-MM-DD HH:mm:ss'), // REQUEST_INFO: str || "", OWNERUNIT_ID: currentUser?.OwnerUnitId, OWNERUNIT_NAME: currentUser?.OwnerUnitName, BUSINESSMAN_ID: currentUser?.BUSINESSMAN_ID, BUSINESSMAN_NAME: currentUser?.BUSINESSMAN_NAME, SOURCE_PLATFORM: "出行平台", USER_LOGINIP: basicInfo?.ip, USER_LOGINPLACE: `${basicInfo?.prov ? basicInfo?.prov : ''}${basicInfo?.prov && basicInfo?.city ? '-' : ''}${basicInfo?.city ? basicInfo?.city : ''}${basicInfo?.prov && basicInfo?.city && basicInfo?.district ? '-' : ''}${basicInfo?.district ? basicInfo?.district : ''}`, BROWSER_VERSION: browserVersion, OPERATING_SYSTEM: systemBasin }) } export function convertTreeToLabelValue( tree: T[], labelKey: keyof T, valueKey: keyof T, childrenKey: keyof T = 'children' as keyof T ): any[] { return tree.map((item: any) => { const node: any = { label: item[labelKey], value: item[valueKey] }; if (Array.isArray(item[childrenKey]) && item[childrenKey].length > 0) { node.children = convertTreeToLabelValue(item[childrenKey], labelKey, valueKey, childrenKey); } return node; }); } export function convertTreeFieldToNumber(tree: any[], key: string): any[] { return tree.map(node => { const newNode = { ...node, [key]: typeof node[key] === 'number' ? node[key].toString() : node[key], }; if (Array.isArray(node.children) && node.children.length > 0) { newNode.children = convertTreeFieldToNumber(node.children, key); } return newNode; }); } export function markDisabled(nodes: any, disabledIds: any) { return nodes.map((node: any) => { const copy = { ...node }; if (disabledIds.includes(copy.FIELDENUM_ID)) { copy.disabled = true; } if (copy.children && Array.isArray(copy.children)) { copy.children = markDisabled(copy.children, disabledIds); } return copy; }); }; /** ======== 列类型(按需裁剪) ======== */ type AnyCol = { title?: any; dataIndex?: string | (string | number)[]; children?: AnyCol[]; valueEnum?: Record; renderText?: (text: any, record: any, index: number) => any; hideInTable?: boolean; valueType?: 'index' | string; align?: 'left' | 'center' | 'right'; }; /** ========== 新增:拍平树形数据 ========== */ function flattenTree>( list: T[] = [], childrenKey = 'children', out: T[] = [] ): T[] { for (const node of list) { // 先推当前节点(浅拷贝去掉 children,避免把对象树写入单元格) const { [childrenKey]: kids, ...rest } = node as any; out.push(rest as T); if (Array.isArray(kids) && kids.length) { flattenTree(kids, childrenKey, out); } } return out; } /** 抽取 React 节点文本(支持换行) */ function extractText(node: any): string { if (node == null || node === false) return ''; if (typeof node === 'string' || typeof node === 'number') { // Excel 内部单元格换行的标准是 \n return String(node).replace(//gi, '\n').replace(/\r\n/g, '\n'); } // 支持 React 的
节点 if (node?.type === 'br') return '\n'; const props = node?.props; if (props) { // 渲染 dangerouslySetInnerHTML const html = props?.dangerouslySetInnerHTML?.__html; if (typeof html === 'string') { return html .replace(//gi, '\n') .replace(/<[^>]+>/g, '') .replace(/ /g, ' '); } // 递归渲染 children const children = props?.children; if (Array.isArray(children)) return children.map(extractText).join(''); if (children != null) return extractText(children); } return String(node ?? ''); } /** 过滤 hideInTable(父级联动) */ function pruneHiddenColumns(cols: AnyCol[]): AnyCol[] { const walk = (arr: AnyCol[]): AnyCol[] => (arr || []) .filter(col => !col?.hideInTable) .map(col => { if (col.children?.length) { const kids = walk(col.children); if (!kids.length) return null as any; return { ...col, children: kids }; } return col; }) .filter(Boolean); return walk(cols); } /** 叶子列(有 dataIndex 的) */ const getLeaves = (cols: AnyCol[]): AnyCol[] => { const out: AnyCol[] = []; const walk = (arr: AnyCol[]) => { arr.forEach(c => { if (c?.children?.length) { walk(c.children!); } else if (c && (c.dataIndex || c.valueType === 'index')) { // << 修改 out.push(c); } }); }; walk(cols); return out; }; const getColSpan = (c: AnyCol): number => c.children?.length ? c.children.map(getColSpan).reduce((a, b) => a + b, 0) : 1; /** 构造多级表头矩阵 & 合并信息(不含顶部大标题/信息行) */ function buildHeaderMatrix(cols: AnyCol[]) { const depth = getDepth(cols); const rows: string[][] = Array.from({ length: depth }, () => []); let colCursor = 0; const merges: Array<{ r1: number; c1: number; r2: number; c2: number }> = []; const place = (list: AnyCol[], level: number) => { list.forEach(col => { const span = getColSpan(col); const rowSpan = col.children?.length ? 1 : depth - level; const title = extractText(col.title ?? ''); rows[level][colCursor] = title; for (let i = 1; i < span; i++) rows[level][colCursor + i] = ''; if (span > 1 || rowSpan > 1) { merges.push({ r1: level + 1, c1: colCursor + 1, r2: level + rowSpan, c2: colCursor + span }); } if (col.children?.length) { place(col.children, level + 1); } else { colCursor += 1; } }); }; place(cols, 0); const maxLen = Math.max(...rows.map(r => r.length)); rows.forEach(r => { for (let i = 0; i < maxLen; i++) if (typeof r[i] === 'undefined') r[i] = ''; }); return { headerAOA: rows, merges, depth, columnCount: maxLen }; } /** 深度和列跨度 */ const getDepth = (cols: AnyCol[]): number => { const dfs = (c: AnyCol): number => c.children?.length ? 1 + Math.max(...c.children.map(dfs)) : 1; return Math.max(...cols.map(dfs)); }; const toPath = (di?: AnyCol['dataIndex']): (string | number)[] => Array.isArray(di) ? di : (typeof di === 'string' ? di.split('.') : []); const getByPath = (obj: any, path: (string | number)[]) => path.reduce((acc, k) => (acc == null ? acc : acc[k]), obj); /** 支持 A|B 或逻辑的取值 */ const getValueWithOr = (obj: any, dataIndex?: AnyCol['dataIndex']): any => { if (!dataIndex) return undefined; // 如果是数组,直接使用原逻辑 if (Array.isArray(dataIndex)) { return getByPath(obj, dataIndex); } // 如果是字符串,检查是否包含 | 符号 if (typeof dataIndex === 'string') { if (dataIndex.includes('|')) { // 分割并按优先级取值 const keys = dataIndex.split('|').map(key => key.trim()); for (const key of keys) { const value = getByPath(obj, toPath(key)); if (value != null && value !== '') { return value; } } return null; } else { // 普通路径,使用原逻辑 return getByPath(obj, toPath(dataIndex)); } } return undefined; }; /** 单元格显示值(valueEnum / renderText / 原始值) */ function getCellValue(col: AnyCol, record: any, rowIndex: number) { // << 新增:序号列 if (col.valueType === 'index') return rowIndex + 1; const raw = getValueWithOr(record, col.dataIndex); if (col.valueEnum) { const ve = col.valueEnum[raw as any]; if (typeof ve === 'string') return ve; if (ve?.text != null) return ve.text; } if (col.renderText) { try { return col.renderText(raw, record, rowIndex); } catch { } } return raw; } /** 估算列宽(简单) */ const estimateWidth = (v: any) => { const s = (v ?? '').toString(); const len = Array.from(s).reduce((n: number, ch: any) => n + (/[^\x00-\xff]/.test(ch) ? 2 : 1), 0); return Math.min(Math.max(len + 2, 8), 60); }; // 导出的方法 配合着 formatTreeData 处理过的数据去用 效果最佳 export async function exportXlsxFromProColumnsExcelJS( rawColumns: AnyCol[], dataSource: any[], filename?: string, options?: { sheetName?: string; chunkSize?: number; topTitle?: string; infoRowLeft?: string; infoRowRight?: string; freezeHeader?: boolean; // 已保留但默认不再生效(不触发冻结) childrenKey?: string; footerItems?: { label: string; value: string | number | null | undefined; labelSpan?: number; valueSpan?: number; startCol?: number; // 新增:起始列(1-based index) align?: 'left' | 'center' | 'right'; labelAlign?: 'left' | 'center' | 'right'; font?: Partial; labelFont?: Partial; }[] } ) { const { sheetName = '数据', chunkSize = 100_000, topTitle, infoRowLeft, infoRowRight, // 为兼容外部调用保留字段,但默认不做冻结以满足你的要求 freezeHeader = false, childrenKey = 'children', footerItems = [] } = options || {}; const flatData = flattenTree(Array.isArray(dataSource) ? dataSource : [], childrenKey); const columns = pruneHiddenColumns(rawColumns); const leafCols = getLeaves(columns); if (!leafCols.length) throw new Error('无可导出的列(可能被 hideInTable 全部隐藏)'); const { headerAOA, merges, columnCount } = buildHeaderMatrix(columns); const wb = new ExcelJS.Workbook(); wb.created = new Date(); wb.modified = new Date(); const total = flatData?.length ?? 0; const totalSheets = Math.max(1, Math.ceil(total / chunkSize)); for (let si = 0; si < totalSheets; si++) { // 注意:不再传 views 配置,避免任何默认冻结 const ws = wb.addWorksheet(totalSheets === 1 ? sheetName : `${sheetName}_${si + 1}`); let currentRowIndex = 1; // 顶部标题 if (topTitle) { const row = ws.getRow(currentRowIndex); const titleContent = extractText(topTitle); // 重要:先执行合并,再分发数据和样式 ws.mergeCells(currentRowIndex, 1, currentRowIndex, columnCount || 1); // 对合并后的 Master Cell 进行赋值和核心样式设置 const masterCell = ws.getCell(currentRowIndex, 1); masterCell.value = titleContent; // 字体正常,不加粗 masterCell.font = { size: 14, name: 'Microsoft YaHei' }; // 强制对主单元格设置居中和换行 masterCell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true }; // 强制整行设置换行对齐属性作为辅助 row.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true }; // 计算行高 (基准 32 保证显示完整) const lines = titleContent.split('\n').length; row.height = Math.max(lines * 32, 40); currentRowIndex += 1; } // 信息行 if (infoRowLeft != null || infoRowRight != null) { const row = ws.getRow(currentRowIndex); const split = Math.max(1, Math.floor(columnCount / 2)); if (infoRowLeft != null) { row.getCell(1).value = infoRowLeft; ws.mergeCells(currentRowIndex, 1, currentRowIndex, split); const leftCell = ws.getCell(currentRowIndex, 1); leftCell.alignment = { horizontal: 'left', vertical: 'middle' }; leftCell.font = { size: 11 }; } if (infoRowRight != null) { row.getCell(split + 1).value = infoRowRight; ws.mergeCells(currentRowIndex, split + 1, currentRowIndex, columnCount); const rightCell = ws.getCell(currentRowIndex, split + 1); rightCell.alignment = { horizontal: 'right', vertical: 'middle' }; rightCell.font = { size: 11 }; } row.height = 18; currentRowIndex += 1; } // 表头(多级支持) const headerStartRow = currentRowIndex; headerAOA.forEach((r, idx) => { const row = ws.getRow(headerStartRow + idx); let maxRowLines = 1; r.forEach((v, cIdx) => { const text = extractText(v); const cell = row.getCell(cIdx + 1); cell.value = text; cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true }; cell.font = { bold: true }; const lines = text.split('\n').length; if (lines > maxRowLines) maxRowLines = lines; }); row.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true }; row.height = Math.max(22, maxRowLines * 18); }); merges.forEach(m => { ws.mergeCells(headerStartRow + (m.r1 - 1), m.c1, headerStartRow + (m.r2 - 1), m.c2); }); currentRowIndex += headerAOA.length; // 数据区 const start = si * chunkSize; const end = Math.min(start + chunkSize, total); const batch = flatData.slice(start, end); for (let i = 0; i < batch.length; i++) { const rec = batch[i]; const row = ws.getRow(currentRowIndex + i); let maxRowLines = 1; leafCols.forEach((col, j) => { const cell = row.getCell(j + 1); const val = getCellValue(col, rec, start + i); if (typeof val === 'number' && !isNaN(val)) { cell.value = val; } else { cell.value = extractText(val); } const alignment: any = { vertical: 'middle', wrapText: true }; if (col.align) alignment.horizontal = col.align; cell.alignment = alignment; const textForHeight = String(cell.value || ''); const lines = textForHeight.split('\n').length; if (lines > maxRowLines) maxRowLines = lines; }); row.height = Math.max(18, maxRowLines * 16); } currentRowIndex += batch.length; // 列宽估算 for (let c = 1; c <= columnCount; c++) { const wsCol = ws.getColumn(c); // 注意:不再此处设置 wsCol.alignment,防止覆盖标题和表头的水平居中样式 // 对齐已在数据行渲染阶段(Step 4)按列配置精准应用 const headerMax = Math.max(...headerAOA.map(r => estimateWidth(r[c - 1]))); let dataMax = 8; const sampleRowsLimit = Math.min(batch.length, 100); for (let i = 0; i < sampleRowsLimit; i++) { const v = leafCols[c - 1] ? getCellValue(leafCols[c - 1], batch[i], start + i) : ''; dataMax = Math.max(dataMax, estimateWidth(v)); } wsCol.width = Math.max(headerMax, dataMax); } // 注意:不再对 ws.views 进行任何设置(避免冻结表头) // if (freezeHeader) { ... } // 已移除 if (Array.isArray(footerItems) && footerItems.length > 0) { currentRowIndex += 1; const footerRow = ws.getRow(currentRowIndex); let colCursor = 1; for (const item of footerItems) { // --- 核心修改 --- // 如果 startCol 被提供,则将光标“跳转”到指定位置 if (typeof item.startCol === 'number' && item.startCol > 0) { colCursor = item.startCol; } // 如果光标已在界外,则跳过此项的渲染 if (colCursor > columnCount) { continue; } const { label, value, labelSpan = 1, valueSpan = 2, align = 'left', labelAlign = 'right', font = { size: 11 }, labelFont = { size: 11 }, } = item; // --- 渲染 Label --- if (labelSpan > 0) { const startCol = colCursor; const endCol = Math.min(colCursor + labelSpan - 1, columnCount); if (endCol > startCol) ws.mergeCells(currentRowIndex, startCol, currentRowIndex, endCol); const labelCell = footerRow.getCell(startCol); labelCell.value = label ? `${label}:` : ''; labelCell.alignment = { horizontal: labelAlign, vertical: 'middle' }; labelCell.font = labelFont; colCursor = endCol + 1; } // --- 渲染 Value --- if (colCursor <= columnCount && valueSpan > 0) { const startCol = colCursor; const endCol = Math.min(colCursor + valueSpan - 1, columnCount); if (endCol > startCol) ws.mergeCells(currentRowIndex, startCol, currentRowIndex, endCol); const valueCell = footerRow.getCell(startCol); valueCell.value = value ?? ''; valueCell.alignment = { horizontal: align, vertical: 'middle' }; valueCell.font = font; colCursor = endCol + 1; } } footerRow.height = 18; currentRowIndex += 1; } } // 生成并触发下载 const buf = await wb.xlsx.writeBuffer(); const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${filename || sheetName}.xlsx`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } /** * 格式化多层级树形数据 - 千分号格式化和枚举解析 * @param data 多层级数组数据 * @param formatFields 需要格式化的字段数组 (支持嵌套字段如 "obj.fieldName") * @param enumFields 需要解析枚举的字段数组 (支持嵌套字段) * @param enumData 枚举对应的数据数组,按顺序对应 enumFields * @param childrenKey 子节点的key名称,默认为 'children' * @returns 格式化后的数组数据 */ export function formatTreeData( data: any[], formatFields: string[] = [], enumFields: string[] = [], enumData: any[] = [], percentFields: string[] = [], childrenKey: string = 'children' ): any[] { if (!data || !Array.isArray(data) || data.length === 0) { return data; } /** * 根据字段路径获取对象的值 * @param obj 目标对象 * @param path 字段路径,如 "obj.fieldName" * @returns 字段值 */ function getNestedValue(obj: any, path: string): any { if (!obj || typeof obj !== 'object') { return undefined; } const keys = path.split('.'); let current = obj; for (const key of keys) { if (current === null || current === undefined) { return undefined; } current = current[key]; } return current; } /** * 根据字段路径设置对象的值 * @param obj 目标对象 * @param path 字段路径,如 "obj.fieldName" * @param value 要设置的值 */ function setNestedValue(obj: any, path: string, value: any): void { if (!obj || typeof obj !== 'object') { return; } const keys = path.split('.'); let current = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (current[key] === null || current[key] === undefined || typeof current[key] !== 'object') { current[key] = {}; } current = current[key]; } current[keys[keys.length - 1]] = value; } /** * 将数字格式化为千分号字符串 * @param value 要格式化的值 * @returns 格式化后的字符串 */ function formatNumber(value: any): string { if (value === null || value === undefined || value === '') { return ''; } const num = Number(value); if (isNaN(num)) { return String(value); } return num.toLocaleString(); } /** * 根据枚举数据解析值 * @param value 要解析的值 * @param enumData 枚举数据,支持对象格式 {1000:"枚举1",2000:"枚举2"} 或数组格式 * @returns 解析后的值 */ function parseEnumValue(value: any, enumData: any): any { if (!enumData) { return value; } // 支持对象格式 {1000:"枚举1",2000:"枚举2"} if (typeof enumData === 'object' && !Array.isArray(enumData)) { // 直接匹配 let enumValue = enumData[value]; if (enumValue !== undefined) { return enumValue; } // 类型转换匹配:尝试字符串和数字的相互转换 const valueStr = String(value); const valueNum = Number(value); // 如果原值是数字,尝试用字符串匹配 if (!isNaN(valueNum)) { enumValue = enumData[valueStr]; if (enumValue !== undefined) { return enumValue; } } // 如果原值是字符串,尝试用数字匹配 if (!isNaN(valueNum)) { enumValue = enumData[valueNum]; if (enumValue !== undefined) { return enumValue; } } // 遍历所有键进行宽松匹配 for (const key in enumData) { if (String(key) === String(value) || Number(key) === Number(value)) { return enumData[key]; } } return value; } // 支持数组格式(保持向下兼容) if (Array.isArray(enumData) && enumData.length > 0) { const enumItem = enumData.find(item => { // 支持多种匹配方式,包括类型转换 const itemValue = item.value || item.id || item.key || item.code; return itemValue === value || String(itemValue) === String(value) || Number(itemValue) === Number(value); }); if (enumItem) { // 返回枚举项的显示文本 return enumItem.label || enumItem.name || enumItem.text || enumItem.title || value; } } return value; } function formatRecursive(nodes: any[]): any[] { return nodes.map(node => { // 深拷贝节点,避免修改原数据 const newNode = JSON.parse(JSON.stringify(node)); // 处理格式化字段 formatFields.forEach(fieldPath => { const value = getNestedValue(newNode, fieldPath); if (value !== undefined) { const formattedValue = formatNumber(value); setNestedValue(newNode, fieldPath, formattedValue); } }); // 处理枚举字段 enumFields.forEach((fieldPath, index) => { const value = getNestedValue(newNode, fieldPath); if (value !== undefined && enumData[index]) { const parsedValue = parseEnumValue(value, enumData[index]); setNestedValue(newNode, fieldPath, parsedValue); } }); // 处理百分号字段 percentFields.forEach((fieldPath) => { const value = getNestedValue(newNode, fieldPath); if (value !== undefined) { // 如果值是null,设置为空字符串 if (value === null) { setNestedValue(newNode, fieldPath, ''); return; } const currentValueStr = String(value); // 如果值后面还没有百分号,则添加 if (!currentValueStr.endsWith('%')) { const percentValue = currentValueStr + '%'; setNestedValue(newNode, fieldPath, percentValue); } } }); // 递归处理子节点 if (newNode[childrenKey] && Array.isArray(newNode[childrenKey]) && newNode[childrenKey].length > 0) { newNode[childrenKey] = formatRecursive(newNode[childrenKey]); } return newNode; }); } return formatRecursive(data); }