new
This commit is contained in:
148
src/utils/dataCenter.ts
Normal file
148
src/utils/dataCenter.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { getDataSourceList, getGroupList, getSymbolList } from '@/services/api';
|
||||
import { transformToSymbolTree2 } from '@/utils/groupUtil';
|
||||
// import { storage } from '@/utils/storage';
|
||||
import {
|
||||
transformToAssetsTree,
|
||||
transformToSymbolTree,
|
||||
} from '@/utils/symbolUtil';
|
||||
|
||||
// const aWeekTime = 3600 * 24 * 7;
|
||||
// const aDay = 3600 * 24;
|
||||
|
||||
export const getSymbolTreeList = async (
|
||||
_companyInfo: API.CompanyListItem | undefined,
|
||||
dataSourceId?: number | string,
|
||||
_forceRefresh = false,
|
||||
) => {
|
||||
if (!dataSourceId) {
|
||||
return [];
|
||||
}
|
||||
// const dataSourceList = await getAllDataSourceList(_companyInfo);
|
||||
// const dataSourceInfo = dataSourceList.find(
|
||||
// (item) => item.id === dataSourceId,
|
||||
// );
|
||||
// if (!dataSourceInfo) {
|
||||
// return [];
|
||||
// }
|
||||
// const sourceKey = btoa(
|
||||
// encodeURIComponent(
|
||||
// `${dataSourceInfo.id}_${dataSourceInfo.tradeIp}:${dataSourceInfo.tradePort}`,
|
||||
// ),
|
||||
// );
|
||||
// const storageKey = `symbolTree_${sourceKey}`;
|
||||
|
||||
// if (!_forceRefresh) {
|
||||
// const cachedTreeList = (await storage.get(storageKey)) as
|
||||
// | API.SymbolTreeNode[]
|
||||
// | null;
|
||||
// if (cachedTreeList && cachedTreeList.length > 0) {
|
||||
// return cachedTreeList;
|
||||
// }
|
||||
// }
|
||||
const {
|
||||
data: { data: symbolList },
|
||||
} = await getSymbolList({ dataSourceId });
|
||||
if (!symbolList || symbolList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const treeList = transformToSymbolTree(symbolList);
|
||||
|
||||
// storage.set(storageKey, treeList, aDay);
|
||||
return treeList;
|
||||
};
|
||||
|
||||
export const getAllDataSourceList = async (
|
||||
_companyInfo: API.CompanyListItem | undefined,
|
||||
_forceRefresh = false,
|
||||
): Promise<API.DataSourceListItem[]> => {
|
||||
if (!_companyInfo) {
|
||||
return [];
|
||||
}
|
||||
// const companyKey = btoa(
|
||||
// encodeURIComponent(`${_companyInfo.id}_${_companyInfo.email}`),
|
||||
// );
|
||||
// const storageKey = `dataSourceList_${companyKey}`;
|
||||
|
||||
// if (!_forceRefresh) {
|
||||
// const cachedList = (await storage.get(storageKey)) as
|
||||
// | API.DataSourceListItem[]
|
||||
// | null
|
||||
// | undefined;
|
||||
// if (cachedList && cachedList.length > 0) {
|
||||
// return cachedList;
|
||||
// }
|
||||
// }
|
||||
const {
|
||||
data: { data: dataSourceList },
|
||||
} = await getDataSourceList({
|
||||
status: 1,
|
||||
});
|
||||
if (!dataSourceList || dataSourceList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
// storage.set(storageKey, dataSourceList, aDay);
|
||||
return dataSourceList;
|
||||
};
|
||||
|
||||
export const getGroupTreeList = async (
|
||||
_companyInfo: API.CompanyListItem | undefined,
|
||||
dataSourceId: number | string,
|
||||
_forceRefresh = false,
|
||||
) => {
|
||||
if (!dataSourceId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// const dataSourceList = await getAllDataSourceList(_companyInfo);
|
||||
// const dataSourceInfo = dataSourceList.find(
|
||||
// (item) => item.id === dataSourceId,
|
||||
// );
|
||||
// if (!dataSourceInfo) {
|
||||
// return [];
|
||||
// }
|
||||
// const sourceKey = btoa(
|
||||
// encodeURIComponent(
|
||||
// `${dataSourceInfo.id}_${dataSourceInfo.tradeIp}:${dataSourceInfo.tradePort}`,
|
||||
// ),
|
||||
// );
|
||||
// const storageKey = `groupTree_${sourceKey}`;
|
||||
|
||||
// if (!_forceRefresh) {
|
||||
// const cachedList = (await storage.get(storageKey)) as
|
||||
// | API.GroupListItem[]
|
||||
// | null
|
||||
// | undefined;
|
||||
// if (cachedList && cachedList.length > 0) {
|
||||
// return cachedList;
|
||||
// }
|
||||
// }
|
||||
const {
|
||||
data: { data: groupList },
|
||||
} = await getGroupList({ dataSourceId });
|
||||
if (!groupList || groupList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const treeList = transformToSymbolTree2(groupList);
|
||||
// storage.set(storageKey, treeList, aDay);
|
||||
return treeList;
|
||||
};
|
||||
|
||||
export const getAssetsTreeList = async (
|
||||
_companyInfo: API.CompanyListItem | undefined,
|
||||
dataSourceId?: number | string,
|
||||
_forceRefresh = false,
|
||||
) => {
|
||||
if (!dataSourceId) {
|
||||
return [];
|
||||
}
|
||||
const {
|
||||
data: { data: symbolList },
|
||||
} = await getSymbolList({ dataSourceId });
|
||||
if (!symbolList || symbolList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const treeList = transformToAssetsTree(symbolList);
|
||||
// storage.set(storageKey, treeList, aDay);
|
||||
return treeList;
|
||||
};
|
||||
10
src/utils/eventBus.ts
Normal file
10
src/utils/eventBus.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import mitt from 'mitt';
|
||||
|
||||
type Events = {
|
||||
'alert-refresh'?: boolean;
|
||||
'alert-new'?: boolean;
|
||||
'check-voice-permission'?: boolean;
|
||||
'open-voice-popup'?: boolean;
|
||||
multiRuleSelect?: any;
|
||||
};
|
||||
export default mitt<Events>();
|
||||
49
src/utils/fp.ts
Normal file
49
src/utils/fp.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import FingerprintJS, { type LoadOptions } from '@fingerprintjs/fingerprintjs';
|
||||
import { storage } from '@/utils/storage';
|
||||
|
||||
type options = LoadOptions & {
|
||||
monitoring?: boolean;
|
||||
};
|
||||
export async function getDeviceId() {
|
||||
let id = await storage.get('device_id');
|
||||
if (!id) {
|
||||
const fp = await FingerprintJS.load({
|
||||
monitoring: false,
|
||||
} as options);
|
||||
const res = await fp.get();
|
||||
const {
|
||||
components: {
|
||||
canvas,
|
||||
webGlBasics,
|
||||
webGlExtensions,
|
||||
audio,
|
||||
platform,
|
||||
hardwareConcurrency,
|
||||
deviceMemory,
|
||||
fonts,
|
||||
plugins,
|
||||
math,
|
||||
},
|
||||
} = res;
|
||||
if ('value' in canvas) {
|
||||
canvas.value.text = '';
|
||||
}
|
||||
const components = {
|
||||
canvas,
|
||||
webGlBasics,
|
||||
webGlExtensions,
|
||||
audio,
|
||||
platform,
|
||||
hardwareConcurrency,
|
||||
deviceMemory,
|
||||
fonts,
|
||||
plugins,
|
||||
math,
|
||||
};
|
||||
const visitorId = FingerprintJS.hashComponents(components);
|
||||
id = visitorId;
|
||||
await storage.set('device_id', visitorId);
|
||||
storage.set('visitor_id', res.visitorId);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
243
src/utils/groupUtil.ts
Normal file
243
src/utils/groupUtil.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
export type GroupTreeNode = {
|
||||
title: string;
|
||||
key: string;
|
||||
value?: string;
|
||||
path?: string;
|
||||
children?: GroupTreeNode[];
|
||||
isLeaf?: boolean;
|
||||
selectable?: boolean;
|
||||
meta?: API.GroupListItem;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform flattened path data into a tree structure
|
||||
* @param data Raw array returned by the backend
|
||||
*/
|
||||
export const transformToSymbolTree2 = (
|
||||
data: API.GroupListItem[],
|
||||
): GroupTreeNode[] => {
|
||||
const tree: GroupTreeNode[] = [];
|
||||
const map = new Map<string, GroupTreeNode>();
|
||||
|
||||
data.forEach((item) => {
|
||||
const parts = item.group.split('\\');
|
||||
let currentPath = '';
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
const isLast = index === parts.length - 1;
|
||||
const parentPath = currentPath;
|
||||
currentPath = parentPath ? `${parentPath}\\${part}` : part;
|
||||
|
||||
if (!map.has(currentPath)) {
|
||||
const newNode: GroupTreeNode = {
|
||||
title: isLast ? `${part} (${item.currency})` : part,
|
||||
// Leaf nodes use symbol, directory nodes use path to prevent name conflicts
|
||||
key: currentPath,
|
||||
};
|
||||
|
||||
if (isLast) {
|
||||
newNode.value = part;
|
||||
newNode.isLeaf = true;
|
||||
newNode.meta = item;
|
||||
} else {
|
||||
newNode.children = [];
|
||||
newNode.selectable = false; // Directory nodes are not selectable
|
||||
}
|
||||
|
||||
map.set(currentPath, newNode);
|
||||
|
||||
// If there is no parent path, it is a root node
|
||||
if (!parentPath) {
|
||||
tree.push(newNode);
|
||||
} else {
|
||||
// Otherwise, add it to the parent's children
|
||||
map.get(parentPath)?.children?.push(newNode);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return tree;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a Pattern string based on the checkedKeys of Ant Design Tree
|
||||
* @param treeData The tree structure of symbols (must contain complete symbol and key information)
|
||||
* @param checkedKeys The checked keys of Ant Design Tree
|
||||
* @param options Configuration items
|
||||
*/
|
||||
export function generatePattern2(
|
||||
treeData: GroupTreeNode[],
|
||||
checkedKeys: string[],
|
||||
options: { useShortMode?: boolean; separator?: string } = {},
|
||||
) {
|
||||
const { useShortMode = true, separator = '\\' } = options;
|
||||
|
||||
const excludes: string[] = [];
|
||||
const includes: string[] = [];
|
||||
|
||||
// --- Utility function: Get all leaf keys under a node ---
|
||||
const getAllLeafKeys = (node: GroupTreeNode): string[] => {
|
||||
if (!node.children || node.children.length === 0) return [node.key];
|
||||
return node.children.flatMap(getAllLeafKeys);
|
||||
};
|
||||
|
||||
// --- Formatting function: process paths and wildcards ---
|
||||
const format = (node: GroupTreeNode, isExclude: boolean) => {
|
||||
const isLeaf = !node.children || node.children.length === 0;
|
||||
|
||||
// Short mode (Logic from screenshot two): !*SYMBOL or path\*
|
||||
if (useShortMode && isLeaf && node.key) {
|
||||
return isExclude ? `!${node.key}` : `${node.key}`;
|
||||
}
|
||||
|
||||
// Short mode still uses wildcards to match categories, but we want to keep the path structure for clarity
|
||||
const path = node.key.split('/').join(separator); // Adapt to backslashes
|
||||
const suffix = isLeaf ? '' : `${separator}*`;
|
||||
const prefix = isExclude ? '!' : '';
|
||||
|
||||
// Special handling for exclusion logic in short mode:
|
||||
// If it is short mode and we want to exclude a specific product, it should be !*SYMBOL according to screenshot two
|
||||
if (useShortMode && isExclude && isLeaf && node.key) {
|
||||
return `!${node.key}`;
|
||||
}
|
||||
|
||||
return `${prefix}${path}${suffix}`;
|
||||
};
|
||||
|
||||
// --- Core recursive logic ---
|
||||
const process = (node: GroupTreeNode) => {
|
||||
const allLeaves = getAllLeafKeys(node);
|
||||
const selectedLeaves = allLeaves.filter((k) => checkedKeys.includes(k));
|
||||
|
||||
if (selectedLeaves.length === 0) return; // This branch is not selected at all
|
||||
|
||||
// 1. If the current level is fully selected
|
||||
if (selectedLeaves.length === allLeaves.length) {
|
||||
includes.push(format(node, false));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. If it is a leaf node (and is selected)
|
||||
if (!node.children || node.children.length === 0) {
|
||||
includes.push(format(node, false));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedLeaves.length > allLeaves.length / 2) {
|
||||
includes.push(format(node, false));
|
||||
|
||||
node.children.forEach((child) => {
|
||||
const childLeaves = getAllLeafKeys(child);
|
||||
const childSelected = childLeaves.filter((k) =>
|
||||
checkedKeys.includes(k),
|
||||
);
|
||||
|
||||
if (childSelected.length === 0) {
|
||||
excludes.push(format(child, true));
|
||||
} else if (childSelected.length < childLeaves.length) {
|
||||
findExcludesOnly(child);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
node.children.forEach(process);
|
||||
}
|
||||
};
|
||||
|
||||
const findExcludesOnly = (node: GroupTreeNode) => {
|
||||
const allLeaves = getAllLeafKeys(node);
|
||||
const selectedLeaves = allLeaves.filter((k) => checkedKeys.includes(k));
|
||||
|
||||
if (selectedLeaves.length === 0) {
|
||||
excludes.push(format(node, true));
|
||||
return;
|
||||
}
|
||||
|
||||
node.children?.forEach((child) => {
|
||||
const childLeaves = getAllLeafKeys(child);
|
||||
const childSelected = childLeaves.filter((k) => checkedKeys.includes(k));
|
||||
|
||||
if (childSelected.length === 0) {
|
||||
excludes.push(format(child, true));
|
||||
} else if (childSelected.length < childLeaves.length) {
|
||||
findExcludesOnly(child);
|
||||
}
|
||||
});
|
||||
};
|
||||
treeData.forEach(process);
|
||||
|
||||
// 3. Final concatenation: Exclude items must be at the front
|
||||
return [...excludes, ...includes].join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the background Pattern string to Ant Design Tree's checkedKeys
|
||||
* @param patternString The string returned by the background, such as "!forex\exotic\*,forex\*"
|
||||
* @param treeData Original tree structure (must contain complete symbol and key information)
|
||||
* @param options Configuration items
|
||||
*/
|
||||
export function parsePatternToKeys2(
|
||||
patternString: string,
|
||||
treeData: GroupTreeNode[],
|
||||
options: { separator?: string } = {},
|
||||
): string[] {
|
||||
if (!patternString) return [];
|
||||
|
||||
const { separator = '\\' } = options;
|
||||
// 1. Split the string into rule array
|
||||
const rules = patternString.split(',').map((s) => s.trim());
|
||||
|
||||
// 2. Get all leaf nodes (final products), because the selection state finally appears on the leaf nodes
|
||||
const allLeafNodes: GroupTreeNode[] = [];
|
||||
const collectLeaves = (nodes: GroupTreeNode[]) => {
|
||||
nodes.forEach((node) => {
|
||||
if (!node.children || node.children.length === 0) {
|
||||
node.path = `${node.key}`;
|
||||
allLeafNodes.push(node);
|
||||
} else {
|
||||
collectLeaves(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
collectLeaves(treeData);
|
||||
|
||||
// 3. Define the matching logic (simulate the background matching process)
|
||||
const isMatched = (
|
||||
node: GroupTreeNode,
|
||||
rule: string,
|
||||
): { matched: boolean; isExclude: boolean } => {
|
||||
let tempRule = rule;
|
||||
const isExclude = tempRule.startsWith('!');
|
||||
if (isExclude) tempRule = tempRule.substring(1); // Remove !
|
||||
|
||||
let matched = false;
|
||||
|
||||
if (tempRule.endsWith('*')) {
|
||||
const prefix = tempRule.substring(0, tempRule.length - 1);
|
||||
const nodePath = (node?.path || node.key).split('/').join(separator);
|
||||
matched = nodePath.startsWith(prefix);
|
||||
} else {
|
||||
matched = node.key === tempRule;
|
||||
}
|
||||
|
||||
return { matched, isExclude };
|
||||
};
|
||||
|
||||
// 4. Execute rule matching for each leaf node
|
||||
const selectedKeys: string[] = [];
|
||||
|
||||
for (const node of allLeafNodes) {
|
||||
// Simulate left-to-right matching, stop when hit
|
||||
for (const rule of rules) {
|
||||
const { matched, isExclude } = isMatched(node, rule);
|
||||
if (matched) {
|
||||
if (!isExclude && node.key) {
|
||||
selectedKeys.push(node.key);
|
||||
}
|
||||
// Once a rule is hit (whether it's inclusion or exclusion), immediately break out of the current node's rule loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return selectedKeys;
|
||||
}
|
||||
8
src/utils/i18n.ts
Normal file
8
src/utils/i18n.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// src/utils/i18n.ts
|
||||
import { formatMessage } from '@umijs/max';
|
||||
|
||||
export const $t = (id: string, data?: Record<string, any>) => {
|
||||
if (!id) return '';
|
||||
return formatMessage({ id }, data);
|
||||
// return formatMessage({ id }, data);
|
||||
};
|
||||
230
src/utils/index.ts
Normal file
230
src/utils/index.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export const waitTime = (time: number = 100) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(true);
|
||||
}, time);
|
||||
});
|
||||
};
|
||||
|
||||
// Check if the device is mobile
|
||||
export function isMobileDevice() {
|
||||
if ('userAgentData' in navigator && navigator?.userAgentData) {
|
||||
return (navigator as any).userAgentData?.mobile;
|
||||
}
|
||||
const hasTouchSupport =
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
('msMaxTouchPoints' in navigator &&
|
||||
(navigator as any).msMaxTouchPoints > 0);
|
||||
|
||||
const mobileRegex =
|
||||
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i;
|
||||
const userAgent =
|
||||
navigator.userAgent || (navigator as any).vendor || (window as any)?.opera;
|
||||
|
||||
const screenWidth =
|
||||
typeof window.screen !== 'undefined' ? window.screen.width : 0;
|
||||
const isSmallScreen = screenWidth <= 480;
|
||||
|
||||
return (
|
||||
mobileRegex.test(userAgent) ||
|
||||
(hasTouchSupport && isSmallScreen) ||
|
||||
typeof window.orientation !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
// Hex color to RGBA conversion
|
||||
export function hexToRgba(hex: string, alpha?: number): string {
|
||||
if (!hex) return '';
|
||||
const cleanHex = hex.replace(/^#/, '');
|
||||
|
||||
let r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
a: number = 1;
|
||||
|
||||
if (cleanHex.length === 3) {
|
||||
r = parseInt(cleanHex[0] + cleanHex[0], 16);
|
||||
g = parseInt(cleanHex[1] + cleanHex[1], 16);
|
||||
b = parseInt(cleanHex[2] + cleanHex[2], 16);
|
||||
} else if (cleanHex.length === 4) {
|
||||
r = parseInt(cleanHex[0] + cleanHex[0], 16);
|
||||
g = parseInt(cleanHex[1] + cleanHex[1], 16);
|
||||
b = parseInt(cleanHex[2] + cleanHex[2], 16);
|
||||
a = parseInt(cleanHex[3] + cleanHex[3], 16) / 255;
|
||||
} else if (cleanHex.length === 6) {
|
||||
r = parseInt(cleanHex.substring(0, 2), 16);
|
||||
g = parseInt(cleanHex.substring(2, 4), 16);
|
||||
b = parseInt(cleanHex.substring(4, 6), 16);
|
||||
} else if (cleanHex.length === 8) {
|
||||
r = parseInt(cleanHex.substring(0, 2), 16);
|
||||
g = parseInt(cleanHex.substring(2, 4), 16);
|
||||
b = parseInt(cleanHex.substring(4, 6), 16);
|
||||
a = parseInt(cleanHex.substring(6, 8), 16) / 255;
|
||||
} else {
|
||||
console.warn(`invalid hex color: ${hex}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
if (alpha !== void 0) {
|
||||
a = Math.max(0, Math.min(1, alpha));
|
||||
}
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
|
||||
// Currency formatting utility
|
||||
export const currencyFormat = (code: string, value: number, decimal = 2) => {
|
||||
if (!Intl || !Intl.NumberFormat) {
|
||||
return value.toFixed(decimal);
|
||||
}
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: code,
|
||||
minimumFractionDigits: decimal,
|
||||
maximumFractionDigits: decimal,
|
||||
});
|
||||
return formatter.format(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Digital Thousands Separator Formatting Function
|
||||
* @param num - Number to format (supports number and string types)
|
||||
* @param decimals - Number of decimal places to retain (default 0)
|
||||
* @param separators - [Thousands separator, decimal point symbol], default [',', '.']
|
||||
* @returns Formatted string
|
||||
*
|
||||
* Example:
|
||||
* formatNumber(1234567.89, 2) -> "1,234,567.89"
|
||||
* formatNumber(1234567.89, 2, [' ', ',']) -> "1 234 567,89"
|
||||
*/
|
||||
export function formatNumber(
|
||||
num: number | string,
|
||||
decimals: number = 2,
|
||||
separators: [string, string] = [',', '.'],
|
||||
): string {
|
||||
const [thousandSep, decimalSep] = separators;
|
||||
|
||||
// 1. Convert to numbers and handle invalid input
|
||||
const parsed = typeof num === 'string' ? parseFloat(num) : num;
|
||||
|
||||
if (!Number.isFinite(parsed) || Number.isNaN(parsed)) {
|
||||
return 'NaN';
|
||||
}
|
||||
|
||||
// 2. Handling decimal precision (use toFixed to avoid floating-point precision issues)
|
||||
const fixedNum = parsed.toFixed(Math.max(0, decimals));
|
||||
|
||||
// 3. Separate integer and decimal parts
|
||||
const [intPart, decPart] = fixedNum.split('.');
|
||||
|
||||
// 4. Handling the integer part with thousand separators
|
||||
// Add a separator every 3 digits from right to left
|
||||
const formattedInt = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandSep);
|
||||
|
||||
// 5. Assemble the result
|
||||
return decimals > 0 ? `${formattedInt}${decimalSep}${decPart}` : formattedInt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Blob and download in browser
|
||||
* @param res The response object returned by the interface (contains data and headers)
|
||||
* @param defaultName Default file name
|
||||
*/
|
||||
export const downloadFile = (res: any, defaultName: string = 'file.xlsx') => {
|
||||
const { data, headers } = res;
|
||||
|
||||
// 1. Get file name (try to extract from header)
|
||||
let fileName = defaultName;
|
||||
const contentDisposition = headers['content-disposition'];
|
||||
if (contentDisposition) {
|
||||
const nameMatch = contentDisposition.match(/filename=(.*)/);
|
||||
if (nameMatch?.[1]) {
|
||||
fileName = decodeURIComponent(nameMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const timePrefix = dayjs().format('YYYYMMDDHHmmssms');
|
||||
|
||||
// 2. Create Blob URL and download
|
||||
const blob = new Blob([data]);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${timePrefix}_${fileName}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// 3. Release memory
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
export const preventScientificNotation = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'e' || e.key === 'E') {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
type DigitComAttr = {
|
||||
type: 'number';
|
||||
min: number;
|
||||
max?: number;
|
||||
step: number;
|
||||
precision: 0 | 1 | 2;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
};
|
||||
export const genDigitComAttr = (
|
||||
precision: DigitComAttr['precision'] = 2,
|
||||
min?: number,
|
||||
max?: number,
|
||||
) => {
|
||||
if (typeof precision !== 'number' || precision < 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const step = 1 / 10 ** precision;
|
||||
const returnObj: DigitComAttr = {
|
||||
type: 'number',
|
||||
min: min ?? step,
|
||||
step: step,
|
||||
precision,
|
||||
onKeyDown: preventScientificNotation,
|
||||
};
|
||||
|
||||
if (max) {
|
||||
returnObj.max = max;
|
||||
}
|
||||
|
||||
return returnObj;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check audio permission of the current page to play media with sound
|
||||
* @returns {Promise<boolean>} true if authorized, false otherwise
|
||||
*/
|
||||
export async function checkAudioPermission() {
|
||||
// Create a silent audio to probe permission for playing media with sound
|
||||
const probeAudio = new Audio();
|
||||
probeAudio.src = `data:audio/wav;base64,UklGRigAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQQAAAAAAA==`; // Silent audio data for probing
|
||||
|
||||
try {
|
||||
// 1. Try to play the silent audio
|
||||
// This should always work unless the environment is extremely restricted
|
||||
const playPromise = probeAudio.play();
|
||||
if (playPromise !== undefined) {
|
||||
await playPromise;
|
||||
}
|
||||
|
||||
// Important: Pause immediately, we are only testing permissions, no sound will be emitted
|
||||
probeAudio.pause();
|
||||
// If we reach here, it means the browser allows playing media with sound from the current state
|
||||
return true;
|
||||
} catch (error) {
|
||||
// If playing the silent audio fails, or if pausing it throws an error, the environment is extremely restricted
|
||||
console.warn('Audio permission check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
54
src/utils/permission.ts
Normal file
54
src/utils/permission.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// /**
|
||||
// * Character permission verification
|
||||
// * @param {Array} value check value
|
||||
// * @returns {Boolean}
|
||||
// */
|
||||
export function matchPerms(permissions: string[], value: string[]) {
|
||||
if (value && Array.isArray(value) && value.length > 0) {
|
||||
const permissionDatas = value;
|
||||
const all_permission = '*:*:*';
|
||||
const hasPermission = permissions.some((permission) => {
|
||||
return (
|
||||
all_permission === permission || permissionDatas.includes(permission)
|
||||
);
|
||||
});
|
||||
if (!hasPermission) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
console.error(
|
||||
`need roles! Like checkPermi="['system:user:add','system:user:edit']"`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function matchPerm(permissions: string[], value: string) {
|
||||
if (value && value.length > 0) {
|
||||
const permissionDatas = value;
|
||||
const all_permission = '*:*:*';
|
||||
const hasPermission = permissions.some((permission) => {
|
||||
return all_permission === permission || permissionDatas === permission;
|
||||
});
|
||||
if (!hasPermission) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
console.error(
|
||||
`need roles! Like checkPermi="['system:user:add','system:user:edit']"`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function matchPermission(
|
||||
permissions: string[] | undefined,
|
||||
value: any,
|
||||
): boolean {
|
||||
if (permissions === void 0) return false;
|
||||
const type = typeof value;
|
||||
if (type === 'string') {
|
||||
return matchPerm(permissions, value);
|
||||
}
|
||||
return matchPerms(permissions, value);
|
||||
}
|
||||
51
src/utils/sentry.ts
Normal file
51
src/utils/sentry.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
if (process.env.UMI_APP_SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: process.env.UMI_APP_SENTRY_DSN,
|
||||
enabled: process.env.NODE_ENV === 'production',
|
||||
integrations: [Sentry.browserTracingIntegration()],
|
||||
environment: process.env.NODE_ENV,
|
||||
release: process.env.UMI_APP_SENTRY_RELEASE,
|
||||
tracesSampleRate: 0.02,
|
||||
});
|
||||
}
|
||||
|
||||
// Print the version number only in the production environment
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.log(
|
||||
`%c Current Version: ${process.env.UMI_APP_SENTRY_RELEASE || 'N/A'} `,
|
||||
'background: #1e88e5; color: #fff; padding: 2px 5px; border-radius: 3px;',
|
||||
);
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register(
|
||||
`/sw.js?v=${encodeURIComponent(process.env.UMI_APP_SENTRY_RELEASE || 'default-version')}`,
|
||||
)
|
||||
.then((registration) => {
|
||||
console.log('SW registered: ', registration.scope);
|
||||
|
||||
// If a new version of the SW is found to be waiting, notify the user or handle it automatically.
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
if (!installingWorker) {
|
||||
return;
|
||||
}
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// New version of the SW is ready, and we're using skipWaiting
|
||||
console.log('New content is available; please refresh.');
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('SW registration failed: ', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
77
src/utils/storage.ts
Normal file
77
src/utils/storage.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import localforage from 'localforage';
|
||||
|
||||
// Define the structure of the stored data
|
||||
interface StorageItem<T> {
|
||||
value: T;
|
||||
expire: number | null; // Expiration timestamp, null means valid permanently
|
||||
}
|
||||
|
||||
class Storage {
|
||||
constructor() {
|
||||
// Default configuration (optional)
|
||||
localforage.config({
|
||||
name: 'RiskGuard_V1',
|
||||
storeName: 'key_value_pairs',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cache (add/update)
|
||||
* @param key Key
|
||||
* @param value Value
|
||||
* @param ttl Expiration time (unit: seconds). If not passed, it will be valid permanently.
|
||||
*/
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<T> {
|
||||
const expire = ttl ? Date.now() + ttl * 1000 : null;
|
||||
const item: StorageItem<T> = {
|
||||
value,
|
||||
expire,
|
||||
};
|
||||
await localforage.setItem(key, item);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache (query)
|
||||
* @param key Key
|
||||
* @returns Returns the original data if not expired or does not exist, otherwise returns null
|
||||
*/
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const item = await localforage.getItem<StorageItem<T>>(key);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
// Check if expired
|
||||
if (item.expire !== null && Date.now() > item.expire) {
|
||||
await this.remove(key); // Immediately clean up if expired
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove cache (delete)
|
||||
* @param key Key
|
||||
*/
|
||||
async remove(key: string): Promise<void> {
|
||||
await localforage.removeItem(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
await localforage.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys
|
||||
*/
|
||||
async keys(): Promise<string[]> {
|
||||
return await localforage.keys();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const storage = new Storage();
|
||||
325
src/utils/symbolUtil.ts
Normal file
325
src/utils/symbolUtil.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Transform flattened path data into a tree structure
|
||||
* @param data Raw array returned by the backend
|
||||
*/
|
||||
export const transformToSymbolTree = (
|
||||
data: API.SymbolDataItem[],
|
||||
): API.SymbolTreeNode[] => {
|
||||
const tree: API.SymbolTreeNode[] = [];
|
||||
const map = new Map<string, API.SymbolTreeNode>();
|
||||
|
||||
data.forEach((item) => {
|
||||
const parts = item.path.split('\\');
|
||||
let currentPath = '';
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
const isLast = index === parts.length - 1;
|
||||
const parentPath = currentPath;
|
||||
currentPath = parentPath ? `${parentPath}\\${part}` : part;
|
||||
|
||||
if (!map.has(currentPath)) {
|
||||
const newNode: API.SymbolTreeNode = {
|
||||
title: part,
|
||||
// Leaf nodes use symbol, directory nodes use path to prevent name conflicts
|
||||
key: isLast ? item.symbol : currentPath,
|
||||
};
|
||||
|
||||
if (isLast) {
|
||||
newNode.value = item.symbol;
|
||||
newNode.isLeaf = true;
|
||||
newNode.meta = item;
|
||||
} else {
|
||||
newNode.children = [];
|
||||
newNode.selectable = false; // Directory nodes are not selectable
|
||||
}
|
||||
|
||||
map.set(currentPath, newNode);
|
||||
|
||||
// If there is no parent path, it is a root node
|
||||
if (!parentPath) {
|
||||
tree.push(newNode);
|
||||
} else {
|
||||
// Otherwise, add it to the parent's children
|
||||
map.get(parentPath)?.children?.push(newNode);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return tree;
|
||||
};
|
||||
|
||||
type SymbolTreeNode = {
|
||||
title: string;
|
||||
key: string;
|
||||
value?: string;
|
||||
path?: string;
|
||||
children?: SymbolTreeNode[];
|
||||
isLeaf?: boolean;
|
||||
selectable?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a Pattern string based on the checkedKeys of Ant Design Tree
|
||||
* @param treeData The tree structure of symbols (must contain complete symbol and key information)
|
||||
* @param checkedKeys The checked keys of Ant Design Tree
|
||||
* @param options Configuration items
|
||||
*/
|
||||
export function generatePattern(
|
||||
treeData: SymbolTreeNode[],
|
||||
checkedKeys: string[],
|
||||
options: { useShortMode?: boolean; separator?: string } = {},
|
||||
) {
|
||||
const { useShortMode = true, separator = '\\' } = options;
|
||||
|
||||
const excludes: string[] = [];
|
||||
const includes: string[] = [];
|
||||
|
||||
// --- Utility function: Get all leaf keys under a node ---
|
||||
const getAllLeafKeys = (node: SymbolTreeNode): string[] => {
|
||||
if (!node.children || node.children.length === 0) return [node.key];
|
||||
return node.children.flatMap(getAllLeafKeys);
|
||||
};
|
||||
|
||||
// --- Formatting function: process paths and wildcards ---
|
||||
const format = (node: SymbolTreeNode, isExclude: boolean) => {
|
||||
const isLeaf = !node.children || node.children.length === 0;
|
||||
|
||||
// Short mode (Logic from screenshot two): !*SYMBOL or path\*
|
||||
if (useShortMode && isLeaf && node.value) {
|
||||
return isExclude ? `!*${node.value}` : `*${node.value}`;
|
||||
}
|
||||
|
||||
// Short mode still uses wildcards to match categories, but we want to keep the path structure for clarity
|
||||
const path = node.key.split('/').join(separator); // Adapt to backslashes
|
||||
const suffix = isLeaf ? '' : `${separator}*`;
|
||||
const prefix = isExclude ? '!' : '';
|
||||
|
||||
// Special handling for exclusion logic in short mode:
|
||||
// If it is short mode and we want to exclude a specific product, it should be !*SYMBOL according to screenshot two
|
||||
if (useShortMode && isExclude && isLeaf && node.value) {
|
||||
return `!*${node.value}`;
|
||||
}
|
||||
|
||||
return `${prefix}${path}${suffix}`;
|
||||
};
|
||||
|
||||
// --- Core recursive logic ---
|
||||
const process = (node: SymbolTreeNode) => {
|
||||
const allLeaves = getAllLeafKeys(node);
|
||||
const selectedLeaves = allLeaves.filter((k) => checkedKeys.includes(k));
|
||||
|
||||
if (selectedLeaves.length === 0) return; // This branch is not selected at all
|
||||
|
||||
// 1. If the current level is fully selected
|
||||
if (selectedLeaves.length === allLeaves.length) {
|
||||
includes.push(format(node, false));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. If it is a leaf node (and is selected)
|
||||
if (!node.children || node.children.length === 0) {
|
||||
includes.push(format(node, false));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedLeaves.length > allLeaves.length / 2) {
|
||||
includes.push(format(node, false));
|
||||
|
||||
node.children.forEach((child) => {
|
||||
const childLeaves = getAllLeafKeys(child);
|
||||
const childSelected = childLeaves.filter((k) =>
|
||||
checkedKeys.includes(k),
|
||||
);
|
||||
|
||||
if (childSelected.length === 0) {
|
||||
excludes.push(format(child, true));
|
||||
} else if (childSelected.length < childLeaves.length) {
|
||||
findExcludesOnly(child);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
node.children.forEach(process);
|
||||
}
|
||||
};
|
||||
|
||||
const findExcludesOnly = (node: SymbolTreeNode) => {
|
||||
const allLeaves = getAllLeafKeys(node);
|
||||
const selectedLeaves = allLeaves.filter((k) => checkedKeys.includes(k));
|
||||
|
||||
if (selectedLeaves.length === 0) {
|
||||
excludes.push(format(node, true));
|
||||
return;
|
||||
}
|
||||
|
||||
node.children?.forEach((child) => {
|
||||
const childLeaves = getAllLeafKeys(child);
|
||||
const childSelected = childLeaves.filter((k) => checkedKeys.includes(k));
|
||||
|
||||
if (childSelected.length === 0) {
|
||||
excludes.push(format(child, true));
|
||||
} else if (childSelected.length < childLeaves.length) {
|
||||
findExcludesOnly(child);
|
||||
}
|
||||
});
|
||||
};
|
||||
treeData.forEach(process);
|
||||
|
||||
// 3. Final concatenation: Exclude items must be at the front
|
||||
return [...excludes, ...includes].join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the background Pattern string to Ant Design Tree's checkedKeys
|
||||
* @param patternString The string returned by the background, such as "!forex\exotic\*,forex\*"
|
||||
* @param treeData Original tree structure (must contain complete symbol and key information)
|
||||
* @param options Configuration items
|
||||
*/
|
||||
export function parsePatternToKeys(
|
||||
patternString: string,
|
||||
treeData: SymbolTreeNode[],
|
||||
options: { separator?: string } = {},
|
||||
): string[] {
|
||||
if (!patternString) return [];
|
||||
|
||||
const { separator = '\\' } = options;
|
||||
// 1. Split the string into rule array
|
||||
const rules = patternString.split(',').map((s) => s.trim());
|
||||
|
||||
// 2. Get all leaf nodes (final products), because the selection state finally appears on the leaf nodes
|
||||
const allLeafNodes: SymbolTreeNode[] = [];
|
||||
const collectLeaves = (nodes: SymbolTreeNode[], parentPath: string = '') => {
|
||||
nodes.forEach((node) => {
|
||||
if (!node.children || node.children.length === 0) {
|
||||
node.path = `${parentPath}${separator}${node.key}`;
|
||||
allLeafNodes.push(node);
|
||||
} else {
|
||||
collectLeaves(node.children, node.key);
|
||||
}
|
||||
});
|
||||
};
|
||||
collectLeaves(treeData);
|
||||
|
||||
// 3. Define the matching logic (simulate the background matching process)
|
||||
const isMatched = (
|
||||
node: SymbolTreeNode,
|
||||
rule: string,
|
||||
): { matched: boolean; isExclude: boolean } => {
|
||||
let tempRule = rule;
|
||||
const isExclude = tempRule.startsWith('!');
|
||||
if (isExclude) tempRule = tempRule.substring(1); // Remove !
|
||||
|
||||
let matched = false;
|
||||
|
||||
// Case A: Abbreviated pattern wildcard *BITCOIN
|
||||
if (tempRule.startsWith('*')) {
|
||||
const symbol = tempRule.substring(1);
|
||||
matched = node.value === symbol;
|
||||
}
|
||||
// Case B: Path wildcard forex/general/*
|
||||
else if (tempRule.endsWith('*')) {
|
||||
const prefix = tempRule.substring(0, tempRule.length - 1);
|
||||
// Check if the node's key starts with the prefix path
|
||||
// Note: We need to handle separator consistency
|
||||
const nodePath = (node?.path || node.key).split('/').join(separator);
|
||||
matched = nodePath.startsWith(prefix);
|
||||
}
|
||||
// Case C: Exact path match forex/general/XAUUSD
|
||||
else {
|
||||
const nodePath = (node?.path || node.key).split('/').join(separator);
|
||||
matched = nodePath === tempRule;
|
||||
}
|
||||
|
||||
return { matched, isExclude };
|
||||
};
|
||||
|
||||
// 4. Execute rule matching for each leaf node
|
||||
const selectedKeys: string[] = [];
|
||||
|
||||
for (const node of allLeafNodes) {
|
||||
// Simulate left-to-right matching, stop when hit
|
||||
for (const rule of rules) {
|
||||
const { matched, isExclude } = isMatched(node, rule);
|
||||
if (matched) {
|
||||
if (!isExclude && node.value) {
|
||||
selectedKeys.push(node.value);
|
||||
}
|
||||
// Once a rule is hit (whether it's inclusion or exclusion), immediately break out of the current node's rule loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selectedKeys;
|
||||
}
|
||||
|
||||
export interface AntdTreeNode {
|
||||
title: string;
|
||||
value: string;
|
||||
key?: string;
|
||||
selectable?: boolean;
|
||||
children?: AntdTreeNode[];
|
||||
}
|
||||
const CALC_MODE_MAP: Record<number, string> = {
|
||||
0: 'Forex',
|
||||
1: 'Futures',
|
||||
2: 'CFD',
|
||||
3: 'CFD Index',
|
||||
4: 'CFD Leverage',
|
||||
5: 'Forex No Leverage',
|
||||
32: 'Exchange Stocks',
|
||||
33: 'Exchange Futures',
|
||||
34: 'Exchange FORTS Futures',
|
||||
35: 'Exchange Options',
|
||||
36: 'Exchange Options Margin',
|
||||
37: 'Exchange Bonds',
|
||||
38: 'Exchange MOEX Stocks',
|
||||
39: 'Exchange MOEX Bonds',
|
||||
64: 'Collateral',
|
||||
};
|
||||
|
||||
export function transformToAssetsTree(
|
||||
sourceData: API.SymbolDataItem[],
|
||||
): AntdTreeNode[] {
|
||||
const groupedData: Record<string, Set<string>> = {};
|
||||
|
||||
sourceData.forEach((item) => {
|
||||
const { calcMode, currencyBase, currencyProfit, symbol } = item;
|
||||
|
||||
// Determine the category name, with 0 and 5 both classified as 'Forex'
|
||||
let categoryName = CALC_MODE_MAP[calcMode] || 'Other';
|
||||
if (calcMode === 0 || calcMode === 5) {
|
||||
categoryName = 'Forex';
|
||||
}
|
||||
|
||||
if (!groupedData[categoryName]) {
|
||||
groupedData[categoryName] = new Set<string>();
|
||||
}
|
||||
|
||||
if (calcMode === 0 || calcMode === 5) {
|
||||
if (currencyBase) groupedData[categoryName].add(currencyBase);
|
||||
if (currencyProfit) groupedData[categoryName].add(currencyProfit);
|
||||
} else {
|
||||
// Other cases extract the symbol directly to remove duplicates
|
||||
if (symbol) groupedData[categoryName].add(symbol);
|
||||
}
|
||||
});
|
||||
|
||||
const treeData: AntdTreeNode[] = Object.keys(groupedData).map((category) => {
|
||||
const uniqueValues = Array.from(groupedData[category]);
|
||||
|
||||
return {
|
||||
title: category,
|
||||
value: `GROUP_${category}`,
|
||||
key: `GROUP_${category}`,
|
||||
selectable: false,
|
||||
children: uniqueValues.map((val) => ({
|
||||
title: val,
|
||||
value: val,
|
||||
// key: `${category}_${val}`,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return treeData;
|
||||
}
|
||||
24
src/utils/timeFormat.ts
Normal file
24
src/utils/timeFormat.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import dayjs from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
/**
|
||||
* Format UTC time to user's local time zone
|
||||
* @param {string} utcTime -UTC time returned by the background
|
||||
* @param {string} userTimezone -User-selected time zone, such as 'Asia/Shanghai'
|
||||
* @param {string} format -Format templates
|
||||
*/
|
||||
export function formatToUserTimezone(
|
||||
utcTime: string | number,
|
||||
userTimezone = 'UTC',
|
||||
format = 'YYYY-MM-DD HH:mm:ss',
|
||||
) {
|
||||
if (!utcTime) return '-';
|
||||
|
||||
return dayjs.utc(utcTime).tz(userTimezone).format(format);
|
||||
}
|
||||
|
||||
export const utcInstance = dayjs;
|
||||
22
src/utils/urlUtils.ts
Normal file
22
src/utils/urlUtils.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// src/utils/urlEncoder.ts
|
||||
import qs from 'query-string';
|
||||
|
||||
export const encodeQ = (params: Record<string, any>): string => {
|
||||
if (!params || Object.keys(params).length === 0) return '';
|
||||
return window.btoa(encodeURIComponent(qs.stringify(params)));
|
||||
};
|
||||
|
||||
export const decodeQ = <T = any>(q: string | null): T => {
|
||||
if (!q) return {} as T;
|
||||
try {
|
||||
const decodedStr = decodeURIComponent(window.atob(q));
|
||||
// Key point: Use qs's configuration to automatically convert numbers and booleans
|
||||
return qs.parse(decodedStr, {
|
||||
parseNumbers: true,
|
||||
parseBooleans: true,
|
||||
}) as unknown as T;
|
||||
} catch (e) {
|
||||
console.error('URL parameter decoding failed', e);
|
||||
return {} as T;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user