chore: initial commit
Some checks failed
Synchronize to Gitee / repo-sync (push) Has been cancelled
Typos Checking / Spell Check with Typos (push) Has been cancelled

This commit is contained in:
2026-06-23 11:56:23 +08:00
commit 72e2110987
2883 changed files with 367388 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
const SESSION_ID = 'sessionId';
const CSRF_TOKEN = 'csrfToken';
const LOGIN_TYPE = 'loginType';
// 获取token
const getToken = () => {
return { [SESSION_ID]: localStorage.getItem(SESSION_ID), [CSRF_TOKEN]: localStorage.getItem(CSRF_TOKEN) || '' };
};
const setToken = (sessionId: string, csrfToken: string) => {
localStorage.setItem(SESSION_ID, sessionId);
localStorage.setItem(CSRF_TOKEN, csrfToken);
};
const setLoginType = (loginType: string) => {
localStorage.setItem(LOGIN_TYPE, loginType);
};
const getLoginType = () => {
return localStorage.getItem(LOGIN_TYPE);
};
const clearToken = () => {
localStorage.removeItem(SESSION_ID);
localStorage.removeItem(CSRF_TOKEN);
};
const hasToken = () => {
return !!localStorage.getItem(SESSION_ID) && !!localStorage.getItem(CSRF_TOKEN);
};
const setLoginExpires = () => {
localStorage.setItem('loginExpires', Date.now().toString());
};
const isLoginExpires = () => {
const lastLoginTime = Number(localStorage.getItem('loginExpires'));
const now = Date.now();
const diff = now - lastLoginTime;
const thirtyDay = 24 * 60 * 60 * 1000 * 30;
return diff > thirtyDay;
};
export { clearToken, getLoginType, getToken, hasToken, isLoginExpires, setLoginExpires, setLoginType, setToken };

View File

@@ -0,0 +1,195 @@
/**
* 滚动到指定元素
*/
export interface ScrollToViewOptions {
behavior?: 'auto' | 'smooth';
block?: 'start' | 'center' | 'end' | 'nearest';
inline?: 'start' | 'center' | 'end' | 'nearest';
}
/**
* 将指定元素滚动至视图区域内
* @param targetRef 目标 ref 或 DOM
* @param options 滚动配置
*/
export function scrollIntoView(targetRef: HTMLElement | Element | null, options: ScrollToViewOptions = {}) {
const scrollOptions: ScrollToViewOptions = {
behavior: options.behavior || 'smooth',
block: options.block || 'start',
inline: options.inline || 'nearest',
};
targetRef?.scrollIntoView(scrollOptions);
}
/**
* 无操作函数
*/
export const NOOP = () => {
return undefined;
};
/**
* 判断是否为服务端渲染
*/
export const isServerRendering = (() => {
try {
return !(typeof window !== 'undefined' && document !== undefined);
} catch (e) {
return true;
}
})();
/**
* 监听事件
*/
export const on = (() => {
if (isServerRendering) {
return NOOP;
}
return <K extends keyof HTMLElementEventMap>(
element: HTMLElement | Window,
event: K,
handler: (ev: HTMLElementEventMap[K]) => void,
options: boolean | AddEventListenerOptions = false
) => {
element.addEventListener(event, handler as EventListenerOrEventListenerObject, options);
};
})();
/**
* 移除监听事件
*/
export const off = (() => {
if (isServerRendering) {
return NOOP;
}
return <K extends keyof HTMLElementEventMap>(
element: HTMLElement | Window,
type: K,
handler: (ev: HTMLElementEventMap[K]) => void,
options: boolean | EventListenerOptions = false
) => {
element.removeEventListener(type, handler as EventListenerOrEventListenerObject, options);
};
})();
/**
* 获取元素宽度
* @param el 当前元素
* @returns number
*/
export function getNodeWidth(el: HTMLElement) {
return el && +el.getBoundingClientRect().width.toFixed(2);
}
/**
* 获取元素样式
* @param element 当前元素
* @param prop 样式属性
* @returns string
*/
export function getStyle(element: HTMLElement | null, prop: string | null) {
if (!element || !prop) return null;
let styleName = prop as keyof CSSStyleDeclaration;
if (styleName === 'float') {
styleName = 'cssFloat';
}
try {
if (document.defaultView) {
const computed = document.defaultView.getComputedStyle(element, '');
return element.style[styleName] || computed ? computed[styleName] : '';
}
} catch (e) {
return element.style[styleName];
}
return null;
}
/**
* 获取当前展示的最上层的浮层(弹窗、抽屉等)
* @param selector 浮层选择器
*/
export function getMaxZIndexLayer(selector: string): HTMLElement | null {
const layers = document.querySelectorAll<HTMLElement>(selector);
let maxZIndex = 0;
let maxZIndexDrawer: HTMLElement | null = null;
layers.forEach((layer) => {
const zIndex = parseInt(window.getComputedStyle(layer).zIndex, 10);
if (!Number.isNaN(zIndex) && zIndex > maxZIndex) {
maxZIndex = zIndex;
maxZIndexDrawer = layer;
}
});
return maxZIndexDrawer;
}
/**
* 合并样式
* @param element 当前元素
* @param stylesToAdd 要添加的样式
*/
export function mergeStyles(element: HTMLElement | Element | null, stylesToAdd: string): void {
if (element) {
const originalStyles = element.getAttribute('style') || '';
const mergedStyles: Record<string, string> = {};
const originalStylePairs = originalStyles.split(';').filter((style) => style.trim() !== '');
// 解析原有的 style 属性
originalStylePairs.forEach((pair) => {
const [key, value] = pair.split(':').map((item) => item.trim());
mergedStyles[key] = value;
});
// 解析要添加的样式属性
const stylesToAddPairs = stylesToAdd.split(';').filter((style) => style.trim() !== '');
stylesToAddPairs.forEach((pair) => {
const [key, value] = pair.split(':').map((item) => item.trim());
mergedStyles[key] = value;
});
// 构造新的 style 属性字符串
const mergedStyleString = Object.entries(mergedStyles)
.map(([key, value]) => `${key}: ${value}`)
.join(';');
// 设置新的 style 属性值
element.setAttribute('style', mergedStyleString);
}
}
/**
* 移除样式
* @param element 当前元素
* @param stylesToRemove 要移除的样式
*/
export function removeStyles(element: HTMLElement | Element | null, stylesToRemove: string): void {
if (element) {
const originalStyles = element.getAttribute('style') || '';
const updatedStyles: Record<string, string> = {};
const originalStylePairs = originalStyles.split(';').filter((style) => style.trim() !== '');
// 解析原有的 style 属性
originalStylePairs.forEach((pair) => {
const [key, value] = pair.split(':').map((item) => item.trim());
updatedStyles[key] = value;
});
// 移除指定的样式属性
const stylesToRemovePairs = stylesToRemove.split(';').filter((style) => style.trim() !== '');
stylesToRemovePairs.forEach((pair) => {
const [key] = pair.split(':').map((item) => item.trim());
delete updatedStyles[key];
});
// 构造新的 style 属性字符串
const updatedStyleString = Object.entries(updatedStyles)
.map(([key, value]) => `${key}: ${value}`)
.join(';');
// 设置新的 style 属性值
element.setAttribute('style', updatedStyleString);
}
}

View File

@@ -0,0 +1,28 @@
import { sortBy } from 'lodash-es';
/**
* 比较两个一维数组对象是否相等,不考虑顺序,
* @param arr1 数组1
* @param arr2 数组2
* @returns boolean
*/
export function isArraysEqualWithOrder<T>(arr1: T[], arr2: T[]): boolean {
if (arr1.length !== arr2.length) {
return false;
}
const sortArr1 = sortBy(arr1, 'dataIndex');
const sortArr2 = sortBy(arr2, 'dataIndex');
for (let i = 0; i < sortArr1.length; i++) {
const obj1 = sortArr1[i];
const obj2 = sortArr2[i];
// 逐一比较对象
if (JSON.stringify(obj1) !== JSON.stringify(obj2)) {
return false;
}
}
return true;
}
export default {};

View File

@@ -0,0 +1,219 @@
import { Canvg } from 'canvg';
import html2canvas from 'html2canvas-pro';
import JSPDF from 'jspdf';
import { nextTick } from 'vue';
const A4_WIDTH = 595;
const A4_HEIGHT = 842;
const HEADER_HEIGHT = 16;
const FOOTER_HEIGHT = 24;
const PAGE_HEIGHT = A4_HEIGHT - FOOTER_HEIGHT - HEADER_HEIGHT;
const PDF_WIDTH = A4_WIDTH - 32; // 左右分别 16px 间距
const CONTAINER_WIDTH = 1190;
export const SCALE_RATIO = window.devicePixelRatio * 1.5;
// 实际每页高度 = PDF页面高度/页面容器宽度与 pdf 宽度的比例(这里比例*SCALE_RATIO 是因为html2canvas截图时生成的是 SCALE_RATIO 倍的清晰度)
export const IMAGE_HEIGHT = Math.ceil(PAGE_HEIGHT * (CONTAINER_WIDTH / PDF_WIDTH) * SCALE_RATIO);
export const MAX_CANVAS_HEIGHT = IMAGE_HEIGHT * 20; // 一次截图最大高度是 20 页整(过长会无法截完整,出现空白)
/**
* 替换svg为base64
*/
async function inlineSvgUseElements(container: HTMLElement) {
const useElements = container.querySelectorAll('use');
useElements.forEach((useElement) => {
const href = useElement.getAttribute('xlink:href') || useElement.getAttribute('href');
if (href) {
const symbolId = href.substring(1);
const symbol = document.getElementById(symbolId);
if (symbol) {
const svgElement = useElement.closest('svg');
if (svgElement) {
svgElement.innerHTML = symbol.innerHTML;
}
}
}
});
}
/**
* 将svg转换为base64
*/
async function convertSvgToBase64(svgElement: SVGSVGElement) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const svgString = new XMLSerializer().serializeToString(svgElement);
if (ctx) {
const v = Canvg.fromString(ctx, svgString);
canvas.width = svgElement.clientWidth;
canvas.height = svgElement.clientHeight;
await v.render();
}
return canvas.toDataURL('image/png');
}
/**
* 替换svg为base64
*/
export async function replaceSvgWithBase64(container: HTMLElement) {
await inlineSvgUseElements(container);
const svgElements = container.querySelectorAll('.c-icon');
svgElements.forEach(async (svgElement) => {
const img = new Image();
img.src = await convertSvgToBase64(svgElement as SVGSVGElement);
img.width = svgElement.clientWidth;
img.height = svgElement.clientHeight;
img.style.marginRight = '8px';
svgElement.parentNode?.replaceChild(img, svgElement);
});
}
/**
* 处理 DOM 元素的分页,防止内容被截断
* @param containerId 容器 ID
* @param pageHeight PDF 一页的有效高度 (对应你的 PAGE_HEIGHT 或 IMAGE_HEIGHT / SCALE_RATIO)
*/
function handlePageBreak(containerId: string, pageHeight: number) {
const container = document.getElementById(containerId);
if (!container) return;
// 获取所有直接子元素,这里假设子元素是不可分割的块(比如一行表格、一个段落)
// 根据实际情况,你可能需要更精确的选择器,比如 '.table-row', 'p', 'img'
const children = Array.from(container.children) as HTMLElement[];
let currentPageHeight = 0;
const nodesToMove: { node: HTMLElement; spacerHeight: number }[] = [];
children.forEach((child) => {
const childHeight = child.offsetHeight;
// 如果元素本身就比一页还高,那没法处理,只能让它截断
if (childHeight > pageHeight) {
currentPageHeight += childHeight;
// 重置当前页高度计数,近似处理
currentPageHeight = currentPageHeight % pageHeight;
return;
}
// 判断加上当前元素后是否超出一页
if (currentPageHeight + childHeight > pageHeight) {
// 计算需要插入的空白高度,把当前元素挤到下一页开头
const spacerHeight = pageHeight - currentPageHeight;
nodesToMove.push({ node: child, spacerHeight });
// 当前元素被挤到下一页了,所以新的当前页高度就是它自己的高度
currentPageHeight = childHeight;
} else {
// 没超出一页,累加高度
currentPageHeight += childHeight;
}
});
// 统一插入空白占位符
// 需要倒序插入,否则会影响后续元素的 offsetTop 计算(虽然这里用的是累加高度,倒序更安全)
for (let i = nodesToMove.length - 1; i >= 0; i--) {
const { node, spacerHeight } = nodesToMove[i];
const spacer = document.createElement('div');
spacer.style.height = `${spacerHeight}px`;
spacer.style.width = '100%';
// 标记一下,方便导出后移除
spacer.className = 'pdf-page-break-spacer';
spacer.style.backgroundColor = 'transparent'; // 确保透明
container.insertBefore(spacer, node);
}
return () => {
// 返回一个清理函数,在导出完成后移除这些占位符,恢复网页原样
const spacers = container.querySelectorAll('.pdf-page-break-spacer');
spacers.forEach(spacer => spacer.remove());
}
}
/**
* 导出PDF
* @param name 文件名
* @param contentId 内容DOM id
* @description 通过html2canvas生成图片再通过jsPDF生成pdf
* 使用html2canvas截图时因为插件有截图极限超出极限部分会出现截图失败所以这里设置了MAX_CANVAS_HEIGHT截图高度然后根据这个截图高度分页截图然后根据每个截图裁剪每页 pdf 的图片并添加到 pdf 内)
*/
export default async function exportPDF(name: string, contentId: string, doneCallback?: () => void) {
const element = document.getElementById(contentId);
if (element) {
await replaceSvgWithBase64(element);
const totalHeight = element.scrollHeight;
// jsPDFs实例
const pdf = new JSPDF({
unit: 'pt',
format: 'a4',
orientation: 'p',
});
pdf.setFontSize(10);
// 计算pdf总页数
let totalPages = 0;
let position = 0; // 当前截图位置
let pageIndex = 1;
let loopTimes = 0;
const screenshotList: HTMLCanvasElement[] = [];
// 创建图片裁剪画布
const cropCanvas = document.createElement('canvas');
cropCanvas.width = CONTAINER_WIDTH * SCALE_RATIO; // 因为截图时放大了 SCALE_RATIO 倍,所以这里也要放大
cropCanvas.height = IMAGE_HEIGHT;
const tempContext = cropCanvas.getContext('2d', { willReadFrequently: true });
// 这里是大的分页,也就是截图画布的分页
while (position < totalHeight) {
// 截图高度
const screenshotHeight = Math.min(MAX_CANVAS_HEIGHT, totalHeight - position);
// eslint-disable-next-line no-await-in-loop
const canvas = await html2canvas(element, {
x: 0,
y: position,
width: CONTAINER_WIDTH,
height: screenshotHeight,
backgroundColor: '#f9f9fe',
scale: SCALE_RATIO, // 缩放增加清晰度
});
screenshotList.push(canvas);
position += screenshotHeight;
totalPages += Math.ceil(canvas.height / IMAGE_HEIGHT);
loopTimes++;
}
totalPages -= loopTimes - 1; // 减去多余的页数
// 生成 PDF
screenshotList.forEach((_canvas) => {
const canvasWidth = _canvas.width;
const canvasHeight = _canvas.height;
const pages = Math.ceil(canvasHeight / IMAGE_HEIGHT);
for (let i = 1; i <= pages; i++) {
// 这里是小的分页,是 pdf 的每一页
const pagePosition = (i - 1) * IMAGE_HEIGHT;
if (tempContext) {
if (pageIndex === totalPages) {
// 填充背景颜色为白色
tempContext.fillStyle = '#ffffff';
tempContext.fillRect(0, 0, cropCanvas.width, cropCanvas.height);
}
// 将大分页的画布图片裁剪成pdf 页面内容大小,并渲染到临时画布上
tempContext.drawImage(_canvas, 0, -pagePosition, canvasWidth, canvasHeight);
const tempCanvasData = cropCanvas.toDataURL('image/jpeg', 1);
// 将临时画布图片渲染到 pdf 上
pdf.addImage(tempCanvasData, 'PNG', 16, 16, PDF_WIDTH, PAGE_HEIGHT);
}
cropCanvas.remove();
pdf.text(
`${pageIndex} / ${totalPages}`,
pdf.internal.pageSize.width / 2 - 10,
pdf.internal.pageSize.height - 4
);
if (i < pages) {
pdf.addPage();
pageIndex++;
}
}
_canvas.remove();
});
pdf.save(`${name}.pdf`);
nextTick(() => {
doneCallback?.();
});
}
}

View File

@@ -0,0 +1,566 @@
import type { CommonList, ModuleField } from '../models/common';
import { FieldTypeEnum } from '../enums/formDesignEnum';
import type { FormCreateField, FormDetail } from '@cordys/web/src/components/business/crm-form-create/types';
import { formatTimeValue, getCityPath, getIndustryPath } from './index';
import { useI18n } from '../hooks/useI18n';
export const linkAllAcceptTypes = [FieldTypeEnum.INPUT, FieldTypeEnum.TEXTAREA];
export const dataSourceTypes = [FieldTypeEnum.DATA_SOURCE, FieldTypeEnum.DATA_SOURCE_MULTIPLE];
export const hiddenTypes = [
FieldTypeEnum.DIVIDER,
FieldTypeEnum.PICTURE,
FieldTypeEnum.ATTACHMENT,
FieldTypeEnum.LINK,
FieldTypeEnum.SUB_PRICE,
FieldTypeEnum.SUB_PRODUCT,
];
export const needSameTypes = [
FieldTypeEnum.PHONE,
FieldTypeEnum.LOCATION,
FieldTypeEnum.DATE_TIME,
FieldTypeEnum.INPUT_NUMBER,
FieldTypeEnum.INDUSTRY,
FieldTypeEnum.SUB_PRICE,
FieldTypeEnum.SUB_PRODUCT,
];
export const multipleTypes = [FieldTypeEnum.CHECKBOX, FieldTypeEnum.SELECT_MULTIPLE, FieldTypeEnum.INPUT_MULTIPLE];
export const memberTypes = [FieldTypeEnum.MEMBER, FieldTypeEnum.MEMBER_MULTIPLE];
export const departmentTypes = [FieldTypeEnum.DEPARTMENT, FieldTypeEnum.DEPARTMENT_MULTIPLE];
export const singleTypes = [FieldTypeEnum.RADIO, FieldTypeEnum.SELECT];
export const specialBusinessKeyMap: Record<string, string> = {
customerId: 'customerName',
contactId: 'contactName',
clueId: 'clueName',
businessId: 'businessName',
contractId: 'contractName',
owner: 'ownerName',
opportunityId: 'opportunityName',
paymentPlanId: 'paymentPlanName',
businessTitleId: 'businessTitleName',
};
export function getRuleType(item: FormCreateField) {
if (
item.type === FieldTypeEnum.SELECT_MULTIPLE ||
item.type === FieldTypeEnum.CHECKBOX ||
item.type === FieldTypeEnum.INPUT_MULTIPLE ||
item.type === FieldTypeEnum.MEMBER_MULTIPLE ||
item.type === FieldTypeEnum.DEPARTMENT_MULTIPLE ||
item.type === FieldTypeEnum.DATA_SOURCE ||
item.type === FieldTypeEnum.DATA_SOURCE_MULTIPLE ||
item.type === FieldTypeEnum.PICTURE ||
item.type === FieldTypeEnum.ATTACHMENT
) {
return 'array';
}
if (item.type === FieldTypeEnum.DATE_TIME) {
return 'date';
}
if ([FieldTypeEnum.INPUT_NUMBER, FieldTypeEnum.FORMULA].includes(item.type)) {
return 'number';
}
return 'string';
}
export function getNormalFieldValue(item: FormCreateField, value: any) {
if (item.type === FieldTypeEnum.DATA_SOURCE && !value) {
return '';
}
if (
[
FieldTypeEnum.SELECT_MULTIPLE,
FieldTypeEnum.MEMBER_MULTIPLE,
FieldTypeEnum.DEPARTMENT_MULTIPLE,
FieldTypeEnum.DATA_SOURCE_MULTIPLE,
FieldTypeEnum.INPUT_MULTIPLE,
].includes(item.type) &&
!value
) {
return [];
}
if (item.type === FieldTypeEnum.INPUT_MULTIPLE && !value) {
return [];
}
if (item.multiple && !value) {
return [];
}
return value;
}
/**
* 格式化数字
* @param value 数字
* @param item
*/
export function formatNumberValue(value: string | number, item: FormCreateField) {
if (value !== undefined && value !== null && value !== '') {
if (item.numberFormat === 'percent') {
return item.precision ? `${Number(value).toFixed(item.precision)}%` : `${value}%`;
}
if (item.showThousandsSeparator) {
return (item.precision ? Number(Number(value).toFixed(item.precision)) : Number(value)).toLocaleString('en-US');
}
return item.precision ? Number(value).toFixed(item.precision) : value.toString();
}
return '-';
}
/**
* 格式化数字显示为字符串
* @param value 数字
* @param item
*/
export function formatNumberValueToString(value: number, item: FormCreateField) {
if (value !== undefined && value !== null) {
if (item.numberFormat === 'percent') {
return item.precision ? `${Number(value).toFixed(item.precision)}%` : `${value}%`;
}
if (item.showThousandsSeparator) {
if (typeof value === 'string') {
return value;
}
return item.precision
? `${value.toLocaleString('en-US').split('.')[0]}.${value.toFixed?.(item.precision).split('.')[1]}`
: value.toLocaleString('en-US');
}
return item.precision ? Number(value).toFixed(item.precision) : value.toString();
}
return '-';
}
export function initFieldValue(field: FormCreateField, value: string | number | (string | number)[]) {
if (
[FieldTypeEnum.DATA_SOURCE, FieldTypeEnum.DATA_SOURCE_MULTIPLE].includes(field.type) &&
typeof value === 'string'
) {
return value ? [value] : [];
}
return value;
}
export function getFieldItemId(field: FormCreateField) {
if (field.resourceFieldId) {
return field.id.split('_ref_')[1]; // 处理数据源显示字段
}
return field.id;
}
/**
*
* @param field
* @param fieldValue
* @returns 获取系统字段的值展示
*/
export function getDisplayFieldText(field: FormCreateField, fieldValue: any) {
const { t } = useI18n();
const fieldKey = field.businessKey || getFieldItemId(field);
if (fieldKey === 'invalid') {
if (fieldValue === true || fieldValue === 'true') {
return t('common.voided');
}
if (fieldValue === false || fieldValue === 'false') {
return t('common.normal');
}
}
const currentOption = field.options?.find((option: any) => {
if (option.value === fieldValue) {
return true;
}
if (typeof option.value === 'boolean' && typeof fieldValue === 'string') {
return String(option.value) === fieldValue;
}
return false;
});
return currentOption ? currentOption.label : fieldValue;
}
export function parseModuleFieldValue(item: FormCreateField, fieldValue: string | string[], options?: any[]) {
if (fieldValue === undefined || fieldValue === null || fieldValue === '') {
return '-';
}
const { t } = useI18n();
let value: string | string[] = fieldValue;
if (options) {
// 若字段值是选项值则取选项值的name
if (Array.isArray(fieldValue)) {
value = fieldValue.map((e) => {
const option = options.find((opt) => opt.id === e);
if (option) {
return option.name || t('common.optionNotExist');
}
return t('common.optionNotExist');
});
} else {
value = options.find((e) => e.id === fieldValue)?.name || t('common.optionNotExist');
}
} else if (
[
FieldTypeEnum.DATA_SOURCE,
FieldTypeEnum.DATA_SOURCE_MULTIPLE,
FieldTypeEnum.MEMBER,
FieldTypeEnum.MEMBER_MULTIPLE,
FieldTypeEnum.DEPARTMENT,
FieldTypeEnum.DEPARTMENT_MULTIPLE,
].includes(item.type)
) {
// 数据源/成员/部门类型字段,且没有匹配到 options则显示不存在
if (Array.isArray(fieldValue)) {
value = fieldValue.map(() => t('common.optionNotExist'));
} else {
value = t('common.optionNotExist');
}
} else if (item.type === FieldTypeEnum.LOCATION) {
const addressArr: string[] = (fieldValue as string)?.split('-')?.filter(Boolean) || [];
if (!addressArr.length) {
value = '-';
} else {
const country = addressArr[0];
const rest = addressArr.filter((e, i) => i > 0).join('-');
value = rest ? `${getCityPath(country, item.scope)}-${rest}` : getCityPath(country, item.scope);
}
} else if (item.type === FieldTypeEnum.INDUSTRY) {
value = fieldValue ? getIndustryPath(fieldValue as string) : '-';
} else if (item.type === FieldTypeEnum.INPUT_NUMBER) {
value = formatNumberValueToString(fieldValue as unknown as number, item);
if (value.includes('NaN') || value.includes('%%')) {
value = fieldValue.toString();
}
} else if (item.type === FieldTypeEnum.DATE_TIME) {
value = formatTimeValue(fieldValue as string, item.dateType);
}
if (Array.isArray(value) && item.resourceFieldId) {
value = value.join(',');
}
return value;
}
export function parseFormDetailValue(item: FormCreateField, form: FormDetail, sourceName?: Ref<string>) {
const { t } = useI18n();
if (item.businessKey && !item.resourceFieldId) {
// 引用数据源字段使用 id 读取数据,而不是 businessKey
const options = form.optionMap?.[item.businessKey];
// 业务标准字段读取最外层读取form[item.businessKey]取到 id 值,然后去 options 里取 name
let name: string | string[] = '';
const value = form[item.businessKey];
// 若字段值是选项值则取选项值的name
if (options) {
if (Array.isArray(value)) {
name = value.map((e) => {
const option = options.find((opt) => opt.id === e);
if (option) {
return option.name || t('common.optionNotExist');
}
return t('common.optionNotExist');
});
} else if (value) {
name = options.find((e) => e.id === value)?.name || t('common.optionNotExist');
}
}
if (item.type === FieldTypeEnum.DATE_TIME) {
return formatTimeValue(name || form[item.businessKey], item.dateType);
}
if (item.type === FieldTypeEnum.INPUT_NUMBER) {
return formatNumberValueToString(name || form[item.businessKey], item);
}
if (item.type === FieldTypeEnum.ATTACHMENT) {
return form.attachmentMap?.[item.businessKey] || [];
}
if (item.businessKey === 'name' && sourceName) {
sourceName.value = name || form[item.businessKey];
}
return name || form[item.businessKey];
}
const options = form.optionMap?.[item.id];
// 其他的字段读取moduleFields
const field = form.moduleFields?.find((moduleField: ModuleField) => moduleField.fieldId === item.id);
if (item.type === FieldTypeEnum.ATTACHMENT) {
return form.attachmentMap?.[item.id] || [];
}
if (field) {
return parseModuleFieldValue(item, field.fieldValue, options);
}
}
/**
* 表单配置表格回显数据
*/
export function transformData({
item,
fields,
originalData,
excludeFieldIds,
needParseSubTable = false,
}: {
fields: FormCreateField[];
item: any;
originalData?: CommonList<any>;
excludeFieldIds?: string[];
needParseSubTable?: boolean;
}) {
const { t } = useI18n();
const businessFieldAttr: Record<string, any> = {};
const customFieldAttr: Record<string, any> = {};
const addressFieldIds: string[] = [];
const industryFieldIds: string[] = [];
const dataSourceFieldIds: string[] = [];
const memberFieldIds: string[] = [];
const departmentFieldIds: string[] = [];
const timeFieldIds: string[] = [];
const fieldOptionMap: Record<string, any[]> = {};
fields.forEach((field) => {
const fieldId = field.resourceFieldId ? field.id : field.businessKey || field.id;
if (field.type === FieldTypeEnum.LOCATION) {
addressFieldIds.push(fieldId);
} else if (field.type === FieldTypeEnum.INDUSTRY) {
industryFieldIds.push(fieldId);
} else if (field.type === FieldTypeEnum.DATA_SOURCE || field.type === FieldTypeEnum.DATA_SOURCE_MULTIPLE) {
dataSourceFieldIds.push(fieldId);
} else if (field.type === FieldTypeEnum.MEMBER || field.type === FieldTypeEnum.MEMBER_MULTIPLE) {
memberFieldIds.push(fieldId);
} else if (field.type === FieldTypeEnum.DEPARTMENT || field.type === FieldTypeEnum.DEPARTMENT_MULTIPLE) {
departmentFieldIds.push(fieldId);
} else if (field.type === FieldTypeEnum.DATE_TIME) {
timeFieldIds.push(fieldId);
} else if ([FieldTypeEnum.SUB_PRICE, FieldTypeEnum.SUB_PRODUCT].includes(field.type) && needParseSubTable) {
field.subFields?.forEach((subField) => {
const subFieldData = (
item[fieldId] || item.moduleFields?.find((mf: any) => mf.fieldId === fieldId)?.fieldValue
)?.map((subItem: Record<string, any>) => {
if (subField.resourceFieldId) {
subItem[`${subField.id}_original`] = subItem[field.id]; // 备份原始值以供编辑时填充数据源
subItem[subField.id] = parseModuleFieldValue(
subField,
subItem[subField.id],
// 数据源显示字段不使用业务 key直接使用字段 id 去取值
originalData?.optionMap?.[subField.id]
);
fieldOptionMap[subField.id] = originalData?.optionMap?.[subField.id] || [];
} else {
subItem[`${subField.id}_original`] = subItem[subField.businessKey || subField.id]; // 备份原始值以供编辑时填充数据源
subItem[subField.id] = parseModuleFieldValue(
subField,
subItem[subField.businessKey || subField.id],
originalData?.optionMap?.[subField.businessKey || subField.id]
);
fieldOptionMap[subField.businessKey || subField.id] =
originalData?.optionMap?.[subField.businessKey || subField.id] || [];
}
return subItem;
});
item[fieldId] = subFieldData;
if (fieldId === field.businessKey) {
// 子表格字段可能会被设置为数据源的显示字段,而数据源显示字段都通过 id 读取,所以这里需要用 id 备份一份数据以供数据源显示字段场景读取
item[field.id] = subFieldData;
}
});
}
if (field.businessKey && !field.resourceFieldId) {
const fieldId = field.businessKey;
const options = originalData?.optionMap?.[fieldId]?.map((e: any) => ({
...e,
name: e.name || t('common.optionNotExist'),
}));
fieldOptionMap[fieldId] = options || [];
if (addressFieldIds.includes(fieldId)) {
// 地址类型字段,解析代码替换成省市区
const addressArr: string[] = item[fieldId]?.split('-')?.filter(Boolean) || [];
let value = '';
if (!addressArr.length) {
value = '-';
} else {
const country = addressArr[0];
const rest = addressArr.filter((e, i) => i > 0).join('-');
value = rest ? `${getCityPath(country)}-${rest}` : getCityPath(country);
}
businessFieldAttr[fieldId] = value;
} else if (industryFieldIds.includes(fieldId)) {
// 行业类型字段,解析代码替换成行业名称
businessFieldAttr[fieldId] = item[fieldId] ? getIndustryPath(item[fieldId] as string) : '-';
} else if (timeFieldIds.includes(fieldId)) {
// 时间类型字段,格式化时间显示
businessFieldAttr[fieldId] = formatTimeValue(item[fieldId], field.dateType);
} else if (options && options.length > 0) {
let name: string | string[] = '';
if (item[fieldId] === '' || item[fieldId] === null) {
name = '-';
} else if (dataSourceFieldIds.includes(fieldId)) {
// 处理数据源字段,需要赋值为数组
if (typeof item[fieldId] === 'string' || typeof item[fieldId] === 'number') {
// 单选
name = options?.find((e) => e.id === item[fieldId])?.name || t('common.optionNotExist');
} else {
// 多选
name = options?.filter((e) => item[fieldId]?.includes(e.id)).map((e) => e.name) || [
t('common.optionNotExist'),
];
}
} else if (typeof item[fieldId] === 'string' || typeof item[fieldId] === 'number') {
// 若值是单个字符串/数字
name = options?.find((e) => e.id === item[fieldId])?.name || t('common.optionNotExist');
} else {
// 若值是数组
name = options?.filter((e) => item[fieldId]?.includes(e.id)).map((e) => e.name) || [
t('common.optionNotExist'),
];
if (Array.isArray(name) && name.length === 0) {
name = [t('common.optionNotExist')];
}
}
if (!excludeFieldIds?.includes(field.businessKey)) {
if (specialBusinessKeyMap[fieldId]) {
// 处理特殊业务 key 映射关系
businessFieldAttr[specialBusinessKeyMap[fieldId]] = name || t('common.optionNotExist');
} else {
businessFieldAttr[fieldId] = name || t('common.optionNotExist');
}
}
if (fieldId === 'owner') {
businessFieldAttr.ownerId = item.owner;
}
} else if (specialBusinessKeyMap[fieldId]) {
// 处理特殊业务 key 映射关系
businessFieldAttr[specialBusinessKeyMap[fieldId]] = item[specialBusinessKeyMap[fieldId]];
}
businessFieldAttr[field.id] = businessFieldAttr[fieldId] || item[fieldId];
}
});
item.moduleFields?.forEach((field: ModuleField) => {
const options = originalData?.optionMap?.[field.fieldId]?.map((e) => ({
...e,
name: e.name || t('common.optionNotExist'),
}));
fieldOptionMap[field.fieldId] = options || [];
if (addressFieldIds.includes(field.fieldId)) {
// 地址类型字段,解析代码替换成省市区
const addressArr: string[] = (field?.fieldValue as string)?.split('-')?.filter(Boolean) || [];
let value = '';
if (!addressArr.length) {
value = '-';
} else {
const country = addressArr[0];
const scope = fields.find((f) => f.id === field.fieldId)?.scope;
const rest = addressArr.filter((e, i) => i > 0).join('-');
value = rest ? `${getCityPath(country, scope)}-${rest}` : getCityPath(country, scope);
}
customFieldAttr[field.fieldId] = value;
} else if (industryFieldIds.includes(field.fieldId)) {
// 行业类型字段,解析代码替换成行业名称
customFieldAttr[field.fieldId] = field.fieldValue ? getIndustryPath(field.fieldValue as string) : '-';
} else if (timeFieldIds.includes(field.fieldId)) {
// 时间类型字段,格式化时间显示
customFieldAttr[field.fieldId] = formatTimeValue(
field.fieldValue as string,
fields.find((f) => f.id === field.fieldId)?.dateType
);
} else if (options && options.length > 0) {
let name: string | string[] = '';
if (dataSourceFieldIds.includes(field.fieldId)) {
// 处理数据源字段,需要赋值为数组
if (typeof field.fieldValue === 'string' || typeof field.fieldValue === 'number') {
// 单选
name = [options.find((e) => e.id === field.fieldValue)?.name || t('common.optionNotExist')];
} else {
// 多选
name = field.fieldValue?.map((e) => options.find((o) => o.id === e)?.name || t('common.optionNotExist'));
}
} else if (typeof field.fieldValue === 'string' || typeof field.fieldValue === 'number') {
// 若值是单个字符串/数字
name = options.find((e) => e.id === field.fieldValue)?.name || t('common.optionNotExist');
} else {
// 若值是数组
name = field.fieldValue?.map((fv) => options.find((e) => e.id === fv)?.name || t('common.optionNotExist'));
if (Array.isArray(name) && name.length === 0) {
name = [t('common.optionNotExist')];
}
}
customFieldAttr[field.fieldId] = name || [t('common.optionNotExist')];
} else if (
[...dataSourceFieldIds, ...memberFieldIds, ...departmentFieldIds].includes(field.fieldId) &&
(!options || options.length === 0)
) {
// 处理匹配不到 optionsMap 的数据源/成员/部门字段
if (typeof field.fieldValue === 'string' || typeof field.fieldValue === 'number') {
// 单选
customFieldAttr[field.fieldId] = field.fieldValue !== '' ? [t('common.optionNotExist')] : ['-'];
} else {
// 避免这里返回 [['选项不存在']] 这样的嵌套数组
customFieldAttr[field.fieldId] = field.fieldValue?.map((e) => (e !== '' ? t('common.optionNotExist') : '-'));
}
} else {
// 其他类型字段,直接赋值
customFieldAttr[field.fieldId] = field.fieldValue;
}
});
// 根据 moduleFields 集合判断 fields 完整字段集合中是否有自定义字段无值,因为无值后台不会在 moduleFields 里返回该字段,需要手动置空
fields.forEach((field) => {
if (!field.resourceFieldId && !field.businessKey) {
const fieldId = field.id;
// 避免将 0 有效计算结果误判为空
if (customFieldAttr[fieldId] === undefined || customFieldAttr[fieldId] === null) {
customFieldAttr[fieldId] = undefined;
}
}
});
return {
...item,
...customFieldAttr,
...businessFieldAttr,
optionMap: fieldOptionMap,
};
}
/**
* 表单子表单计算汇总数值转换
*/
export function normalizeNumber(val: unknown): number {
if (val === null || val === undefined || val === '') return 0;
if (typeof val === 'number') {
return Number.isFinite(val) ? val : 0;
}
if (typeof val === 'string') {
let str = val.trim();
if (!str) return 0;
// 是否是百分比
const isPercent = str.endsWith('%');
// 去掉百分号
if (isPercent) {
str = str.slice(0, -1);
}
// 去掉千分位
str = str.replace(/,/g, '');
const num = Number(str);
return Number.isNaN(num) ? 0 : num;
}
return 0;
}
/**
* 合并初始选项和追加选项,去重后返回新的初始选项数组
* @param sumInitialOptions
* @param appendOptions
* @returns
*/
export function mergeUniqueOptions(sumInitialOptions: Record<string, any>[], appendOptions: Record<string, any>[]) {
const optionMap = new Map<any, Record<string, any>>();
[...sumInitialOptions, ...appendOptions].forEach((option) => {
if (!option) {
return;
}
const optionKey = option.id ?? option.value;
if (optionKey !== undefined) {
if (optionMap.has(optionKey)) {
Object.assign(optionMap.get(optionKey) || {}, option);
} else {
optionMap.set(optionKey, option);
}
}
});
sumInitialOptions = Array.from(optionMap.values());
return sumInitialOptions;
}

View File

@@ -0,0 +1,737 @@
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 = <T = any>(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<void> {
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<string, string>, host?: string): EventSource {
const queryString = new URLSearchParams(params).toString();
return apiSSE(`${sseUrl}?${queryString}`, host);
}
export interface TreeNode<T> {
children?: TreeNode<T>[];
[key: string]: any;
}
/**
* 递归遍历树形数组或树
* @param tree 树形数组或树
* @param customNodeFn 自定义节点函数
* @param customChildrenKey 自定义子节点的key
* @param continueCondition 继续递归的条件,某些情况下需要无需递归某些节点的子孙节点,可传入该条件
*/
export function traverseTree<T>(
tree: TreeNode<T> | TreeNode<T>[] | T | T[],
customNodeFn: (node: TreeNode<T>) => void,
continueCondition?: (node: TreeNode<T>) => boolean,
customChildrenKey = 'children'
) {
if (!Array.isArray(tree)) {
tree = [tree];
}
for (let i = 0; i < tree.length; i++) {
const node = (tree as TreeNode<T>[])[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<T>(treeArr: TreeNode<T>[], targetKey: string | number, customKey = 'key'): void {
function deleteNodeInTree(tree: TreeNode<T>[]): 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<T>(
tree: TreeNode<T> | TreeNode<T>[] | T | T[],
customNodeFn: (node: TreeNode<T>, path: string, _level: number) => TreeNode<T> | null = (node) => node,
customChildrenKey = 'children',
parentPath = '',
level = 0,
parent: TreeNode<T> | null = null
): T[] {
let cloneTree = cloneDeep(tree);
if (!Array.isArray(cloneTree)) {
cloneTree = [cloneTree];
}
function mapFunc(
_tree: TreeNode<T> | TreeNode<T>[] | T | T[],
_parentPath = '',
_level = 0,
_parent: TreeNode<T> | null = null
): T[] {
if (!Array.isArray(_tree)) {
_tree = [_tree];
}
return _tree
.map((node: TreeNode<T>, 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<T> | null) => node !== null);
}
return mapFunc(cloneTree, parentPath, level, parent);
}
/**
* 获取树形数据所有有子节点的父节点
* @param treeData 树形数组
* @param childrenKey 自定义子节点的key
* @returns 遍历后父节点数组
*/
export function getAllParentNodeIds<T>(
treeData: TreeNode<T>[],
childrenKey = 'children',
customKey = 'id'
): Array<string | number> {
const parentIds: Array<string | number> = [];
const traverse = (nodes: TreeNode<T>) => {
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<T>(
tree: TreeNode<T> | TreeNode<T>[] | T | T[],
filterFn: (node: TreeNode<T>, nodeIndex: number, parent?: TreeNode<T> | null) => boolean,
customChildrenKey = 'children',
parentNode: TreeNode<T> | null = null
): TreeNode<T>[] {
if (!Array.isArray(tree)) {
tree = [tree];
}
const filteredTree: TreeNode<T>[] = [];
for (let i = 0; i < tree.length; i++) {
const node = (tree as TreeNode<T>[])[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<T>(
trees: TreeNode<T>[],
targetKey: string | number,
customKey = 'key',
dataKey: string | undefined = undefined
): TreeNode<T> | 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<T>(
tree: TreeNode<T>[],
targetKey: string,
dataKey?: string,
customKey = 'key'
): TreeNode<T> | 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);
// 创建一个虚拟的<a>标签来触发下载
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,
});
}

View File

@@ -0,0 +1,54 @@
const opt = Object.prototype.toString;
export function isArray(obj: any): obj is any[] {
return opt.call(obj) === '[object Array]';
}
export function isObject(obj: any): obj is { [key: string]: any } {
return opt.call(obj) === '[object Object]';
}
export function isString(obj: any): obj is string {
return opt.call(obj) === '[object String]';
}
export function isNumber(obj: any): obj is number {
return opt.call(obj) === '[object Number]' && obj === obj; // eslint-disable-line
}
export function isRegExp(obj: any) {
return opt.call(obj) === '[object RegExp]';
}
export function isFile(obj: any): obj is File {
return opt.call(obj) === '[object File]';
}
export function isBlob(obj: any): obj is Blob {
return opt.call(obj) === '[object Blob]';
}
export function isUndefined(obj: any): obj is undefined {
return obj === undefined;
}
export function isNull(obj: any): obj is null {
return obj === null;
}
export function isFunction(obj: any): obj is (...args: any[]) => any {
return typeof obj === 'function';
}
export function isEmptyObject(obj: any): boolean {
return isObject(obj) && Object.keys(obj).length === 0;
}
export function isExist(obj: any): boolean {
return obj || obj === 0;
}
// 判断变量非空值
export function isNotEmpty(obj: any): boolean {
return obj !== undefined && obj !== null && obj !== '';
}

View File

@@ -0,0 +1,53 @@
export const getLocalStorage = <T = string>(name: string, isJson?: boolean): T | null => {
try {
const value = localStorage.getItem(name);
if (value && isJson) {
return JSON.parse(value) as T;
}
return value as T;
} catch {
return null;
}
};
export const setLocalStorage = (name: string, value: any): void => {
try {
if (typeof value !== 'string') {
value = JSON.stringify(value);
}
localStorage.setItem(name, value);
} catch {
// ignore
}
};
export const removeLocalStorage = (name: string) => {
try {
localStorage.removeItem(name);
} catch {
// ignore
}
};
export const setSessionStorageTempState = (name: string, value: any) => {
try {
if (typeof value !== 'string') {
value = JSON.stringify(value);
}
sessionStorage.setItem(name, value);
} catch {
// ignore
}
};
export const getSessionStorageTempState = <T>(name: string, isJson?: boolean): T | null => {
try {
const value = sessionStorage.getItem(name);
if (value && isJson) {
return JSON.parse(value) as T;
}
return value as T;
} catch {
return null;
}
};

View File

@@ -0,0 +1,39 @@
/**
* 单独监听路由会浪费渲染性能。使用发布订阅模式去进行分发管理。
*/
import mitt, { Handler } from 'mitt';
import type { RouteLocationNormalized } from 'vue-router';
const emitter = mitt();
const key = Symbol('ROUTE_CHANGE');
let latestRoute: RouteLocationNormalized;
/**
* 设置路由监听
* @param to 要跳转的路由信息
*/
export function setRouteEmitter(to: RouteLocationNormalized) {
emitter.emit(key, to);
latestRoute = to;
}
/**
* 监听路由变化
* @param handler 处理回调
* @param immediate 是否立即执行
*/
export function listenerRouteChange(handler: (route: RouteLocationNormalized) => void, immediate = true) {
emitter.on(key, handler as Handler);
if (immediate && latestRoute) {
handler(latestRoute);
}
}
/**
* 移除路由监听
*/
export function removeRouteListener() {
emitter.off(key);
}

View File

@@ -0,0 +1,93 @@
import { setupDrag } from './setupDrag';
import { CompanyTypeEnum } from '@lib/shared/enums/commonEnum';
interface ScriptOptions {
identifier: string; // 脚本标识
}
const scriptElementsMap = new Map<string, string>();
function extractSQLBotId(input: string) {
const regex = /sqlbot-[^\s"']+/;
const match = input.match(regex);
return match ? match[0] : null;
}
export function loadScript(scriptContent: string, options: ScriptOptions): Promise<void> {
if (!scriptContent) {
return Promise.reject(new Error('scriptContent is empty'));
}
return new Promise((resolve, reject) => {
const content = scriptContent?.trim();
if (scriptElementsMap.has(options.identifier)) return;
// 处理IIFE格式
if (content.startsWith('(function')) {
const scriptId = extractSQLBotId(content);
if (scriptId) {
scriptElementsMap.set(options.identifier, scriptId);
}
// eslint-disable-next-line no-eval
eval(content);
setTimeout(() => {
const button = document.querySelector('.sqlbot-assistant-chat-button');
setupDrag(button as HTMLElement);
}, 300); // 等待DOM渲染
resolve();
return;
}
// 处理<script>标签
if (content.startsWith('<script')) {
const div = document.createElement('div');
div.innerHTML = content;
const originalScript = div.querySelector('script');
if (!originalScript) {
reject(new Error('无效的script标签'));
return;
}
const script = document.createElement('script');
// 复制所有属性
for (let i = 0; i < originalScript.attributes.length; i++) {
const attr = originalScript.attributes[i];
if (attr.name === 'id') {
scriptElementsMap.set(options.identifier, attr.value);
}
script.setAttribute(attr.name, attr.value);
}
script.onload = () => {
setTimeout(() => {
const button = document.querySelector('.sqlbot-assistant-chat-button');
setupDrag(button as HTMLElement);
}, 300); // 等待DOM渲染
};
document.body.appendChild(script);
resolve();
return;
}
reject(new Error('不支持的脚本格式'));
});
}
export function removeScript(identifier: string): void {
const scriptId = scriptElementsMap.get(identifier);
if (scriptId && identifier === CompanyTypeEnum.SQLBot) {
// 清理全局单例标记
const propName = `${scriptId}-state`;
delete (window as any)[propName];
if ((window as any).sqlbot_assistant_handler) {
delete (window as any).sqlbot_assistant_handler;
}
// 删除页面上渲染的
const floatingElements = document.querySelectorAll('[id^="sqlbot-"]');
floatingElements.forEach((el) => {
if (el.parentNode && !el.parentNode.isEqualNode(document.body) && !el.parentNode.isEqualNode(document.head)) {
(el.parentNode as Element).remove();
}
el.remove();
});
scriptElementsMap.delete(identifier);
}
}

View File

@@ -0,0 +1,113 @@
interface DragPosition {
startX: number;
startY: number;
startLeft: number;
startTop: number;
}
export function setupDrag(dragElement: HTMLElement | null) {
if (!dragElement) return;
let isDragging = false;
let dragPosition: DragPosition;
const moveHandler = (clientX: number, clientY: number) => {
dragElement.style.cursor = 'grabbing';
// 移动超过5px才认为是拖动
if (!isDragging && (Math.abs(clientX - dragPosition.startX) > 5 || Math.abs(clientY - dragPosition.startY) > 5)) {
isDragging = true;
}
if (isDragging) {
// 计算新位置
const newLeft = dragPosition.startLeft + clientX - dragPosition.startX;
const newTop = dragPosition.startTop + clientY - dragPosition.startY;
// 边界检查(可选) - 确保元素不会移出视口
const maxX = window.innerWidth - dragElement.offsetWidth;
const maxY = window.innerHeight - dragElement.offsetHeight;
// 设置新位置(限制在边界内)
dragElement.style.left = `${Math.max(0, Math.min(newLeft, maxX))}px`;
dragElement.style.top = `${Math.max(0, Math.min(newTop, maxY))}px`;
dragElement.style.right = 'auto';
dragElement.style.bottom = 'auto';
}
};
// 鼠标/触摸移动处理
const move = (e: MouseEvent | TouchEvent) => {
e.preventDefault();
if (e instanceof MouseEvent) {
moveHandler(e.clientX, e.clientY);
} else if (e.touches?.[0]) {
moveHandler(e.touches[0].clientX, e.touches[0].clientY);
}
};
// 停止拖动
const stopDrag = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('touchmove', move);
document.removeEventListener('mouseup', stopDrag);
document.removeEventListener('touchend', stopDrag);
dragElement.style.cursor = 'pointer';
// 如果是拖拽操作(不是点击),则阻止接下来的点击事件
if (isDragging) {
const clickHandler = (e: Event) => {
e.stopImmediatePropagation();
e.preventDefault();
dragElement.removeEventListener('click', clickHandler);
};
dragElement.addEventListener('click', clickHandler, true);
// 300ms后移除点击拦截
setTimeout(() => {
dragElement.removeEventListener('click', clickHandler, true);
}, 300);
}
};
// 开始拖动 - 鼠标事件
const startMouseDrag = (e: MouseEvent) => {
isDragging = false;
const style = window.getComputedStyle(dragElement);
dragPosition = {
startX: e.clientX,
startY: e.clientY,
startLeft: parseInt(style.left, 10) || 0,
startTop: parseInt(style.top, 10) || 0,
};
// 添加移动和松开事件
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', stopDrag);
};
// 开始拖动 - 触摸事件
const startTouchDrag = (e: TouchEvent) => {
isDragging = false;
if (e.touches[0]) {
const style = window.getComputedStyle(dragElement);
dragPosition = {
startX: e.touches[0].clientX,
startY: e.touches[0].clientY,
startLeft: parseInt(style.left, 10) || 0,
startTop: parseInt(style.top, 10) || 0,
};
document.addEventListener('touchmove', move, { passive: false });
document.addEventListener('touchend', stopDrag);
}
};
// 添加事件监听
dragElement.addEventListener('mousedown', startMouseDrag);
dragElement.addEventListener('touchstart', startTouchDrag, { passive: false });
}
export default {};

View File

@@ -0,0 +1,81 @@
// 邮箱校验
export const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
// 手机号校验11位
export const phoneRegex = /^\d{11}$/;
// 密码校验8-32位
export const passwordLengthRegex = /^.{8,32}$/;
// 密码校验,必须包含数字和字母,特殊符号范围校验
export const passwordWordRegex = /^(?=.*\d)(?=.*[a-zA-Z])[0-9a-zA-Z!@#$%^&*()_+.]+$/;
// Git地址校验
export const gitRepositoryUrlRegex = /\.git$/;
// Webhook 地址校验,允许 HTTP / HTTPS
export const httpUrlRegex = /^https?:\/\/[^\s/$.?#].[^\s]*$/i;
/**
* 校验邮箱
* @param email 邮箱
* @returns boolean
*/
export function validateEmail(email: string): boolean {
return emailRegex.test(email);
}
/**
* 校验手机号
* @param phone 手机号
* @returns boolean
*/
export function validatePhone(phone: string): boolean {
return phoneRegex.test(phone);
}
/**
* 校验密码长度
* @param password 密码
* @returns boolean
*/
export function validatePasswordLength(password: string): boolean {
return passwordLengthRegex.test(password);
}
/**
* 校验密码组成
* @param password 密码
* @returns boolean
*/
export function validateWordPassword(password: string): boolean {
return passwordWordRegex.test(password);
}
/**
* 校验密码
* @param password 密码
* @returns boolean
*/
export function validatePassword(password: string): boolean {
return validatePasswordLength(password) && validateWordPassword(password);
}
/**
* 校验 HTTP / HTTPS 地址
* @param url 地址
* @returns boolean
*/
export function validateHttpUrl(url: string): boolean {
return httpUrlRegex.test(url.trim());
}
export function getPatternByAreaCode(code: string): RegExp | null {
switch (code) {
case '+86': // 中国大陆
return /^\d{10,12}$/;
case '+852': // 香港
return /^\d{8}$/;
case '+853': // 澳门
return /^\d{8}$/;
case '+886': // 台湾
return /^\d{8,11}$/;
default: // 其他
return /^\d+$/;
}
}