This commit is contained in:
2026-04-24 20:30:52 +08:00
commit 704bc34625
152 changed files with 45612 additions and 0 deletions

148
src/utils/dataCenter.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
};