import { cloneDeep } from 'lodash-es'; import dayjs from 'dayjs'; import JSEncrypt from 'jsencrypt'; import { isObject } from './is'; import { CHINA_PCD, COUNTRIES_TREE } from '@cordys/web/src/components/business/crm-city-select/config'; import type { FormCreateField, FormCreateFieldDateType, } from '@cordys/web/src/components/business/crm-form-create/types'; import { getLocalStorage } from '@lib/shared/method/local-storage'; import industryOptions from '@cordys/web/src/components/pure/crm-industry-select/config'; /** * 递归深度合并 * @param src 源对象 * @param target 待合并的目标对象 * @returns 合并后的对象 */ export const deepMerge = (src: any = {}, target: any = {}): T => { Object.keys(target).forEach((key) => { src[key] = isObject(src[key]) ? deepMerge(src[key], target[key]) : (src[key] = target[key]); }); return src; }; /** * 遍历对象属性并一一添加到 url 地址参数上 * @param baseUrl 需要添加参数的 url * @param obj 参数对象 * @returns 拼接后的 url */ export function setObjToUrlParams(baseUrl: string, obj: any): string { let parameters = ''; Object.keys(obj).forEach((key) => { parameters += `${key}=${encodeURIComponent(obj[key])}&`; }); parameters = parameters.replace(/&$/, ''); return /\?$/.test(baseUrl) ? baseUrl + parameters : baseUrl.replace(/\/?$/, '?') + parameters; } /** * 加密 * @param input 输入的字符串 * @param publicKey 公钥 * @returns */ export function encrypted(input: string) { const publicKey = getLocalStorage('publicKey') || ''; const encrypt = new JSEncrypt({ default_key_size: '1024' }); encrypt.setPublicKey(publicKey); return encrypt.encrypt(input); } /** * 休眠 * @param ms 睡眠时长,单位毫秒 * @returns */ export function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(() => resolve(), ms); }); } export function getQueryVariable(variable: string) { const urlString = window.location.href; const queryIndex = urlString.indexOf('?'); if (queryIndex !== -1) { // 先获取?到#之间的内容,如果没有#则获取到结尾 const hashIndex = urlString.indexOf('#'); const queryEnd = hashIndex !== -1 ? hashIndex : urlString.length; const query = urlString.substring(queryIndex + 1, queryEnd); // 分割查询参数 const params = query.split('&'); // 遍历参数,找到 _token 参数的值 let variableValue; params.forEach((param) => { const equalIndex = param.indexOf('='); const variableName = param.substring(0, equalIndex); if (variableName === variable) { variableValue = param.substring(equalIndex + 1); } }); return variableValue; } } export function getUrlParameterWidthRegExp(name: string) { const url = window.location.href; name = name.replace(/[[\]]/g, '\\$&'); const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`); const results = regex.exec(url); if (!results) return null; if (!results[2]) return ''; return decodeURIComponent(results[2].replace(/\+/g, ' ')); } /** * 建立 SSE 连接 * @param url 连接地址 * @param host 连接主机 * @returns EventSource 实例 */ export const apiSSE = (url: string, host?: string): EventSource => { let protocol = 'http://'; // 判断是否使用 HTTPS if (!host?.startsWith('http') && (window.location.protocol === 'https:' || host?.startsWith('https'))) { protocol = 'https://'; } // 解析 URL,自动适配 host const uri = protocol + (host?.split('://')[1] || window.location.host) + url; return new EventSource(uri, { withCredentials: true, }); }; /** * 获取 SSE 连接 * @param sseUrl,自定义 SSE 地址 * @param host 自定义主机 * @returns EventSource 实例 */ export function getSSE(sseUrl: string, params: Record, host?: string): EventSource { const queryString = new URLSearchParams(params).toString(); return apiSSE(`${sseUrl}?${queryString}`, host); } export interface TreeNode { children?: TreeNode[]; [key: string]: any; } /** * 递归遍历树形数组或树 * @param tree 树形数组或树 * @param customNodeFn 自定义节点函数 * @param customChildrenKey 自定义子节点的key * @param continueCondition 继续递归的条件,某些情况下需要无需递归某些节点的子孙节点,可传入该条件 */ export function traverseTree( tree: TreeNode | TreeNode[] | T | T[], customNodeFn: (node: TreeNode) => void, continueCondition?: (node: TreeNode) => boolean, customChildrenKey = 'children' ) { if (!Array.isArray(tree)) { tree = [tree]; } for (let i = 0; i < tree.length; i++) { const node = (tree as TreeNode[])[i]; if (typeof customNodeFn === 'function') { customNodeFn(node); } if (node[customChildrenKey] && Array.isArray(node[customChildrenKey]) && node[customChildrenKey].length > 0) { if (typeof continueCondition === 'function' && !continueCondition(node)) { // 如果有继续递归的条件,则判断是否继续递归 break; } traverseTree(node[customChildrenKey], customNodeFn, continueCondition, customChildrenKey); } } } /** * 生成 id 序列号 * @returns */ let lastTimestamp = 0; let sequence = 0; export const getGenerateId = () => { let timestamp = new Date().getTime(); if (timestamp === lastTimestamp) { sequence++; if (sequence >= 100000) { // 如果超过999,则重置为0,等待下一秒 sequence = 0; while (timestamp <= lastTimestamp) { timestamp = new Date().getTime(); } } } else { sequence = 0; } lastTimestamp = timestamp; return timestamp.toString() + sequence.toString().padStart(5, '0'); }; /** * 删除树形数组中的某个节点 * @param treeArr 目标树 * @param targetKey 目标节点唯一值 */ export function deleteNode(treeArr: TreeNode[], targetKey: string | number, customKey = 'key'): void { function deleteNodeInTree(tree: TreeNode[]): void { for (let i = 0; i < tree.length; i++) { const node = tree[i]; if (node[customKey] === targetKey) { tree.splice(i, 1); // 直接删除当前节点 // 重新调整剩余子节点的 sort 序号 for (let j = i; j < tree.length; j++) { tree[j].sort = j + 1; } return; } if (Array.isArray(node.children)) { deleteNodeInTree(node.children); // 递归删除子节点 } } } deleteNodeInTree(treeArr); } /** * 递归遍历树形数组或树,返回新的树 * @param tree 树形数组或树 * @param customNodeFn 自定义节点函数 * @param customChildrenKey 自定义子节点的key * @param parent 父节点 * @param parentPath 父节点路径 * @param level 节点层级 * @returns 遍历后的树形数组 */ export function mapTree( tree: TreeNode | TreeNode[] | T | T[], customNodeFn: (node: TreeNode, path: string, _level: number) => TreeNode | null = (node) => node, customChildrenKey = 'children', parentPath = '', level = 0, parent: TreeNode | null = null ): T[] { let cloneTree = cloneDeep(tree); if (!Array.isArray(cloneTree)) { cloneTree = [cloneTree]; } function mapFunc( _tree: TreeNode | TreeNode[] | T | T[], _parentPath = '', _level = 0, _parent: TreeNode | null = null ): T[] { if (!Array.isArray(_tree)) { _tree = [_tree]; } return _tree .map((node: TreeNode, i: number) => { const fullPath = node.path ? `${_parentPath}/${node.path}`.replace(/\/+/g, '/') : ''; node.sort = i + 1; // sort 从 1 开始 node.parent = _parent || undefined; // 没有父节点说明是树的第一层 const newNode = typeof customNodeFn === 'function' ? customNodeFn(node, fullPath, _level) : node; if (newNode) { newNode.level = _level; if (newNode[customChildrenKey] && newNode[customChildrenKey].length > 0) { newNode[customChildrenKey] = mapFunc(newNode[customChildrenKey], fullPath, _level + 1, newNode); } } return newNode; }) .filter((node: TreeNode | null) => node !== null); } return mapFunc(cloneTree, parentPath, level, parent); } /** * 获取树形数据所有有子节点的父节点 * @param treeData 树形数组 * @param childrenKey 自定义子节点的key * @returns 遍历后父节点数组 */ export function getAllParentNodeIds( treeData: TreeNode[], childrenKey = 'children', customKey = 'id' ): Array { const parentIds: Array = []; const traverse = (nodes: TreeNode) => { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (node[childrenKey] && node[childrenKey].length > 0) { parentIds.push(node[customKey]); // 记录当前节点的 ID traverse(node[childrenKey]); // 递归遍历子节点 } } }; traverse(treeData); // 开始递归 return parentIds; } /** * 过滤树形数组或树 * @param tree 树形数组或树 * @param customNodeFn 自定义节点函数 * @param customChildrenKey 自定义子节点的key * @returns 遍历后的树形数组 */ export function filterTree( tree: TreeNode | TreeNode[] | T | T[], filterFn: (node: TreeNode, nodeIndex: number, parent?: TreeNode | null) => boolean, customChildrenKey = 'children', parentNode: TreeNode | null = null ): TreeNode[] { if (!Array.isArray(tree)) { tree = [tree]; } const filteredTree: TreeNode[] = []; for (let i = 0; i < tree.length; i++) { const node = (tree as TreeNode[])[i]; // 如果节点满足过滤条件,则保留该节点,并递归过滤子节点 if (filterFn(node, i, parentNode)) { const newNode = cloneDeep({ ...node, [customChildrenKey]: [] }); if (node[customChildrenKey] && node[customChildrenKey].length > 0) { // 递归过滤子节点,并将过滤后的子节点添加到当前节点中 newNode[customChildrenKey] = filterTree(node[customChildrenKey], filterFn, customChildrenKey, node); } else { newNode[customChildrenKey] = []; } filteredTree.push(newNode); } } return filteredTree; } /** * * 返回文件的大小 * @param fileSize file文件的大小size * @returns */ export function formatFileSize(fileSize: number): string { const units = ['B', 'KB', 'MB', 'GB', 'TB']; let size = fileSize; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } const unit = units[unitIndex]; if (size) { const formattedSize = size.toFixed(2); return `${formattedSize} ${unit}`; } const formattedSize = 0; return `${formattedSize} ${unit}`; } /** * 字符串脱敏 * @param str 需要脱敏的字符串 * @returns 脱敏后的字符串 */ export function desensitize(str: string): string { if (!str || typeof str !== 'string') { return ''; } return str.replace(/./g, '*'); } /** * 对话框标题动态内容字符限制 * @param str 标题的动态内容 * @returns 转化后的字符串 */ export function characterLimit(str?: string, length?: number): string { if (!str) return ''; const limit = length ?? 20; if (str.length <= limit) return str; return `${str.slice(0, limit - 3)}...`; } /** * 根据属性 key 查找树形数组中匹配的某个节点 * @param trees 属性数组 * @param targetKey 需要匹配的属性值 * @param customKey 默认为 key,可自定义需要匹配的属性名 * @returns 匹配的节点/null */ export function findNodeByKey( trees: TreeNode[], targetKey: string | number, customKey = 'key', dataKey: string | undefined = undefined ): TreeNode | T | null { for (let i = 0; i < trees.length; i++) { const node = trees[i]; if (dataKey ? node[dataKey]?.[customKey] === targetKey : node[customKey] === targetKey) { return node; // 如果当前节点的 key 与目标 key 匹配,则返回当前节点 } if (Array.isArray(node.children) && node.children.length > 0) { const _node = findNodeByKey(node.children, targetKey, customKey, dataKey); // 递归在子节点中查找 if (_node) { return _node; // 如果在子节点中找到了匹配的节点,则返回该节点 } } } return null; // 如果在整个树形数组中都没有找到匹配的节点,则返回 null } /** * 根据 key 遍历树,并返回找到的节点路径和节点 */ export function findNodePathByKey( tree: TreeNode[], targetKey: string, dataKey?: string, customKey = 'key' ): TreeNode | null { for (let i = 0; i < tree.length; i++) { const node = tree[i]; if (dataKey ? node[dataKey]?.[customKey] === targetKey : node[customKey] === targetKey) { return { ...node, treePath: [dataKey ? node[dataKey] : node] }; // 如果当前节点的 key 与目标 key 匹配,则返回当前节点 } if (Array.isArray(node.children) && node.children.length > 0) { const result = findNodePathByKey(node.children, targetKey, dataKey, customKey); // 递归在子节点中查找 if (result) { result.treePath.unshift(dataKey ? node[dataKey] : node); return result; // 如果在子节点中找到了匹配的节点,则返回该节点 } } } return null; } /** * 根据 cityId 返回城市路径 */ export function getCityPath(cityId: string | null, scope?: string): string { if (!cityId) return ''; const nodePathObject = findNodePathByKey(scope === 'CN' ? CHINA_PCD.children : [CHINA_PCD, ...COUNTRIES_TREE], cityId, undefined, 'value'); const nodePathName = (nodePathObject?.treePath || []).map((item: any) => item.label); return nodePathName.length === 1 ? nodePathName[0] : nodePathName.join('/'); } /** * 根据 industryId 返回行业路径 */ export function getIndustryPath(industryId: string | null): string { if (!industryId) return ''; const nodePathObject = findNodePathByKey(industryOptions, industryId, undefined, 'value'); const nodePathName = (nodePathObject?.treePath || []).map((item: any) => item.label); return nodePathName.length === 1 ? nodePathName[0] : nodePathName.join('/'); } /** * 返回添加节点下一个有效未命名name * @param existingNames 已存在名称列表 * @param baseName 基础名称 */ export function getNextAvailableName(existingNames: string[], baseName: string): string { const baseNamePattern = new RegExp(`^${baseName}(\\d+)$`); const existingSuffixes = existingNames.reduce((suffixes: number[], name: string) => { const match = baseNamePattern.exec(name); if (match) { suffixes.push(parseInt(match[1], 10)); } return suffixes; }, []); if (existingSuffixes.length === 0) { return existingNames.includes(baseName) ? `${baseName}1` : baseName; } return `${baseName}${Math.max(...existingSuffixes) + 1}`; } /** * 分步处理分数表达式 * @param str 分数表达式 */ export function safeFractionConvert(str: string | number) { if (!str) { return 1; } if (typeof str === 'number') { return str; } const parts = str.split('/').map(Number); // 分割分子分母 if (parts.length !== 2 || parts.some((e) => Number.isNaN(e))) return 1; return parts[0] / parts[1]; } /** * 打开网页链接 * @param url 链接地址 */ export function openDocumentLink(url: string) { const a = document.createElement('a'); a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer'; // 防止打开页面控制当前页面 document.body.appendChild(a); a.click(); document.body.removeChild(a); } /** * 格式化时间 * @param value 时间戳 * @param type 类型 */ export function formatTimeValue(value: string | number, type?: FormCreateFieldDateType) { if (value) { const date = dayjs(Number(value)); switch (type) { case 'month': return date.format('YYYY-MM'); case 'date': return date.format('YYYY-MM-DD'); case 'datetime': default: return date.format('YYYY-MM-DD HH:mm:ss'); } } return '-'; } /** * 下载文件 * @param byte 字节流 * @param fileName 文件名 */ export const downloadByteFile = (byte: BlobPart, fileName: string) => { // 创建一个Blob对象 const blob = new Blob([byte], { type: 'application/octet-stream' }); // 创建一个URL对象,用于生成下载链接 const url = window.URL.createObjectURL(blob); // 创建一个虚拟的标签来触发下载 const link = document.createElement('a'); link.href = url; link.download = fileName; // 设置下载文件的名称 document.body.appendChild(link); link.click(); // 释放URL对象 window.URL.revokeObjectURL(url); document.body.removeChild(link); }; /** * 获取每三位使用逗号隔开数字格式 * @param number 目标值 */ export function addCommasToNumber(number: number) { if (number === 0 || number === undefined) { return '0'; } // 将数字转换为字符串 const numberStr = number.toString(); // 分割整数部分和小数部分 const parts = numberStr.split('.'); const integerPart = parts[0]; const decimalPart = parts[1] || ''; // 如果没有小数部分,则设为空字符串 // 对整数部分添加逗号分隔 const integerWithCommas = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); // 拼接整数部分和小数部分(如果有) const result = decimalPart ? `${integerWithCommas}.${decimalPart}` : integerWithCommas; return result; } // 是否是企业微信端打开 export function isWeComBrowser() { const ua = window.navigator.userAgent.toLowerCase(); return ua.includes('wxwork'); // 企业微信 UA 一定包含 wxwork } export function isDingTalkBrowser() { const ua = window.navigator.userAgent.toLowerCase(); return ( ua.includes('dingtalk') || ua.includes('aliapp(dingtalk') || (getQueryVariable('authCode') !== '' && getQueryVariable('authCode') !== undefined && getQueryVariable('authCode') !== null) ); } // 飞书 export function isLarkBrowser(): boolean { const ua = window.navigator.userAgent.toLowerCase(); return ua.includes('lark') || ua.includes('feishu') || getQueryVariable('state') === 'LARK'; } /** * 国际单位数字缩写 * @param amount 金额数字 * @param decimals 保留小数位数 * @param currency 货币单位 */ export function abbreviateNumber(count: number | string, currency: string, decimals = 2) { if (typeof count !== 'number') { return { value: '-', unit: '', full: '-' }; } const locale = localStorage.getItem('CRM-locale') || 'zh-CN'; const truncateNumber = (num: number) => { const factor = 10 ** decimals; return Math.round(num * factor) / factor; }; const full = `${count.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals, })} (${currency})`; let value = ''; let unit = ''; if (locale === 'zh-CN') { if (count >= 1e8) { value = truncateNumber(count / 1e8).toString(); unit = '亿'; } else if (count >= 1e4) { value = truncateNumber(count / 1e4).toString(); unit = '万'; } else { value = truncateNumber(count).toString(); unit = ''; } } else if (locale === 'en-US') { if (count >= 1e9) { value = truncateNumber(count / 1e9).toString(); unit = 'B'; } else if (count >= 1e6) { value = truncateNumber(count / 1e6).toString(); unit = 'M'; } else if (count >= 1e3) { value = truncateNumber(count / 1e3).toString(); unit = 'K'; } else { value = truncateNumber(count).toString(); unit = ''; } } return { value, unit, full }; } export function getFileIconType(type: string) { switch (type) { case 'zip': return 'icona-icon_file-compressed_colorful'; case 'ppt': return 'iconicon_file-ppt_colorful'; case 'pdf': return 'iconicon_file-pdf_colorful'; case 'docx': return 'iconicon_file-word_colorful'; case 'xlsx': return 'iconicon_file-excel_colorful'; case 'csv': return 'iconicon_file-CSV_colorful'; case 'xmind': return 'iconicon_file-xmind_colorful'; case 'sql': return 'iconicon_file-sql_colorful'; case 'jar': return 'icona-icon_file-jar_colorful'; case 'json': return 'icona-icon_file-json'; case 'jmx': return 'icona-icon_file-JMX'; case 'har': return 'iconicon_file_har'; case 'mp4': case 'mov': case 'wmv': return 'iconicon_file_video_colorful'; default: return /(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(type) ? 'iconicon_file-image_colorful' : 'iconicon_file-unknown_colorful1'; } } /** * 限制字符串长度并添加后缀(如 copy),保证总长度不超过 maxLen * @param name 原名称 * @param suffix 后缀(默认 "copy") * @param maxLen 最大长度(默认 255) */ export function getCopiedName(name: string, suffix = 'copy', maxLen = 255): string { const baseName = name || ''; if (baseName.length + suffix.length > maxLen) { return baseName.slice(0, maxLen - suffix.length) + suffix; } return baseName + suffix; } /** * 通用数字千分位展示 * @param value 数值或数值字符串 * @param options 小数位配置 * @returns 格式化后的字符串 */ export function formatThousands( value: string | number | null | undefined, options?: { minimumFractionDigits?: number; maximumFractionDigits?: number; } ) { if (value === null || value === undefined || value === '') { return ''; } const numberValue = Number(value); if (!Number.isFinite(numberValue)) { return String(value); } const decimalLength = String(value).includes('.') ? String(value).split('.')[1]?.length || 0 : 0; return numberValue.toLocaleString('en-US', { minimumFractionDigits: options?.minimumFractionDigits, maximumFractionDigits: options?.maximumFractionDigits ?? decimalLength, }); }