ylj20011123 94b60d1895 update
2026-02-06 09:29:04 +08:00

1553 lines
43 KiB
TypeScript
Raw 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 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(`
<html>
<head>
<title>${printName || ''}</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
position: relative;
overflow: hidden;
}
${styles}
.handlePrintBox{
width: 100%;
height: 100%;
position: relative;
z-index: 0;
box-sizing: border-box;
padding: 20px 0 0 0;
}
.handlePrintBox .custom-header {
font-size: 24px;
text-align: center;
margin-bottom: 20px;
font-weight: 600;
}
.handlePrintBox .neckBox{
width: 100%;
box-sizing: border-box;
padding: 12px 12px 12px 20px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.handlePrintBox .neckBox .neckBoxItem{
width: calc(100% / 3);
display: flex;
align-items: center;
box-sizing: border-box;
padding-left: 5%;
}
.handlePrintBox .neckBox .bigNeckBoxItem{
width: calc((100% / 3) * 2);
display: flex;
align-items: center;
box-sizing: border-box;
padding-left: 5%;
}
.handlePrintBox .neckBox .bigNeckBoxItem .itemLabel{
font-size: 14px;
font-weight: 600;
margin-right: 2px;
}
.handlePrintBox .neckBox .bigNeckBoxItem .itemValue{
font-size: 12px;
}
.handlePrintBox .neckBox .neckBoxItem .itemLabel{
font-size: 14px;
font-weight: 600;
margin-right: 2px;
}
.handlePrintBox .neckBox .neckBoxItem .itemValue{
font-size: 12px;
}
.handlePrintBox .tableBox{
width: 100%;
box-sizing: border-box;
padding: 12px;
display: flex;
justify-content: center;
border-collapse: separate;
border-spacing: 0;
}
.handlePrintBox .tableBox .ant-table-thead tr th{
border: 2px solid #000;
}
.handlePrintBox .tableBox .ant-table-tbody tr td{
border: 2px solid #000;
}
.handlePrintBox .tableBox .ant-table-summary tr td{
border: 2px solid #000;
}
.handlePrintBox .tableUnit{
width: 100%;
display: flex;
justify-content: flex-end;
box-sizing: border-box;
font-size: 14px;
}
.handlePrintBox .tableUnit .tableRight{
width: calc(100% / 3);
font-size: 14px;
box-sizing: border-box;
padding-left: 5%;
}
.pro-table {
width: 100%;
border-collapse: collapse;
}
.pro-table th, .pro-table td {
border: 1px solid #ddd;
padding: 8px;
}
.pro-table th {
background-color: #f2f2f2;
text-align: left;
}
/* 水印样式 */
.watermark {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: grid;
grid-template-rows: repeat(4, 1fr); /* 水平行数 */
grid-template-columns: repeat(4, 1fr); /* 垂直列数 */
opacity: 0.2;
pointer-events: none;
z-index: 1;
overflow: hidden;
}
.watermark span {
display: flex;
justify-content: center;
align-items: center;
transform: rotate(-45deg); /* 水印旋转 */
font-size: 14px;
color: rgba(0, 0, 0); /* 半透明水印 */
user-select: none;
}
</style>
</head>
<body>
<div class="handlePrintBox">
<div class="custom-header">${title}</div>
<div class="neckBox">
${neckBox && neckBox.length > 0
? neckBox
.map((item: any) => {
return `<div class="${item.label === '门店名称' ? 'bigNeckBoxItem' : 'neckBoxItem'}">
<span class="itemLabel">${item.label ? item.label + '' : ''}</span>
<span class="itemValue">${item.value}</span>
</div>`;
})
.join('') // 连接成单一字符串,避免逗号
: ''}
</div>
<div class="tableUnit">
<div class="tableRight">单位:元</div>
</div>
<div class="tableBox">
${tableDom}
</div>
<div>
${footer}
</div>
</div>
<div class="watermark">
${Array(16) // 5x5 网格的水印内容
.fill('<span>安徽省驿达高速公路服务区经营管理有限公司</span>')
.join('')}
</div>
</body>
</html>
`)
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(`
<html>
<head>
<title>${printName || ''}</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
position: relative;
overflow: hidden;
}
${styles}
.handlePrintBox{
width: 100%;
height: 100%;
position: relative;
z-index: 0;
box-sizing: border-box;
padding: 20px 0 0 0;
}
.handlePrintBox .custom-header {
font-size: 24px;
text-align: center;
margin-bottom: 20px;
font-weight: 600;
}
.handlePrintBox .neckBox{
width: 100%;
box-sizing: border-box;
padding: 12px 5% 12px 5%;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.handlePrintBox .neckBox .neckBoxItem{
width: calc(100% / 4);
display: flex;
align-items: center;
box-sizing: border-box;
}
.handlePrintBox .neckBox .bigNeckBoxItem{
width: calc((100% / 4) * 2);
display: flex;
align-items: center;
box-sizing: border-box;
}
.handlePrintBox .neckBox .bigNeckBoxItem .itemLabel{
font-size: 14px;
font-weight: 600;
margin-right: 2px;
}
.handlePrintBox .neckBox .bigNeckBoxItem .itemValue{
font-size: 12px;
}
.handlePrintBox .neckBox .neckBoxItem .itemLabel{
font-size: 14px;
font-weight: 600;
margin-right: 2px;
}
.handlePrintBox .neckBox .neckBoxItem .itemValue{
font-size: 12px;
}
.handlePrintBox .tableBox{
width: 100%;
box-sizing: border-box;
padding: 12px;
display: flex;
justify-content: center;
border-collapse: separate;
border-spacing: 0;
}
.handlePrintBox .tableBox .ant-table-thead tr th{
border: 2px solid #000;
}
.handlePrintBox .tableBox .ant-table-tbody tr td{
border: 2px solid #000;
}
.handlePrintBox .tableBox .ant-table-summary tr td{
border: 2px solid #000;
}
.handlePrintBox .tableUnit{
width: 100%;
display: flex;
justify-content: flex-end;
box-sizing: border-box;
font-size: 14px;
}
.handlePrintBox .tableUnit .tableRight{
width: calc(100% / 3);
font-size: 14px;
box-sizing: border-box;
padding-left: 5%;
}
.pro-table {
width: 100%;
border-collapse: collapse;
}
.pro-table th, .pro-table td {
border: 1px solid #ddd;
padding: 8px;
}
.pro-table th {
background-color: #f2f2f2;
text-align: left;
}
/* 水印样式 */
.watermark {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: grid;
grid-template-rows: repeat(4, 1fr); /* 水平行数 */
grid-template-columns: repeat(4, 1fr); /* 垂直列数 */
opacity: 0.2;
pointer-events: none;
z-index: 1;
overflow: hidden;
}
.watermark span {
display: flex;
justify-content: center;
align-items: center;
transform: rotate(-45deg); /* 水印旋转 */
font-size: 14px;
color: rgba(0, 0, 0); /* 半透明水印 */
user-select: none;
}
</style>
</head>
<body>
<div class="handlePrintBox">
<div class="custom-header">${title}</div>
<div class="neckBox">
${neckBox && neckBox.length > 0
? neckBox
.map((item: any) => {
return `<div class="${item.label === '门店名称' ? 'bigNeckBoxItem' : 'neckBoxItem'}">
<span class="itemLabel">${item.label ? item.label + '' : ''}</span>
<span class="itemValue">${item.value}</span>
</div>`;
})
.join('') // 连接成单一字符串,避免逗号
: ''}
</div>
<div class="tableBox">
${tableDom}
</div>
<div>
${footer}
</div>
</div>
<div class="watermark">
${Array(16) // 5x5 网格的水印内容
.fill('<span>安徽建工集团投资运营管理有限公司</span>')
.join('')}
</div>
</body>
</html>
`)
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(`
<html>
<head>
<style>
</style>
<body>
<div style="width:100%;height:100%;display:flex;justify-content: center;">
<img style="max-width:100%;max-height:100%" src=${url}>
</div>
</body>
</head>
</html>
`)
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<string> {
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<object> 地理位置信息
*/
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<T>(
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<string | number, { text?: string } | string>;
renderText?: (text: any, record: any, index: number) => any;
hideInTable?: boolean;
valueType?: 'index' | string;
align?: 'left' | 'center' | 'right';
};
/** ========== 新增:拍平树形数据 ========== */
function flattenTree<T extends Record<string, any>>(
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(/<br\s*\/?>/gi, '\n').replace(/\r\n/g, '\n');
}
// 支持 React 的 <br/> 节点
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(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/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<ExcelJS.Font>;
labelFont?: Partial<ExcelJS.Font>;
}[]
}
) {
const {
sheetName = '数据',
chunkSize = 100_000,
topTitle,
infoRowLeft,
infoRowRight,
// 为兼容外部调用保留字段,但默认不做冻结以满足你的要求
freezeHeader = false,
childrenKey = 'children',
footerItems = []
} = options || {};
const flatData = flattenTree<any>(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);
}