Jelajahi Sumber

fix: utils bug 修复

BaiLuoYan 1 bulan lalu
induk
melakukan
ac5c8a61bb

+ 1 - 0
package.json

@@ -40,6 +40,7 @@
     "copy-to-clipboard": "^3.3.3",
     "crypto-js": "^4.2.0",
     "dayjs": "^1.11.13",
+    "dayjs-plugin-utc": "0.1.2",
     "fflate": "0.8.2",
     "file-saver": "^2.0.5",
     "firebase": "^11.5.0",

+ 8 - 0
pnpm-lock.yaml

@@ -56,6 +56,9 @@ importers:
       dayjs:
         specifier: ^1.11.13
         version: 1.11.13
+      dayjs-plugin-utc:
+        specifier: 0.1.2
+        version: 0.1.2
       fflate:
         specifier: 0.8.2
         version: 0.8.2
@@ -2051,6 +2054,9 @@ packages:
     resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
     engines: {node: '>=0.11'}
 
+  [email protected]:
+    resolution: {integrity: sha512-ExERH5o3oo6jFOdkvMP3gytTCQ9Ksi5PtylclJWghr7k7m3o2U5QrwtdiJkOxLOH4ghr0EKhpqGefzGz1VvVJg==}
+
   [email protected]:
     resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
 
@@ -6801,6 +6807,8 @@ snapshots:
       '@babel/runtime': 7.28.6
     optional: true
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]:

+ 1 - 1
src/components/Dialog/index.tsx

@@ -2,7 +2,7 @@ import { Fragment, memo } from 'react';
 
 import { Icon, IconifyIcon } from '@iconify/react';
 
-import { useResponsive } from '@/hooks/useResponsive';
+import { useResponsive } from '@/hooks/useSize';
 import { dialogModel } from '@/models/dialogModel';
 import { useAction } from './useAction';
 

+ 1 - 1
src/components/Footerbar/index.tsx

@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router-dom';
 
 import logoIcon from '@/assets/iconify/multi-color/logo.svg';
-import { useResponsive } from '@/hooks/useResponsive';
+import { useResponsive } from '@/hooks/useSize';
 
 const Footerbar = memo(() => {
     const { t } = useTranslation();

+ 1 - 1
src/components/Topbar/index.tsx

@@ -9,7 +9,7 @@ import menuIcon from '@/assets/iconify/single-color/menu.svg';
 import closeIcon from '@/assets/iconify/single-color/close.svg';
 import chevronDownIcon from '@/assets/iconify/single-color/chevron-down.svg';
 import type { NavMenuItem } from '@/utils/navUtils';
-import { useResponsive } from '@/hooks/useResponsive';
+import { useResponsive } from '@/hooks/useSize';
 import { useAction } from './useAction';
 import { useService } from './useService';
 

+ 1 - 0
src/defines/index.ts

@@ -2,3 +2,4 @@ export * from './docLastUpdated';
 export * from './errorShowType';
 export * from './planTagType';
 export * from './payMethodType';
+export * from './payOrderStatus';

+ 6 - 0
src/defines/payOrderStatus.ts

@@ -0,0 +1,6 @@
+export enum PayOrderStatus {
+    PENDING = 0,
+    PAID = 1,  // 已支付
+    FAILED = 2,  // 支付失败
+    OTHER = 3,  // 其他状态
+}

+ 0 - 23
src/hooks/useResponsive.ts

@@ -1,23 +0,0 @@
-import { useEffect, useState } from 'react';
-
-const MOBILE_BREAKPOINT = 768;
-
-/**
- * 响应式检测 Hook
- * 检测当前是否为移动端
- */
-export function useResponsive() {
-    const [isMobile, setIsMobile] = useState(false);
-
-    useEffect(() => {
-        const checkMobile = () => {
-            setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
-        };
-
-        checkMobile();
-        window.addEventListener('resize', checkMobile);
-        return () => window.removeEventListener('resize', checkMobile);
-    }, []);
-
-    return { isMobile };
-}

+ 71 - 0
src/hooks/useSize.ts

@@ -0,0 +1,71 @@
+import { useEffect, useState } from 'react';
+
+/**
+ * 订阅 body 的宽高,在 window resize 时更新。
+ * @returns 当前 body 的 { width, height }(clientWidth/clientHeight)
+ */
+export const useScreenSize = () => {
+    const [screenSize, setScreenSize] = useState<{ width: number; height: number }>({
+        width: 0,
+        height: 0,
+    });
+
+    useEffect(() => {
+        const handleResize = () => {
+            const body = document.getElementsByTagName('body')[0];
+            setScreenSize({ width: body?.clientWidth ?? 0, height: body?.clientHeight ?? 0 });
+        };
+        handleResize();
+        window.addEventListener('resize', handleResize);
+        return () => window.removeEventListener('resize', handleResize);
+    }, []);
+
+    return screenSize;
+};
+
+/**
+ * 订阅 ref 对应 DOM 元素的尺寸,使用 ResizeObserver 在尺寸变化时更新。
+ * @param ref 目标元素的 RefObject
+ * @returns 当前元素的 { width, height }(contentRect)
+ */
+export const useRefSize = <T extends HTMLElement = HTMLDivElement>(
+    ref: React.RefObject<T>
+) => {
+    const [size, setSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
+
+    useEffect(() => {
+        const element = ref.current;
+        if (!element) return;
+
+        setSize({
+            width: element.clientWidth,
+            height: element.clientHeight,
+        });
+
+        const resizeObserver = new ResizeObserver((entries) => {
+            for (const entry of entries) {
+                const { width, height } = entry.contentRect;
+                setSize({ width, height });
+            }
+        });
+
+        resizeObserver.observe(element);
+
+        return () => {
+            resizeObserver.disconnect();
+        };
+    }, [ref]);
+
+    return size;
+};
+
+const MOBILE_BREAKPOINT = 768;
+
+/**
+ * 响应式检测:基于 useScreenSize 的 body 宽度判断是否为移动端(宽度 < 768px)。
+ * @returns { isMobile } 当前是否视为移动端
+ */
+export function useResponsive() {
+    const { width } = useScreenSize();
+    return { isMobile: width < MOBILE_BREAKPOINT };
+}

+ 1 - 1
src/pages/pricing/components/OrderSummary/index.tsx

@@ -4,7 +4,7 @@ import { Button } from 'antd';
 import { useTranslation } from 'react-i18next';
 
 import type { PayMethodType } from '@/defines';
-import { useResponsive } from '@/hooks/useResponsive';
+import { useResponsive } from '@/hooks/useSize';
 
 import LabelValueItem from '../LabelValueItem';
 import type { Plan } from '../../useService';

+ 1 - 1
src/pages/pricing/components/PayMethodCard/index.tsx

@@ -3,7 +3,7 @@ import { memo } from 'react';
 import { Icon } from '@iconify/react';
 
 import type { PayMethodType } from '@/defines';
-import { useResponsive } from '@/hooks/useResponsive';
+import { useResponsive } from '@/hooks/useSize';
 import checkOnIcon from '@/assets/iconify/multi-color/check-on.svg';
 import checkOffIcon from '@/assets/iconify/multi-color/check-off.svg';
 

+ 1 - 1
src/pages/pricing/components/PlanCard/index.tsx

@@ -3,7 +3,7 @@ import { memo } from 'react';
 import { Icon } from '@iconify/react';
 import { useTranslation } from 'react-i18next';
 
-import { useResponsive } from '@/hooks/useResponsive';
+import { useResponsive } from '@/hooks/useSize';
 
 import checkOnIcon from '@/assets/iconify/multi-color/check-on.svg';
 import checkOffIcon from '@/assets/iconify/multi-color/check-off.svg';

+ 1 - 1
src/pages/pricing/components/UserInfo/index.tsx

@@ -2,7 +2,7 @@ import { memo } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
-import { useResponsive } from '@/hooks/useResponsive';
+import { useResponsive } from '@/hooks/useSize';
 import { maskAccount } from '@/utils/stringUtils';
 import { unixTimeFormat } from '@/utils/timeUtils';
 

+ 1 - 1
src/pages/pricing/index.tsx

@@ -2,7 +2,7 @@ import { memo, useRef } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
-import { useResponsive } from '@/hooks/useResponsive';
+import { useResponsive } from '@/hooks/useSize';
 
 import OrderSummary from './components/OrderSummary';
 import PayMethodCard from './components/PayMethodCard';

+ 2 - 2
src/pages/pricing/useService.ts

@@ -5,8 +5,8 @@ import { userConfigModel } from '@/models/userConfigModel';
 import { fetchGetUserConfig, fetchPlanList } from '@/services/config';
 import { setToken, userKey } from '@/utils/authUtils';
 import { secureLocalStorage as ls } from '@/utils/localUtils';
+import { getUniqueSign } from '@/utils/stringUtils';
 import { currentUnixTimestamp } from '@/utils/timeUtils';
-import * as stringUtils from '@/utils/stringUtils';
 
 export interface Plan {
     id: string;
@@ -48,7 +48,7 @@ export function useService(): UseServiceReturn {
             .then((res) => {
                 const list = res?.data?.list ?? [];
                 const mapped: Plan[] = list.map((item) => ({
-                    id: item.channelItemId ?? stringUtils.getUniqueSign(),
+                    id: item.channelItemId ?? getUniqueSign(),
                     title: item.title ?? '',
                     subTitle: item.subTitle ?? '',
                     introduce: item.introduce ?? '',

+ 1 - 1
src/pages/privacyPolicy/index.tsx

@@ -5,7 +5,7 @@ import ReactMarkdown from 'react-markdown';
 import remarkGfm from 'remark-gfm';
 
 import { DOC_LAST_UPDATED } from '@/defines';
-import { useResponsive } from '@/hooks/useResponsive';
+import { useResponsive } from '@/hooks/useSize';
 import { getDirByLang, loadMdByLang } from '@/utils/mdLoader';
 
 const PrivacyPolicy: React.FC = () => {

+ 1 - 1
src/pages/termsOfService/index.tsx

@@ -5,7 +5,7 @@ import ReactMarkdown from 'react-markdown';
 import remarkGfm from 'remark-gfm';
 
 import { DOC_LAST_UPDATED } from '@/defines';
-import { useResponsive } from '@/hooks/useResponsive';
+import { useResponsive } from '@/hooks/useSize';
 import { getDirByLang, loadMdByLang } from '@/utils/mdLoader';
 
 const TermsOfService: React.FC = () => {

+ 27 - 0
src/services/config/index.ts

@@ -14,3 +14,30 @@ export async function fetchPlanList(_: API.Empty, options?: { [key: string]: any
         requireToken: false,
     });
 }
+
+export async function fetchPayTypeList(_: API.Empty, options?: { [key: string]: any }) {
+    return request<API.Result<API.PayTypeListResp>>('/pay/payTypeList', {
+        method: 'POST',
+        ...(options || {}),
+        requireToken: false,
+    });
+}
+
+export async function fetchPayOrderCreate(body: API.PayOrderReq, options?: { [key: string]: any }) {
+    return request<API.Result<API.PayOrderResp>>('/pay/userPay', {
+        method: 'POST',
+        data: body,
+        ...(options || {}),
+    });
+}
+
+export async function fetchPayOrderStatus(
+    body: API.PayOrderStatusReq,
+    options?: { [key: string]: any }
+) {
+    return request<API.Result<API.PayOrderStatusResp>>('/pay/queryState', {
+        method: 'POST',
+        data: body,
+        ...(options || {}),
+    });
+}

+ 55 - 1
src/services/config/typings.d.ts

@@ -1,4 +1,5 @@
 type PlanTagType = import('@/defines').PlanTagType;
+type PayOrderStatus = import('@/defines').PayOrderStatus;
 
 declare namespace API {
     type Plan = {
@@ -25,5 +26,58 @@ declare namespace API {
     type PlanList = {
         total: number;
         list: Plan[];
-    }
+    };
+
+    type PayTypeItem = {
+        payType: string;
+        name: string;
+        icon: string;
+    };
+
+    type PayTypeListResp = {
+        payTypeList: PayTypeItem[];
+    };
+
+    type UserPayOrder = {
+        id: number;
+        userId: number;
+        productCode: string;
+        orderId: string;
+        amount: number;
+        settleAmount: number;
+        currency: string;
+        currencyAmount: number;
+        orderType: number;
+        planOrderType: number;
+        payType: string;
+        platform: string;
+        snNo: string;
+        payUrl: string;
+        paidAddress: string;
+        ip: string;
+        remark: string;
+        state: string;
+        stateDesc: string;
+        status: number;
+        createTime: number;
+        updateTime: number;
+    };
+
+    type PayOrderReq = {
+        orderType: number;
+        payType: string;
+        channelItemId: string;
+    };
+
+    type PayOrderResp = {
+        userPayOrder: UserPayOrder;
+    };
+
+    type PayOrderStatusReq = {
+        orderId: string;
+    };
+
+    type PayOrderStatusResp = {
+        orderState: PayOrderStatus;
+    };
 }

+ 211 - 0
src/utils/httpUtils.ts

@@ -0,0 +1,211 @@
+import saveAs from 'file-saver';
+
+import { ErrorShowType } from '@/defines';
+
+import { toLoginPage } from './routerUtils';
+import { request, RequestConfig } from './request';
+
+type RequestMethods =
+    | 'get'
+    | 'GET'
+    | 'post'
+    | 'POST'
+    | 'put'
+    | 'PUT'
+    | 'delete'
+    | 'DELETE'
+    | 'patch'
+    | 'PATCH';
+
+function handleDownloadError(
+    errorMessage: string,
+    errorCode: string | number,
+    showType: ErrorShowType,
+    config?: RequestConfig,
+    data?: any
+) {
+    if (!(config as any)?.skipErrorHandler) {
+        if (errorCode === 401) {
+            toLoginPage();
+            return;
+        } else {
+            switch (showType) {
+                case ErrorShowType.SILENT:
+                    // TODO: 静默处理,不显示任何提示
+                    break;
+                case ErrorShowType.WARN_MESSAGE:
+                    // TODO: 显示消息弹窗(警告)
+                    break;
+                case ErrorShowType.ERROR_MESSAGE:
+                    // TODO: 显示消息弹窗(错误)
+                    break;
+                case ErrorShowType.NOTIFICATION:
+                    // TODO: 显示一个通知
+                    break;
+                case ErrorShowType.REDIRECT:
+                    // TODO: 将错误重定向到指定页面
+                    break;
+                default:
+                    // TODO: 显示消息弹窗(错误)
+                    break;
+            }
+        }
+    }
+
+    const error: any = new Error(errorMessage);
+    error.name = 'BizError';
+    error.info = { errorCode, errorMessage, showType, data };
+    return Promise.reject(error);
+}
+
+async function handleBlobErrorResponse(blob: Blob, config?: RequestConfig) {
+    try {
+        const text = await blob.text();
+        if (!text.trim()) {
+            return handleDownloadError(
+                'Empty error response',
+                '500',
+                ErrorShowType.ERROR_MESSAGE,
+                config
+            );
+        }
+
+        const errorData: API.Result = JSON.parse(text);
+        const errorMessage = errorData.errorMessage || 'Unknown error';
+        const errorCode = errorData.errorCode || 500;
+        const showType = errorData.showType ?? ErrorShowType.ERROR_MESSAGE;
+
+        return handleDownloadError(errorMessage, errorCode, showType, config, errorData.data);
+    } catch (parseError) {
+        return handleDownloadError(
+            'Failed to parse error response',
+            500,
+            ErrorShowType.ERROR_MESSAGE,
+            config
+        );
+    }
+}
+
+function extractFileName(contentDisposition: string | undefined, defaultFileName: string): string {
+    if (!contentDisposition || typeof contentDisposition !== 'string') {
+        return defaultFileName;
+    }
+
+    // 支持标准的 filename 和 filename* 格式
+    const patterns = [
+        /filename\*=UTF-8''([^;]+)/i, // RFC 5987 格式
+        /filename\*=['"]?([^'";]+)['"]?/i,
+        /filename=['"]?([^'";]+)['"]?/i,
+    ];
+
+    for (const pattern of patterns) {
+        const matches = contentDisposition.match(pattern);
+        if (matches?.[1]) {
+            try {
+                return decodeURIComponent(matches[1].trim());
+            } catch {
+                return matches[1].trim();
+            }
+        }
+    }
+
+    return defaultFileName;
+}
+
+/**
+ * 发起文件下载请求:以 Blob 方式请求并保存为本地文件。超时 10 分钟;4xx/5xx 或业务错误 Blob 会按 ErrorShowType 处理(401 跳转登录),并 reject BizError。
+ * @param method HTTP 方法
+ * @param uri 请求地址
+ * @param params 请求配置(如 data、params),会与 config 合并
+ * @param opts 下载选项:mime 保存时的 MIME,defaultFileName 未从 Content-Disposition 解析到文件名时的默认名;不传时默认 txt
+ * @param config 额外请求配置,可与 params 合并;含 skipErrorHandler 时跳过统一错误处理
+ * @returns Promise,成功时无返回值(触发浏览器保存);失败时 reject 带 info 的 BizError
+ */
+export const downloadFile = <D = any>(
+    method: RequestMethods,
+    uri: string,
+    params?: RequestConfig<D>,
+    opts?: { mime: string; defaultFileName: string },
+    config?: RequestConfig
+) => {
+    const { defaultFileName = 'default.txt', mime = 'text/plain;charset=utf-8' } = opts ?? {};
+    return request(uri, {
+        method,
+        ...(params || {}),
+        ...(config || {}),
+        responseType: 'blob',
+        getResponse: true,
+        timeout: 10 * 60 * 1000,
+    }).then(async (response) => {
+        if (!response.data || !(response.data instanceof Blob)) {
+            return handleDownloadError(
+                'Invalid response data',
+                '500',
+                ErrorShowType.ERROR_MESSAGE,
+                config
+            );
+        }
+
+        if (response.status >= 400) {
+            if (response.data.type === 'application/json') {
+                return handleBlobErrorResponse(response.data, config);
+            } else {
+                return handleDownloadError(
+                    `HTTP Error: ${response.status}`,
+                    response.status,
+                    ErrorShowType.ERROR_MESSAGE,
+                    config
+                );
+            }
+        }
+
+        const contentDisposition = response.headers['content-disposition'];
+        const isFileDownload = contentDisposition && contentDisposition.includes('attachment');
+        if (response.data.type === 'application/json' && !isFileDownload) {
+            return handleBlobErrorResponse(response.data, config);
+        }
+
+        const fileName = extractFileName(contentDisposition, defaultFileName);
+        const downloadBlob = new Blob([response.data], { type: mime });
+        saveAs(downloadBlob, fileName);
+    });
+};
+
+/**
+ * 使用 POST 请求下载文本文件并保存为本地文件;等价于 downloadFile('post', url, params, undefined, config)。
+ * @param url 请求地址
+ * @param params 请求配置(如 data)
+ * @param config 额外请求配置
+ * @returns 同 downloadFile
+ */
+export const downloadText = <D = any>(
+    url: string,
+    params: RequestConfig<D>,
+    config?: RequestConfig
+) => {
+    return downloadFile('post', url, params, void 0, config);
+};
+
+/**
+ * 使用 POST 请求下载 Excel(xlsx)并保存为本地文件;MIME 与默认文件名已固定为 xlsx。
+ * @param url 请求地址
+ * @param params 请求配置(如 data)
+ * @param config 额外请求配置
+ * @returns 同 downloadFile
+ */
+export const downloadExcel = <D = any>(
+    url: string,
+    params: RequestConfig<D>,
+    config?: RequestConfig
+) => {
+    return downloadFile(
+        'post',
+        url,
+        params,
+        {
+            mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+            defaultFileName: 'default.xlsx',
+        },
+        config
+    );
+};

+ 92 - 0
src/utils/jsonUtils.ts

@@ -0,0 +1,92 @@
+/**
+ * JSON格式化工具函数
+ */
+
+/**
+ * 验证JSON字符串是否有效
+ * @param jsonString JSON字符串
+ * @returns 验证结果和错误信息
+ */
+export const validateJSON = (jsonString: string): { isValid: boolean; error?: string } => {
+    if (!jsonString || jsonString.trim() === '') {
+        return { isValid: true }; // 空字符串视为有效
+    }
+
+    try {
+        JSON.parse(jsonString);
+        return { isValid: true };
+    } catch (error) {
+        return {
+            isValid: false,
+            error: error instanceof Error ? error.message : 'Invalid JSON format',
+        };
+    }
+};
+
+/**
+ * 格式化JSON字符串
+ * @param jsonString JSON字符串
+ * @param indent 缩进空格数,默认为2
+ * @returns 格式化后的JSON字符串
+ */
+export const formatJSON = (jsonString: string, indent: number = 2): string => {
+    if (!jsonString || jsonString.trim() === '') {
+        return '';
+    }
+
+    try {
+        const parsed = JSON.parse(jsonString);
+        return JSON.stringify(parsed, null, indent);
+    } catch (error) {
+        throw new Error(
+            `JSON格式化失败: ${error instanceof Error ? error.message : 'Unknown error'}`
+        );
+    }
+};
+
+/**
+ * 压缩JSON字符串(移除所有空格和换行)
+ * @param jsonString JSON字符串
+ * @returns 压缩后的JSON字符串
+ */
+export const minifyJSON = (jsonString: string): string => {
+    if (!jsonString || jsonString.trim() === '') {
+        return '';
+    }
+
+    try {
+        const parsed = JSON.parse(jsonString);
+        return JSON.stringify(parsed);
+    } catch (error) {
+        throw new Error(
+            `JSON压缩失败: ${error instanceof Error ? error.message : 'Unknown error'}`
+        );
+    }
+};
+
+/**
+ * 获取JSON字符串的语法高亮显示
+ * @param jsonString JSON字符串
+ * @returns 带有语法高亮的HTML字符串
+ */
+export const getJSONHighlight = (jsonString: string): string => {
+    if (!jsonString || jsonString.trim() === '') {
+        return '';
+    }
+
+    try {
+        const parsed = JSON.parse(jsonString);
+        const formatted = JSON.stringify(parsed, null, 2);
+
+        // 简单的语法高亮
+        return formatted
+            .replace(/(".*?")\s*:/g, '<span style="color: #0369a1;">$1</span>:')
+            .replace(/:\s*(".*?")/g, ': <span style="color: #059669;">$1</span>')
+            .replace(/:\s*(\d+)/g, ': <span style="color: #dc2626;">$1</span>')
+            .replace(/:\s*(true|false|null)/g, ': <span style="color: #7c3aed;">$1</span>')
+            .replace(/\n/g, '<br>')
+            .replace(/ /g, '&nbsp;');
+    } catch (error) {
+        return jsonString;
+    }
+};

+ 19 - 2
src/utils/numberUtils.ts

@@ -18,7 +18,10 @@ export const toFixed = (num: number, count: number) => {
  */
 export const toFixedLimit = (num: number, limit: number) => {
     if (!isNumber(num)) return num;
-    return num.toFixed(limit).replace(/(?<=\d+)\.?0+$/, '');
+    return num
+        .toFixed(limit)
+        .replace(/(\.\d*?)0+$/, '$1')
+        .replace(/\.$/, '');
 };
 
 /**
@@ -69,7 +72,10 @@ export const toCommaFixed = (num: number, count: number) => {
  */
 export const toCommaFixedLimit = (num: number, limit: number) => {
     if (!isNumber(num)) return num;
-    const fixedNum = num.toFixed(limit).replace(/(?<=\d+)\.?0+$/, '');
+    const fixedNum = num
+        .toFixed(limit)
+        .replace(/(\.\d*?)0+$/, '$1')
+        .replace(/\.$/, '');
     const [i, d] = fixedNum.split('.');
     const integerPart = i.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
     const decimalPart = d ? `.${d}` : '';
@@ -106,3 +112,14 @@ export const toCommaTruncateLimit = (num: number, limit: number) => {
     const decimalPart = d ? `.${d}` : '';
     return integerPart + decimalPart;
 };
+
+/**
+ * 格式化百分比
+ * @param rate 百分比数值,范围为 0-1
+ * @param decimalPlaces 保留的小数位数
+ * @returns
+ */
+export const formatPercentage = (rate: number | undefined, decimalPlaces: number) => {
+    if (!rate) return 0;
+    return (rate * 100).toFixed(decimalPlaces);
+};

+ 162 - 22
src/utils/stringUtils.ts

@@ -6,8 +6,11 @@ import split from 'ramda/es/split';
 import trim from 'ramda/es/trim';
 
 /**
- * 移除字符串首尾的指定字符
- * @returns
+ * 移除字符串首尾的指定字符(仅各移除一个,若首/尾等于 char)。
+ * 支持柯里化:仅传 char 时返回 (str) => string。
+ * @param char 要移除的单个字符
+ * @param str 原字符串;省略时返回柯里化函数
+ * @returns 处理后的字符串,或柯里化后的函数
  */
 function trimChar(char: string, str: string): string;
 function trimChar(char: string): (str: string) => string;
@@ -38,33 +41,29 @@ function trimChar(char: string, str?: string) {
 export { trimChar };
 
 /**
- * 将字符串的首字母转换为小写字母
- * @param str
- * @returns
+ * 将字符串的首字母转为小写,其余不变;空字符串原样返回。
+ * @param str 原字符串
+ * @returns 首字母小写后的字符串
  */
 export function toLowerCaseFirstLetter(str: string) {
-    if (!str) return str; // 如果字符串为空,直接返回
+    if (!str) return str;
     return str.charAt(0).toLowerCase() + str.slice(1);
 }
 
 /**
- * 将字符串的首字母转换为大写字母
- * @param str
- * @returns
+ * 将字符串的首字母转为大写,其余不变;空字符串原样返回。
+ * @param str 原字符串
+ * @returns 首字母大写后的字符串
  */
 export function toUpperCaseFirstLetter(str: string) {
-    if (!str) return str; // 如果字符串为空,直接返回
+    if (!str) return str;
     return str.charAt(0).toUpperCase() + str.slice(1);
 }
 
-export function isEmail(str: string): boolean {
-    return /^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/.test(str);
-}
-
 /**
- * 账号脱敏:中间用 ** 代替,前后各保留 4 位;少于 8 位时显示 ** + 后四位
- * @param str 原始账号
- * @returns 脱敏后的字符串,如 user**com.、**5678
+ * 账号脱敏:前四后四保留,中间用 '**' 代替;长度 ≤8 时仅保留后四位并前缀 '**'。空串返回 ''。
+ * @param str 原始账号字符串
+ * @returns 脱敏后的字符串,如 'user**com.'、'**5678'
  */
 export function maskAccount(str: string): string {
     if (!str) return '';
@@ -74,8 +73,40 @@ export function maskAccount(str: string): string {
 }
 
 /**
- * 生成具备加密强度的唯一ID,该算法能保证在同一时刻生成的ID不会重复,并具有加密强度(不可预测),但效率稍低(需调用底层的操作系统加密API,实测100000次调用,需450毫秒左右)
- * @returns
+ * 判断字符串是否为常见邮箱格式(本地部分 + @ + 域名 + 至少两位 TLD)。
+ * @param str 待检测字符串
+ * @returns 符合邮箱格式返回 true,否则 false
+ */
+export function isEmail(str: string): boolean {
+    const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
+    return regex.test(str);
+}
+
+/**
+ * 判断字符串是否为 Gmail 邮箱([email protected])。
+ * @param str 待检测字符串
+ * @returns 为 Gmail 格式返回 true,否则 false
+ */
+export function isGmail(str: string): boolean {
+    const regex = /^[a-zA-Z0-9._%+-]+@gmail([.])com$/;
+    return regex.test(str);
+}
+
+/**
+ * 判断字符串是否为合法 IPv4 地址(四段、每段 0–255)。
+ * @param str 待检测字符串
+ * @returns 合法 IPv4 返回 true,否则 false
+ */
+export function isIp(str: string): boolean {
+    const octet = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])';
+    const regex = new RegExp(`^${octet}\\.${octet}\\.${octet}\\.${octet}$`);
+    return regex.test(str);
+}
+
+/**
+ * 生成具备加密强度的唯一标识(基于 crypto.getRandomValues + 时间戳,base36)。
+ * 同一时刻多次调用不重复、不可预测,但较慢(依赖底层加密 API)。
+ * @returns 唯一字符串,适合用作防篡改或安全场景的 sign
  */
 export function getCryptoUniqueSign() {
     const v = Array.from(crypto.getRandomValues(new Uint32Array(3)))
@@ -85,8 +116,8 @@ export function getCryptoUniqueSign() {
 }
 
 /**
- * 生成唯一ID,该算法能保证在同一时刻生成的ID不会重复,不具有加密强度(可预测),但效率较高(实测100000次调用,需100毫秒左右)
- * @returns
+ * 生成唯一标识(基于 Math.random + 时间戳,base36)。同一时刻多次调用不重复,可预测、效率较高,不适用于安全场景。
+ * @returns 唯一字符串,适合用作非安全场景的 ID 或 sign
  */
 export function getUniqueSign() {
     const v = [Math.floor(Math.random() * 2 ** 32), Math.floor(Math.random() * 2 ** 32)]
@@ -96,7 +127,62 @@ export function getUniqueSign() {
 }
 
 /**
- * 字符串转数组,支持逗号分隔、换行分隔、空格分隔
+ * 将 IPv4 字符串转为 32 位无符号整数,语义与 MySQL INET_ATON 一致。非法或空串返回 0。
+ * @param ip 点分十进制 IPv4 字符串
+ * @returns 无符号整数,或 0(格式错误/空)
+ */
+export function ipToNum(ip: string): number {
+    if (!ip) return 0;
+    const parts = ip.split('.');
+    if (parts.length !== 4) return 0;
+
+    const num0 = parseInt(parts[0], 10);
+    const num1 = parseInt(parts[1], 10);
+    const num2 = parseInt(parts[2], 10);
+    const num3 = parseInt(parts[3], 10);
+
+    if (
+        isNaN(num0) ||
+        num0 < 0 ||
+        num0 > 255 ||
+        isNaN(num1) ||
+        num1 < 0 ||
+        num1 > 255 ||
+        isNaN(num2) ||
+        num2 < 0 ||
+        num2 > 255 ||
+        isNaN(num3) ||
+        num3 < 0 ||
+        num3 > 255
+    ) {
+        return 0;
+    }
+
+    return ((num0 << 24) | (num1 << 16) | (num2 << 8) | num3) >>> 0;
+}
+
+/**
+ * 将 32 位无符号整数转为 IPv4 点分十进制字符串,语义与 MySQL INET_NTOA 一致。超出 [0, 0xffffffff] 返回 ''。
+ * @param num 无符号整数
+ * @returns 点分十进制 IP 字符串,或 ''(越界)
+ */
+export function numToIp(num: number): string {
+    if (num < 0 || num > 0xffffffff) {
+        return '';
+    }
+
+    const byte3 = (num >>> 24) & 0xff;
+    const byte2 = (num >>> 16) & 0xff;
+    const byte1 = (num >>> 8) & 0xff;
+    const byte0 = num & 0xff;
+
+    return `${byte3}.${byte2}.${byte1}.${byte0}`;
+}
+
+/**
+ * 将字符串按逗号、空白(含换行)拆分为非空字符串数组;先 trim 再按 /[,\s]+/ 拆分,并对每项 trim、过滤空串。
+ * @param str 原字符串(由 compose 管道传入,调用方式 toStrArray(str))
+ * @returns 非空字符串数组
  */
 export const toStrArray = compose(
     filter((v) => v !== ''),
@@ -105,3 +191,57 @@ export const toStrArray = compose(
     trimChar(','),
     trim
 );
+
+/**
+ * 根据字符组成检测文本主要语言:中文(含 CJK 等)与英文(拉丁字母)数量比较,空或仅空白返回 'unknown'。
+ * @param text 待检测文本
+ * @returns 'zh-CN' | 'en' | 'unknown'
+ */
+export function detectLanguage(text: string): 'zh-CN' | 'en' | 'unknown' {
+    if (!text || !text.trim()) {
+        return 'unknown';
+    }
+
+    const trimmedText = text.trim();
+
+    const chineseRegex =
+        /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u3040-\u309f\u30a0-\u30ff\uff00-\uffef]/;
+    const englishRegex = /[a-zA-Z]/;
+
+    const hasChineseChars = chineseRegex.test(trimmedText);
+    const hasEnglishChars = englishRegex.test(trimmedText);
+
+    const chineseMatches = trimmedText.match(/[\u4e00-\u9fff]/g) || [];
+    const englishMatches = trimmedText.match(/[a-zA-Z]/g) || [];
+
+    const chineseCount = chineseMatches.length;
+    const englishCount = englishMatches.length;
+
+    if (hasChineseChars && (!hasEnglishChars || chineseCount > englishCount)) {
+        return 'zh-CN';
+    }
+
+    if (hasEnglishChars && (!hasChineseChars || englishCount > chineseCount)) {
+        return 'en';
+    }
+
+    return 'unknown';
+}
+
+/**
+ * 根据标题文本检测语言并返回源语言代码;与 TranslateSourceLang 的 value 对应,未知时默认 'zh-CN'。
+ * @param title 标题文本
+ * @returns 'zh-CN' | 'en'(检测为英文时返回 'en',其余返回 'zh-CN')
+ */
+export function getSourceLanguageFromTitle(title: string): string {
+    const detectedLang = detectLanguage(title);
+
+    switch (detectedLang) {
+        case 'zh-CN':
+            return 'zh-CN';
+        case 'en':
+            return 'en';
+        default:
+            return 'zh-CN';
+    }
+}

+ 71 - 112
src/utils/timeUtils.ts

@@ -4,144 +4,116 @@ import dayjs from 'dayjs';
 // Date.getTimezoneOffset() = utcTime - localTime;
 
 /**
- * 获取当前的 unix 时间戳(10位 秒级)
- * @returns
+ * 获取当前时间的 Unix 时间戳(10 位,秒级)。
+ * @returns 当前时间的 Unix 秒级时间戳
  */
 export function currentUnixTimestamp() {
     return dayjs().unix();
 }
 
 /**
- * 获取当前的 js 时间戳(13位 毫秒级)
- * @returns
+ * 获取当前时间的 JavaScript 时间戳(13 位,毫秒级)。
+ * @returns 当前时间的毫秒级时间戳
  */
 export function currentJsTimestamp() {
     return dayjs().valueOf();
 }
 
 /**
- * 获取当前时间
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 获取当前本地时间的格式化字符串。
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 当前本地时间字符串
  */
 export function currentDateTime(fmt = 'YYYY-MM-DD HH:mm:ss') {
     return dayjs().format(fmt);
 }
 
 /**
- * 获取当前 UTC 时间
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 获取当前 UTC 时间的格式化字符串。
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 当前 UTC 时间字符串
  */
 export function currentUtcDateTime(fmt = 'YYYY-MM-DD HH:mm:ss') {
     return dayjs().utc().format(fmt);
 }
 
 /**
- * 基于当前时间,计算 days 天前的时间
- * @param days
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 基于当前本地时间,计算若干天前的本地时间字符串。
+ * @param days 往前推的天数(正数表示过去)
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 对应日期的本地时间字符串
  */
 export function getDateTimeString(days: number, fmt = 'YYYY-MM-DD HH:mm:ss') {
     return dayjs().subtract(days, 'day').format(fmt);
 }
 
 /**
- * 基于当前时间,计算 days 天前的 UTC 时间
- * @param days
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 基于当前时间,计算若干天前的 UTC 时间字符串。
+ * @param days 往前推的天数(正数表示过去)
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 对应日期的 UTC 时间字符串
  */
 export function getUtcDateTimeString(days: number, fmt = 'YYYY-MM-DD HH:mm:ss') {
     return dayjs().subtract(days, 'day').utc().format(fmt);
-
-    // 不使用 dayjs 的 utc 插件时,可以用如下代码:
-    // const date = new Date();
-    // const utcDt = date.getTime() + date.getTimezoneOffset() * 60 * 1000 - days * 24 * 3600 * 1000; //公式:utcTime = localTime + date.getTimezoneOffset()
-    // return dayjs(utcDt).format(fmt);
-
-    // 也可以使用如下代码,更容易理解
-    // const date = new Date();
-    // const crtUtcDate = new Date(
-    //     date.getUTCFullYear(),
-    //     date.getUTCMonth(),
-    //     date.getUTCDate(),
-    //     date.getUTCHours(),
-    //     date.getUTCMinutes(),
-    //     date.getUTCSeconds()
-    // ).getTime();
-    // const distUtcDt = crtUtcDate - days * 24 * 3600 * 1000;
-    // return dayjs(distUtcDt).format(fmt);
 }
 
 /**
- * 格式化 unix 时间戳
- * @param unixTimestamp
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将 Unix 时间戳(秒级)格式化为本地时间字符串。
+ * @param unixTimestamp Unix 秒级时间戳
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 本地时间字符串
  */
 export function unixTimeFormat(unixTimestamp: number, fmt = 'YYYY-MM-DD HH:mm:ss') {
-    return dayjs.unix(unixTimestamp).format(fmt); // 格式化时间戳
+    return dayjs.unix(unixTimestamp).format(fmt);
 }
 
 /**
- * 格式化 unix 时间戳为 UTC 时间
- * @param unixTimestamp
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将 Unix 时间戳(秒级)格式化为 UTC 时间字符串。
+ * @param unixTimestamp Unix 秒级时间戳
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns UTC 时间字符串
  */
 export function unixTimeFormatToUtc(unixTimestamp: number, fmt = 'YYYY-MM-DD HH:mm:ss') {
     return dayjs.unix(unixTimestamp).utc().format(fmt);
-    // const oDt = new Date(unixTime * 1000);
-    // const dt = new Date(
-    //     oDt.getUTCFullYear(),
-    //     oDt.getUTCMonth(),
-    //     oDt.getUTCDate(),
-    //     oDt.getUTCHours(),
-    //     oDt.getUTCMinutes(),
-    //     oDt.getUTCSeconds()
-    // ).getTime();
-    // return dayjs(dt).format(fmt);
 }
 
 /**
- * 格式化 JavaScript 时间戳
- * @param jsTimestamp
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将 JavaScript 时间戳(毫秒级)格式化为本地时间字符串。
+ * @param jsTimestamp 毫秒级时间戳
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 本地时间字符串
  */
 export function jsTimeFormat(jsTimestamp: number, fmt = 'YYYY-MM-DD HH:mm:ss') {
-    return dayjs(jsTimestamp).format(fmt); // 格式化时间戳
+    return dayjs(jsTimestamp).format(fmt);
 }
 
 /**
- * 格式化 JavaScript 时间戳为 UTC 时间
- * @param jsTimestamp
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将 JavaScript 时间戳(毫秒级)格式化为 UTC 时间字符串。
+ * @param jsTimestamp 毫秒级时间戳
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns UTC 时间字符串
  */
 export function jsTimeFormatToUtc(jsTimestamp: number, fmt = 'YYYY-MM-DD HH:mm:ss') {
-    return dayjs(jsTimestamp).utc().format(fmt); // 格式化时间戳
+    return dayjs(jsTimestamp).utc().format(fmt);
 }
 
 /**
- * 将一个 inFmt 格式的时间字符串格式化为 fmt 格式的时间字符串
- * @param dateTime
- * @param inFmt
- * @param fmt
- * @returns
+ * 按输入格式解析时间字符串,再格式化为目标格式的本地时间字符串。
+ * @param dateTime 时间字符串
+ * @param inFmt 输入字符串的 dayjs 格式模板
+ * @param fmt 输出字符串的 dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 格式化后的本地时间字符串
  */
 export function stringTimeFormat(dateTime: string, inFmt: string, fmt = 'YYYY-MM-DD HH:mm:ss') {
     return dayjs(dateTime, inFmt).format(fmt);
 }
 
 /**
- * 将一个 inFmt 格式的时间字符串格式化为 fmt 格式的utc的时间字符串
- * @param dateTime
- * @param inFmt
- * @param fmt
- * @returns
+ * 按输入格式解析时间字符串,再格式化为目标格式的 UTC 时间字符串。
+ * @param dateTime 时间字符串
+ * @param inFmt 输入字符串的 dayjs 格式模板
+ * @param fmt 输出字符串的 dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 格式化后的 UTC 时间字符串
  */
 export function stringTimeFormatToUtc(
     dateTime: string,
@@ -152,10 +124,10 @@ export function stringTimeFormatToUtc(
 }
 
 /**
- * 严格根据格式模板,格式化秒数,仅支持天、时、分、秒,低于2位时,高位补0
- * @param seconds
- * @param fmt 返回的时间格式,默认为 DD HH:mm:ss
- * @returns
+ * 将总秒数按格式模板格式化为「天 时:分:秒」字符串;仅支持 D/d、H/h、m、s 占位符,数值不足两位时高位补 0。
+ * @param seconds 总秒数
+ * @param fmt 占位符模板,默认 'DD HH:mm:ss'(D/d 天,H/h 时,m 分,s 秒)
+ * @returns 格式化后的时长字符串
  */
 export function secondsFormat(seconds: number, fmt = 'DD HH:mm:ss') {
     let result = fmt;
@@ -184,10 +156,10 @@ export function secondsFormat(seconds: number, fmt = 'DD HH:mm:ss') {
 }
 
 /**
- * 严格根据格式模板,格式化秒数,仅支持天、时、分、秒,不显示为0的级别,例如 seconds 为 61 时,返回 1分钟1秒
- * @param seconds
- * @param fmt 返回的时间格式,默认为 DD天HH小时mm分钟ss秒
- * @returns
+ * 将总秒数按格式模板格式化为时长字符串,值为 0 的档位会省略(秒档位始终保留);seconds ≤ 0 时返回空字符串。
+ * @param seconds 总秒数
+ * @param fmt 占位符模板,默认 'DD天HH小时mm分钟ss秒'(DD/HH/mm/ss 对应天/时/分/秒及后缀)
+ * @returns 精简后的时长字符串,如 61 秒对应「1分钟1秒」
  */
 export function secondsFormatFit(seconds: number, fmt = 'DD天HH小时mm分钟ss秒') {
     if (seconds <= 0) {
@@ -223,10 +195,10 @@ export function secondsFormatFit(seconds: number, fmt = 'DD天HH小时mm分钟ss
 }
 
 /**
- * 输入一个时间字符串,返回其对应的 js 时间戳(13位 毫秒级)
- * @param dateTimeString 时间字符串,若不指定 fmt,可以支持的格式类似 '2013-11-11T15:30:00Z'、'2013-11-11T15:30:00+08:00'、'Thu Nov 14 2024 14:57:29 GMT+0800 (中国标准时间)'
- * @param fmt 时间字符串的格式,默认 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将时间字符串解析为 JavaScript 时间戳(13 位,毫秒级)。若为 ISO 或 GMT 格式则自动识别,否则按 fmt 解析。
+ * @param dateTimeString 时间字符串, '2013-11-11T15:30:00Z'、'2013-11-11T15:30:00+08:00'、'Thu Nov 14 2024 14:57:29 GMT+0800 (中国标准时间)' 或 'YYYY-MM-DD HH:mm:ss'
+ * @param fmt 非 ISO/GMT 时的 dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 毫秒级时间戳
  */
 export function getTimeFromDateTimeString(
     dateTimeString: string,
@@ -246,10 +218,10 @@ export function getTimeFromDateTimeString(
 }
 
 /**
- *  输入一个表示 UTC 时间的字符串,返回其对应的 js 时间戳(13位 毫秒级)
- * @param utcDateTimeString UTC 时间字符串,若不指定 fmt,可以支持的格式类似 '2013-11-11T15:30:00Z'、'2013-11-11T15:30:00+08:00'、'Thu Nov 14 2024 14:57:29 GMT+0800 (中国标准时间)'
- * @param fmt 时间字符串的格式,默认 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将表示 UTC 的时间字符串按 UTC 解析为 JavaScript 时间戳(13 位,毫秒级)。若为 ISO 或 GMT 格式则自动识别,否则按 fmt 解析。
+ * @param utcDateTimeString UTC 时间字符串,格式同 getTimeFromDateTimeString 的 dateTimeString
+ * @param fmt 非 ISO/GMT 时的 dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 毫秒级时间戳
  */
 export function getTimeFromUtcDateTimeString(
     utcDateTimeString: string,
@@ -266,26 +238,13 @@ export function getTimeFromUtcDateTimeString(
     } else {
         return dayjs.utc(utcDateTimeString, fmt).valueOf();
     }
-
-    // if (
-    //     /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(utcDateTimeString) ||
-    //     /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/.test(utcDateTimeString) ||
-    //     /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{2} \d{4} \d{2}:\d{2}:\d{2} GMT[+-]\d{4}( \(.+\))?$/.test(
-    //         utcDateTimeString
-    //     )
-    // ) {
-    //     return new Date(utcDateTimeString).getTime();
-    // } else {
-    //     const dt = dayjs(utcDateTimeString, fmt).toDate();
-    //     return dt.getTime() - dt.getTimezoneOffset() * 60 * 1000;
-    // }
 }
 
 /**
- * 输入一个时间字符串,返回其对应的 unix 时间戳(10位 秒级)
- * @param dateTimeString 时间字符串,若不指定 fmt,可以支持的格式类似 '2013-11-11T15:30:00Z'、'2013-11-11T15:30:00+08:00'、'Thu Nov 14 2024 14:57:29 GMT+0800 (中国标准时间)'
- * @param fmt 时间字符串的格式,默认 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将时间字符串解析为 Unix 时间戳(10 位,秒级)。解析规则同 getTimeFromDateTimeString,结果为毫秒戳向下取整除以 1000。
+ * @param dateTimeString 时间字符串,格式同 getTimeFromDateTimeString
+ * @param fmt 非 ISO/GMT 时的 dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns Unix 秒级时间戳
  */
 export function getUnixFromDateTimeString(
     dateTimeString: string,
@@ -295,10 +254,10 @@ export function getUnixFromDateTimeString(
 }
 
 /**
- *  输入一个表示 UTC 时间的字符串,返回其对应的 unix 时间戳(10位 秒级)
- * @param utcDateTimeString UTC 时间字符串,若不指定 fmt,可以支持的格式类似 '2013-11-11T15:30:00Z'、'2013-11-11T15:30:00+08:00'、'Thu Nov 14 2024 14:57:29 GMT+0800 (中国标准时间)'
- * @param fmt 时间字符串的格式,默认 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将表示 UTC 的时间字符串解析为 Unix 时间戳(10 位,秒级)。解析规则同 getTimeFromUtcDateTimeString,结果为毫秒戳向下取整除以 1000。
+ * @param utcDateTimeString UTC 时间字符串,格式同 getTimeFromUtcDateTimeString
+ * @param fmt 非 ISO/GMT 时的 dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns Unix 秒级时间戳
  */
 export function getUnixFromUtcDateTimeString(
     utcDateTimeString: string,

+ 73 - 0
src/utils/unitUtils.ts

@@ -0,0 +1,73 @@
+import isNil from 'ramda/es/isNil';
+
+/**
+ * 将流量数值格式化为可读字符串(按 1024 进制:bit, Kb, Mb, Gb...)
+ * @param traffic 流量数值,单位由 isByte 决定
+ * @param isByte 若为 true 则 traffic 为字节,会乘以 8 转为 bit;若为 false 则 traffic 已为 bit
+ * @returns 格式化后的字符串,如 "1.5Gb";空值或 undefined 返回 "-"
+ */
+export const formatTraffic = (traffic: number | undefined, isByte: boolean = true) => {
+    if (isNil(traffic)) return '-';
+    if (traffic === 0) return '0B';
+    let bits = traffic;
+    if (isByte === true) {
+        bits = bits * 8;
+    }
+    const units = ['bit', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb'];
+    const index = Math.floor(Math.log(bits) / Math.log(1024));
+    return `${(bits / Math.pow(1024, index)).toFixed(1)}${units[index]}`;
+};
+
+/**
+ * 将带宽数值格式化为可读字符串(按 1000 进制:bps, Kbps, Mbps, Gbps...)
+ * @param bandwidth 带宽数值,单位由 isByte 决定
+ * @param isByte 若为 true 则 bandwidth 为字节/秒,会乘以 8 转为 bps;若为 false 则已为 bps
+ * @returns 格式化后的字符串,如 "100.0Mbps";空值、undefined 或 ≤0 返回 "-" 或 "0bps"
+ */
+export const formatBandwidth = (bandwidth: number | undefined, isByte: boolean = true) => {
+    if (isNil(bandwidth)) return '-';
+    if (bandwidth <= 0) return '0bps';
+    let bits = bandwidth;
+    if (isByte === true) {
+        bits = bits * 8;
+    }
+    const units = ['bps', 'Kbps', 'Mbps', 'Gbps', 'Tbps', 'Pbps', 'Ebps', 'Zbps', 'Ybps'];
+    const index = Math.floor(Math.log(bits) / Math.log(1000));
+    return `${(bits / Math.pow(1000, index)).toFixed(1)}${units[index]}`;
+};
+
+/**
+ * 将磁盘容量格式化为可读字符串(按 1000 进制:B, K, M, G, T...)
+ * @param size 容量数值,单位由 isByte 决定
+ * @param isByte 若为 true 则 size 为字节;若为 false 则 size 为 bit,会除以 8 转为字节
+ * @returns 格式化后的字符串,如 "500.0G";空值或 undefined 返回 "-"
+ */
+export const formatDiskSize = (size: number | undefined, isByte: boolean = true) => {
+    if (isNil(size)) return '-';
+    if (size === 0) return '0B';
+    let bytes = size;
+    if (isByte === false) {
+        bytes = bytes / 8;
+    }
+    const units = ['B', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
+    const index = Math.floor(Math.log(bytes) / Math.log(1000));
+    return `${(bytes / Math.pow(1000, index)).toFixed(1)}${units[index]}`;
+};
+
+/**
+ * 将内存大小格式化为可读字符串(按 1024 进制:B, K, M, G, T...)
+ * @param size 内存数值,单位由 isByte 决定
+ * @param isByte 若为 true 则 size 为字节;若为 false 则 size 为 bit,会除以 8 转为字节
+ * @returns 格式化后的字符串,如 "8.0G";空值或 undefined 返回 "-"
+ */
+export const formatMemorySize = (size: number | undefined, isByte: boolean = true) => {
+    if (isNil(size)) return '-';
+    if (size === 0) return '0B';
+    let bytes = size;
+    if (isByte === false) {
+        bytes = bytes / 8;
+    }
+    const units = ['B', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
+    const index = Math.floor(Math.log(bytes) / Math.log(1024));
+    return `${(bytes / Math.pow(1024, index)).toFixed(1)}${units[index]}`;
+};