Merge pull request #287 from SuanmoSuanyangTechnology/docs/web_zy
Docs/web zy
This commit is contained in:
@@ -1,14 +1,36 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:01:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:46:05
|
||||
*/
|
||||
|
||||
/**
|
||||
* ButtonCheckbox - A custom checkbox component styled as a button
|
||||
*
|
||||
* This component provides a button-like interface for checkbox functionality,
|
||||
* with support for custom icons and visual states (checked/unchecked).
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC, type ReactNode, useEffect } from 'react';
|
||||
import { type RadioGroupProps } from 'antd';
|
||||
import clsx from 'clsx'
|
||||
|
||||
|
||||
// Button checkbox component props
|
||||
interface ButtonCheckboxProps extends Omit<RadioGroupProps, 'onChange'> {
|
||||
/** Whether the checkbox is checked */
|
||||
checked?: boolean;
|
||||
/** Callback fired when value changes (for side effects) */
|
||||
onValueChange?: (checked: boolean) => void;
|
||||
/** Callback fired when checkbox state changes */
|
||||
onChange?: (checked: boolean) => void;
|
||||
/** Icon path for unchecked state */
|
||||
icon?: string;
|
||||
/** Icon path for checked state */
|
||||
checkedIcon?: string;
|
||||
/** Button content */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
@@ -20,13 +42,14 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
||||
checkedIcon,
|
||||
children,
|
||||
}) => {
|
||||
// 监听value变化
|
||||
// Listen to value changes and trigger side effects via onValueChange callback
|
||||
useEffect(() => {
|
||||
if (onValueChange) {
|
||||
onValueChange(checked);
|
||||
}
|
||||
}, [checked, onValueChange]);
|
||||
|
||||
// Toggle checked state when button is clicked
|
||||
const handleChange = () => {
|
||||
if (onChange) {
|
||||
onChange(!checked);
|
||||
@@ -34,11 +57,18 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8]", {
|
||||
"rb:bg-[rgba(21,94,239,0.06)] rb:border-[#155EEF] rb:text-[#155EEF]": checked,
|
||||
"rb:border-[#DFE4ED] rb:text-[#212332]": !checked,
|
||||
})} onClick={handleChange}>
|
||||
<div
|
||||
className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8]", {
|
||||
// Checked state: blue background and border
|
||||
"rb:bg-[rgba(21,94,239,0.06)] rb:border-[#155EEF] rb:text-[#155EEF]": checked,
|
||||
// Unchecked state: gray border and dark text
|
||||
"rb:border-[#DFE4ED] rb:text-[#212332]": !checked,
|
||||
})}
|
||||
onClick={handleChange}
|
||||
>
|
||||
{/* Display unchecked icon when not checked */}
|
||||
{icon && !checked && <img src={icon} className="rb:w-4 rb:h-4 rb:mr-1" />}
|
||||
{/* Display checked icon when checked */}
|
||||
{checkedIcon && checked && <img src={checkedIcon} className="rb:w-4 rb:h-4 rb:mr-1" />}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,61 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:02:17
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:46:29
|
||||
*/
|
||||
/**
|
||||
* CustomSelect - A select component that fetches options from an API
|
||||
*
|
||||
* This component extends Ant Design's Select with automatic data fetching,
|
||||
* search functionality, and customizable option formatting.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useMemo, type FC, type Key } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import type { SelectProps, DefaultOptionType } from 'antd/es/select';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
// Generic option type for API response data
|
||||
interface OptionType {
|
||||
[key: string]: Key | string | number;
|
||||
}
|
||||
|
||||
// API response structure
|
||||
interface ApiResponse<T> {
|
||||
items?: T[];
|
||||
}
|
||||
|
||||
interface CustomSelectProps extends Omit<SelectProps, 'filterOption'> {
|
||||
/** API endpoint URL to fetch options */
|
||||
url: string;
|
||||
/** Query parameters for the API request */
|
||||
params?: Record<string, unknown>;
|
||||
/** Key name for option value in response data */
|
||||
valueKey?: string;
|
||||
/** Key name for option label in response data */
|
||||
labelKey?: string;
|
||||
/** Placeholder text for the select */
|
||||
placeholder?: string;
|
||||
/** Whether to show "All" option */
|
||||
hasAll?: boolean;
|
||||
/** Custom text for "All" option */
|
||||
allTitle?: string;
|
||||
/** Function to format/transform the options data */
|
||||
format?: (items: OptionType[]) => OptionType[];
|
||||
/** Whether to enable search functionality */
|
||||
showSearch?: boolean;
|
||||
/** Property name to filter options by */
|
||||
optionFilterProp?: string;
|
||||
/** Custom filter function for search */
|
||||
filterOption?: (inputValue: string, option?: DefaultOptionType) => boolean;
|
||||
}
|
||||
|
||||
// Default filter function for search - performs case-insensitive substring matching
|
||||
const defaultFilterOption = (inputValue: string, option?: DefaultOptionType): boolean => {
|
||||
if (!option || !inputValue) return true;
|
||||
const label = String(option.children || option.label || '');
|
||||
@@ -47,8 +77,10 @@ const CustomSelect: FC<CustomSelectProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [options, setOptions] = useState<OptionType[]>([]);
|
||||
// Memoize params to prevent unnecessary re-fetches
|
||||
const memoizedParams = useMemo(() => params, [JSON.stringify(params)]);
|
||||
|
||||
// Fetch options from API when url or params change
|
||||
useEffect(() => {
|
||||
request.get<ApiResponse<OptionType>>(url, memoizedParams).then((res) => {
|
||||
const data = Array.isArray(res) ? res : res?.items || [];
|
||||
@@ -56,6 +88,7 @@ const CustomSelect: FC<CustomSelectProps> = ({
|
||||
});
|
||||
}, [url, memoizedParams]);
|
||||
|
||||
// Apply custom format function if provided
|
||||
const displayOptions = format ? format(options) : options;
|
||||
|
||||
return (
|
||||
@@ -66,7 +99,9 @@ const CustomSelect: FC<CustomSelectProps> = ({
|
||||
filterOption={filterOption || defaultFilterOption}
|
||||
{...props}
|
||||
>
|
||||
{/* Optional "All" option for selecting all items */}
|
||||
{hasAll && <Select.Option value={null}>{allTitle || t('common.all')}</Select.Option>}
|
||||
{/* Render options from API data */}
|
||||
{displayOptions.map((option) => (
|
||||
<Select.Option key={option[valueKey]} value={option[valueKey]}>
|
||||
{String(option[labelKey])}
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:02:47
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:47:24
|
||||
*/
|
||||
/**
|
||||
* BodyWrapper Component
|
||||
*
|
||||
* A wrapper component that conditionally renders loading, empty, or content states.
|
||||
* Simplifies state management for data-driven components.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
import PageEmpty from './PageEmpty'
|
||||
import PageLoading from './PageLoading'
|
||||
|
||||
interface BodyWrapperProps {
|
||||
/** Content to render when not loading or empty */
|
||||
children: ReactNode
|
||||
/** Whether to show loading state */
|
||||
loading?: boolean
|
||||
/** Whether the content is empty */
|
||||
empty: boolean
|
||||
}
|
||||
const BodyWrapper: FC<BodyWrapperProps> = ({ children, loading = false, empty }) => {
|
||||
// Show loading spinner while data is being fetched
|
||||
if (loading) {
|
||||
return <PageLoading />
|
||||
}
|
||||
// Show empty state when no data is available
|
||||
if (!loading && empty) {
|
||||
return <PageEmpty />
|
||||
}
|
||||
// Render actual content when data is loaded and available
|
||||
return children
|
||||
}
|
||||
export default BodyWrapper
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:03:52
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:48:41
|
||||
*/
|
||||
/**
|
||||
* Loading Component
|
||||
*
|
||||
* A specialized empty state component that displays a loading animation.
|
||||
* Uses the Empty component with a loading icon and localized loading messages.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import LoadingIcon from '@/assets/images/loading.svg'
|
||||
import Empty from './index'
|
||||
const Loading = ({ size = 200 }: { size?: number }) => {
|
||||
|
||||
/**
|
||||
* @param size - Icon size in pixels (default: 200)
|
||||
*/
|
||||
const Loading: FC<{ size?: number }> = ({ size = 200 }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Empty
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:04:18
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:49:01
|
||||
*/
|
||||
/**
|
||||
* PageEmpty Component
|
||||
*
|
||||
* A full-page empty state component that displays when no content is available.
|
||||
* Uses the Empty component with a page-specific empty icon and messages.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import pageEmptyIcon from '@/assets/images/empty/pageEmpty.png'
|
||||
import Empty from './index'
|
||||
const PageEmpty = ({ size = [240, 210] }: { size?: number | number[] }) => {
|
||||
|
||||
/**
|
||||
* @param size - Icon size in pixels - single number or [width, height] array (default: [240, 210])
|
||||
*/
|
||||
const PageEmpty: FC<{ size?: number | number[] }> = ({ size = [240, 210] }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Empty
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:04:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:49:49
|
||||
*/
|
||||
/**
|
||||
* PageLoading Component
|
||||
*
|
||||
* A full-page loading state component that displays while content is being fetched.
|
||||
* Uses the Empty component with a loading icon and localized loading messages.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import LoadingIcon from '@/assets/images/empty/pageLoading.png'
|
||||
import Empty from './index'
|
||||
const PageLoading = ({ size = [240, 210] }: { size?: number | number[] }) => {
|
||||
|
||||
/**
|
||||
* @param size - Icon size in pixels - single number or [width, height] array (default: [240, 210])
|
||||
*/
|
||||
const PageLoading: FC<{ size?: number | number[] }> = ({ size = [240, 210] }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Empty
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:03:25
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:47:31
|
||||
*/
|
||||
/**
|
||||
* Empty Component
|
||||
*
|
||||
* A customizable empty state component that displays an icon with optional title and subtitle.
|
||||
* Used to indicate when no data or content is available.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import emptyIcon from '@/assets/images/empty/empty.svg';
|
||||
|
||||
interface EmptyProps {
|
||||
/** Custom icon URL for the empty state */
|
||||
url?: string;
|
||||
/** Icon size - single number or [width, height] array */
|
||||
size?: number | number[];
|
||||
/** Main title text */
|
||||
title?: string;
|
||||
/** Whether to show subtitle */
|
||||
isNeedSubTitle?: boolean;
|
||||
/** Custom subtitle text */
|
||||
subTitle?: string;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
const Empty: FC<EmptyProps> = ({
|
||||
@@ -19,14 +41,19 @@ const Empty: FC<EmptyProps> = ({
|
||||
className = '',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Calculate width and height from size prop (supports single value or [width, height] array)
|
||||
const width = Array.isArray(size) ? size[0] : size ? size : url ? 200 : 88;
|
||||
const height = Array.isArray(size) ? size[1] : size ? size : url ? 200 : 88;
|
||||
|
||||
// Use custom subtitle or default translation if subtitle is needed
|
||||
const curSubTitle = isNeedSubTitle ? (subTitle || t('empty.tableEmpty')) : null;
|
||||
return (
|
||||
<div className={`rb:flex rb:items-center rb:justify-center rb:flex-col ${className}`}>
|
||||
{/* Empty state icon */}
|
||||
<img src={url || emptyIcon} alt="404" style={{ width: `${width}px`, height: `${height}px` }} />
|
||||
{/* Optional title */}
|
||||
{title && <div className="rb:mt-2 rb:leading-5">{title}</div>}
|
||||
{/* Optional subtitle with conditional styling */}
|
||||
{curSubTitle && <div className={`rb:mt-[${url ? 8 : 5}px] rb:leading-4 rb:text-[12px] rb:text-[#A8A9AA]`}>{curSubTitle}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:05:16
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:05:16
|
||||
*/
|
||||
/**
|
||||
* DescWrapper Component
|
||||
*
|
||||
* A styled wrapper for displaying description text in forms.
|
||||
* Provides consistent typography and styling for form field descriptions.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import clsx from "clsx";
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* @param desc - Description content (string or React node)
|
||||
* @param className - Additional CSS classes for customization
|
||||
*/
|
||||
const DescWrapper: FC<{desc: string | ReactNode, className?: string}> = ({desc, className}) => {
|
||||
return (
|
||||
<div className={clsx(className, "rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4 ")}>
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:05:41
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:05:41
|
||||
*/
|
||||
/**
|
||||
* LabelWrapper Component
|
||||
*
|
||||
* A styled wrapper for displaying form field labels with optional child content.
|
||||
* Provides consistent typography and layout for form labels.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import clsx from "clsx";
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* @param title - Label text or React node to display
|
||||
* @param className - Additional CSS classes for customization
|
||||
* @param children - Optional child content to render below the label
|
||||
*/
|
||||
const LabelWrapper: FC<{ title: string | ReactNode, className?: string; children?: ReactNode}> = ({title, className, children}) => {
|
||||
return (
|
||||
<div className={clsx(className)}>
|
||||
{/* Label title with consistent styling */}
|
||||
<div className="rb:text-[14px] rb:font-medium rb:leading-5">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import { Switch, Form, ConfigProvider } from "antd";
|
||||
import useSize from 'antd/lib/config-provider/hooks/useSize'
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:06:24
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:50:49
|
||||
*/
|
||||
/**
|
||||
* SwitchFormItem Component
|
||||
*
|
||||
* A form item component that combines a switch control with a label and optional description.
|
||||
* Provides a consistent layout for switch-based form fields.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { Switch, Form } from "antd";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useContext } from "react";
|
||||
|
||||
import LabelWrapper from './LabelWrapper'
|
||||
import DescWrapper from './DescWrapper'
|
||||
|
||||
interface SwitchFormItemProps {
|
||||
/** Label text or React node */
|
||||
title: string | ReactNode;
|
||||
/** Optional description text or React node */
|
||||
desc?: string | ReactNode;
|
||||
/** Form field name (string or nested path array) */
|
||||
name: string | string[];
|
||||
/** Switch size */
|
||||
size?: 'small' | 'default'
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Whether the switch is disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -23,14 +42,13 @@ const SwitchFormItem: FC<SwitchFormItemProps> = ({
|
||||
className,
|
||||
disabled
|
||||
}) => {
|
||||
const componentSize = useSize()
|
||||
console.log('componentSize', componentSize)
|
||||
|
||||
return (
|
||||
<div className={`${className} rb:flex rb:items-center rb:justify-between`}>
|
||||
{/* Label and description section */}
|
||||
<LabelWrapper title={title}>
|
||||
{desc && <DescWrapper desc={desc} className="rb:mt-2" />}
|
||||
</LabelWrapper>
|
||||
{/* Switch control */}
|
||||
<Form.Item
|
||||
name={name}
|
||||
valuePropName="checked"
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:08:58
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:08:58
|
||||
*/
|
||||
/**
|
||||
* SettingModal Component
|
||||
*
|
||||
* A modal dialog for configuring application settings including language and timezone.
|
||||
* Uses forwardRef to expose open/close methods to parent components.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Select } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -7,34 +22,39 @@ import { useI18n } from '@/store/locale'
|
||||
import { timezones } from '@/utils/timezones'
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
/** Interface for SettingModal ref methods exposed to parent components */
|
||||
export interface SettingModalRef {
|
||||
/** Open the settings modal */
|
||||
handleOpen: () => void;
|
||||
/** Close the settings modal */
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
/** Settings modal component for language and timezone configuration */
|
||||
const SettingModal = forwardRef<SettingModalRef>((_props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { changeLanguage, language, timeZone, changeTimeZone } = useI18n()
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const values = Form.useWatch([], form);
|
||||
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
/** Close modal and reset form to initial state */
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
/** Open modal and populate form with current settings */
|
||||
const handleOpen = () => {
|
||||
form.setFieldsValue({ language, timeZone })
|
||||
setVisible(true);
|
||||
};
|
||||
// 封装保存方法,添加提交逻辑
|
||||
|
||||
/** Validate and save settings, update language and timezone if changed */
|
||||
const handleSave = () => {
|
||||
form
|
||||
.validateFields()
|
||||
.then(() => {
|
||||
.then((values) => {
|
||||
const { language: newLanguage, timeZone: newTimeZone } = values
|
||||
if (newLanguage !== language) {
|
||||
changeLanguage(newLanguage);
|
||||
@@ -47,11 +67,12 @@ const SettingModal = forwardRef<SettingModalRef>((_props, ref) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
/** Expose handleOpen and handleClose methods to parent component via ref */
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('header.setting')}
|
||||
@@ -64,7 +85,7 @@ const SettingModal = forwardRef<SettingModalRef>((_props, ref) => {
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
{/* 中英文切换 */}
|
||||
{/* Language selection dropdown */}
|
||||
<FormItem
|
||||
name="language"
|
||||
label={t('header.language')}
|
||||
@@ -73,7 +94,7 @@ const SettingModal = forwardRef<SettingModalRef>((_props, ref) => {
|
||||
options={['zh', 'en'].map(key => ({ label: t(`header.${key}`), value: key }))}
|
||||
/>
|
||||
</FormItem>
|
||||
{/* 时区切换 */}
|
||||
{/* Timezone selection dropdown */}
|
||||
<FormItem
|
||||
name="timeZone"
|
||||
label={t('header.timeZone')}
|
||||
|
||||
@@ -1,39 +1,61 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:09:47
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:51:54
|
||||
*/
|
||||
/**
|
||||
* UserInfoModal Component
|
||||
*
|
||||
* A modal dialog that displays user profile information and security settings.
|
||||
* Includes basic user details and password change functionality.
|
||||
* Uses forwardRef to expose open/close methods to parent components.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { UnlockOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUser } from '@/store/user';
|
||||
|
||||
import { useUser } from '@/store/user';
|
||||
import RbModal from '@/components/RbModal'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import ResetPasswordModal from '@/views/UserManagement/components/ResetPasswordModal'
|
||||
import type { ResetPasswordModalRef } from '@/views/UserManagement/types'
|
||||
|
||||
/** Interface for UserInfoModal ref methods exposed to parent components */
|
||||
export interface UserInfoModalRef {
|
||||
/** Open the user info modal */
|
||||
handleOpen: () => void;
|
||||
/** Close the user info modal */
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
/** User information modal component displaying user details and security settings */
|
||||
const UserInfoModal = forwardRef<UserInfoModalRef>((_props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const resetPasswordModalRef = useRef<ResetPasswordModalRef>(null)
|
||||
const { user } = useUser();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
/** Close the modal */
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
/** Open the modal */
|
||||
const handleOpen = () => {
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
// 暴露给父组件的方法
|
||||
/** Expose handleOpen and handleClose methods to parent component via ref */
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('header.userInfo')}
|
||||
@@ -41,32 +63,40 @@ const UserInfoModal = forwardRef<UserInfoModalRef>((_props, ref) => {
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
>
|
||||
{/* Basic Information Section */}
|
||||
<div className="rb:text-[#5B6167] rb:font-medium">{t('header.basicInfo')}</div>
|
||||
|
||||
<div className="rb:flex rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-[20px] rb:mb-[12px] rb:mt-[12px]">
|
||||
{/* Username */}
|
||||
<div className="rb:flex rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-5 rb:mb-3 rb:mt-3">
|
||||
<span className="rb:whitespace-nowrap">{t('user.username')}</span>
|
||||
<span className="rb:text-[#212332]">{user.username}</span>
|
||||
</div>
|
||||
<div className="rb:flex rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-[20px] rb:mb-[12px]">
|
||||
{/* Email */}
|
||||
<div className="rb:flex rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-5 rb:mb-3">
|
||||
<span className="rb:whitespace-nowrap">{t('user.email')}</span>
|
||||
<span className="rb:text-[#212332]">{user.email}</span>
|
||||
</div>
|
||||
<div className="rb:flex rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-[20px] rb:mb-[12px]">
|
||||
{/* Role */}
|
||||
<div className="rb:flex rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-5 rb:mb-3">
|
||||
<span className="rb:whitespace-nowrap">{t('user.role')}</span>
|
||||
<span className="rb:text-[#212332]">{user.is_superuser ? t('user.superuser') : t('user.normalUser')}</span>
|
||||
</div>
|
||||
<div className="rb:flex rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-[20px] rb:mb-[12px]">
|
||||
{/* Created Date */}
|
||||
<div className="rb:flex rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-5 rb:mb-3">
|
||||
<span className="rb:whitespace-nowrap">{t('user.createdAt')}</span>
|
||||
<span className="rb:text-[#212332]">{formatDateTime(user.created_at, 'YYYY-MM-DD HH:mm:ss')}</span>
|
||||
</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-medium rb:mt-[24px]">{t('header.securitySettings')}</div>
|
||||
|
||||
{/* Security Settings Section */}
|
||||
<div className="rb:text-[#5B6167] rb:font-medium rb:mt-6">{t('header.securitySettings')}</div>
|
||||
|
||||
<div className="rb:mt-[12px] rb:bg-[#F0F3F8] rb:p-[10px_12px] rb:rounded-[6px] rb:flex rb:items-center rb:justify-between rb:gap-[8px]">
|
||||
<div className="rb:flex rb:items-center rb:gap-[12px]">
|
||||
{/* Password Change Card */}
|
||||
<div className="rb:mt-3 rb:bg-[#F0F3F8] rb:p-[10px_12px] rb:rounded-md rb:flex rb:items-center rb:justify-between rb:gap-2">
|
||||
<div className="rb:flex rb:items-center rb:gap-3">
|
||||
<UnlockOutlined className="rb:text-[24px]" />
|
||||
<div>
|
||||
<div className="rb:leading-[20px]">{t('header.changePassword')}</div>
|
||||
<div className="rb:text-[#5B6167] rb:text-[12px] rb:mt-[4px] rb:leading-[16px]">{t('header.changePasswordDesc')}</div>
|
||||
<div className="rb:leading-5">{t('header.changePassword')}</div>
|
||||
<div className="rb:text-[#5B6167] rb:text-[12px] rb:mt-1 rb:leading-4">{t('header.changePasswordDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => resetPasswordModalRef.current?.handleOpen(user)}>{t('common.change')}</Button>
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:07:49
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:07:49
|
||||
*/
|
||||
/**
|
||||
* AppHeader Component
|
||||
*
|
||||
* The main application header that displays breadcrumb navigation and user menu.
|
||||
* Supports different breadcrumb sources based on the current route.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC, useRef } from 'react';
|
||||
import { Layout, Dropdown, Space, Breadcrumb } from 'antd';
|
||||
import { Layout, Dropdown, Breadcrumb } from 'antd';
|
||||
import type { MenuProps, BreadcrumbProps } from 'antd';
|
||||
import { UserOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { useUser } from '@/store/user';
|
||||
import { useMenu } from '@/store/menu';
|
||||
import styles from './index.module.css'
|
||||
import SettingModal, { type SettingModalRef } from './SettingModal'
|
||||
import UserInfoModal, { type UserInfoModalRef } from './UserInfoModal'
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
/**
|
||||
* @param source - Breadcrumb source type ('space' or 'manage'), defaults to 'manage'
|
||||
*/
|
||||
const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
@@ -20,21 +40,26 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
|
||||
const { user, logout } = useUser();
|
||||
const { allBreadcrumbs } = useMenu();
|
||||
|
||||
// 根据当前路由动态选择面包屑源
|
||||
/**
|
||||
* Dynamically select breadcrumb source based on current route
|
||||
* - Knowledge base list: uses 'space' breadcrumb
|
||||
* - Knowledge base detail: uses 'space-detail' breadcrumb
|
||||
* - Other pages: uses the passed source prop
|
||||
*/
|
||||
const getBreadcrumbSource = () => {
|
||||
const pathname = location.pathname;
|
||||
|
||||
// 知识库列表页面使用默认的 space 面包屑
|
||||
// Knowledge base list page uses default space breadcrumb
|
||||
if (pathname === '/knowledge-base') {
|
||||
return 'space';
|
||||
}
|
||||
|
||||
// 知识库详情相关页面使用独立的面包屑
|
||||
// Knowledge base detail pages use independent breadcrumb
|
||||
if (pathname.includes('/knowledge-base/') && pathname !== '/knowledge-base') {
|
||||
return 'space-detail';
|
||||
}
|
||||
|
||||
// 其他页面使用传入的 source
|
||||
// Other pages use the passed source
|
||||
return source;
|
||||
};
|
||||
|
||||
@@ -42,13 +67,12 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
|
||||
const breadcrumbs = allBreadcrumbs[breadcrumbSource] || [];
|
||||
|
||||
|
||||
|
||||
// 处理退出登录
|
||||
/** Handle user logout */
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
};
|
||||
|
||||
// 用户下拉菜单配置
|
||||
/** User dropdown menu configuration with profile, settings, and logout options */
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
@@ -89,18 +113,25 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Format breadcrumb items with proper titles, paths, and click handlers
|
||||
* - Translates i18n keys to display text
|
||||
* - Handles custom onClick events
|
||||
* - Disables navigation for the last breadcrumb item
|
||||
*/
|
||||
const formatBreadcrumbNames = () => {
|
||||
return breadcrumbs.map((menu, index) => {
|
||||
const item: any = {
|
||||
title: menu.i18nKey ? t(menu.i18nKey) : menu.label,
|
||||
};
|
||||
|
||||
// 如果是最后一项,不设置 path
|
||||
// If it's the last item, don't set path
|
||||
if (index === breadcrumbs.length - 1) {
|
||||
return item;
|
||||
}
|
||||
|
||||
// 如果有自定义 onClick,使用 onClick 并设置 href 为 '#' 以显示手型光标
|
||||
// If has custom onClick, use onClick and set href to '#' to show pointer cursor
|
||||
if ((menu as any).onClick) {
|
||||
item.onClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -108,35 +139,26 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
|
||||
};
|
||||
item.href = '#';
|
||||
} else if (menu.path && menu.path !== '#') {
|
||||
// 只有当 path 不是 '#' 时才设置 path
|
||||
// Only set path when path is not '#'
|
||||
item.path = menu.path;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Header className={styles.header}>
|
||||
{/* Breadcrumb navigation */}
|
||||
<Breadcrumb separator=">" items={formatBreadcrumbNames() as BreadcrumbProps['items']} />
|
||||
{/* 语言切换和主题切换按钮 */}
|
||||
<Space>
|
||||
{/* <Button
|
||||
size="small"
|
||||
type="default"
|
||||
onClick={handleLanguageChange}
|
||||
>
|
||||
{t(`language.${language === 'en' ? 'zh' : 'en'}`)}
|
||||
</Button> */}
|
||||
|
||||
{/* 用户信息下拉菜单 */}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: userMenuItems
|
||||
}}
|
||||
>
|
||||
<div className="rb:cursor-pointer">{user.username}</div>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
{/* User info dropdown menu */}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: userMenuItems
|
||||
}}
|
||||
>
|
||||
<div className="rb:cursor-pointer">{user.username}</div>
|
||||
</Dropdown>
|
||||
<SettingModal
|
||||
ref={settingModalRef}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:11:02
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:11:02
|
||||
*/
|
||||
/**
|
||||
* AuthLayout Component
|
||||
*
|
||||
* The main authenticated layout wrapper that provides:
|
||||
* - Route authentication and permission checks
|
||||
* - Automatic breadcrumb navigation updates
|
||||
* - Sidebar navigation and header
|
||||
* - Token-based authentication validation
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useEffect, type FC } from 'react';
|
||||
import { Layout } from 'antd';
|
||||
|
||||
import useRouteGuard from '@/hooks/useRouteGuard';
|
||||
import { useNavigationBreadcrumbs } from '@/hooks/useNavigationBreadcrumbs';
|
||||
import AppHeader from '@/components/Header';
|
||||
@@ -11,13 +30,20 @@ import { cookieUtils } from '@/utils/request';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
// 认证布局组件,使用useRouteGuard hook进行路由鉴权
|
||||
/**
|
||||
* Authentication layout component that wraps all authenticated pages.
|
||||
* Handles route guards, breadcrumb navigation, and user authentication.
|
||||
*/
|
||||
const AuthLayout: FC = () => {
|
||||
const { getUserInfo } = useUser();
|
||||
// 使用路由守卫hook处理认证和权限检查
|
||||
|
||||
// Use route guard hook to handle authentication and permission checks
|
||||
useRouteGuard('manage');
|
||||
// 自动更新面包屑导航
|
||||
|
||||
// Automatically update breadcrumb navigation based on current route
|
||||
useNavigationBreadcrumbs('manage');
|
||||
|
||||
// Check authentication token and fetch user info on mount
|
||||
useEffect(() => {
|
||||
const authToken = cookieUtils.get('authToken')
|
||||
if (!authToken && !window.location.hash.includes('#/login')) {
|
||||
@@ -29,9 +55,12 @@ const AuthLayout: FC = () => {
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
{/* Sidebar navigation */}
|
||||
<Sider />
|
||||
<Layout style={{maxHeight: '100vh', width: '100vh', overflowY: 'auto' }}>
|
||||
{/* Header with breadcrumbs and user menu */}
|
||||
<AppHeader />
|
||||
{/* Main content area - renders child routes */}
|
||||
<Content style={{ padding: '16px 17px 24px 16px', zIndex: 0 }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:11:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:11:43
|
||||
*/
|
||||
/**
|
||||
* AuthSpaceLayout Component
|
||||
*
|
||||
* The authenticated layout wrapper for knowledge base (space) pages that provides:
|
||||
* - Route authentication and permission checks for space context
|
||||
* - Automatic breadcrumb navigation updates
|
||||
* - Sidebar navigation and header configured for space mode
|
||||
* - Token-based authentication validation
|
||||
* - Storage type initialization
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useEffect, type FC } from 'react';
|
||||
import { Layout } from 'antd';
|
||||
|
||||
import useRouteGuard from '@/hooks/useRouteGuard';
|
||||
import { useNavigationBreadcrumbs } from '@/hooks/useNavigationBreadcrumbs';
|
||||
import AppHeader from '@/components/Header';
|
||||
@@ -11,28 +31,38 @@ import { cookieUtils } from '@/utils/request';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
// 认证布局组件,使用useRouteGuard hook进行路由鉴权
|
||||
/**
|
||||
* Authentication layout component for knowledge base (space) pages.
|
||||
* Similar to AuthLayout but configured for space context with storage type management.
|
||||
*/
|
||||
const AuthSpaceLayout: FC = () => {
|
||||
const { getUserInfo, getStorageType } = useUser();
|
||||
// 使用路由守卫hook处理认证和权限检查
|
||||
|
||||
// Use route guard hook to handle authentication and permission checks for space context
|
||||
useRouteGuard('space');
|
||||
// 自动更新面包屑导航
|
||||
|
||||
// Automatically update breadcrumb navigation based on current route in space context
|
||||
useNavigationBreadcrumbs('space');
|
||||
|
||||
// Check authentication token, fetch user info and storage type on mount
|
||||
useEffect(() => {
|
||||
const authToken = cookieUtils.get('authToken')
|
||||
if (!authToken && !window.location.hash.includes('#/login')) {
|
||||
window.location.href = `/#/login`;
|
||||
} else {
|
||||
getUserInfo()
|
||||
getStorageType()
|
||||
getStorageType() // Fetch storage type for knowledge base operations
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
{/* Sidebar navigation configured for space mode */}
|
||||
<Sider source="space" />
|
||||
<Layout style={{maxHeight: '100vh', width: '100vh', overflowY: 'auto' }}>
|
||||
{/* Header with breadcrumbs and user menu configured for space mode */}
|
||||
<AppHeader source="space" />
|
||||
{/* Main content area for knowledge base pages - renders child routes */}
|
||||
<Content style={{ padding: '16px 17px 24px 16px', zIndex: 0, height: 'calc(100vh - 64px)', overflowY: 'auto' }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:12:42
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:12:42
|
||||
*/
|
||||
/**
|
||||
* BasicLayout Component
|
||||
*
|
||||
* A minimal layout wrapper that provides:
|
||||
* - User information initialization
|
||||
* - Storage type initialization
|
||||
* - Simple container for child routes without navigation UI
|
||||
*
|
||||
* Used for pages that don't require sidebar/header (e.g., login, public pages).
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useEffect, type FC } from 'react';
|
||||
|
||||
import { useUser } from '@/store/user';
|
||||
|
||||
// 基础布局组件,用于展示内容并保留用户信息获取功能
|
||||
/**
|
||||
* Basic layout component for pages without navigation UI.
|
||||
* Fetches user info and storage type on mount, then renders child routes.
|
||||
*/
|
||||
const BasicLayout: FC = () => {
|
||||
const { getUserInfo, getStorageType } = useUser();
|
||||
|
||||
// 获取用户信息
|
||||
// Fetch user information and storage type on component mount
|
||||
useEffect(() => {
|
||||
getUserInfo();
|
||||
getStorageType()
|
||||
@@ -14,6 +37,7 @@ const BasicLayout: FC = () => {
|
||||
|
||||
return (
|
||||
<div className="rb:relative rb:h-full rb:w-full">
|
||||
{/* Render child routes without additional UI */}
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:13:20
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:13:20
|
||||
*/
|
||||
/**
|
||||
* LayoutBg Component
|
||||
*
|
||||
* A decorative background component that displays styled background elements.
|
||||
* Provides visual aesthetics with positioned decorative shapes.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import styles from './layout.module.css';
|
||||
|
||||
/**
|
||||
* Background layout component with decorative elements.
|
||||
* Renders a fixed full-screen background with styled shapes.
|
||||
*/
|
||||
const LayoutBg: FC = () => {
|
||||
return (
|
||||
<div className="rb:fixed rb:top-0 rb:right-0 rb:left-0 rb:bottom-0 rb:bg-[#FBFDFF]">
|
||||
<div className={clsx('rb:h-[240px]', styles.bgTop)}>
|
||||
{/* Top section with decorative background shapes */}
|
||||
<div className={clsx('rb:h-60', styles.bgTop)}>
|
||||
{/* Left decorative element 1 */}
|
||||
<div className={clsx(styles.left1)}></div>
|
||||
{/* Left decorative element 2 */}
|
||||
<div className={clsx(styles.left2)}></div>
|
||||
{/* Right decorative element */}
|
||||
<div className={clsx(styles.right1)}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:13:38
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:13:38
|
||||
*/
|
||||
/**
|
||||
* LoginLayout Component
|
||||
*
|
||||
* A minimal layout wrapper for authentication pages (login, register, etc.).
|
||||
* Provides a simple container without navigation UI or authentication checks.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { type FC } from 'react';
|
||||
|
||||
// 基础布局组件,用于展示内容并保留用户信息获取功能
|
||||
/**
|
||||
* Login layout component for unauthenticated pages.
|
||||
* Renders child routes in a simple full-size container.
|
||||
*/
|
||||
const LoginLayout: FC = () => {
|
||||
|
||||
return (
|
||||
<div className="rb:relative rb:h-full rb:w-full">
|
||||
{/* Render authentication pages (login, register, etc.) */}
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:13:55
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:52:17
|
||||
*/
|
||||
/**
|
||||
* NoAuthLayout Component
|
||||
*
|
||||
* A minimal layout wrapper for public pages that don't require authentication.
|
||||
* Provides a simple container without navigation UI or authentication checks.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { type FC } from 'react';
|
||||
|
||||
// 基础布局组件,用于展示内容并保留用户信息获取功能
|
||||
/**
|
||||
* No-authentication layout component for public pages.
|
||||
* Renders child routes in a simple full-size container without any auth requirements.
|
||||
*/
|
||||
const NoAuthLayout: FC = () => {
|
||||
|
||||
return (
|
||||
<div className="rb:relative rb:h-full rb:w-full">
|
||||
{/* Render public pages without authentication */}
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import { memo } from 'react'
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:14:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:14:59
|
||||
*/
|
||||
/**
|
||||
* AudioBlock Component
|
||||
*
|
||||
* Renders audio elements from markdown nodes.
|
||||
* Extracts audio source URLs and creates HTML audio players with controls.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { memo, type FC } from 'react'
|
||||
|
||||
/** Props interface for AudioBlock component */
|
||||
interface AudioBlockProps {
|
||||
node: {
|
||||
children: { properties: { src: string } }[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Audio block component that renders audio elements from markdown nodes */
|
||||
const AudioBlock: FC<AudioBlockProps> = (props) => {
|
||||
// console.log('AudioBlock', props)
|
||||
const { children } = props.node;
|
||||
/** Extract audio source URLs from node children and filter out empty values */
|
||||
const srcs = children.map(item => item.properties?.src).filter(item => item)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,22 +1,45 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:15:05
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:15:05
|
||||
*/
|
||||
/**
|
||||
* Code Component
|
||||
*
|
||||
* A versatile code rendering component that supports:
|
||||
* - Syntax-highlighted code blocks
|
||||
* - ECharts visualizations
|
||||
* - SVG rendering
|
||||
* - Mermaid diagrams
|
||||
* - Inline code snippets
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC, useMemo } from 'react'
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter';
|
||||
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs';
|
||||
import CopyBtn from './CopyBtn';
|
||||
import ReactEcharts from 'echarts-for-react';
|
||||
|
||||
import CopyBtn from './CopyBtn';
|
||||
import Svg from './Svg'
|
||||
import MermaidChart from './MermaidChart'
|
||||
|
||||
|
||||
/** Props interface for Code component */
|
||||
type ICodeProps = {
|
||||
children: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
/** Code block component that renders syntax-highlighted code or special visualizations */
|
||||
const Code: FC<ICodeProps> = (props) => {
|
||||
const { children, className } = props;
|
||||
/** Extract language from className (e.g., 'language-javascript' -> 'javascript') */
|
||||
const language = className?.split('-')[1]
|
||||
console.log('Code', props)
|
||||
|
||||
// Parse ECharts configuration from code content
|
||||
const charData = useMemo(() => {
|
||||
if (language !== 'echarts') return null;
|
||||
try {
|
||||
@@ -27,6 +50,7 @@ const Code: FC<ICodeProps> = (props) => {
|
||||
}
|
||||
}, [language, children])
|
||||
|
||||
// Render ECharts visualization
|
||||
if (language === 'echarts') {
|
||||
return (
|
||||
<ReactEcharts
|
||||
@@ -39,6 +63,7 @@ const Code: FC<ICodeProps> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Render SVG content
|
||||
if (language === 'svg') {
|
||||
return (
|
||||
<Svg
|
||||
@@ -46,6 +71,7 @@ const Code: FC<ICodeProps> = (props) => {
|
||||
/>
|
||||
)
|
||||
}
|
||||
// Render Mermaid diagram
|
||||
if (language === 'mermaid') {
|
||||
return (
|
||||
<MermaidChart
|
||||
@@ -54,6 +80,7 @@ const Code: FC<ICodeProps> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Render syntax-highlighted code block with copy button
|
||||
if (className) {
|
||||
return (
|
||||
<div className="rb:relative">
|
||||
@@ -81,6 +108,7 @@ const Code: FC<ICodeProps> = (props) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Render inline code
|
||||
return <code className="rb:bg-[#F0F3F8] rb:px-1 rb:py-0.5 rb:rounded rb:text-sm rb:font-mono rb:whitespace-break-spaces">{children}</code>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:15:11
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:15:11
|
||||
*/
|
||||
/**
|
||||
* CodeBlock Component
|
||||
*
|
||||
* A standalone code block component for displaying formatted code with:
|
||||
* - Syntax highlighting
|
||||
* - Optional copy functionality
|
||||
* - Configurable size and line numbers
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC } from 'react'
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter';
|
||||
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs';
|
||||
|
||||
import CopyBtn from './CopyBtn';
|
||||
|
||||
|
||||
/** Props interface for CodeBlock component */
|
||||
type ICodeBlockProps = {
|
||||
value: string;
|
||||
needCopy?: boolean;
|
||||
@@ -11,12 +29,7 @@ type ICodeBlockProps = {
|
||||
showLineNumbers?: boolean;
|
||||
}
|
||||
|
||||
// enum languageType {
|
||||
// echarts = 'echarts',
|
||||
// mermaid = 'mermaid',
|
||||
// svg = 'svg',
|
||||
// }
|
||||
|
||||
/** Code block component for displaying formatted code with optional copy functionality */
|
||||
const CodeBlock: FC<ICodeBlockProps> = ({
|
||||
value,
|
||||
needCopy = true,
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:15:21
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:15:21
|
||||
*/
|
||||
/**
|
||||
* CopyBtn Component
|
||||
*
|
||||
* A button component that copies text to clipboard and displays a success message.
|
||||
* Uses the copy-to-clipboard library for cross-browser compatibility.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { Button, App } from 'antd'
|
||||
|
||||
|
||||
/** Props interface for CopyBtn component */
|
||||
type ICopyBtnProps = {
|
||||
value: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/** Copy button component that copies text to clipboard and shows success message */
|
||||
const CopyBtn: FC<ICopyBtnProps> = ({
|
||||
value,
|
||||
className,
|
||||
@@ -18,6 +34,7 @@ const CopyBtn: FC<ICopyBtnProps> = ({
|
||||
const { t } = useTranslation()
|
||||
const { message } = App.useApp()
|
||||
|
||||
/** Copy value to clipboard and show success message */
|
||||
const handleCopy = () => {
|
||||
copy(value)
|
||||
message.success(t('common.copySuccess'))
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import { memo } from 'react'
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:15:55
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:15:55
|
||||
*/
|
||||
/**
|
||||
* Link Component
|
||||
*
|
||||
* A secure link component that opens URLs in a new tab.
|
||||
* Includes security attributes (noopener, noreferrer) to prevent security vulnerabilities.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
/** Props interface for Link component */
|
||||
interface LinkProps {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Link component that opens in a new tab with security attributes */
|
||||
const Link: FC<LinkProps> = (props) => {
|
||||
// console.log('Link', props)
|
||||
const { children, href } = props;
|
||||
return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:16:01
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:16:01
|
||||
*/
|
||||
/**
|
||||
* MermaidChart Component
|
||||
*
|
||||
* Renders Mermaid diagrams as images.
|
||||
* - Converts Mermaid syntax to SVG
|
||||
* - Converts SVG to base64 data URL for display
|
||||
* - Generates unique IDs based on content hash
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useState, type FC } from 'react'
|
||||
import mermaid from 'mermaid'
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { Image } from 'antd'
|
||||
|
||||
/** Initialize Mermaid with default configuration */
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: 'default',
|
||||
@@ -12,6 +30,7 @@ mermaid.initialize({
|
||||
},
|
||||
})
|
||||
|
||||
/** Convert SVG string to base64 data URL for image display */
|
||||
const svgToBase64 = (svgGraph: string) => {
|
||||
const svgBytes = new TextEncoder().encode(svgGraph)
|
||||
const blob = new Blob([svgBytes], { type: 'image/svg+xml;charset=utf-8' })
|
||||
@@ -22,8 +41,11 @@ const svgToBase64 = (svgGraph: string) => {
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
/** Mermaid chart component that renders Mermaid diagrams as images */
|
||||
const MermaidChart: FC<{ content: string }> = ({ content }) => {
|
||||
const [chartSvg, setChartSvg] = useState<string>('')
|
||||
/** Generate unique ID based on content hash to avoid conflicts */
|
||||
const id = useRef(`mermaidchart_${CryptoJS.MD5(content).toString()}`)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,6 +55,7 @@ const MermaidChart: FC<{ content: string }> = ({ content }) => {
|
||||
drawDiagram()
|
||||
}, [content])
|
||||
|
||||
/** Render Mermaid diagram and convert to base64 image */
|
||||
const drawDiagram = async function () {
|
||||
const { svg } = await mermaid.render(id.current, content);
|
||||
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { memo } from 'react'
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:16:06
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:16:06
|
||||
*/
|
||||
/**
|
||||
* Paragraph Component
|
||||
*
|
||||
* A simple paragraph component for rendering markdown paragraphs.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
/** Props interface for Paragraph component */
|
||||
interface ParagraphProps {
|
||||
node: {
|
||||
children: ReactNode;
|
||||
};
|
||||
children: string[]
|
||||
}
|
||||
|
||||
/** Paragraph component for rendering markdown paragraphs */
|
||||
const Paragraph: FC<ParagraphProps> = (props) => {
|
||||
// console.log('Paragraph', props)
|
||||
const { children } = props
|
||||
|
||||
return <p>{children}</p>
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:16:10
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:16:10
|
||||
*/
|
||||
/**
|
||||
* RbButton Component
|
||||
*
|
||||
* A button component for rendering buttons in markdown content.
|
||||
* Wraps Ant Design Button component.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { Button } from 'antd'
|
||||
|
||||
/** Props interface for RbButton component */
|
||||
interface RbButtonProps {
|
||||
node: {
|
||||
children: ReactNode;
|
||||
};
|
||||
children: string[]
|
||||
}
|
||||
|
||||
/** Button component for rendering buttons in markdown */
|
||||
const RbButton: FC<RbButtonProps> = (props) => {
|
||||
console.log('RbButton', props)
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:16:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:16:14
|
||||
*/
|
||||
/**
|
||||
* Svg Component
|
||||
*
|
||||
* Renders SVG content from string using dangerouslySetInnerHTML.
|
||||
* Used for displaying SVG code blocks in markdown.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
/** Props interface for Svg component */
|
||||
interface SvgProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染SVG内容的组件
|
||||
*/
|
||||
/** Component for rendering SVG content from string */
|
||||
function Svg(props: SvgProps): JSX.Element {
|
||||
const { content } = props;
|
||||
// console.log('Svg', props)
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
import { memo } from 'react'
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:16:18
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:54:55
|
||||
*/
|
||||
/**
|
||||
* VideoBlock Component
|
||||
*
|
||||
* Renders video elements from markdown nodes.
|
||||
* Extracts video source URLs and creates HTML video players with controls.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { FC } from 'react'
|
||||
|
||||
/** Props interface for VideoBlock component */
|
||||
interface VideoBlockProps {
|
||||
node: {
|
||||
children: { properties: { src: string } }[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Video block component that renders video elements from markdown nodes */
|
||||
const VideoBlock: FC<VideoBlockProps> = (props) => {
|
||||
// console.log('VideoBlock', props)
|
||||
const { children } = props.node;
|
||||
/** Extract video source URLs from node children and filter out empty values */
|
||||
const srcs = children.map(item => item.properties?.src).filter(item => item)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:17:31
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:17:31
|
||||
*/
|
||||
/**
|
||||
* RbMarkdown Component
|
||||
*
|
||||
* A comprehensive markdown renderer with support for:
|
||||
* - Standard markdown syntax (headings, lists, tables, etc.)
|
||||
* - Code syntax highlighting
|
||||
* - Math equations (KaTeX)
|
||||
* - Mermaid diagrams
|
||||
* - ECharts visualizations
|
||||
* - SVG rendering
|
||||
* - Audio/video embedding
|
||||
* - Interactive form elements
|
||||
* - HTML comments visibility toggle
|
||||
* - Editable mode with live preview
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, type FC } from 'react'
|
||||
import { Image, Input, Select, Form, Checkbox, Radio, ColorPicker, DatePicker, TimePicker, InputNumber, Slider } from 'antd'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import RemarkGfm from 'remark-gfm'
|
||||
@@ -5,8 +30,6 @@ import RemarkMath from 'remark-math'
|
||||
import RemarkBreaks from 'remark-breaks'
|
||||
import RehypeKatex from 'rehype-katex'
|
||||
import RehypeRaw from 'rehype-raw'
|
||||
import type { FC } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
import Code from './Code'
|
||||
import VideoBlock from './VideoBlock'
|
||||
@@ -14,14 +37,21 @@ import AudioBlock from './AudioBlock'
|
||||
import Link from './Link'
|
||||
import RbButton from './RbButton'
|
||||
|
||||
/** Props interface for RbMarkdown component */
|
||||
interface RbMarkdownProps {
|
||||
/** Markdown content to render */
|
||||
content: string;
|
||||
showHtmlComments?: boolean; // 是否显示 HTML 注释,默认为 false(隐藏)
|
||||
editable?: boolean; // 是否可编辑,默认为 false
|
||||
onContentChange?: (content: string) => void; // 内容变化回调
|
||||
/** Whether to display HTML comments (default: false) */
|
||||
showHtmlComments?: boolean;
|
||||
/** Whether the content is editable (default: false) */
|
||||
editable?: boolean;
|
||||
/** Callback fired when content changes in edit mode */
|
||||
onContentChange?: (content: string) => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Custom component mappings for markdown elements */
|
||||
const components = {
|
||||
h1: ({ children, ...props }: any) => <h1 className="rb:text-2xl rb:font-bold rb:mb-2" {...props}>{children}</h1>,
|
||||
h2: ({ children, ...props }: any) => <h2 className="rb:text-xl rb:font-bold rb:mb-2" {...props}>{children}</h2>,
|
||||
@@ -38,7 +68,7 @@ const components = {
|
||||
em: ({ children, ...props }: any) => <em className="rb:italic" {...props}>{children}</em>,
|
||||
del: ({ children, ...props }: any) => <del className="rb:line-through" {...props}>{children}</del>,
|
||||
span: ({ children, style, ...restProps }: any) => {
|
||||
// 如果是 HTML 注释的 span,应用特殊样式
|
||||
// Apply special styling for HTML comment spans
|
||||
if (style?.color === '#999') {
|
||||
return <span style={{ color: '#999', fontSize: '0.9em' }}>{children}</span>
|
||||
}
|
||||
@@ -104,30 +134,33 @@ const RbMarkdown: FC<RbMarkdownProps> = ({
|
||||
const [editContent, setEditContent] = useState(content)
|
||||
const textareaRef = useRef<any>(null)
|
||||
|
||||
// 当外部 content 变化时,同步更新编辑内容
|
||||
/** Sync edit content when external content changes */
|
||||
useEffect(() => {
|
||||
setEditContent(content)
|
||||
}, [content])
|
||||
|
||||
// 处理 textarea 内容变化
|
||||
/** Handle textarea content changes and trigger callback */
|
||||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newContent = e.target.value
|
||||
setEditContent(newContent)
|
||||
// 实时回调内容变化
|
||||
/** Trigger real-time content change callback */
|
||||
onContentChange?.(newContent)
|
||||
}
|
||||
|
||||
// 根据参数决定是否将 HTML 注释转换为可见文本
|
||||
// 使用特殊的 markdown 语法来显示注释,避免被 rehype-raw 过滤
|
||||
/**
|
||||
* Process content based on showHtmlComments flag
|
||||
* Converts HTML comments to visible text when showHtmlComments is true
|
||||
* Uses special span markup to display comments with styling
|
||||
*/
|
||||
const processedContent = showHtmlComments
|
||||
? (editable ? editContent : content).replace(/<!--([\s\S]*?)-->/g, (_match, commentContent) => {
|
||||
// 转换为带样式的文本,使用 <span class="html-comment"> 标记
|
||||
/** Convert to styled text using span with html-comment class */
|
||||
const escaped = commentContent.trim().replace(/</g, '<').replace(/>/g, '>')
|
||||
return `<span class="html-comment"><!-- ${escaped} --></span>`
|
||||
})
|
||||
: (editable ? editContent : content)
|
||||
|
||||
// 如果是编辑模式,显示 textarea
|
||||
/** Render textarea in edit mode */
|
||||
if (editable) {
|
||||
return (
|
||||
<div className="rb:relative">
|
||||
@@ -138,21 +171,21 @@ const RbMarkdown: FC<RbMarkdownProps> = ({
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 编辑区域 */}
|
||||
{/* Edit area with textarea */}
|
||||
<Input.TextArea
|
||||
ref={textareaRef}
|
||||
value={editContent}
|
||||
onChange={handleTextareaChange}
|
||||
rows={10}
|
||||
className="rb:font-mono rb:text-sm"
|
||||
placeholder="请输入 Markdown 内容..."
|
||||
placeholder="Enter Markdown content..."
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 处理键盘快捷键
|
||||
/** Handle keyboard shortcuts (e.g., Ctrl+C for copy) */
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
||||
const selection = window.getSelection()
|
||||
@@ -162,7 +195,7 @@ const RbMarkdown: FC<RbMarkdownProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 预览模式
|
||||
/** Render markdown preview mode */
|
||||
return (
|
||||
<div className={`rb:relative ${className || ''}`} onKeyDown={handleKeyDown} tabIndex={0}>
|
||||
<style>{`
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:18:19
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 15:44:42
|
||||
*/
|
||||
/**
|
||||
* PageScrollList Component
|
||||
*
|
||||
* An infinite scroll list component with pagination support that:
|
||||
* - Automatically loads more data when scrolling to bottom
|
||||
* - Supports grid layout with configurable columns
|
||||
* - Handles loading and empty states
|
||||
* - Exposes refresh method via ref
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import { List } from 'antd';
|
||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
import PageEmpty from '@/components/Empty/PageEmpty'
|
||||
import PageLoading from '@/components/Empty/PageLoading'
|
||||
|
||||
/** Default page size for pagination */
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/** API response structure with pagination metadata */
|
||||
interface ApiResponse<T> {
|
||||
items?: T[];
|
||||
page: {
|
||||
@@ -16,18 +37,28 @@ interface ApiResponse<T> {
|
||||
hasnext: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** Ref methods exposed to parent component */
|
||||
export interface PageScrollListRef {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/** Props interface for PageScrollList component */
|
||||
interface PageScrollListProps<T, Q = Record<string, unknown>> {
|
||||
/** API endpoint URL */
|
||||
url: string;
|
||||
/** Function to render each list item */
|
||||
renderItem: (item: T) => React.ReactNode;
|
||||
/** Query parameters for API request */
|
||||
query?: Q;
|
||||
/** Number of columns in grid layout */
|
||||
column?: number;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
needLoading?: boolean;
|
||||
}
|
||||
|
||||
/** Infinite scroll list component with pagination support */
|
||||
const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
renderItem,
|
||||
query,
|
||||
@@ -36,6 +67,7 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
className = '',
|
||||
needLoading = true,
|
||||
}: PageScrollListProps<T, Q>, ref: React.Ref<PageScrollListRef>) => {
|
||||
/** Expose refresh method to parent component */
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh,
|
||||
}));
|
||||
@@ -45,6 +77,7 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/** Load more data from API with pagination */
|
||||
const loadMoreData = (flag?: boolean) => {
|
||||
if (!flag && (loading || !hasMore)) {
|
||||
return;
|
||||
@@ -58,6 +91,7 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
.then((res) => {
|
||||
const response = res as ApiResponse<T>;
|
||||
const results = Array.isArray(response.items) ? response.items : Array.isArray(response) ? response as T[] : [];
|
||||
// Replace data if flag is true, otherwise append
|
||||
if (flag) {
|
||||
setData(results);
|
||||
} else {
|
||||
@@ -78,17 +112,19 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
});
|
||||
};
|
||||
|
||||
// 刷新列表数据
|
||||
/** Reset list to initial state and reload data */
|
||||
const refresh = () => {
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setData([]);
|
||||
}
|
||||
|
||||
/** Refresh when query parameters change */
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [query]);
|
||||
|
||||
/** Load initial data when list is reset */
|
||||
useEffect(() => {
|
||||
if (page === 1 && hasMore && data.length === 0) {
|
||||
loadMoreData(true);
|
||||
@@ -111,6 +147,7 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
scrollableTarget="scrollableDiv"
|
||||
className='rb:h-full!'
|
||||
>
|
||||
{/* Render grid list or empty state */}
|
||||
{data.length > 0 ? (
|
||||
<List
|
||||
grid={{ gutter: 16, column: column }}
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:18:50
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:18:50
|
||||
*/
|
||||
/**
|
||||
* PageTabs Component
|
||||
*
|
||||
* A styled wrapper around Ant Design's Segmented component for page-level tab navigation.
|
||||
* Provides consistent styling for tab interfaces across the application.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
import { Segmented, type SegmentedProps } from 'antd';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
/**
|
||||
* Page tabs component wrapper for Ant Design Segmented component.
|
||||
* Applies custom styling via CSS modules.
|
||||
*/
|
||||
const PageTabs: FC<SegmentedProps> = ({
|
||||
value,
|
||||
options,
|
||||
|
||||
@@ -1,24 +1,59 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:19:30
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:19:30
|
||||
*/
|
||||
/**
|
||||
* RadioGroupCard Component
|
||||
*
|
||||
* A radio group component that displays options as selectable cards with:
|
||||
* - Visual card-based selection interface
|
||||
* - Optional icons and descriptions
|
||||
* - Support for clear selection
|
||||
* - Block or inline layout modes
|
||||
* - Custom item rendering
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC, type Key, type ReactNode, useEffect } from 'react';
|
||||
import { type RadioGroupProps } from 'antd';
|
||||
import clsx from 'clsx'
|
||||
|
||||
/** Radio card option interface */
|
||||
interface RadioCardOption {
|
||||
/** Option value */
|
||||
value: string | number | boolean | null | undefined | Key;
|
||||
/** Option label text */
|
||||
label: string;
|
||||
/** Optional description text */
|
||||
labelDesc?: string;
|
||||
/** Optional icon URL */
|
||||
icon?: string;
|
||||
/** Whether the option is disabled */
|
||||
disabled?: boolean;
|
||||
/** Additional properties */
|
||||
[key: string]: string | number | boolean | undefined | null | Key;
|
||||
}
|
||||
|
||||
/** Props interface for RadioGroupCard component */
|
||||
interface RadioCardProps extends Omit<RadioGroupProps, 'onChange'> {
|
||||
/** Array of radio card options */
|
||||
options: RadioCardOption[];
|
||||
/** Callback fired when value changes (for side effects) */
|
||||
onValueChange?: (value: string | null | undefined, option?: RadioCardOption) => void;
|
||||
/** Callback fired when selection changes */
|
||||
onChange?: (value: string | null | undefined, option?: RadioCardOption) => void;
|
||||
/** Custom render function for each option */
|
||||
itemRender?: (option: RadioCardOption) => ReactNode;
|
||||
/** Whether clicking selected option clears selection */
|
||||
allowClear?: boolean;
|
||||
/** Whether to display cards in block (vertical) layout */
|
||||
block?: boolean;
|
||||
}
|
||||
|
||||
/** Radio group card component that displays options as selectable cards */
|
||||
const RadioGroupCard: FC<RadioCardProps> = ({
|
||||
options,
|
||||
value,
|
||||
@@ -28,16 +63,19 @@ const RadioGroupCard: FC<RadioCardProps> = ({
|
||||
allowClear = true,
|
||||
block = false,
|
||||
}) => {
|
||||
// 监听value变化
|
||||
/** Listen to value changes and trigger side effects via onValueChange callback */
|
||||
useEffect(() => {
|
||||
if (onValueChange) {
|
||||
onValueChange(value);
|
||||
}
|
||||
}, [value, onValueChange]);
|
||||
|
||||
/** Handle option selection with support for clear and disabled states */
|
||||
const handleChange = (option: RadioCardOption) => {
|
||||
// Ignore clicks on disabled options
|
||||
if (option.disabled) return
|
||||
if (onChange) {
|
||||
// Clear selection if allowClear is true and option is already selected
|
||||
if (allowClear && value === option.value) {
|
||||
onChange(null, undefined);
|
||||
} else {
|
||||
@@ -51,6 +89,7 @@ const RadioGroupCard: FC<RadioCardProps> = ({
|
||||
'rb:gap-3': !block,
|
||||
'rb:gap-4': block,
|
||||
})}>
|
||||
{/* Render each option as a selectable card */}
|
||||
{options.map(option => (
|
||||
<div key={String(option.value)} className={clsx("rb:border rb:rounded-lg rb:w-full rb:p-[20px_12px] rb:text-center rb:cursor-pointer", {
|
||||
'rb:bg-[rgba(21,94,239,0.06)] rb:border-[#155EEF]': option.value === value,
|
||||
@@ -58,6 +97,7 @@ const RadioGroupCard: FC<RadioCardProps> = ({
|
||||
'rb:opacity-[0.75]': option.disabled,
|
||||
'rb:flex rb:items-center rb:text-left rb:gap-4': block,
|
||||
})} onClick={() => handleChange(option)}>
|
||||
{/* Use custom render or default card layout */}
|
||||
{itemRender ? itemRender(option) : (
|
||||
<>
|
||||
{option.icon && <img src={option.icon} className={clsx("rb:w-10 rb:h-10", {
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:19:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:19:59
|
||||
*/
|
||||
/**
|
||||
* RbAlert Component
|
||||
*
|
||||
* A custom alert component with predefined color themes and optional icon support.
|
||||
* Provides consistent styling for informational messages across the application.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC, type ReactNode } from 'react'
|
||||
|
||||
/** Props interface for RbAlert component */
|
||||
interface RbAlertProps {
|
||||
/** Color theme for the alert */
|
||||
color?: 'blue' | 'green' | 'orange' | 'purple',
|
||||
/** Alert content */
|
||||
children: ReactNode | string;
|
||||
/** Optional icon to display before content */
|
||||
icon?: ReactNode;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Color theme mappings with text, background, and border colors */
|
||||
const colors = {
|
||||
blue: 'rb:text-[rgba(21,94,239,1)] rb:bg-[rgba(21,94,239,0.08)] rb:border-[rgba(21,94,239,0.30)]',
|
||||
green: 'rb:text-[rgba(54,159,33,1)] rb:bg-[rgba(54,159,33,0.08)] rb:border-[rgba(54,159,33,0.30)]',
|
||||
@@ -14,6 +35,7 @@ const colors = {
|
||||
purple: 'rb:text-[rgba(156,111,255,1)] rb:bg-[rgba(156,111,255,0.08)] rb:border-[rgba(156,111,255,0.30)]',
|
||||
}
|
||||
|
||||
/** Custom alert component with color themes and optional icon */
|
||||
const RbAlert: FC<RbAlertProps> = ({ color = 'blue', icon, className, children }) => {
|
||||
return (
|
||||
<div className={`${colors[color]} ${className} rb:p-[6px_9px] rb:flex rb:items-center rb:text-[12px] rb:font-regular rb:leading-4 rb:border rb:rounded-md`}>
|
||||
|
||||
@@ -1,24 +1,59 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:21:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:21:14
|
||||
*/
|
||||
/**
|
||||
* RbCard Component
|
||||
*
|
||||
* A customizable card component that extends Ant Design's Card with:
|
||||
* - Multiple header styles (border, borderless, borderBL, borderL)
|
||||
* - Avatar support with image or custom component
|
||||
* - Flexible padding and styling options
|
||||
* - Tooltip support for long titles
|
||||
* - Hover effects
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC, type ReactNode } from 'react'
|
||||
import { Card, Tooltip } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
|
||||
/** Props interface for RbCard component */
|
||||
interface RbCardProps {
|
||||
/** Additional CSS classes for header */
|
||||
headerClassName?: string;
|
||||
/** Card title (string, ReactNode, or function) */
|
||||
title?: string | ReactNode | (() => ReactNode);
|
||||
/** Subtitle text displayed below title */
|
||||
subTitle?: string | ReactNode;
|
||||
/** Extra content displayed in header (top-right) */
|
||||
extra?: ReactNode;
|
||||
/** Card body content */
|
||||
children?: ReactNode;
|
||||
/** Custom avatar component */
|
||||
avatar?: ReactNode;
|
||||
/** Avatar image URL */
|
||||
avatarUrl?: string | null;
|
||||
/** Custom padding for card body */
|
||||
bodyPadding?: string;
|
||||
/** Additional CSS classes for body */
|
||||
bodyClassName?: string;
|
||||
/** Header style variant */
|
||||
headerType?: 'border' | 'borderless' | 'borderBL' | 'borderL';
|
||||
/** Background color */
|
||||
bgColor?: string;
|
||||
/** Card height */
|
||||
height?: string;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Click handler */
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/** Custom card component with flexible styling and header options */
|
||||
const RbCard: FC<RbCardProps> = ({
|
||||
headerClassName,
|
||||
title,
|
||||
@@ -35,6 +70,7 @@ const RbCard: FC<RbCardProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
/** Calculate body padding based on header type and avatar presence */
|
||||
const bodyClassName = bodyPadding
|
||||
? `rb:p-[${bodyPadding}]!`
|
||||
: headerType === 'borderL'
|
||||
@@ -46,11 +82,13 @@ const RbCard: FC<RbCardProps> = ({
|
||||
: (headerType === 'border' && !avatarUrl && !avatar) || headerType === 'borderBL'
|
||||
? 'rb:p-[16px_16px_20px_16px]!'
|
||||
: ''
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...props}
|
||||
title={typeof title === 'function' ? title() : title ?
|
||||
<div className="rb:flex rb:items-center rb:gap-2">
|
||||
{/* Avatar image or custom avatar component */}
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} className="rb:mr-3.25 rb:w-12 rb:h-12 rb:rounded-lg" />
|
||||
: avatar ? avatar : null
|
||||
@@ -63,7 +101,9 @@ const RbCard: FC<RbCardProps> = ({
|
||||
}
|
||||
)
|
||||
}>
|
||||
{/* Title with tooltip for overflow text */}
|
||||
<Tooltip title={title}><div className="rb:w-full rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{title}</div></Tooltip>
|
||||
{/* Optional subtitle */}
|
||||
{subTitle && <div className="rb:text-[#5B6167] rb:text-[12px]">{subTitle}</div>}
|
||||
</div>
|
||||
</div> : null
|
||||
@@ -73,10 +113,15 @@ const RbCard: FC<RbCardProps> = ({
|
||||
header: clsx(
|
||||
'rb:font-medium',
|
||||
{
|
||||
/** Borderless header style */
|
||||
'rb:border-[0]! rb:text-[16px] rb:p-[0_16px]!': headerType === 'borderless',
|
||||
/** Header with avatar */
|
||||
'rb:border-[0]! rb:text-[16px] rb:p-[16px_16px_0_16px]!': avatarUrl || avatar,
|
||||
/** Standard border header */
|
||||
'rb:text-[18px] rb:p-[0]! rb:m-[0_20px]!': headerType === 'border' && !avatarUrl && !avatar,
|
||||
/** Border bottom-left style */
|
||||
"rb:m-[0_16px]! rb:p-[0]! rb:relative rb:before:content-[''] rb:before:w-[4px] rb:before:h-[16px] rb:before:bg-[#5B6167] rb:before:absolute rb:before:top-[50%] rb:before:left-[-16px] rb:before:translate-y-[-50%] rb:before:bg-[#5B6167]! rb:before:h-[16px]!": headerType === 'borderBL',
|
||||
/** Border left style */
|
||||
"rb:m-[0_16px]! rb:p-[0]! rb:leading-[20px] rb:min-h-[48px]! rb:relative rb:border-[0]! rb:before:content-[''] rb:before:w-[4px] rb:before:h-[16px] rb:before:bg-[#5B6167] rb:before:absolute rb:before:top-[50%] rb:before:left-[-16px] rb:before:translate-y-[-50%] rb:before:bg-[#5B6167]! rb:before:h-[16px]!": headerType === 'borderL',
|
||||
},
|
||||
headerClassName,
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { type FC, type ReactNode } from 'react'
|
||||
import { Card } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface RbCardProps {
|
||||
title?: string | ReactNode;
|
||||
subTitle?: string;
|
||||
extra?: ReactNode;
|
||||
children: ReactNode;
|
||||
avatar?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const RbCard: FC<RbCardProps> = ({
|
||||
title,
|
||||
subTitle,
|
||||
extra,
|
||||
children,
|
||||
avatar,
|
||||
className,
|
||||
}) => {
|
||||
if (avatar) {
|
||||
return (
|
||||
<Card
|
||||
classNames={{
|
||||
header: 'rb:p-[0]! rb:m-[0_20px]!',
|
||||
body: 'rb:p-[16px_20px_16px_16px]',
|
||||
}}
|
||||
style={{
|
||||
background: '#FBFDFF'
|
||||
}}
|
||||
>
|
||||
{title &&
|
||||
<div className={clsx("rb:text-[#212332] rb:text-[16px] rb:font-medium rb:flex rb:items-center rb:mb-[20px]", {
|
||||
'rb:justify-between': extra
|
||||
})}>
|
||||
<div className="rb:flex rb:items-center">
|
||||
<div className="rb:mr-[13px] rb:w-[48px] rb:h-[48px] rb:rounded-[8px] rb:overflow-hidden">{avatar}</div>
|
||||
<div className="rb:truncate">{title}</div>
|
||||
</div>
|
||||
{subTitle && <div className="rb:text-[#5B6167] rb:text-[12px]">{subTitle}</div>}
|
||||
{extra}
|
||||
</div>
|
||||
}
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Card
|
||||
title={ title ?
|
||||
<div className={clsx("rb:text-[#212332] rb:text-[18px] rb:font-medium rb:flex rb:items-center", {
|
||||
'rb:justify-between': extra
|
||||
})}>
|
||||
<div className="rb:truncate">{title}</div>
|
||||
{subTitle && <div className="rb:text-[#5B6167] rb:text-[12px]">{subTitle}</div>}
|
||||
{extra}
|
||||
</div> : null
|
||||
}
|
||||
classNames={{
|
||||
header: 'rb:p-[0]! rb:m-[0_20px]!',
|
||||
body: `rb:p-[16px_20px_20px_16px] ${className || ''}`,
|
||||
}}
|
||||
style={{
|
||||
background: '#FBFDFF'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default RbCard
|
||||
@@ -3,14 +3,27 @@
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-07 14:16:33
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2025-11-27 20:02:46
|
||||
* @LastEditors: ZhaoYing
|
||||
* @LastEditTime: 2026-02-02 15:23:01
|
||||
*/
|
||||
/**
|
||||
* RbDrawer Component
|
||||
*
|
||||
* A customized drawer component that extends Ant Design's Drawer with:
|
||||
* - Internal state management for open/close
|
||||
* - Custom close button in header
|
||||
* - Full-height flex layout for content
|
||||
* - Automatic state synchronization with external control
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC, useState, useEffect } from 'react'
|
||||
import { Button, Drawer, Space } from 'antd';
|
||||
import type { DrawerProps } from 'antd';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
|
||||
/** Custom drawer component with internal state management and custom close button */
|
||||
const RbDrawer: FC<DrawerProps> =({
|
||||
children,
|
||||
size = 'large',
|
||||
@@ -18,30 +31,32 @@ const RbDrawer: FC<DrawerProps> =({
|
||||
onClose,
|
||||
...props
|
||||
}) => {
|
||||
// 内部状态管理,组件内部完全控制 open 状态
|
||||
/** Internal state management - component fully controls open state internally */
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
|
||||
// 当外部 open 变化时,同步到内部状态
|
||||
/** Sync internal state when external open prop changes */
|
||||
useEffect(() => {
|
||||
if (externalOpen !== undefined) {
|
||||
setInternalOpen(externalOpen);
|
||||
}
|
||||
}, [externalOpen]);
|
||||
|
||||
// 确保当外部 open 为 true 时,内部状态也同步为 true(处理重复打开的情况)
|
||||
/** Ensure internal state syncs to true when external open is true (handles repeated opening) */
|
||||
useEffect(() => {
|
||||
if (externalOpen === true && !internalOpen) {
|
||||
setInternalOpen(true);
|
||||
}
|
||||
}, [externalOpen, internalOpen]);
|
||||
|
||||
/** Handle drawer close - updates internal state and notifies parent */
|
||||
const handleClose = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
// 更新内部状态,关闭抽屉
|
||||
/** Update internal state to close drawer */
|
||||
setInternalOpen(false);
|
||||
// 如果外部传入了 onClose,调用它通知外部
|
||||
/** If external onClose is provided, call it to notify parent */
|
||||
onClose?.(e);
|
||||
}
|
||||
|
||||
/** Handle close button click */
|
||||
const handleButtonClose = (e: React.MouseEvent) => {
|
||||
handleClose(e);
|
||||
}
|
||||
@@ -56,11 +71,13 @@ const RbDrawer: FC<DrawerProps> =({
|
||||
open={internalOpen}
|
||||
extra={
|
||||
<Space>
|
||||
{/* Custom close button in header */}
|
||||
<Button type='text' icon={<CloseOutlined />} onClick={handleButtonClose}/>
|
||||
</Space>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{/* Full-height flex container for content */}
|
||||
<div className='rb:flex rb:flex-col rb:h-full'>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import React, { createContext } from 'react';
|
||||
import { Button, Modal, Space } from 'antd';
|
||||
|
||||
const ReachableContext = createContext<string | null>(null);
|
||||
const UnreachableContext = createContext<string | null>(null);
|
||||
|
||||
const config = {
|
||||
title: 'Use Hook!',
|
||||
content: (
|
||||
<>
|
||||
<ReachableContext.Consumer>{(name) => `Reachable: ${name}!`}</ReachableContext.Consumer>
|
||||
<br />
|
||||
<UnreachableContext.Consumer>{(name) => `Unreachable: ${name}!`}</UnreachableContext.Consumer>
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
return (
|
||||
<ReachableContext.Provider value="Light">
|
||||
<Space>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const confirmed = await modal.confirm(config);
|
||||
console.log('Confirmed: ', confirmed);
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
modal.warning(config);
|
||||
}}
|
||||
>
|
||||
Warning
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
modal.info(config);
|
||||
}}
|
||||
>
|
||||
Info
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
modal.error(config);
|
||||
}}
|
||||
>
|
||||
Error
|
||||
</Button>
|
||||
</Space>
|
||||
{/* `contextHolder` should always be placed under the context you want to access */}
|
||||
{contextHolder}
|
||||
|
||||
{/* Can not access this context since `contextHolder` is not in it */}
|
||||
<UnreachableContext.Provider value="Bamboo" />
|
||||
</ReachableContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -1,15 +1,29 @@
|
||||
/*
|
||||
* @Description:
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-12-16 10:19:18
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2025-12-22 12:31:31
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:23:01
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:23:01
|
||||
*/
|
||||
/**
|
||||
* RbModal Component
|
||||
*
|
||||
* A customized modal component that extends Ant Design's Modal with:
|
||||
* - Default width and styling
|
||||
* - Internationalized cancel button text
|
||||
* - Scrollable content area with max height
|
||||
* - Prevents closing on mask click
|
||||
* - Auto-destroys on hidden
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC } from 'react'
|
||||
import { Modal, type ModalProps } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import './index.css'
|
||||
|
||||
/** Custom modal component wrapper with default configurations */
|
||||
const RbModal: FC<ModalProps> = ({
|
||||
onOk,
|
||||
onCancel,
|
||||
@@ -29,6 +43,7 @@ const RbModal: FC<ModalProps> = ({
|
||||
maskClosable={false}
|
||||
{...props}
|
||||
>
|
||||
{/* Scrollable content container */}
|
||||
<div className='rb:max-h-137.5 rb:overflow-y-auto rb:overflow-x-hidden'>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:23:39
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:23:39
|
||||
*/
|
||||
/**
|
||||
* RbSlider Component
|
||||
*
|
||||
* A custom slider component that extends Ant Design's Slider with:
|
||||
* - Value display next to the slider
|
||||
* - Value change callback for side effects
|
||||
* - Fixed width and custom styling
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC, useEffect } from 'react';
|
||||
import { Slider, type SliderSingleProps } from 'antd';
|
||||
|
||||
/** Props interface for RbSlider component */
|
||||
interface RbSliderProps extends SliderSingleProps {
|
||||
/** Callback fired when value changes (for side effects) */
|
||||
onValueChange?: (value: number | null | undefined) => void;
|
||||
}
|
||||
|
||||
/** Custom slider component with value display */
|
||||
const RbSlider: FC<RbSliderProps> = ({
|
||||
value,
|
||||
min = 0,
|
||||
@@ -12,21 +32,18 @@ const RbSlider: FC<RbSliderProps> = ({
|
||||
step = 1,
|
||||
...rest
|
||||
}) => {
|
||||
// 监听value变化,包括初始值
|
||||
/** Listen to value changes and trigger side effects via onValueChange callback */
|
||||
useEffect(() => {
|
||||
if (onValueChange) {
|
||||
onValueChange(value);
|
||||
}
|
||||
}, [value, onValueChange]);
|
||||
|
||||
// const flag1 = value && value > (min + step * 1)
|
||||
// const flag = value && value > (min + step * 1)
|
||||
|
||||
return (
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:gap-[8px] rb:rounded-[5px]">
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:gap-2 rb:rounded-[5px]">
|
||||
{/* Slider with fixed width */}
|
||||
<Slider
|
||||
style={{
|
||||
// width: flag1 ? '384px' : '373px',
|
||||
// margin: flag ? '0 11px 0 0': '0 5px 0 11px'
|
||||
overflow: 'inherit',
|
||||
width: '384px'
|
||||
}}
|
||||
@@ -34,7 +51,8 @@ const RbSlider: FC<RbSliderProps> = ({
|
||||
step={step}
|
||||
value={value}
|
||||
/>
|
||||
<div className="rb:text-[14px] rb:text-[#155EEF] rb:leading-[20px]">{value || min}</div>
|
||||
{/* Display current value or minimum value */}
|
||||
<div className="rb:text-[14px] rb:text-[#155EEF] rb:leading-5">{value || min}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,49 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:24:23
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:24:23
|
||||
*/
|
||||
/**
|
||||
* SearchInput Component
|
||||
*
|
||||
* A search input component with debounce and throttle support:
|
||||
* - Configurable debounce delay for search optimization
|
||||
* - Optional throttle mode for rate limiting
|
||||
* - Search icon prefix
|
||||
* - Clear button
|
||||
* - Internationalized placeholder
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { useState, type FC, useCallback, useRef } from 'react';
|
||||
import { Input, type InputProps } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import searchIcon from '@/assets/images/search.svg'
|
||||
|
||||
/** Props interface for SearchInput component */
|
||||
interface SearchInputProps {
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Callback fired when search value changes */
|
||||
onSearch?: (value: string) => void;
|
||||
/** Debounce delay in milliseconds (default: 300) */
|
||||
debounceDelay?: number;
|
||||
/** Throttle delay in milliseconds (overrides debounce if set) */
|
||||
throttleDelay?: number;
|
||||
/** Default input value */
|
||||
defaultValue?: string;
|
||||
/** Custom styles */
|
||||
style?: Record<string, string | number>;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Input size */
|
||||
size?: InputProps['size']
|
||||
}
|
||||
|
||||
/** Search input component with debounce and throttle support */
|
||||
const SearchInput: FC<SearchInputProps> = ({
|
||||
placeholder,
|
||||
onSearch,
|
||||
@@ -29,7 +59,7 @@ const SearchInput: FC<SearchInputProps> = ({
|
||||
const throttleRef = useRef<boolean>(false);
|
||||
const lastCallRef = useRef<number>(0);
|
||||
|
||||
// 防抖函数
|
||||
/** Debounce function - delays callback execution until after delay period */
|
||||
const debounce = useCallback(<T extends (...args: any[]) => void>(callback: T, delay: number) => {
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timerRef.current) {
|
||||
@@ -41,7 +71,7 @@ const SearchInput: FC<SearchInputProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 节流函数
|
||||
/** Throttle function - limits callback execution to once per delay period */
|
||||
const throttle = useCallback(<T extends (...args: any[]) => void>(callback: T, delay: number) => {
|
||||
return (...args: Parameters<T>) => {
|
||||
const now = Date.now();
|
||||
@@ -56,12 +86,12 @@ const SearchInput: FC<SearchInputProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 处理输入变化
|
||||
/** Handle input change with debounce or throttle based on configuration */
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setValue(newValue);
|
||||
|
||||
// 根据是否设置了throttleDelay来决定使用防抖还是节流
|
||||
/** Decide whether to use debounce or throttle based on throttleDelay setting */
|
||||
if (onSearch) {
|
||||
if (throttleDelay) {
|
||||
const throttledSearch = throttle(() => {
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:25:31
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:25:31
|
||||
*/
|
||||
/**
|
||||
* SiderMenu Component
|
||||
*
|
||||
* A collapsible sidebar navigation menu with:
|
||||
* - Dynamic menu generation from configuration
|
||||
* - Active state management with icon switching
|
||||
* - Nested submenu support
|
||||
* - Workspace/space context switching
|
||||
* - Role-based menu filtering
|
||||
* - Internationalization support
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { useState, useEffect, type FC } from 'react';
|
||||
import { Menu as AntMenu, Layout } from 'antd';
|
||||
import { UserOutlined } from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { useMenu, type MenuItem } from '@/store/menu';
|
||||
import styles from './index.module.css'
|
||||
import logo from '@/assets/images/logo.png'
|
||||
import menuFold from '@/assets/images/menuFold.png'
|
||||
import menuUnfold from '@/assets/images/menuUnfold.png'
|
||||
import clsx from 'clsx';
|
||||
import { useUser } from '@/store/user';
|
||||
import logout from '@/assets/images/logout.svg'
|
||||
|
||||
// 导入SVG文件
|
||||
// Import SVG files
|
||||
import dashboardIcon from '@/assets/images/menu/dashboard.svg';
|
||||
import dashboardActiveIcon from '@/assets/images/menu/dashboard_active.svg';
|
||||
import modelIcon from '@/assets/images/menu/model.svg';
|
||||
@@ -47,7 +68,7 @@ import ontologyActiveIcon from '@/assets/images/menu/ontology_active.svg'
|
||||
import promptIcon from '@/assets/images/menu/prompt.svg'
|
||||
import promptActiveIcon from '@/assets/images/menu/prompt_active.svg'
|
||||
|
||||
// 图标路径映射表
|
||||
/** Icon path mapping table for menu items (normal and active states) */
|
||||
const iconPathMap: Record<string, string> = {
|
||||
'dashboard': dashboardIcon,
|
||||
'dashboardActive': dashboardActiveIcon,
|
||||
@@ -85,8 +106,11 @@ const iconPathMap: Record<string, string> = {
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
/** Sidebar menu component with collapsible navigation */
|
||||
const Menu: FC<{
|
||||
/** Menu display mode */
|
||||
mode?: 'vertical' | 'horizontal' | 'inline';
|
||||
/** Menu context (space or manage) */
|
||||
source?: 'space' | 'manage';
|
||||
}> = ({ mode = 'inline', source = 'manage' }) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -97,6 +121,7 @@ const Menu: FC<{
|
||||
const [menus, setMenus] = useState<MenuItem[]>([])
|
||||
const { user, storageType } = useUser()
|
||||
|
||||
/** Filter menus based on user role and source */
|
||||
useEffect(() => {
|
||||
if (user.role === 'member' && source === 'space') {
|
||||
setMenus((allMenus[source] || []).filter(menu => menu.code !== 'member'))
|
||||
@@ -104,7 +129,8 @@ const Menu: FC<{
|
||||
setMenus(allMenus[source] || [])
|
||||
}
|
||||
}, [source, allMenus, user])
|
||||
// 处理菜单项点击
|
||||
|
||||
/** Handle menu item click and navigate to path */
|
||||
const handleMenuClick: MenuProps['onClick'] = (e) => {
|
||||
const path = e.key;
|
||||
if (path) {
|
||||
@@ -113,14 +139,14 @@ const Menu: FC<{
|
||||
}
|
||||
};
|
||||
|
||||
// 将自定义菜单格式转换为Ant Design Menu的items格式
|
||||
/** Convert custom menu format to Ant Design Menu items format */
|
||||
const generateMenuItems = (menuList: MenuItem[]): MenuProps['items'] => {
|
||||
|
||||
return menuList.filter(menu => menu.display).map((menu) => {
|
||||
const iconKey = selectedKeys.includes(menu.path || '') ? `${menu.code}Active` : menu.code;
|
||||
const iconSrc = iconPathMap[iconKey as keyof typeof iconPathMap];
|
||||
const subs = (menu.subs || []).filter(sub => sub.display);
|
||||
// 叶子节点
|
||||
/** Leaf node - menu item without children */
|
||||
if (!subs || subs.length === 0) {
|
||||
if (!menu.path) return null;
|
||||
|
||||
@@ -134,13 +160,12 @@ const Menu: FC<{
|
||||
),
|
||||
icon: iconSrc ? <img
|
||||
src={iconSrc}
|
||||
className="rb:w-[16px] rb:h-[16px] rb:mr-[8px]"
|
||||
className="rb:w-4 rb:h-4 rb:mr-2"
|
||||
/> : null,
|
||||
};
|
||||
}
|
||||
|
||||
// 有子菜单的节点
|
||||
|
||||
/** Node with submenu - menu item with children */
|
||||
const menuLabel = menu.i18nKey ? t(menu.i18nKey) : menu.label;
|
||||
return {
|
||||
key: `submenu-${menu.id}`,
|
||||
@@ -148,32 +173,33 @@ const Menu: FC<{
|
||||
label: menuLabel,
|
||||
icon: iconSrc ? <img
|
||||
src={iconSrc}
|
||||
className="rb:w-[16px] rb:h-[16px] rb:mr-[8px]"
|
||||
className="rb:w-4 rb:h-4 rb:mr-2"
|
||||
/> : <UserOutlined/>,
|
||||
children: generateMenuItems(subs),
|
||||
};
|
||||
}).filter(Boolean);
|
||||
};
|
||||
|
||||
// 生成菜单项
|
||||
/** Generate menu items from configuration */
|
||||
const menuItems = generateMenuItems(menus);
|
||||
// 初始加载菜单
|
||||
|
||||
/** Load menus on component mount */
|
||||
useEffect(() => {
|
||||
loadMenus(source);
|
||||
}, [])
|
||||
|
||||
// 处理当前路径匹配
|
||||
/** Handle current path matching and update selected keys */
|
||||
useEffect(() => {
|
||||
// 使用location.pathname获取当前路径,确保与路由系统保持一致
|
||||
/** Use location.pathname to get current path, ensuring consistency with routing system */
|
||||
const currentPath = location.pathname || '/';
|
||||
|
||||
// 尝试找到匹配的菜单项和对应的父菜单路径
|
||||
/** Try to find matching menu item and corresponding parent menu path */
|
||||
const findMatchingKey = (menuList: MenuItem[], parentPaths: string[] = []): { key: string | null; } => {
|
||||
for (const menu of menuList) {
|
||||
if (menu.path) {
|
||||
const menuPath = menu.path[0] !== '/' ? '/' + menu.path : menu.path;
|
||||
|
||||
// 精确匹配或路径前缀匹配(确保是完整路径段匹配)
|
||||
/** Exact match or path prefix match (ensure complete path segment match) */
|
||||
const isExactMatch = menuPath === currentPath;
|
||||
const isPrefixMatch = currentPath.startsWith(menuPath + '/') ||
|
||||
currentPath === menuPath;
|
||||
@@ -183,7 +209,7 @@ const Menu: FC<{
|
||||
}
|
||||
}
|
||||
|
||||
// 递归检查子菜单
|
||||
/** Recursively check submenus */
|
||||
if (menu.subs && menu.subs.length > 0) {
|
||||
const newParentPaths = [...parentPaths, `submenu-${menu.id}`];
|
||||
const found = findMatchingKey(menu.subs, newParentPaths);
|
||||
@@ -203,6 +229,7 @@ const Menu: FC<{
|
||||
}
|
||||
}, [menus, location.pathname]);
|
||||
|
||||
/** Navigate to space list and clear user cache */
|
||||
const goToSpace = () => {
|
||||
navigate('/space')
|
||||
localStorage.removeItem('user')
|
||||
@@ -215,14 +242,15 @@ const Menu: FC<{
|
||||
collapsed={collapsed}
|
||||
className={styles.sider}
|
||||
>
|
||||
{/* Sidebar header with logo/workspace name and collapse toggle */}
|
||||
<div className={clsx(styles.title, {
|
||||
[styles.collapsed]: collapsed,
|
||||
'rb:flex rb:items-center rb:text-[14px]! rb:py-[8px]!': !collapsed && source === 'space' && user.current_workspace_name,
|
||||
'rb:flex rb:items-center rb:text-[14px]! rb:py-2!': !collapsed && source === 'space' && user.current_workspace_name,
|
||||
})}>
|
||||
{!collapsed && source === 'space' && user.current_workspace_name
|
||||
? <div className="rb:w-[175px] rb:text-center">
|
||||
? <div className="rb:w-43.75 rb:text-center">
|
||||
<div className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{user.current_workspace_name}</div>
|
||||
<span className="rb:text-[12px] rb:text-[#5B6167] rb:leading-[16px] rb:font-regular">
|
||||
<span className="rb:text-[12px] rb:text-[#5B6167] rb:leading-4 rb:font-regular">
|
||||
{t(`space.${storageType}`)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -235,6 +263,7 @@ const Menu: FC<{
|
||||
}
|
||||
<img src={collapsed ? menuUnfold : menuFold} className={styles.menuIcon} onClick={toggleSider} />
|
||||
</div>
|
||||
{/* Main navigation menu */}
|
||||
<AntMenu
|
||||
style={{ borderRight: 0 }}
|
||||
mode={mode}
|
||||
@@ -246,12 +275,13 @@ const Menu: FC<{
|
||||
inlineIndent={13}
|
||||
className="rb:max-h-[calc(100vh-136px)] rb:overflow-y-auto"
|
||||
/>
|
||||
{/* Return to space button for superusers */}
|
||||
{user?.is_superuser && source === 'space' &&
|
||||
<div
|
||||
onClick={goToSpace}
|
||||
className="rb:pl-[25px] rb:flex rb:items-center rb:justify-start rb:absolute rb:bottom-[32px] rb:w-full rb:text-[12px] rb:text-[#5B6167] rb:hover:text-[#212332] rb:leading-[16px] rb:font-regular rb:text-center rb:mt-[24px] rb:cursor-pointer"
|
||||
className="rb:pl-6.25 rb:flex rb:items-center rb:justify-start rb:absolute rb:bottom-8 rb:w-full rb:text-[12px] rb:text-[#5B6167] rb:hover:text-[#212332] rb:leading-4 rb:font-regular rb:text-center rb:mt-6 rb:cursor-pointer"
|
||||
>
|
||||
<img src={logout} className="rb:w-[16px] rb:h-[16px] rb:mr-[16px]" />
|
||||
<img src={logout} className="rb:w-4 rb:h-4 rb:mr-4" />
|
||||
{collapsed ? null : t('common.returnToSpace')}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,19 +1,52 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:26:44
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:26:44
|
||||
*/
|
||||
/**
|
||||
* SliderInput Component
|
||||
*
|
||||
* A combined slider and input number component for precise value control:
|
||||
* - Synchronized slider and input number
|
||||
* - Value range validation
|
||||
* - Optional label and marks
|
||||
* - Customizable tooltip
|
||||
* - Disabled state support
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { useState, useEffect, type FC } from 'react';
|
||||
import { Slider, InputNumber, Row, Col } from 'antd';
|
||||
|
||||
/** Props interface for SliderInput component */
|
||||
interface SliderInputProps {
|
||||
/** Current value */
|
||||
value?: number;
|
||||
/** Callback fired when value changes */
|
||||
onChange?: (value: number | null) => void;
|
||||
/** Minimum value */
|
||||
min?: number;
|
||||
/** Maximum value */
|
||||
max?: number;
|
||||
/** Step increment */
|
||||
step?: number;
|
||||
/** Default value */
|
||||
defaultValue?: number;
|
||||
/** Whether the component is disabled */
|
||||
disabled?: boolean;
|
||||
/** Optional label text */
|
||||
label?: string;
|
||||
/** Additional CSS classes for container */
|
||||
className?: string;
|
||||
/** Additional CSS classes for slider */
|
||||
sliderClassName?: string;
|
||||
/** Additional CSS classes for input */
|
||||
inputClassName?: string;
|
||||
/** Marks to display on slider */
|
||||
marks?: Record<number, string | { style: React.CSSProperties; label: string }>;
|
||||
/** Tooltip configuration */
|
||||
tooltip?: {
|
||||
open?: boolean;
|
||||
placement?: 'top' | 'left' | 'right' | 'bottom';
|
||||
@@ -21,6 +54,7 @@ interface SliderInputProps {
|
||||
};
|
||||
}
|
||||
|
||||
/** Slider with input number component for precise value control */
|
||||
const SliderInput: FC<SliderInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
@@ -38,23 +72,26 @@ const SliderInput: FC<SliderInputProps> = ({
|
||||
}) => {
|
||||
const [internalValue, setInternalValue] = useState<number>(value ?? defaultValue);
|
||||
|
||||
/** Sync internal value when external value changes */
|
||||
useEffect(() => {
|
||||
if (value !== undefined && value !== internalValue) {
|
||||
setInternalValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
/** Handle slider value change */
|
||||
const handleSliderChange = (newValue: number) => {
|
||||
setInternalValue(newValue);
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
/** Handle input number value change with range validation */
|
||||
const handleInputChange = (newValue: number | null) => {
|
||||
if (newValue === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保值在范围内
|
||||
/** Ensure value is within min/max range */
|
||||
let validValue = newValue;
|
||||
if (newValue < min) {
|
||||
validValue = min;
|
||||
@@ -68,12 +105,14 @@ const SliderInput: FC<SliderInputProps> = ({
|
||||
|
||||
return (
|
||||
<div className={`rb:w-full ${className}`}>
|
||||
{/* Optional label */}
|
||||
{label && (
|
||||
<div className="rb:text-sm rb:font-medium rb:text-gray-700">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
<Row gutter={16} align="middle">
|
||||
{/* Slider component */}
|
||||
<Col flex="auto">
|
||||
<Slider
|
||||
min={min}
|
||||
@@ -87,6 +126,7 @@ const SliderInput: FC<SliderInputProps> = ({
|
||||
className={sliderClassName}
|
||||
/>
|
||||
</Col>
|
||||
{/* Input number component */}
|
||||
<Col flex="120px">
|
||||
<InputNumber
|
||||
min={min}
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:27:25
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:27:25
|
||||
*/
|
||||
/**
|
||||
* DragHandle Component
|
||||
*
|
||||
* A drag handle button for sortable list items.
|
||||
* Uses the HolderOutlined icon and connects to the sortable context.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import { HolderOutlined } from '@ant-design/icons';
|
||||
import { Button } from 'antd';
|
||||
|
||||
import SortableListItemContext from './SortableListItemContext';
|
||||
|
||||
/** Drag handle component for sortable list items */
|
||||
const DragHandle: React.FC = () => {
|
||||
const { setActivatorNodeRef, listeners, attributes } = useContext(SortableListItemContext);
|
||||
return (
|
||||
|
||||
@@ -3,9 +3,18 @@
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-11 20:42:28
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2025-11-20 14:20:27
|
||||
* @LastEditors: ZhaoYing
|
||||
* @LastEditTime: 2026-02-02 15:27:46
|
||||
*/
|
||||
/**
|
||||
* SortableListItem Component
|
||||
*
|
||||
* A wrapper component that makes Ant Design List.Item draggable and sortable.
|
||||
* Integrates with @dnd-kit for drag-and-drop functionality.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
useSortable,
|
||||
@@ -13,13 +22,15 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { List } from 'antd';
|
||||
import type { GetProps } from 'antd';
|
||||
|
||||
import type { SortableListItemContextProps } from './types';
|
||||
import SortableListItemContext from './SortableListItemContext';
|
||||
|
||||
|
||||
/** Sortable list item component that wraps Ant Design List.Item with drag-and-drop functionality */
|
||||
const SortableListItem: React.FC<GetProps<typeof List.Item> & { itemKey: number }> = (props) => {
|
||||
const { itemKey, style, ...rest } = props;
|
||||
|
||||
/** Get sortable hooks and properties from @dnd-kit */
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -30,6 +41,7 @@ const SortableListItem: React.FC<GetProps<typeof List.Item> & { itemKey: number
|
||||
isDragging,
|
||||
} = useSortable({ id: itemKey });
|
||||
|
||||
/** Apply drag transform and transition styles */
|
||||
const listStyle: React.CSSProperties = {
|
||||
...style,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
@@ -41,6 +53,7 @@ const SortableListItem: React.FC<GetProps<typeof List.Item> & { itemKey: number
|
||||
padding: '8px 0',
|
||||
};
|
||||
|
||||
/** Memoize context value to avoid unnecessary re-renders */
|
||||
const memoizedValue = useMemo<SortableListItemContextProps>(
|
||||
() => ({ setActivatorNodeRef, listeners, attributes }),
|
||||
[setActivatorNodeRef, listeners, attributes],
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:27:52
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:27:52
|
||||
*/
|
||||
/**
|
||||
* SortableListItemContext
|
||||
*
|
||||
* React context for sharing sortable item properties with child components.
|
||||
* Used by DragHandle to access drag-and-drop functionality.
|
||||
*
|
||||
* @context
|
||||
*/
|
||||
|
||||
import { createContext } from 'react';
|
||||
|
||||
import type { SortableListItemContextProps } from './types';
|
||||
|
||||
|
||||
/** Context for sharing sortable item properties with child components (e.g., DragHandle) */
|
||||
const SortableListItemContext = createContext<SortableListItemContextProps>({});
|
||||
|
||||
export default SortableListItemContext;
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:27:36
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:27:36
|
||||
*/
|
||||
/**
|
||||
* SortableList Component
|
||||
*
|
||||
* A drag-and-drop sortable list with:
|
||||
* - Vertical drag-and-drop reordering
|
||||
* - Editable text inputs for each item
|
||||
* - Add new item functionality
|
||||
* - Integration with @dnd-kit library
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
@@ -9,24 +27,36 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { List, Input, Button } from 'antd';
|
||||
|
||||
import SortableListItem from './SortableListItem';
|
||||
import DragHandle from './DragHandle';
|
||||
|
||||
/** Item interface for sortable list */
|
||||
interface Item {
|
||||
/** Unique key for the item */
|
||||
key: number;
|
||||
/** Text content of the item */
|
||||
content: string;
|
||||
/** Special type for add button */
|
||||
type?: 'add';
|
||||
}
|
||||
|
||||
/** Props interface for SortableList component */
|
||||
interface SortableListProps {
|
||||
/** Array of list items */
|
||||
value?: Item[];
|
||||
/** Callback fired when items change */
|
||||
onChange?: (items?: Item[]) => void;
|
||||
}
|
||||
|
||||
/** Sortable list component with drag-and-drop functionality */
|
||||
const SortableList: React.FC<SortableListProps> = ({
|
||||
value = [],
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
/** Handle drag end event to reorder items */
|
||||
const onDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!active || !over) {
|
||||
return;
|
||||
@@ -38,18 +68,22 @@ const SortableList: React.FC<SortableListProps> = ({
|
||||
onChange?.(arrayMove([...value], activeIndex, overIndex));
|
||||
}
|
||||
};
|
||||
// 监听value变化,包括初始值
|
||||
|
||||
/** Listen to value changes and trigger callback */
|
||||
useEffect(() => {
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
}
|
||||
}, [value, onChange]);
|
||||
|
||||
/** Handle input content change for a specific item */
|
||||
const inputChange = (e: React.ChangeEvent<HTMLInputElement>, index: number) => {
|
||||
const newItems = [...value];
|
||||
newItems[index].content = e.target.value;
|
||||
onChange?.(newItems);
|
||||
}
|
||||
|
||||
/** Add new item to the list */
|
||||
const handleAdd = () => {
|
||||
onChange?.([...value, { key: Date.now(), content: '' }]);
|
||||
}
|
||||
@@ -64,9 +98,11 @@ const SortableList: React.FC<SortableListProps> = ({
|
||||
dataSource={[...value, { type: 'add', key: Date.now(), content: '' }]}
|
||||
renderItem={(item: Item, index: number) => {
|
||||
console.log('renderItem', item, index)
|
||||
/** Render add button for special 'add' type */
|
||||
if (item.type === 'add') {
|
||||
return <Button block onClick={handleAdd}>{t('common.addOption')}</Button>
|
||||
} else {
|
||||
/** Render sortable item with drag handle and input */
|
||||
return (
|
||||
<SortableListItem key={item.key} itemKey={item.key}>
|
||||
<DragHandle /> <Input variant="underlined" value={item.content} onChange={(e) => inputChange(e, index)} />
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:29:39
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:29:39
|
||||
*/
|
||||
/**
|
||||
* SortableList Type Definitions
|
||||
*
|
||||
* Type definitions for sortable list components.
|
||||
* Defines the context props interface for drag-and-drop functionality.
|
||||
*/
|
||||
|
||||
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
||||
import type { DraggableAttributes } from '@dnd-kit/core';
|
||||
|
||||
/** Props interface for SortableListItem context */
|
||||
export interface SortableListItemContextProps {
|
||||
/** Function to set the activator node ref for drag handle */
|
||||
setActivatorNodeRef?: (element: HTMLElement | null) => void;
|
||||
/** Event listeners for drag interactions */
|
||||
listeners?: SyntheticListenerMap;
|
||||
/** Accessibility attributes for draggable elements */
|
||||
attributes?: DraggableAttributes;
|
||||
}
|
||||
@@ -1,11 +1,31 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:29:42
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:29:42
|
||||
*/
|
||||
/**
|
||||
* StatusTag Component
|
||||
*
|
||||
* A tag component that displays status with a colored indicator dot.
|
||||
* Supports multiple status types with predefined color schemes.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC } from 'react'
|
||||
import { Tag } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
|
||||
/** Props interface for StatusTag component */
|
||||
interface StatusTagProps {
|
||||
/** Status type determining the indicator color */
|
||||
status: 'success' | 'error' | 'warning' | 'default' | 'lightBlue' | 'purple',
|
||||
/** Text to display in the tag */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Color mappings for different status types */
|
||||
const Colors = {
|
||||
success: 'rb:bg-[#369F21]',
|
||||
error: 'rb:bg-[#FF5D34]',
|
||||
@@ -15,6 +35,7 @@ const Colors = {
|
||||
purple: 'rb:bg-[#9C6FFF]'
|
||||
}
|
||||
|
||||
/** Status tag component with colored indicator dot */
|
||||
const StatusTag: FC<StatusTagProps> = ({
|
||||
status,
|
||||
text
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.row {
|
||||
color: #5B6167;
|
||||
}
|
||||
@@ -1,32 +1,73 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:29:46
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:32:11
|
||||
*/
|
||||
/**
|
||||
* RbTable Component
|
||||
*
|
||||
* A table component with built-in pagination and API integration:
|
||||
* - Automatic data fetching from API
|
||||
* - Pagination with customizable page size
|
||||
* - Row selection support
|
||||
* - Custom empty state
|
||||
* - Configurable scroll behavior
|
||||
* - Exposes loadData and getList methods via ref
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
import styles from './index.module.css';
|
||||
import Empty from '@/components/Empty';
|
||||
|
||||
interface TablePaginationConfig { pagesize: number; page: number; }
|
||||
|
||||
/** Props interface for Table component */
|
||||
interface TableComponentProps extends Omit<TableProps, 'pagination'> {
|
||||
/** Table column definitions */
|
||||
columns: ColumnsType;
|
||||
/** API endpoint URL for data fetching */
|
||||
apiUrl?: string;
|
||||
/** Query parameters for API request */
|
||||
apiParams?: Record<string, unknown>;
|
||||
/** Pagination configuration or boolean to enable/disable */
|
||||
pagination?: boolean | TablePaginationConfig;
|
||||
/** Key to use for row identification */
|
||||
rowKey: string;
|
||||
/** Row selection configuration */
|
||||
rowSelection?: TableProps['rowSelection'];
|
||||
/** Initial data to display (used when no API) */
|
||||
initialData?: Record<string, unknown>[];
|
||||
/** Size of empty state icon */
|
||||
emptySize?: number;
|
||||
/** Custom empty state text */
|
||||
emptyText?: string;
|
||||
/** Whether to enable scroll */
|
||||
isScroll?: boolean;
|
||||
scrollX?: number | string | true; // 支持自定义横向滚动宽度
|
||||
scrollY?: number | string; // 支持自定义纵向滚动高度
|
||||
/** Custom horizontal scroll width */
|
||||
scrollX?: number | string | true;
|
||||
/** Custom vertical scroll height */
|
||||
scrollY?: number | string;
|
||||
/** Key name for current page in API params */
|
||||
currentPageKey?: string;
|
||||
}
|
||||
|
||||
/** Ref methods exposed to parent component */
|
||||
export interface TableRef {
|
||||
/** Reload data from first page */
|
||||
loadData: () => void;
|
||||
/** Fetch data with specific pagination */
|
||||
getList: (pageData: TablePaginationConfig) => void;
|
||||
}
|
||||
|
||||
/** Filter out empty or invalid parameters from API request */
|
||||
const dealSo = (params: any) => {
|
||||
let so: any = {}
|
||||
Object.keys(params).forEach(key => {
|
||||
@@ -38,7 +79,9 @@ const dealSo = (params: any) => {
|
||||
|
||||
return so
|
||||
}
|
||||
const TableComponent = forwardRef<TableRef, TableComponentProps>(({
|
||||
|
||||
/** Table component with pagination and API integration */
|
||||
const RbTable = forwardRef<TableRef, TableComponentProps>(({
|
||||
columns,
|
||||
apiUrl,
|
||||
apiParams,
|
||||
@@ -63,14 +106,14 @@ const TableComponent = forwardRef<TableRef, TableComponentProps>(({
|
||||
});
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
/** Sync initial data when provided without API */
|
||||
useEffect(() => {
|
||||
if (initialData && !apiUrl) {
|
||||
setData(initialData)
|
||||
}
|
||||
}, [initialData, apiUrl])
|
||||
|
||||
// 数据加载
|
||||
// 表格初始化
|
||||
/** Initialize table and load data from first page */
|
||||
const loadData = () => {
|
||||
if (apiUrl) {
|
||||
getList({
|
||||
@@ -79,7 +122,8 @@ const TableComponent = forwardRef<TableRef, TableComponentProps>(({
|
||||
})
|
||||
}
|
||||
}
|
||||
// 获取数据
|
||||
|
||||
/** Fetch data from API with pagination */
|
||||
const getList = (pageData: TablePaginationConfig) => {
|
||||
if (!apiUrl) {
|
||||
return
|
||||
@@ -93,10 +137,10 @@ const TableComponent = forwardRef<TableRef, TableComponentProps>(({
|
||||
params = { ...params, ...pageData, [currentPageKey]: pageData.page}
|
||||
}
|
||||
setLoading(true)
|
||||
// 构建查询参数并调用API
|
||||
/** Build query parameters and call API */
|
||||
request.get(apiUrl, params)
|
||||
.then((res: any) => {
|
||||
// 支持两种响应格式:直接返回 total 或在 page 对象中返回
|
||||
/** Support two response formats: direct total or total in page object */
|
||||
const totalCount = res.page?.total ?? res.total ?? 0;
|
||||
setTotal(totalCount)
|
||||
setData(Array.isArray(res.items) ? res.items : Array.isArray(res.hosts) ? res.hosts : Array.isArray(res.list) ? res.list : res || [])
|
||||
@@ -107,20 +151,21 @@ const TableComponent = forwardRef<TableRef, TableComponentProps>(({
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
// 初始化和apiParams变化时重新加载数据
|
||||
|
||||
/** Reload data when initialized or apiParams changes */
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [apiParams])
|
||||
|
||||
// 分页相关
|
||||
// 切换分页
|
||||
/** Handle page change event */
|
||||
const handlePageChange = (page: number, pagesize: number) => {
|
||||
getList({
|
||||
page: page,
|
||||
pagesize
|
||||
})
|
||||
}
|
||||
// 分页配置
|
||||
|
||||
/** Pagination configuration with i18n support */
|
||||
const paginationConfig = pagination ? ({
|
||||
...(typeof pagination === 'object' ? pagination : {}),
|
||||
...currentPagination,
|
||||
@@ -132,19 +177,19 @@ const TableComponent = forwardRef<TableRef, TableComponentProps>(({
|
||||
}) : false;
|
||||
|
||||
|
||||
// 暴露给父组件的方法
|
||||
/** Expose loadData and getList methods to parent component via ref */
|
||||
useImperativeHandle(ref, () => ({
|
||||
loadData,
|
||||
getList,
|
||||
}));
|
||||
|
||||
// 计算 scroll 配置
|
||||
/** Calculate scroll configuration based on props */
|
||||
const getScrollConfig = () => {
|
||||
if (!isScroll && !scrollX && !scrollY) return undefined;
|
||||
|
||||
const config: { x?: number | string | true; y?: number | string } = {};
|
||||
|
||||
// 只有在有数据时才应用横向滚动
|
||||
/** Only apply horizontal scroll when there is data */
|
||||
if (scrollX !== undefined && data.length > 0) {
|
||||
config.x = scrollX;
|
||||
} else if (isScroll) {
|
||||
@@ -169,8 +214,7 @@ const TableComponent = forwardRef<TableRef, TableComponentProps>(({
|
||||
dataSource={data}
|
||||
pagination={paginationConfig}
|
||||
rowSelection={rowSelection}
|
||||
rowClassName={styles.row}
|
||||
className={styles.table}
|
||||
rowClassName="rb:text-[#5B6167]"
|
||||
locale={{ emptyText: <Empty size={emptySize} subTitle={emptyText} /> }}
|
||||
scroll={getScrollConfig()}
|
||||
tableLayout="auto"
|
||||
@@ -178,4 +222,4 @@ const TableComponent = forwardRef<TableRef, TableComponentProps>(({
|
||||
);
|
||||
});
|
||||
|
||||
export default TableComponent;
|
||||
export default RbTable;
|
||||
@@ -1,11 +1,31 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:29:57
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:29:57
|
||||
*/
|
||||
/**
|
||||
* Tag Component
|
||||
*
|
||||
* A custom tag component with predefined color themes.
|
||||
* Supports different status colors: processing, error, success, warning, and default.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { type FC, type ReactNode } from 'react'
|
||||
|
||||
/** Props interface for Tag component */
|
||||
export interface TagProps {
|
||||
/** Color theme for the tag */
|
||||
color?: 'processing' | 'error' | 'success' | 'warning' | 'default',
|
||||
/** Tag content */
|
||||
children: ReactNode;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Color theme mappings with text, border, and background colors */
|
||||
const colors = {
|
||||
processing: 'rb:text-[#155EEF] rb:border-[rgba(21,94,239,0.25)] rb:bg-[rgba(21,94,239,0.06)]',
|
||||
error: 'rb:text-[#FF5D34] rb:border-[rgba(255,138,76,0.20)] rb:bg-[rgba(255,138,76,0.08)]',
|
||||
@@ -14,6 +34,7 @@ const colors = {
|
||||
default: 'rb:text-[#5B6167] rb:border-[rgba(91,97,103,0.30)] rb:bg-[rgba(91,97,103,0.08)]',
|
||||
}
|
||||
|
||||
/** Custom tag component with color themes */
|
||||
const Tag: FC<TagProps> = ({ color = 'processing', children, className }) => {
|
||||
return (
|
||||
<span className={`rb:inline-block rb:px-1 rb:py-0.5 rb:rounded-sm rb:text-[12px] rb:font-regular! rb:leading-4 rb:border ${colors[color]} ${className || ''}`}>
|
||||
|
||||
@@ -1,35 +1,58 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:30:52
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:57:03
|
||||
*/
|
||||
/**
|
||||
* UploadImages Component
|
||||
*
|
||||
* A comprehensive image upload component with:
|
||||
* - Single/multiple file upload support
|
||||
* - File type and size validation
|
||||
* - Image preview functionality
|
||||
* - Auto or manual upload modes
|
||||
* - Drag-and-drop support
|
||||
* - Base64 conversion for non-auto upload
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { Upload, Image, App } from 'antd';
|
||||
import type { GetProp, UploadFile, UploadProps } from 'antd';
|
||||
// import { UploadOutlined, } from '@ant-design/icons';
|
||||
import type { UploadProps as RcUploadProps } from 'antd/es/upload/interface';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import PlusIcon from '@/assets/images/plus.svg'
|
||||
import { cookieUtils } from '@/utils/request'
|
||||
import { fileUploadUrl } from '@/api/fileStorage'
|
||||
import styles from './index.module.less'
|
||||
|
||||
/** Props interface for UploadImages component */
|
||||
interface UploadImagesProps extends Omit<UploadProps, 'onChange' | 'fileList'> {
|
||||
/** 上传接口地址 */
|
||||
/** Upload API URL */
|
||||
action?: string;
|
||||
/** 是否支持多选 */
|
||||
/** Support multiple file selection */
|
||||
multiple?: boolean;
|
||||
/** 已上传的文件列表 */
|
||||
/** Uploaded file list */
|
||||
fileList?: UploadFile[] | UploadFile;
|
||||
/** 文件列表变化回调 */
|
||||
/** File list change callback */
|
||||
onChange?: (fileList?: UploadFile[] | UploadFile) => void;
|
||||
/** 禁用上传 */
|
||||
/** Disable upload */
|
||||
disabled?: boolean;
|
||||
/** 文件大小限制(MB) */
|
||||
/** File size limit (MB) */
|
||||
fileSize?: number;
|
||||
/** 文件类型限制 */
|
||||
/** File type restrictions */
|
||||
fileType?: string[];
|
||||
/** 是否自动上传,默认为true */
|
||||
/** Auto upload, default is true */
|
||||
isAutoUpload?: boolean;
|
||||
/** 最大上传文件数 */
|
||||
/** Maximum upload file count */
|
||||
maxCount?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Supported file type mappings (extension to MIME type) */
|
||||
const ALL_FILE_TYPE: {
|
||||
[key: string]: string;
|
||||
} = {
|
||||
@@ -41,11 +64,15 @@ const ALL_FILE_TYPE: {
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
}
|
||||
|
||||
/** Ref methods exposed to parent component */
|
||||
interface UploadImagesRef {
|
||||
fileList: UploadFile[];
|
||||
clearFiles: () => void;
|
||||
}
|
||||
type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];
|
||||
|
||||
/** Convert file to base64 string for preview */
|
||||
const getBase64 = (file: FileType): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -56,8 +83,8 @@ const getBase64 = (file: FileType): Promise<string> => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共上传组件,基于Ant Design Upload组件封装
|
||||
* 支持单文件/多文件上传、拖拽上传、文件验证、预览等功能
|
||||
* Common upload component based on Ant Design Upload component
|
||||
* Supports single/multiple file upload, drag-and-drop, file validation, preview, etc.
|
||||
*/
|
||||
const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
||||
action = fileUploadUrl,
|
||||
@@ -86,6 +113,7 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
||||
}
|
||||
}, [propFileList])
|
||||
|
||||
/** Update value based on maxCount (single or multiple) */
|
||||
const updateValue = (list: UploadFile[]) => {
|
||||
if (maxCount === 1) {
|
||||
onChange?.(list[0])
|
||||
@@ -94,7 +122,7 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件移除
|
||||
/** Handle file removal with confirmation dialog */
|
||||
const handleRemove = (file: UploadFile) => {
|
||||
modal.confirm({
|
||||
title: t('common.confirmRemoveFile'),
|
||||
@@ -107,12 +135,12 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
||||
updateValue(newFileList)
|
||||
},
|
||||
});
|
||||
return false; // 阻止默认删除行为,由confirm控制
|
||||
return false; // Prevent default delete behavior, controlled by confirm
|
||||
};
|
||||
|
||||
// 校验文件类型和大小
|
||||
/** Validate file type and size before upload */
|
||||
const beforeUpload: RcUploadProps['beforeUpload'] = async (file: UploadFile) => {
|
||||
// 校验文件大小
|
||||
// Validate file size
|
||||
if (fileSize && file.size) {
|
||||
const isLtMaxSize = (file.size / 1024 / 1024) < fileSize;
|
||||
if (!isLtMaxSize) {
|
||||
@@ -120,7 +148,7 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
}
|
||||
// 校验文件类型
|
||||
// Validate file type
|
||||
if (accept && accept.length > 0 && file.type) {
|
||||
const isAccept = accept.includes(file.type);
|
||||
if (!isAccept) {
|
||||
@@ -136,24 +164,25 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
||||
const newFileList = [...fileList, file];
|
||||
setFileList(newFileList);
|
||||
updateValue(newFileList);
|
||||
return Upload.LIST_IGNORE; // 阻止自动上传
|
||||
return Upload.LIST_IGNORE; // Prevent auto upload
|
||||
}
|
||||
|
||||
return isAutoUpload;
|
||||
};
|
||||
|
||||
// 处理上传状态变化
|
||||
/** Handle upload status change */
|
||||
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => {
|
||||
setFileList(newFileList);
|
||||
updateValue(newFileList);
|
||||
};
|
||||
|
||||
// 清空已上传文件
|
||||
/** Clear all uploaded files */
|
||||
const clearFiles = () => {
|
||||
setFileList([]);
|
||||
updateValue([]);
|
||||
}
|
||||
|
||||
/** Handle image preview */
|
||||
const handlePreview = async (file: UploadFile) => {
|
||||
if (!file.thumbUrl && !file.url && !file.preview) {
|
||||
file.preview = await getBase64(file.originFileObj as FileType);
|
||||
@@ -163,6 +192,7 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
||||
setPreviewOpen(true);
|
||||
};
|
||||
|
||||
/** Build accept string from fileType array */
|
||||
useEffect(() => {
|
||||
if (fileType && fileType.length > 0) {
|
||||
const acceptArray = fileType.map((type: string) => ALL_FILE_TYPE[type.toLowerCase()]).filter(Boolean);
|
||||
@@ -172,7 +202,7 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
||||
}
|
||||
}, [fileType])
|
||||
|
||||
// 生成上传组件配置
|
||||
/** Generate upload component configuration */
|
||||
const uploadProps: UploadProps = {
|
||||
action,
|
||||
multiple: multiple && maxCount > 1,
|
||||
@@ -196,7 +226,7 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
||||
...props,
|
||||
};
|
||||
|
||||
// 暴露给父组件的方法
|
||||
/** Expose methods to parent component via ref */
|
||||
useImperativeHandle(ref, () => ({
|
||||
fileList,
|
||||
clearFiles
|
||||
|
||||
@@ -1,25 +1,52 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:24:44
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:24:44
|
||||
*/
|
||||
/**
|
||||
* useBreadcrumbManager Hook
|
||||
*
|
||||
* Manages breadcrumb navigation for knowledge base pages with:
|
||||
* - Dynamic breadcrumb generation based on folder/document paths
|
||||
* - Separate breadcrumb handling for list and detail views
|
||||
* - Click handlers for navigation between folders
|
||||
* - Support for custom callbacks
|
||||
*
|
||||
* @hook
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMenu } from '@/store/menu';
|
||||
import type { MenuItem } from '@/store/menu';
|
||||
|
||||
/** Breadcrumb item interface */
|
||||
export interface BreadcrumbItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: 'knowledgeBase' | 'folder' | 'document';
|
||||
}
|
||||
|
||||
/** Breadcrumb path structure */
|
||||
export interface BreadcrumbPath {
|
||||
knowledgeBaseFolderPath: BreadcrumbItem[]; // 知识库文件夹路径
|
||||
knowledgeBase?: BreadcrumbItem; // 知识库信息
|
||||
documentFolderPath: BreadcrumbItem[]; // 文档文件夹路径
|
||||
document?: BreadcrumbItem; // 文档信息
|
||||
/** Knowledge base folder path */
|
||||
knowledgeBaseFolderPath: BreadcrumbItem[];
|
||||
/** Knowledge base information */
|
||||
knowledgeBase?: BreadcrumbItem;
|
||||
/** Document folder path */
|
||||
documentFolderPath: BreadcrumbItem[];
|
||||
/** Document information */
|
||||
document?: BreadcrumbItem;
|
||||
}
|
||||
|
||||
/** Options for breadcrumb manager */
|
||||
export interface BreadcrumbOptions {
|
||||
/** Callback when knowledge base menu is clicked */
|
||||
onKnowledgeBaseMenuClick?: () => void;
|
||||
/** Callback when knowledge base folder is clicked */
|
||||
onKnowledgeBaseFolderClick?: (folderId: string, folderPath: BreadcrumbItem[]) => void;
|
||||
// 新增:区分面包屑类型
|
||||
/** Breadcrumb type: list or detail view */
|
||||
breadcrumbType?: 'list' | 'detail';
|
||||
}
|
||||
|
||||
@@ -27,14 +54,15 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => {
|
||||
const { allBreadcrumbs, setCustomBreadcrumbs } = useMenu();
|
||||
const navigate = useNavigate();
|
||||
|
||||
/** Update breadcrumbs based on current path and type */
|
||||
const updateBreadcrumbs = useCallback((breadcrumbPath: BreadcrumbPath) => {
|
||||
const breadcrumbType = options?.breadcrumbType || 'list';
|
||||
|
||||
// 对于详情页面,直接使用固定的知识库管理面包屑,不依赖可能被污染的 allBreadcrumbs
|
||||
/** For detail pages, use fixed knowledge base breadcrumb */
|
||||
let baseBreadcrumbs: MenuItem[] = [];
|
||||
|
||||
if (breadcrumbType === 'detail') {
|
||||
// 详情页面:始终使用固定的知识库管理面包屑
|
||||
/** Detail page: always use fixed knowledge base management breadcrumb */
|
||||
baseBreadcrumbs = [
|
||||
{
|
||||
id: 6,
|
||||
@@ -61,14 +89,14 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => {
|
||||
}
|
||||
];
|
||||
} else {
|
||||
// 列表页面:从 space 获取基础面包屑,但确保包含知识库管理
|
||||
/** List page: get base breadcrumbs from space, ensure knowledge base management is included */
|
||||
const spaceBreadcrumbs = allBreadcrumbs['space'] || [];
|
||||
const knowledgeBaseMenuIndex = spaceBreadcrumbs.findIndex(item => item.path === '/knowledge-base');
|
||||
|
||||
if (knowledgeBaseMenuIndex >= 0) {
|
||||
baseBreadcrumbs = spaceBreadcrumbs.slice(0, knowledgeBaseMenuIndex + 1);
|
||||
} else {
|
||||
// 如果没有找到知识库菜单,使用默认的知识库管理面包屑
|
||||
/** If knowledge base menu not found, use default knowledge base management breadcrumb */
|
||||
baseBreadcrumbs = [
|
||||
{
|
||||
id: 6,
|
||||
@@ -99,7 +127,7 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => {
|
||||
|
||||
const filteredBaseBreadcrumbs = baseBreadcrumbs;
|
||||
|
||||
// 给"知识库管理"添加点击事件
|
||||
/** Add click event to "Knowledge Base Management" */
|
||||
const breadcrumbsWithClick = filteredBaseBreadcrumbs.map((item) => {
|
||||
if (item.path === '/knowledge-base') {
|
||||
return {
|
||||
@@ -109,10 +137,10 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => {
|
||||
e?.stopPropagation();
|
||||
|
||||
if (options?.onKnowledgeBaseMenuClick) {
|
||||
// 如果提供了回调函数,执行回调
|
||||
/** If callback provided, execute callback */
|
||||
options.onKnowledgeBaseMenuClick();
|
||||
} else if (breadcrumbType === 'detail') {
|
||||
// 知识库详情页面:没有回调函数时,返回到知识库列表页面
|
||||
/** Knowledge base detail page: return to knowledge base list page when no callback */
|
||||
navigate('/knowledge-base', {
|
||||
state: {
|
||||
resetToRoot: true,
|
||||
@@ -129,7 +157,7 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => {
|
||||
let customBreadcrumbs: MenuItem[] = [...breadcrumbsWithClick];
|
||||
|
||||
if (breadcrumbType === 'list') {
|
||||
// 知识库列表页面:只显示知识库文件夹路径
|
||||
/** Knowledge base list page: only show knowledge base folder path */
|
||||
customBreadcrumbs = [
|
||||
...breadcrumbsWithClick,
|
||||
...breadcrumbPath.knowledgeBaseFolderPath.map((folder, index) => ({
|
||||
@@ -158,11 +186,11 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
|
||||
// 如果有回调函数,直接调用回调函数来更新状态
|
||||
/** If callback provided, call callback to update state */
|
||||
if (options?.onKnowledgeBaseFolderClick) {
|
||||
options.onKnowledgeBaseFolderClick(folder.id, breadcrumbPath.knowledgeBaseFolderPath.slice(0, index + 1));
|
||||
} else {
|
||||
// 否则使用导航(兜底逻辑)
|
||||
/** Otherwise use navigation (fallback logic) */
|
||||
navigate('/knowledge-base', {
|
||||
state: {
|
||||
navigateToFolder: folder.id,
|
||||
@@ -175,11 +203,11 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => {
|
||||
})),
|
||||
];
|
||||
} else {
|
||||
// 知识库详情页面:显示知识库名称 + 文档文件夹路径 + 文档名称
|
||||
/** Knowledge base detail page: show knowledge base name + document folder path + document name */
|
||||
customBreadcrumbs = [
|
||||
...breadcrumbsWithClick,
|
||||
|
||||
// 添加知识库名称
|
||||
/** Add knowledge base name */
|
||||
...(breadcrumbPath.knowledgeBase ? [{
|
||||
id: 0,
|
||||
parent: 0,
|
||||
@@ -205,27 +233,27 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => {
|
||||
onClick: (e?: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
// 返回到知识库详情页的根目录
|
||||
/** Return to knowledge base detail page root directory */
|
||||
const navigationState = {
|
||||
fromKnowledgeBaseList: true,
|
||||
knowledgeBaseFolderPath: breadcrumbPath.knowledgeBaseFolderPath,
|
||||
resetToRoot: true, // 添加重置到根目录的标志
|
||||
refresh: true, // 添加刷新标志
|
||||
timestamp: Date.now(), // 添加时间戳确保状态变化
|
||||
resetToRoot: true, /** Add flag to reset to root directory */
|
||||
refresh: true, /** Add refresh flag */
|
||||
timestamp: Date.now(), /** Add timestamp to ensure state change */
|
||||
};
|
||||
|
||||
// 使用当前页面路径进行导航,避免不必要的路由变化
|
||||
/** Use current page path for navigation to avoid unnecessary route changes */
|
||||
const currentPath = window.location.pathname;
|
||||
const targetPath = `/knowledge-base/${breadcrumbPath.knowledgeBase!.id}/private`;
|
||||
|
||||
if (currentPath === targetPath) {
|
||||
// 如果已经在目标页面,直接更新状态而不导航
|
||||
/** If already on target page, update state directly without navigation */
|
||||
navigate(targetPath, {
|
||||
state: navigationState,
|
||||
replace: true // 使用 replace 避免历史记录堆积
|
||||
replace: true /** Use replace to avoid history stack buildup */
|
||||
});
|
||||
} else {
|
||||
// 如果不在目标页面,正常导航
|
||||
/** If not on target page, navigate normally */
|
||||
navigate(targetPath, {
|
||||
state: navigationState
|
||||
});
|
||||
@@ -234,7 +262,7 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => {
|
||||
},
|
||||
}] : []),
|
||||
|
||||
// 添加文档文件夹路径
|
||||
/** Add document folder path */
|
||||
...breadcrumbPath.documentFolderPath.map((folder, index) => ({
|
||||
id: 0,
|
||||
parent: 0,
|
||||
@@ -260,24 +288,24 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => {
|
||||
onClick: (e?: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
// 返回到知识库详情页的对应文件夹
|
||||
/** Return to corresponding folder in knowledge base detail page */
|
||||
const navigationState = {
|
||||
fromKnowledgeBaseList: true,
|
||||
knowledgeBaseFolderPath: breadcrumbPath.knowledgeBaseFolderPath,
|
||||
navigateToDocumentFolder: folder.id,
|
||||
documentFolderPath: breadcrumbPath.documentFolderPath.slice(0, index + 1),
|
||||
refresh: true, // 添加刷新标志
|
||||
timestamp: Date.now(), // 添加时间戳确保状态变化
|
||||
refresh: true, /** Add refresh flag */
|
||||
timestamp: Date.now(), /** Add timestamp to ensure state change */
|
||||
};
|
||||
navigate(`/knowledge-base/${breadcrumbPath.knowledgeBase!.id}/private`, {
|
||||
state: navigationState,
|
||||
replace: true // 使用 replace 避免历史记录堆积
|
||||
replace: true /** Use replace to avoid history stack buildup */
|
||||
});
|
||||
return false;
|
||||
},
|
||||
})),
|
||||
|
||||
// 添加文档名称(如果存在)
|
||||
/** Add document name (if exists) */
|
||||
...(breadcrumbPath.document ? [{
|
||||
id: 0,
|
||||
parent: 0,
|
||||
@@ -300,12 +328,12 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => {
|
||||
disposable: false,
|
||||
appSystem: null,
|
||||
subs: [],
|
||||
// 文档名称不可点击
|
||||
/** Document name is not clickable */
|
||||
}] : []),
|
||||
];
|
||||
}
|
||||
|
||||
// 根据面包屑类型使用不同的键,实现独立的面包屑路径
|
||||
/** Use different keys based on breadcrumb type to implement independent breadcrumb paths */
|
||||
const breadcrumbKey = breadcrumbType === 'list' ? 'space' : 'space-detail';
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:24:49
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:24:49
|
||||
*/
|
||||
/**
|
||||
* useNavigationBreadcrumbs Hook
|
||||
*
|
||||
* Automatically updates breadcrumbs based on current route:
|
||||
* - Matches current path against menu structure
|
||||
* - Supports dynamic routes with parameters
|
||||
* - Handles nested menu hierarchies
|
||||
* - Updates breadcrumbs on route changes
|
||||
*
|
||||
* @hook
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useMenu } from '@/store/menu';
|
||||
|
||||
/**
|
||||
* Hook to automatically update breadcrumbs based on navigation.
|
||||
*
|
||||
* @param source - Menu source type ('space' or 'manage')
|
||||
*/
|
||||
export const useNavigationBreadcrumbs = (source: 'space' | 'manage' = 'manage') => {
|
||||
const location = useLocation();
|
||||
const { allMenus, updateBreadcrumbs } = useMenu();
|
||||
@@ -10,26 +33,26 @@ export const useNavigationBreadcrumbs = (source: 'space' | 'manage' = 'manage')
|
||||
const currentPath = location.pathname;
|
||||
const menus = allMenus[source] || [];
|
||||
|
||||
// 查找匹配的菜单项并构建keyPath
|
||||
/** Find matching menu item and build key path */
|
||||
const findMenuKeyPath = (menuList: any[], parentKeys: string[] = []): string[] | null => {
|
||||
let bestMatch: { path: string; parentId?: string; score: number } | null = null;
|
||||
|
||||
for (const menu of menuList) {
|
||||
// 检查子菜单
|
||||
/** Check submenus */
|
||||
if (menu.subs && menu.subs.length > 0) {
|
||||
const menuPath = menu.path ? (menu.path[0] !== '/' ? '/' + menu.path : menu.path) : '';
|
||||
for (const sub of menu.subs) {
|
||||
if (sub.path) {
|
||||
const subPath = sub.path[0] !== '/' ? '/' + sub.path : sub.path;
|
||||
|
||||
// 精确匹配优先
|
||||
/** Exact match has priority */
|
||||
if (subPath === currentPath) {
|
||||
return [sub.path, `${menu.id}`];
|
||||
}
|
||||
console.log('menuPath', menuPath)
|
||||
// 动态路由匹配
|
||||
/** Dynamic route matching */
|
||||
if (subPath.includes(':')) {
|
||||
// 检查是否在父菜单下
|
||||
/** Check if under parent menu */
|
||||
if (menuPath && currentPath.startsWith(menuPath + '/')) {
|
||||
const relativePath = currentPath.replace(menuPath, '');
|
||||
const pathSegments = subPath.split('/');
|
||||
@@ -42,7 +65,7 @@ export const useNavigationBreadcrumbs = (source: 'space' | 'manage' = 'manage')
|
||||
}
|
||||
}
|
||||
}
|
||||
// 直接匹配子菜单路径
|
||||
/** Direct match submenu path */
|
||||
const pathSegments = subPath.split('/');
|
||||
const currentSegments = currentPath.split('/');
|
||||
if (pathSegments.length === currentSegments.length) {
|
||||
@@ -57,14 +80,14 @@ export const useNavigationBreadcrumbs = (source: 'space' | 'manage' = 'manage')
|
||||
}
|
||||
}
|
||||
|
||||
// 检查主菜单
|
||||
/** Check main menu */
|
||||
if (menu.path) {
|
||||
const menuPath = menu.path[0] !== '/' ? '/' + menu.path : menu.path;
|
||||
// 精确匹配优先
|
||||
/** Exact match has priority */
|
||||
if (menuPath === currentPath) {
|
||||
return [menu.path, ...parentKeys].reverse();
|
||||
}
|
||||
// 动态路由匹配
|
||||
/** Dynamic route matching */
|
||||
if (menuPath.includes(':')) {
|
||||
const pathSegments = menuPath.split('/');
|
||||
const currentSegments = currentPath.split('/');
|
||||
|
||||
@@ -1,27 +1,44 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:24:54
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:24:54
|
||||
*/
|
||||
/**
|
||||
* useRouteGuard Hook
|
||||
*
|
||||
* Provides route authentication and permission checking:
|
||||
* - Validates user authentication status
|
||||
* - Checks route permissions against menu structure
|
||||
* - Redirects unauthorized users
|
||||
* - Monitors route changes
|
||||
*
|
||||
* @hook
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useMenu, type MenuItem } from '@/store/menu'
|
||||
|
||||
// 模拟认证状态检查函数
|
||||
/** Check authentication status */
|
||||
export const checkAuthStatus = (): boolean => {
|
||||
// 在实际应用中,这里应该检查localStorage或cookie中的认证信息
|
||||
// 这里为了演示,我们假设首页不需要认证,其他页面需要认证
|
||||
return true; // 暂时返回true以便测试
|
||||
/** In production, check localStorage or cookie for auth info */
|
||||
return true; /** Temporarily return true for testing */
|
||||
};
|
||||
|
||||
// 递归检查路由是否存在于菜单数据中
|
||||
/** Recursively check if route exists in menu data */
|
||||
export const checkRoutePermission = (menus: MenuItem[], currentPath: string): boolean => {
|
||||
// 首页和知识库相关页面默认有权限
|
||||
/** Home and knowledge base pages have default permission */
|
||||
if (currentPath === '/' || currentPath.includes('knowledge-detail') || currentPath.includes('knowledge-base')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const menu of menus) {
|
||||
// 检查当前菜单的path是否匹配
|
||||
/** Check if current menu path matches */
|
||||
if (menu.path && currentPath.includes(menu.path)) {
|
||||
return true;
|
||||
}
|
||||
// 递归检查子菜单
|
||||
/** Recursively check submenus */
|
||||
if (menu.subs && menu.subs.length > 0) {
|
||||
if (checkRoutePermission(menu.subs, currentPath)) {
|
||||
return true;
|
||||
@@ -32,40 +49,44 @@ export const checkRoutePermission = (menus: MenuItem[], currentPath: string): bo
|
||||
return false;
|
||||
};
|
||||
|
||||
// 路由守卫Hook,用于处理路由权限检查
|
||||
/**
|
||||
* Route guard hook for handling route permission checks.
|
||||
*
|
||||
* @param source - Menu source type ('space' or 'manage')
|
||||
*/
|
||||
export const useRouteGuard = (source: 'space' | 'manage') => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { allMenus } = useMenu();
|
||||
const menus = allMenus[source];
|
||||
|
||||
// 确保在路由变化时重新执行所有检查逻辑
|
||||
/** Re-execute all checks on route changes */
|
||||
useEffect(() => {
|
||||
// 模拟认证检查逻辑
|
||||
/** Simulate authentication check */
|
||||
const isAuthenticated = checkAuthStatus();
|
||||
|
||||
if (!isAuthenticated && location.pathname !== '/') {
|
||||
// TODO: 未认证用户重定向到登录页(这里是首页)
|
||||
/** Redirect unauthenticated users to home/login page */
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// 认证通过后,检查路由权限
|
||||
/** After authentication, check route permissions */
|
||||
if (isAuthenticated && location.pathname !== '/' && location.pathname !== '/not-found') {
|
||||
const hasPermission = checkRoutePermission(menus, location.pathname);
|
||||
if (!hasPermission) {
|
||||
// 无权限访问该路由,重定向到无权限页面
|
||||
/** No permission, redirect to no-permission page */
|
||||
// navigate('/no-permission', { replace: true });
|
||||
}
|
||||
}
|
||||
}, [navigate, location.pathname, location.search, location.hash, menus]);
|
||||
|
||||
// 返回当前路径和权限状态,确保组件能感知到路由变化
|
||||
/** Return current path and permission status */
|
||||
return {
|
||||
currentPath: location.pathname,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
isChecking: false, // 可以扩展添加加载状态
|
||||
isChecking: false, /** Can be extended to add loading state */
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:33:11
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:33:11
|
||||
*/
|
||||
/**
|
||||
* Route Configuration
|
||||
*
|
||||
* Manages application routing with:
|
||||
* - Dynamic route generation from JSON configuration
|
||||
* - Lazy loading of components for code splitting
|
||||
* - Nested route support
|
||||
* - Component mapping validation
|
||||
* - Hash-based routing
|
||||
*
|
||||
* @module routes
|
||||
*/
|
||||
|
||||
import { lazy, type LazyExoticComponent, type ComponentType, type ReactNode } from 'react';
|
||||
import { createHashRouter, createRoutesFromElements, Route } from 'react-router-dom';
|
||||
|
||||
// 导入路由配置JSON
|
||||
/** Import route configuration JSON */
|
||||
import routesConfig from './routes.json';
|
||||
import Ontology from '@/views/Ontology';
|
||||
|
||||
|
||||
// 递归函数,用于生成路由元素
|
||||
|
||||
// 递归收集所有路由中的element
|
||||
/** Recursively collect all element names from routes */
|
||||
function collectElements(routes: RouteConfig[]): Set<string> {
|
||||
const elements = new Set<string>();
|
||||
|
||||
function traverse(routeList: RouteConfig[]) {
|
||||
routeList.forEach(route => {
|
||||
// 添加当前路由的element
|
||||
/** Add current route's element */
|
||||
elements.add(route.element);
|
||||
|
||||
// 递归处理子路由
|
||||
/** Recursively process child routes */
|
||||
if (route.children && route.children.length > 0) {
|
||||
traverse(route.children);
|
||||
}
|
||||
@@ -28,15 +44,15 @@ function collectElements(routes: RouteConfig[]): Set<string> {
|
||||
return elements;
|
||||
}
|
||||
|
||||
// 直接定义组件映射表,避免动态路径解析问题
|
||||
/** Component mapping table - maps element names to lazy-loaded components */
|
||||
const componentMap: Record<string, LazyExoticComponent<ComponentType<object>>> = {
|
||||
// 布局组件
|
||||
/** Layout components */
|
||||
AuthLayout: lazy(() => import('@/components/Layout/AuthLayout')),
|
||||
AuthSpaceLayout: lazy(() => import('@/components/Layout/AuthSpaceLayout')),
|
||||
BasicLayout: lazy(() => import('@/components/Layout/BasicLayout')),
|
||||
LoginLayout: lazy(() => import('@/components/Layout/LoginLayout')),
|
||||
NoAuthLayout: lazy(() => import('@/components/Layout/NoAuthLayout')),
|
||||
// 视图组件
|
||||
/** View components */
|
||||
Index: lazy(() => import('@/views/Index')),
|
||||
Home: lazy(() => import('@/views/Home')),
|
||||
UserMemory: lazy(() => import('@/views/UserMemory')),
|
||||
@@ -78,7 +94,7 @@ const componentMap: Record<string, LazyExoticComponent<ComponentType<object>>> =
|
||||
NotFound: lazy(() => import('@/views/NotFound'))
|
||||
};
|
||||
|
||||
// 检查并报告缺失的组件
|
||||
/** Check and report missing components */
|
||||
const allElements = collectElements(routesConfig);
|
||||
allElements.forEach(elementName => {
|
||||
if (!componentMap[elementName]) {
|
||||
@@ -86,23 +102,27 @@ allElements.forEach(elementName => {
|
||||
}
|
||||
});
|
||||
|
||||
// 确保NotFound组件总是存在作为兜底
|
||||
/** Ensure NotFound component always exists as fallback */
|
||||
if (!componentMap['NotFound']) {
|
||||
componentMap['NotFound'] = lazy(() => import('@/views/NotFound/index.tsx'));
|
||||
}
|
||||
|
||||
// 路由配置类型定义
|
||||
/** Route configuration type definition */
|
||||
interface RouteConfig {
|
||||
/** Route path */
|
||||
path?: string;
|
||||
/** Component element name */
|
||||
element: string;
|
||||
/** Component file path (optional) */
|
||||
componentPath?: string;
|
||||
/** Child routes */
|
||||
children?: RouteConfig[];
|
||||
}
|
||||
|
||||
// 递归函数,用于生成路由元素
|
||||
/** Recursively generate route elements from configuration */
|
||||
const generateRoutes = (routes: RouteConfig[]): ReactNode => {
|
||||
return routes.map((route, index) => {
|
||||
// 获取组件
|
||||
/** Get component from mapping */
|
||||
const componentKey = route.element as keyof typeof componentMap;
|
||||
const Component = componentMap[componentKey];
|
||||
|
||||
@@ -111,7 +131,7 @@ const generateRoutes = (routes: RouteConfig[]): ReactNode => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果有子路由
|
||||
/** If has child routes, create nested route */
|
||||
if (route.children) {
|
||||
return (
|
||||
<Route key={index} element={<Component />}>
|
||||
@@ -120,7 +140,7 @@ const generateRoutes = (routes: RouteConfig[]): ReactNode => {
|
||||
);
|
||||
}
|
||||
|
||||
// 如果有path属性,则为普通路由
|
||||
/** If has path property, create regular route */
|
||||
if (route.path) {
|
||||
return <Route key={index} path={route.path} element={<Component />} />;
|
||||
}
|
||||
@@ -129,7 +149,7 @@ const generateRoutes = (routes: RouteConfig[]): ReactNode => {
|
||||
});
|
||||
};
|
||||
|
||||
// 创建路由
|
||||
/** Create hash router from route configuration */
|
||||
const router = createHashRouter(
|
||||
createRoutesFromElements(
|
||||
generateRoutes(routesConfig)
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
/*
|
||||
* @Description:
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2026-01-05 17:22:23
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2026-01-15 21:02:43
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:33:22
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:33:22
|
||||
*/
|
||||
/**
|
||||
* Locale Store
|
||||
*
|
||||
* Manages internationalization (i18n) and localization with:
|
||||
* - Language switching (English/Chinese)
|
||||
* - Timezone management
|
||||
* - Ant Design locale configuration
|
||||
* - Custom Tour component translations
|
||||
* - Day.js timezone support
|
||||
*
|
||||
* @store
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import enUS from 'antd/locale/en_US';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
@@ -14,13 +25,12 @@ import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import i18n from '@/i18n';
|
||||
import { timezoneToAntdLocaleMap } from '@/utils/timezones';
|
||||
|
||||
// 扩展dayjs插件
|
||||
/** Extend dayjs with timezone plugins */
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
// 自定义中文 locale,修改 Tour 组件的按钮文字
|
||||
/** Custom Chinese locale with modified Tour component button text */
|
||||
const customZhCN: Locale = {
|
||||
...zhCN,
|
||||
Tour: {
|
||||
@@ -31,7 +41,7 @@ const customZhCN: Locale = {
|
||||
},
|
||||
};
|
||||
|
||||
// 自定义英文 locale,修改 Tour 组件的按钮文字
|
||||
/** Custom English locale with modified Tour component button text */
|
||||
const customEnUS: Locale = {
|
||||
...enUS,
|
||||
Tour: {
|
||||
@@ -43,19 +53,27 @@ const customEnUS: Locale = {
|
||||
};
|
||||
|
||||
|
||||
/** Internationalization state interface */
|
||||
interface I18nState {
|
||||
/** Current language code */
|
||||
language: string;
|
||||
/** Ant Design locale object */
|
||||
locale: Locale;
|
||||
/** Current timezone */
|
||||
timeZone: string;
|
||||
/** Change application language */
|
||||
changeLanguage: (language: string) => void;
|
||||
/** Change timezone (triggers page reload) */
|
||||
changeTimeZone: (timeZone: string) => void;
|
||||
}
|
||||
|
||||
/** Initialize from localStorage or use defaults */
|
||||
const initialTimeZone = localStorage.getItem('timeZone') || 'Asia/Shanghai'
|
||||
const initialLanguage = localStorage.getItem('language') || 'en'
|
||||
const initialLocale = initialLanguage === 'en' ? customEnUS : customZhCN
|
||||
i18n.changeLanguage(initialLanguage)
|
||||
|
||||
/** Internationalization store */
|
||||
export const useI18n = create<I18nState>((set, get) => ({
|
||||
language: initialLanguage,
|
||||
locale: initialLocale,
|
||||
@@ -68,8 +86,9 @@ export const useI18n = create<I18nState>((set, get) => ({
|
||||
changeTimeZone: (timeZone: string) => {
|
||||
const { timeZone: lastTimeZone } = get()
|
||||
set({ timeZone })
|
||||
/** Reload page if timezone changed */
|
||||
if (lastTimeZone !== timeZone) {
|
||||
window.location.reload()
|
||||
}
|
||||
},
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:33:34
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:33:34
|
||||
*/
|
||||
/**
|
||||
* Menu Store
|
||||
*
|
||||
* Manages application menu and breadcrumb navigation with:
|
||||
* - Menu loading from JSON configuration
|
||||
* - Sidebar collapse state
|
||||
* - Breadcrumb generation from menu paths
|
||||
* - Custom breadcrumb support
|
||||
* - Separate menu contexts (space/manage)
|
||||
*
|
||||
* @store
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import AllMenus from './menu.json'
|
||||
|
||||
/** Menu item interface */
|
||||
export interface MenuItem {
|
||||
id: number;
|
||||
parent: number;
|
||||
@@ -22,20 +42,32 @@ export interface MenuItem {
|
||||
master?: string | null;
|
||||
disposable?: boolean;
|
||||
appSystem?: string | null;
|
||||
subs: MenuItem[] | null;
|
||||
subs?: MenuItem[] | null;
|
||||
onClick?: (e?: React.MouseEvent) => void | boolean;
|
||||
}
|
||||
|
||||
/** Menu state interface */
|
||||
interface MenuState {
|
||||
/** Sidebar collapsed state */
|
||||
collapsed: boolean;
|
||||
/** Toggle sidebar collapse */
|
||||
toggleSider: () => void;
|
||||
/** All menus by context */
|
||||
allMenus: Record<'space' | 'manage', MenuItem[]>;
|
||||
/** All breadcrumbs by context */
|
||||
allBreadcrumbs: Record<'space' | 'manage' | string, MenuItem[]>;
|
||||
/** Load menus for specific context */
|
||||
loadMenus: (source: 'space' | 'manage') => void;
|
||||
/** Update breadcrumbs based on key path */
|
||||
updateBreadcrumbs: (keyPath: string[], source: 'space' | 'manage') => void;
|
||||
/** Set custom breadcrumbs */
|
||||
setCustomBreadcrumbs: (breadcrumbs: MenuItem[], source: string) => void;
|
||||
}
|
||||
|
||||
/** Initialize breadcrumbs from localStorage */
|
||||
const initBreadcrumbs = localStorage.getItem('breadcrumbs') || '[]'
|
||||
|
||||
/** Menu store */
|
||||
export const useMenu = create<MenuState>((set, get) => ({
|
||||
collapsed: localStorage.getItem('collapsed') === 'true',
|
||||
allMenus: {
|
||||
@@ -61,7 +93,7 @@ export const useMenu = create<MenuState>((set, get) => ({
|
||||
console.log('updateBreadcrumbs paths:', paths);
|
||||
|
||||
if (paths.length === 3) {
|
||||
// 三级菜单:[subSubPath, subId, menuId]
|
||||
/** Three-level menu: [subSubPath, subId, menuId] */
|
||||
const menuId = paths[2];
|
||||
const subId = paths[1];
|
||||
const subSubPath = paths[0];
|
||||
@@ -81,7 +113,7 @@ export const useMenu = create<MenuState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 原有逻辑处理一级和二级菜单
|
||||
/** Original logic for one-level and two-level menus */
|
||||
const matchedMenu: MenuItem | undefined = menus.find(menu => menu.path === paths[paths.length - 1] || `${menu.id}` === paths[1]);
|
||||
|
||||
if (matchedMenu) {
|
||||
@@ -107,4 +139,4 @@ export const useMenu = create<MenuState>((set, get) => ({
|
||||
set({ allBreadcrumbs })
|
||||
localStorage.setItem('breadcrumbs', JSON.stringify(allBreadcrumbs))
|
||||
},
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:33:54
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:33:54
|
||||
*/
|
||||
/**
|
||||
* User Store
|
||||
*
|
||||
* Manages user authentication and profile with:
|
||||
* - User information storage
|
||||
* - Login/logout functionality
|
||||
* - Token management (access & refresh)
|
||||
* - Workspace storage type
|
||||
* - Navigation guards for workspace access
|
||||
*
|
||||
* @store
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { clearAuthData } from '@/utils/auth';
|
||||
import type { User } from '@/views/UserManagement/types'
|
||||
@@ -5,6 +24,7 @@ import { getUsers, refreshToken, logout } from '@/api/user'
|
||||
import { getWorkspaceStorageType } from '@/api/workspaces';
|
||||
import { cookieUtils } from '@/utils/request'
|
||||
|
||||
/** Login information interface */
|
||||
export interface LoginInfo {
|
||||
access_token: string;
|
||||
expires_at: string;
|
||||
@@ -12,24 +32,37 @@ export interface LoginInfo {
|
||||
refresh_token: string;
|
||||
token_type: 'bearer'
|
||||
}
|
||||
|
||||
/** User state interface */
|
||||
export interface UserState {
|
||||
/** Current user information */
|
||||
user: User;
|
||||
/** Login token information */
|
||||
loginInfo: LoginInfo;
|
||||
/** Workspace storage type */
|
||||
storageType: string | null;
|
||||
/** Update login information */
|
||||
updateLoginInfo: (values: LoginInfo) => void;
|
||||
/** Get user information */
|
||||
getUserInfo: (flag?: boolean) => void;
|
||||
/** Clear user information */
|
||||
clearUserInfo: () => void;
|
||||
/** Logout user */
|
||||
logout: () => void;
|
||||
/** Get workspace storage type */
|
||||
getStorageType: () => void;
|
||||
/** Check and redirect if workspace not set */
|
||||
checkJump: () => void;
|
||||
}
|
||||
|
||||
/** Pages that don't require workspace */
|
||||
export const whitePage = [
|
||||
'/conversation',
|
||||
'/login',
|
||||
'/invite-register'
|
||||
]
|
||||
|
||||
/** User store */
|
||||
export const useUser = create<UserState>((set, get) => ({
|
||||
user: localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') || '{}') as User : {} as User,
|
||||
loginInfo: {} as LoginInfo,
|
||||
@@ -101,9 +134,10 @@ export const useUser = create<UserState>((set, get) => ({
|
||||
const localUser = JSON.parse(localStorage.getItem('user') || '{}') as User;
|
||||
const hash = window.location.hash;
|
||||
|
||||
/** Redirect to index if user has no workspace and not on whitelist page */
|
||||
if (localUser.id && (!localUser.current_workspace_id || localUser.current_workspace_id === '') && !whitePage.find(vo => hash.includes(vo))) {
|
||||
console.log('whitePage', whitePage.find(vo => hash.includes(vo)))
|
||||
window.location.href = '/#/index'
|
||||
}
|
||||
},
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:34:04
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:34:04
|
||||
*/
|
||||
/**
|
||||
* API密钥替换工具
|
||||
* API Key Replacer Utility
|
||||
*
|
||||
* Provides functions to mask and detect API keys in text for security purposes.
|
||||
* Supports multiple API key formats (service, agent, multi-agent, workflow).
|
||||
*
|
||||
* @module apiKeyReplacer
|
||||
*/
|
||||
|
||||
/** API key pattern definitions for different types */
|
||||
const API_KEY_PATTERNS = {
|
||||
service: /sk-service-[A-Za-z0-9_-]+/g,
|
||||
agent: /sk-agent-[A-Za-z0-9_-]+/g,
|
||||
multiAgent: /sk-multi_agent-[A-Za-z0-9_-]+/g,
|
||||
workflow: /sk-workflow-[A-Za-z0-9_-]+/g
|
||||
}
|
||||
|
||||
/** API key prefix definitions */
|
||||
const API_KEY_PREFIX = {
|
||||
service: 'sk-service-',
|
||||
agent: 'sk-agent-',
|
||||
@@ -16,9 +30,9 @@ const API_KEY_PREFIX = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换文本中的API密钥为*号
|
||||
* @param text 原始文本
|
||||
* @returns 替换后的文本
|
||||
* Replace API keys in text with asterisks
|
||||
* @param text - Original text
|
||||
* @returns Text with masked API keys
|
||||
*/
|
||||
export const maskApiKeys = (text: string): string => {
|
||||
if (!text) return text
|
||||
@@ -37,10 +51,10 @@ export const maskApiKeys = (text: string): string => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测文本中是否包含API密钥
|
||||
* @param text 待检测文本
|
||||
* @returns 是否包含API密钥
|
||||
* Detect if text contains API keys
|
||||
* @param text - Text to check
|
||||
* @returns Whether text contains API keys
|
||||
*/
|
||||
export const hasApiKeys = (text: string): boolean => {
|
||||
return Object.values(API_KEY_PATTERNS).some(pattern => pattern.test(text))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:34:12
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:34:12
|
||||
*/
|
||||
/**
|
||||
* Authentication Utility
|
||||
*
|
||||
* Provides functions to clear authentication data and redirect to login.
|
||||
*
|
||||
* @module auth
|
||||
*/
|
||||
|
||||
import { cookieUtils } from './request'
|
||||
|
||||
/**
|
||||
* Clear all authentication data and cookies
|
||||
* Removes user info, breadcrumbs, and all cookies
|
||||
*/
|
||||
export const clearAuthData = () => {
|
||||
console.log("Clearing auth data and redirecting to login");
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('breadcrumbs')
|
||||
cookieUtils.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:34:23
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:34:23
|
||||
*/
|
||||
/**
|
||||
* Common Utility Functions
|
||||
*
|
||||
* Provides general-purpose utility functions.
|
||||
*
|
||||
* @module common
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a random string with specified length and character types
|
||||
* @param length - Length of the string (default: 12)
|
||||
* @param isHasSpecialChars - Whether to include special characters (default: true)
|
||||
* @returns Random string
|
||||
*/
|
||||
export const randomString = (length: number = 12, isHasSpecialChars: boolean = true) => {
|
||||
// 定义字符集:大写字母、小写字母、数字和特殊字符
|
||||
/** Define character sets: uppercase, lowercase, numbers, and special characters */
|
||||
const uppercaseChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const lowercaseChars = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const numberChars = '0123456789';
|
||||
const specialChars = '!@#$%^&*_+-=|;:,.?';
|
||||
|
||||
// 合并所有字符集
|
||||
/** Combine all character sets */
|
||||
let allChars = uppercaseChars + lowercaseChars + numberChars;
|
||||
|
||||
// 确保至少包含每种类型的字符
|
||||
/** Ensure at least one character of each type */
|
||||
let str =
|
||||
uppercaseChars[Math.floor(Math.random() * uppercaseChars.length)] +
|
||||
lowercaseChars[Math.floor(Math.random() * lowercaseChars.length)] +
|
||||
@@ -18,11 +38,11 @@ export const randomString = (length: number = 12, isHasSpecialChars: boolean = t
|
||||
str+= specialChars[Math.floor(Math.random() * specialChars.length)];
|
||||
}
|
||||
|
||||
// 填充剩余的字符,使总长度为12
|
||||
/** Fill remaining characters to reach desired length */
|
||||
for (let i = 4; i < length; i++) {
|
||||
str += allChars[Math.floor(Math.random() * allChars.length)];
|
||||
}
|
||||
|
||||
// 打乱密码字符顺序
|
||||
/** Shuffle the string characters */
|
||||
return str.split('').sort(() => Math.random() - 0.5).join('');
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,32 +1,46 @@
|
||||
/**
|
||||
* 格式化日期时间
|
||||
* @param value 时间戳(毫秒)或日期字符串
|
||||
* @param format 目标格式,支持 YYYY-MM-DD HH:mm:ss、YYYY/MM/DD HH:mm:ss、HH:mm 等
|
||||
* @returns 格式化后的日期时间字符串
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:34:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:34:43
|
||||
*/
|
||||
/**
|
||||
* Format Utility
|
||||
*
|
||||
* Provides date/time formatting functions with timezone support.
|
||||
*
|
||||
* @module format
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
|
||||
// 扩展dayjs插件
|
||||
/** Extend dayjs with timezone plugins */
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
/**
|
||||
* Format date/time with timezone support
|
||||
* @param value - Timestamp (milliseconds) or date string
|
||||
* @param format - Target format, supports YYYY-MM-DD HH:mm:ss, YYYY/MM/DD HH:mm:ss, HH:mm, etc.
|
||||
* @returns Formatted date/time string
|
||||
*/
|
||||
export const formatDateTime = (
|
||||
value: string | number | null | undefined,
|
||||
format: string = 'YYYY-MM-DD HH:mm:ss'
|
||||
): string => {
|
||||
if (!value) return '';
|
||||
|
||||
// 检查日期是否有效
|
||||
/** Check if date is valid */
|
||||
if (!dayjs(value).isValid()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 每次调用都获取最新的时区设置
|
||||
/** Get current timezone setting from localStorage */
|
||||
const currentTimeZone = localStorage.getItem('timeZone') || 'Asia/Shanghai';
|
||||
dayjs.tz.setDefault(currentTimeZone);
|
||||
|
||||
// 使用最新时区格式化日期
|
||||
/** Format date with current timezone */
|
||||
return dayjs.tz(value).format(format);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:35:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:35:15
|
||||
*/
|
||||
/**
|
||||
* HTTP Request Utility Module
|
||||
*
|
||||
* Provides axios-based HTTP client with:
|
||||
* - Automatic token refresh on 401 errors
|
||||
* - Request/response interceptors
|
||||
* - Cookie-based authentication
|
||||
* - Error handling and user notifications
|
||||
* - File upload/download support
|
||||
*
|
||||
* @module request
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import { clearAuthData } from './auth';
|
||||
@@ -5,6 +24,9 @@ import { message } from 'antd';
|
||||
import { refreshTokenUrl, refreshToken, loginUrl, logoutUrl } from '@/api/user'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
/**
|
||||
* Standard API response structure
|
||||
*/
|
||||
export interface ResponseData {
|
||||
code: number;
|
||||
msg: string;
|
||||
@@ -12,6 +34,10 @@ export interface ResponseData {
|
||||
error: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated data structure
|
||||
*/
|
||||
interface data {
|
||||
"items": Record<string, string | number | boolean | object | null | undefined>[];
|
||||
"page": {
|
||||
@@ -22,21 +48,22 @@ interface data {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const API_PREFIX = '/api'
|
||||
// 创建axios实例
|
||||
|
||||
// Create axios instance
|
||||
const service = axios.create({
|
||||
baseURL: API_PREFIX, // 与vite.config.ts中的代理配置对应
|
||||
// timeout: 10000, // 请求超时时间
|
||||
baseURL: API_PREFIX, // Corresponds to proxy config in vite.config.ts
|
||||
// timeout: 10000, // Request timeout
|
||||
withCredentials: false,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
});
|
||||
|
||||
// 是否正在刷新token
|
||||
// Token refresh state
|
||||
let isRefreshing = false;
|
||||
// 存储待重试的请求队列
|
||||
|
||||
// Queue for pending requests during token refresh
|
||||
interface RequestQueueItem {
|
||||
config: AxiosRequestConfig;
|
||||
resolve: (token: string) => void;
|
||||
@@ -44,7 +71,7 @@ interface RequestQueueItem {
|
||||
}
|
||||
let requests: RequestQueueItem[] = [];
|
||||
|
||||
// 请求拦截器
|
||||
// Request interceptor
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
if (!config.headers.Authorization) {
|
||||
@@ -59,13 +86,16 @@ service.interceptors.request.use(
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
// 对请求错误做些什么
|
||||
console.error('请求错误:', error);
|
||||
// Handle request errors
|
||||
console.error('Request error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 刷新token的函数
|
||||
/**
|
||||
* Refresh authentication token
|
||||
* @returns New access token
|
||||
*/
|
||||
const tokenRefresh = async (): Promise<string> => {
|
||||
try {
|
||||
const refresh_token = cookieUtils.get('refreshToken');
|
||||
@@ -75,16 +105,16 @@ const tokenRefresh = async (): Promise<string> => {
|
||||
if (!refresh_token) {
|
||||
throw new Error(i18n.t('common.refreshTokenNotExist'));
|
||||
}
|
||||
// 使用原生axios调用refresh接口,避免触发拦截器导致的循环调用
|
||||
// Use native axios to call refresh API, avoiding interceptor circular calls
|
||||
const response: any = await refreshToken();
|
||||
const newToken = response.access_token;
|
||||
cookieUtils.set('authToken', newToken);
|
||||
return newToken;
|
||||
} catch (error) {
|
||||
// 如果refresh接口也返回401,则退出登录
|
||||
// If refresh API also returns 401, logout
|
||||
clearAuthData();
|
||||
message.warning(i18n.t('common.loginExpired'));
|
||||
// 这里可以添加重定向到登录页的逻辑
|
||||
// Redirect to login page
|
||||
if (!window.location.hash.includes('#/login')) {
|
||||
window.location.href = `/#/login`;
|
||||
}
|
||||
@@ -92,13 +122,13 @@ const tokenRefresh = async (): Promise<string> => {
|
||||
}
|
||||
};
|
||||
|
||||
// 响应拦截器
|
||||
// Response interceptor
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
// 对响应数据做点什么
|
||||
// Process response data
|
||||
const { data: responseData } = response;
|
||||
|
||||
// 如果响应数据不是对象,直接返回
|
||||
// If response data is not an object, return directly
|
||||
if (!responseData || typeof responseData !== 'object') {
|
||||
return responseData;
|
||||
}
|
||||
@@ -110,7 +140,7 @@ service.interceptors.response.use(
|
||||
case 200:
|
||||
return data !== undefined ? data : responseData;
|
||||
case 401:
|
||||
// 处理未授权情况
|
||||
// Handle unauthorized
|
||||
return handle401Error(response.config);
|
||||
default:
|
||||
if (code === undefined) {
|
||||
@@ -123,18 +153,18 @@ service.interceptors.response.use(
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
// 如果是取消请求,不显示错误提示
|
||||
// If request was cancelled, don't show error message
|
||||
if (axios.isCancel(error) || error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 处理网络错误、超时等
|
||||
// Handle network errors, timeouts, etc.
|
||||
let msg = error.response?.data?.error || error.response?.error;
|
||||
const status = error?.response ? error.response.status : error;
|
||||
// 服务器响应了但状态码不在2xx范围
|
||||
// Server responded but status code is not in 2xx range
|
||||
switch (status) {
|
||||
case 401:
|
||||
// 处理未授权情况
|
||||
// Handle unauthorized
|
||||
return handle401Error(error.config);
|
||||
case 403:
|
||||
msg = i18n.t('common.permissionDenied');
|
||||
@@ -165,9 +195,13 @@ service.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
// 处理401错误的函数
|
||||
/**
|
||||
* Handle 401 unauthorized errors with token refresh
|
||||
* @param config - Original request configuration
|
||||
* @returns Retried request with new token
|
||||
*/
|
||||
const handle401Error = async (config: AxiosRequestConfig): Promise<unknown> => {
|
||||
// 如果是refresh接口本身返回401,则直接退出登录
|
||||
// If refresh API itself returns 401, logout directly
|
||||
if (config.url === refreshTokenUrl) {
|
||||
clearAuthData();
|
||||
message.warning(i18n.t('common.loginExpired'));
|
||||
@@ -184,39 +218,39 @@ const handle401Error = async (config: AxiosRequestConfig): Promise<unknown> => {
|
||||
return Promise.reject(new Error(i18n.t('common.publicApiCannotRefreshToken')));
|
||||
}
|
||||
|
||||
// 如果正在刷新token,则将当前请求加入队列
|
||||
// If token refresh is in progress, queue the request
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
requests.push({ config, resolve, reject });
|
||||
}).then((token) => {
|
||||
// 使用新token重新发送请求
|
||||
// Retry request with new token
|
||||
config.headers = config.headers || {};
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
return service(config);
|
||||
});
|
||||
}
|
||||
|
||||
// 开始刷新token
|
||||
// Start token refresh
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const newToken = await tokenRefresh();
|
||||
|
||||
// 更新队列中所有请求的token并重新发送
|
||||
// Update token for all queued requests and resolve them
|
||||
requests.forEach(({ config, resolve }) => {
|
||||
config.headers = config.headers || {};
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
resolve(newToken);
|
||||
});
|
||||
|
||||
// 清空队列
|
||||
// Clear queue
|
||||
requests = [];
|
||||
|
||||
// 使用新token重新发送当前请求
|
||||
// Retry current request with new token
|
||||
config.headers = config.headers || {};
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
return service(config);
|
||||
} catch (error) {
|
||||
// 刷新token失败,清空队列并拒绝所有请求
|
||||
// Token refresh failed, clear queue and reject all requests
|
||||
requests.forEach(({ reject }) => {
|
||||
reject(error as Error);
|
||||
});
|
||||
@@ -232,6 +266,12 @@ interface ObjectWithPush {
|
||||
[key: string]: string | number | boolean | object | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and clean request parameters
|
||||
* - Removes null/undefined values
|
||||
* - Trims string values
|
||||
* - Handles objects with _push flag
|
||||
*/
|
||||
function paramFilter(params: Record<string, string | number | boolean | ObjectWithPush | null | undefined> = {}) {
|
||||
|
||||
Object.keys(params).forEach(key => {
|
||||
@@ -255,7 +295,9 @@ function paramFilter(params: Record<string, string | number | boolean | ObjectWi
|
||||
return params;
|
||||
}
|
||||
|
||||
// 封装请求方法
|
||||
/**
|
||||
* HTTP request methods wrapper
|
||||
*/
|
||||
export const request = {
|
||||
get<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.get(url, {
|
||||
@@ -308,10 +350,13 @@ export const request = {
|
||||
|
||||
|
||||
|
||||
// 获取父级域名
|
||||
/**
|
||||
* Get parent domain for cookie setting
|
||||
* @returns Parent domain or IP address
|
||||
*/
|
||||
const getParentDomain = () => {
|
||||
const hostname = window.location.hostname
|
||||
// 检查是否为IP地址
|
||||
// Check if it's an IP address
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
|
||||
return hostname
|
||||
}
|
||||
@@ -319,7 +364,9 @@ const getParentDomain = () => {
|
||||
return parts.length > 2 ? `.${parts.slice(-2).join('.')}` : hostname
|
||||
}
|
||||
|
||||
// Cookie操作工具
|
||||
/**
|
||||
* Cookie utility functions
|
||||
*/
|
||||
export const cookieUtils = {
|
||||
set: (name: string, value: string, domain = getParentDomain()) => {
|
||||
document.cookie = `${name}=${value}; domain=${domain}; path=/; secure; samesite=strict`
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:35:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:35:43
|
||||
*/
|
||||
/**
|
||||
* Server-Sent Events (SSE) Stream Utility Module
|
||||
*
|
||||
* Provides SSE handling with:
|
||||
* - Automatic token refresh on 401 errors
|
||||
* - SSE message parsing and JSON decoding
|
||||
* - HTML entity decoding
|
||||
* - Stream buffering for incomplete messages
|
||||
*
|
||||
* @module stream
|
||||
*/
|
||||
|
||||
import { message } from 'antd';
|
||||
import i18n from '@/i18n'
|
||||
import { cookieUtils } from './request'
|
||||
@@ -9,7 +27,10 @@ const API_PREFIX = '/api'
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<string> | null = null;
|
||||
|
||||
// Refresh token function for SSE
|
||||
/**
|
||||
* Refresh authentication token for SSE requests
|
||||
* @returns New access token
|
||||
*/
|
||||
const refreshTokenForSSE = async (): Promise<string> => {
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
@@ -42,10 +63,19 @@ const refreshTokenForSSE = async (): Promise<string> => {
|
||||
return refreshPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* SSE message structure
|
||||
*/
|
||||
export interface SSEMessage {
|
||||
event?: string
|
||||
data?: string | object
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SSE string format to JSON objects
|
||||
* @param sseString - Raw SSE string data
|
||||
* @returns Array of parsed SSE messages
|
||||
*/
|
||||
export function parseSSEToJSON(sseString: string) {
|
||||
const events: SSEMessage[] = []
|
||||
const lines = sseString.trim().split('\n')
|
||||
@@ -77,9 +107,14 @@ export function parseSSEToJSON(sseString: string) {
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SSE data content with HTML entity decoding
|
||||
* @param dataContent - Raw data content string
|
||||
* @returns Parsed object or original string
|
||||
*/
|
||||
function parseDataContent(dataContent: string): string | object {
|
||||
try {
|
||||
// 第一层解码:HTML实体
|
||||
// First layer: HTML entity decoding
|
||||
let unescaped = dataContent
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, '&')
|
||||
@@ -87,15 +122,15 @@ function parseDataContent(dataContent: string): string | object {
|
||||
.replace(/>/g, '>')
|
||||
.replace(/'/g, "'")
|
||||
|
||||
// 解析第一层JSON
|
||||
// Parse first layer JSON
|
||||
const firstParse = JSON.parse(unescaped)
|
||||
|
||||
// 如果data字段是字符串且包含JSON,解析data层但保持chunk为字符串
|
||||
// If data field is a string containing JSON, parse data layer but keep chunk as string
|
||||
if (firstParse.data && typeof firstParse.data === 'string' && firstParse.data.includes("{")) {
|
||||
try {
|
||||
firstParse.data = JSON.parse(firstParse.data)
|
||||
} catch {
|
||||
// 保持原字符串
|
||||
// Keep original string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +140,14 @@ function parseDataContent(dataContent: string): string | object {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make SSE request with authentication
|
||||
* @param url - API endpoint
|
||||
* @param data - Request payload
|
||||
* @param token - Authentication token
|
||||
* @param config - Additional request configuration
|
||||
* @returns Fetch response
|
||||
*/
|
||||
const makeSSERequest = async (url: string, data: any, token: string, config = { headers: {} }) => {
|
||||
return fetch(`${API_PREFIX}${url}`, {
|
||||
method: 'POST',
|
||||
@@ -117,6 +160,13 @@ const makeSSERequest = async (url: string, data: any, token: string, config = {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle SSE stream with automatic token refresh and message parsing
|
||||
* @param url - API endpoint
|
||||
* @param data - Request payload
|
||||
* @param onMessage - Callback for each parsed message
|
||||
* @param config - Additional request configuration
|
||||
*/
|
||||
export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMessage[]) => void, config = { headers: {} }) => {
|
||||
try {
|
||||
let token = cookieUtils.get('authToken');
|
||||
@@ -153,7 +203,7 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = ''; // 添加缓冲区来处理不完整的消息
|
||||
let buffer = ''; // Buffer for handling incomplete messages
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -162,9 +212,9 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
|
||||
// 处理完整的事件
|
||||
// Process complete events
|
||||
const events = buffer.split('\n\n');
|
||||
buffer = events.pop() || ''; // 保留最后一个可能不完整的事件
|
||||
buffer = events.pop() || ''; // Keep last potentially incomplete event
|
||||
|
||||
for (const event of events) {
|
||||
if (event.trim() && onMessage) {
|
||||
@@ -173,7 +223,7 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
|
||||
}
|
||||
}
|
||||
|
||||
// 处理剩余的缓冲区内容
|
||||
// Process remaining buffer content
|
||||
if (buffer.trim() && onMessage) {
|
||||
onMessage(parseSSEToJSON(buffer) ?? {});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:37:10
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:37:10
|
||||
*/
|
||||
/**
|
||||
* Timezone Configuration Module
|
||||
*
|
||||
* Provides:
|
||||
* - Major world timezone list
|
||||
* - Timezone to Ant Design locale mapping
|
||||
* - Dayjs locale imports
|
||||
*
|
||||
* Note: Timezone display names are in i18n translation files (zh.ts and en.ts)
|
||||
* Use i18n.t('timezones.timezone_name') to get localized timezone display names
|
||||
*
|
||||
* @module timezones
|
||||
*/
|
||||
|
||||
import en_US from 'antd/locale/en_US';
|
||||
import en_GB from 'antd/locale/en_GB';
|
||||
import de_DE from 'antd/locale/de_DE';
|
||||
@@ -12,157 +32,159 @@ import 'dayjs/locale/de'
|
||||
import 'dayjs/locale/en-gb'
|
||||
import 'dayjs/locale/en'
|
||||
|
||||
// 全世界主要时区列表
|
||||
/**
|
||||
* Major world timezones list
|
||||
*/
|
||||
export const timezones = [
|
||||
|
||||
'America/Los_Angeles', // 美国洛杉矶
|
||||
'America/New_York', // 美国纽约
|
||||
'Europe/London', // 英国伦敦
|
||||
'Europe/Berlin', // 德国柏林
|
||||
'Europe/Moscow', // 俄罗斯莫斯科
|
||||
'Asia/Kolkata', // 印度加尔各答
|
||||
'Asia/Shanghai', // 中国上海
|
||||
'America/Los_Angeles', // Los Angeles, USA
|
||||
'America/New_York', // New York, USA
|
||||
'Europe/London', // London, UK
|
||||
'Europe/Berlin', // Berlin, Germany
|
||||
'Europe/Moscow', // Moscow, Russia
|
||||
'Asia/Kolkata', // Kolkata, India
|
||||
'Asia/Shanghai', // Shanghai, China
|
||||
|
||||
// 亚洲
|
||||
// 'Asia/Tokyo', // 日本东京
|
||||
// 'Asia/Singapore', // 新加坡
|
||||
// 'Asia/Hong_Kong', // 中国香港
|
||||
// 'Asia/Taipei', // 中国台北
|
||||
// 'Asia/Seoul', // 韩国首尔
|
||||
// 'Asia/Bangkok', // 泰国曼谷
|
||||
// 'Asia/Jakarta', // 印度尼西亚雅加达
|
||||
// 'Asia/Manila', // 菲律宾马尼拉
|
||||
// 'Asia/Dubai', // 阿联酋迪拜
|
||||
// 'Asia/Tashkent', // 乌兹别克斯坦塔什干
|
||||
// 'Asia/Riyadh', // 沙特阿拉伯利雅得
|
||||
// 'Asia/Baku', // 阿塞拜疆巴库
|
||||
// 'Asia/Istanbul', // 土耳其伊斯坦布尔
|
||||
// Asia
|
||||
// 'Asia/Tokyo', // Tokyo, Japan
|
||||
// 'Asia/Singapore', // Singapore
|
||||
// 'Asia/Hong_Kong', // Hong Kong, China
|
||||
// 'Asia/Taipei', // Taipei, Taiwan
|
||||
// 'Asia/Seoul', // Seoul, South Korea
|
||||
// 'Asia/Bangkok', // Bangkok, Thailand
|
||||
// 'Asia/Jakarta', // Jakarta, Indonesia
|
||||
// 'Asia/Manila', // Manila, Philippines
|
||||
// 'Asia/Dubai', // Dubai, UAE
|
||||
// 'Asia/Tashkent', // Tashkent, Uzbekistan
|
||||
// 'Asia/Riyadh', // Riyadh, Saudi Arabia
|
||||
// 'Asia/Baku', // Baku, Azerbaijan
|
||||
// 'Asia/Istanbul', // Istanbul, Turkey
|
||||
|
||||
// 欧洲
|
||||
// 'Europe/Paris', // 法国巴黎
|
||||
// 'Europe/Rome', // 意大利罗马
|
||||
// 'Europe/Madrid', // 西班牙马德里
|
||||
// 'Europe/Amsterdam', // 荷兰阿姆斯特丹
|
||||
// 'Europe/Vienna', // 奥地利维也纳
|
||||
// 'Europe/Stockholm', // 瑞典斯德哥尔摩
|
||||
// 'Europe/Oslo', // 挪威奥斯陆
|
||||
// 'Europe/Copenhagen', // 丹麦哥本哈根
|
||||
// 'Europe/Zurich', // 瑞士苏黎世
|
||||
// 'Europe/Athens', // 希腊雅典
|
||||
// 'Europe/Warsaw', // 波兰华沙
|
||||
// 'Europe/Prague', // 捷克布拉格
|
||||
// 'Europe/Budapest', // 匈牙利布达佩斯
|
||||
// 'Europe/Belgrade', // 塞尔维亚贝尔格莱德
|
||||
// Europe
|
||||
// 'Europe/Paris', // Paris, France
|
||||
// 'Europe/Rome', // Rome, Italy
|
||||
// 'Europe/Madrid', // Madrid, Spain
|
||||
// 'Europe/Amsterdam', // Amsterdam, Netherlands
|
||||
// 'Europe/Vienna', // Vienna, Austria
|
||||
// 'Europe/Stockholm', // Stockholm, Sweden
|
||||
// 'Europe/Oslo', // Oslo, Norway
|
||||
// 'Europe/Copenhagen', // Copenhagen, Denmark
|
||||
// 'Europe/Zurich', // Zurich, Switzerland
|
||||
// 'Europe/Athens', // Athens, Greece
|
||||
// 'Europe/Warsaw', // Warsaw, Poland
|
||||
// 'Europe/Prague', // Prague, Czech Republic
|
||||
// 'Europe/Budapest', // Budapest, Hungary
|
||||
// 'Europe/Belgrade', // Belgrade, Serbia
|
||||
|
||||
// 北美洲
|
||||
// 'America/Chicago', // 美国芝加哥
|
||||
// 'America/Denver', // 美国丹佛
|
||||
// 'America/Toronto', // 加拿大多伦多
|
||||
// 'America/Vancouver', // 加拿大温哥华
|
||||
// 'America/Mexico_City', // 墨西哥墨西哥城
|
||||
// North America
|
||||
// 'America/Chicago', // Chicago, USA
|
||||
// 'America/Denver', // Denver, USA
|
||||
// 'America/Toronto', // Toronto, Canada
|
||||
// 'America/Vancouver', // Vancouver, Canada
|
||||
// 'America/Mexico_City', // Mexico City, Mexico
|
||||
|
||||
// 南美洲
|
||||
// 'America/Sao_Paulo', // 巴西圣保罗
|
||||
// 'America/Buenos_Aires', // 阿根廷布宜诺斯艾利斯
|
||||
// 'America/Santiago', // 智利圣地亚哥
|
||||
// 'America/Lima', // 秘鲁利马
|
||||
// 'America/Bogota', // 哥伦比亚波哥大
|
||||
// 'America/Caracas', // 委内瑞拉加拉加斯
|
||||
// South America
|
||||
// 'America/Sao_Paulo', // São Paulo, Brazil
|
||||
// 'America/Buenos_Aires', // Buenos Aires, Argentina
|
||||
// 'America/Santiago', // Santiago, Chile
|
||||
// 'America/Lima', // Lima, Peru
|
||||
// 'America/Bogota', // Bogotá, Colombia
|
||||
// 'America/Caracas', // Caracas, Venezuela
|
||||
|
||||
// // 大洋洲
|
||||
// 'Australia/Sydney', // 澳大利亚悉尼
|
||||
// 'Australia/Melbourne', // 澳大利亚墨尔本
|
||||
// 'Australia/Brisbane', // 澳大利亚布里斯班
|
||||
// 'Australia/Perth', // 澳大利亚珀斯
|
||||
// 'New_Zealand/Auckland', // 新西兰奥克兰
|
||||
// Oceania
|
||||
// 'Australia/Sydney', // Sydney, Australia
|
||||
// 'Australia/Melbourne', // Melbourne, Australia
|
||||
// 'Australia/Brisbane', // Brisbane, Australia
|
||||
// 'Australia/Perth', // Perth, Australia
|
||||
// 'New_Zealand/Auckland', // Auckland, New Zealand
|
||||
|
||||
// // 非洲
|
||||
// 'Africa/Cairo', // 埃及开罗
|
||||
// 'Africa/Johannesburg', // 南非约翰内斯堡
|
||||
// 'Africa/Lagos', // 尼日利亚拉各斯
|
||||
// 'Africa/Casablanca', // 摩洛哥卡萨布兰卡
|
||||
// 'Africa/Nairobi', // 肯尼亚内罗毕
|
||||
// 'Africa/Addis_Ababa', // 埃塞俄比亚亚的斯亚贝巴
|
||||
// Africa
|
||||
// 'Africa/Cairo', // Cairo, Egypt
|
||||
// 'Africa/Johannesburg', // Johannesburg, South Africa
|
||||
// 'Africa/Lagos', // Lagos, Nigeria
|
||||
// 'Africa/Casablanca', // Casablanca, Morocco
|
||||
// 'Africa/Nairobi', // Nairobi, Kenya
|
||||
// 'Africa/Addis_Ababa', // Addis Ababa, Ethiopia
|
||||
|
||||
// // 其他
|
||||
// 'UTC', // 协调世界时
|
||||
// Other
|
||||
// 'UTC', // Coordinated Universal Time
|
||||
];
|
||||
|
||||
// 注意:时区显示名称已移至i18n翻译文件中(zh.ts和en.ts)
|
||||
// 请使用i18n.t('timezones.时区名称')来获取本地化的时区显示名称
|
||||
|
||||
// 时区与antd本地化文件的映射
|
||||
// 键为时区,值为antd本地化文件的名称
|
||||
/**
|
||||
* Timezone to Ant Design locale mapping
|
||||
* Key: timezone identifier
|
||||
* Value: Ant Design locale object
|
||||
*/
|
||||
export const timezoneToAntdLocaleMap: Record<string, Locale> = {
|
||||
// 亚洲
|
||||
'Asia/Shanghai': zh_CN, // 中国上海 - 中文(中国大陆)
|
||||
'Asia/Kolkata': hi_IN, // 印度加尔各答 - 印地语
|
||||
'Europe/Moscow': ru_RU, // 俄罗斯莫斯科 - 俄语
|
||||
'Europe/Berlin': de_DE, // 德国柏林 - 德语
|
||||
'Europe/London': en_GB, // 英国伦敦 - 英语(英国)
|
||||
'America/New_York': en_US, // 美国纽约 - 英语(美国)
|
||||
'America/Los_Angeles': en_US, // 美国洛杉矶 - 英语(美国)
|
||||
// Asia
|
||||
'Asia/Shanghai': zh_CN, // Shanghai, China - Chinese (Mainland)
|
||||
'Asia/Kolkata': hi_IN, // Kolkata, India - Hindi
|
||||
'Europe/Moscow': ru_RU, // Moscow, Russia - Russian
|
||||
'Europe/Berlin': de_DE, // Berlin, Germany - German
|
||||
'Europe/London': en_GB, // London, UK - English (UK)
|
||||
'America/New_York': en_US, // New York, USA - English (US)
|
||||
'America/Los_Angeles': en_US, // Los Angeles, USA - English (US)
|
||||
|
||||
// 'Asia/Tokyo': 'ja_JP', // 日本东京 - 日语
|
||||
// 'Asia/Singapore': 'en_SG', // 新加坡 - 英语(新加坡)
|
||||
// 'Asia/Hong_Kong': 'zh_HK', // 中国香港 - 中文(香港)
|
||||
// 'Asia/Taipei': 'zh_TW', // 中国台北 - 中文(台湾)
|
||||
// 'Asia/Seoul': 'ko_KR', // 韩国首尔 - 韩语
|
||||
// 'Asia/Bangkok': 'th_TH', // 泰国曼谷 - 泰语
|
||||
// 'Asia/Jakarta': 'id_ID', // 印度尼西亚雅加达 - 印尼语
|
||||
// 'Asia/Manila': 'en_PH', // 菲律宾马尼拉 - 英语(菲律宾)
|
||||
// 'Asia/Dubai': 'ar_AE', // 阿联酋迪拜 - 阿拉伯语
|
||||
// 'Asia/Tashkent': 'uz_UZ', // 乌兹别克斯坦塔什干 - 乌兹别克语
|
||||
// 'Asia/Riyadh': 'ar_SA', // 沙特阿拉伯利雅得 - 阿拉伯语
|
||||
// 'Asia/Baku': 'az_AZ', // 阿塞拜疆巴库 - 阿塞拜疆语
|
||||
// 'Asia/Istanbul': 'tr_TR', // 土耳其伊斯坦布尔 - 土耳其语
|
||||
// 'Asia/Tokyo': 'ja_JP', // Tokyo, Japan - Japanese
|
||||
// 'Asia/Singapore': 'en_SG', // Singapore - English (Singapore)
|
||||
// 'Asia/Hong_Kong': 'zh_HK', // Hong Kong, China - Chinese (Hong Kong)
|
||||
// 'Asia/Taipei': 'zh_TW', // Taipei, Taiwan - Chinese (Taiwan)
|
||||
// 'Asia/Seoul': 'ko_KR', // Seoul, South Korea - Korean
|
||||
// 'Asia/Bangkok': 'th_TH', // Bangkok, Thailand - Thai
|
||||
// 'Asia/Jakarta': 'id_ID', // Jakarta, Indonesia - Indonesian
|
||||
// 'Asia/Manila': 'en_PH', // Manila, Philippines - English (Philippines)
|
||||
// 'Asia/Dubai': 'ar_AE', // Dubai, UAE - Arabic
|
||||
// 'Asia/Tashkent': 'uz_UZ', // Tashkent, Uzbekistan - Uzbek
|
||||
// 'Asia/Riyadh': 'ar_SA', // Riyadh, Saudi Arabia - Arabic
|
||||
// 'Asia/Baku': 'az_AZ', // Baku, Azerbaijan - Azerbaijani
|
||||
// 'Asia/Istanbul': 'tr_TR', // Istanbul, Turkey - Turkish
|
||||
|
||||
// // 欧洲
|
||||
// 'Europe/Paris': 'fr_FR', // 法国巴黎 - 法语
|
||||
// 'Europe/Rome': 'it_IT', // 意大利罗马 - 意大利语
|
||||
// 'Europe/Madrid': 'es_ES', // 西班牙马德里 - 西班牙语
|
||||
// 'Europe/Amsterdam': 'nl_NL', // 荷兰阿姆斯特丹 - 荷兰语
|
||||
// 'Europe/Vienna': 'de_AT', // 奥地利维也纳 - 德语(奥地利)
|
||||
// 'Europe/Stockholm': 'sv_SE', // 瑞典斯德哥尔摩 - 瑞典语
|
||||
// 'Europe/Oslo': 'nb_NO', // 挪威奥斯陆 - 挪威语
|
||||
// 'Europe/Copenhagen': 'da_DK', // 丹麦哥本哈根 - 丹麦语
|
||||
// 'Europe/Zurich': 'de_CH', // 瑞士苏黎世 - 德语(瑞士)
|
||||
// 'Europe/Athens': 'el_GR', // 希腊雅典 - 希腊语
|
||||
// 'Europe/Warsaw': 'pl_PL', // 波兰华沙 - 波兰语
|
||||
// 'Europe/Prague': 'cs_CZ', // 捷克布拉格 - 捷克语
|
||||
// 'Europe/Budapest': 'hu_HU', // 匈牙利布达佩斯 - 匈牙利语
|
||||
// 'Europe/Belgrade': 'sr_RS', // 塞尔维亚贝尔格莱德 - 塞尔维亚语
|
||||
// Europe
|
||||
// 'Europe/Paris': 'fr_FR', // Paris, France - French
|
||||
// 'Europe/Rome': 'it_IT', // Rome, Italy - Italian
|
||||
// 'Europe/Madrid': 'es_ES', // Madrid, Spain - Spanish
|
||||
// 'Europe/Amsterdam': 'nl_NL', // Amsterdam, Netherlands - Dutch
|
||||
// 'Europe/Vienna': 'de_AT', // Vienna, Austria - German (Austria)
|
||||
// 'Europe/Stockholm': 'sv_SE', // Stockholm, Sweden - Swedish
|
||||
// 'Europe/Oslo': 'nb_NO', // Oslo, Norway - Norwegian
|
||||
// 'Europe/Copenhagen': 'da_DK', // Copenhagen, Denmark - Danish
|
||||
// 'Europe/Zurich': 'de_CH', // Zurich, Switzerland - German (Switzerland)
|
||||
// 'Europe/Athens': 'el_GR', // Athens, Greece - Greek
|
||||
// 'Europe/Warsaw': 'pl_PL', // Warsaw, Poland - Polish
|
||||
// 'Europe/Prague': 'cs_CZ', // Prague, Czech Republic - Czech
|
||||
// 'Europe/Budapest': 'hu_HU', // Budapest, Hungary - Hungarian
|
||||
// 'Europe/Belgrade': 'sr_RS', // Belgrade, Serbia - Serbian
|
||||
|
||||
// // 北美洲
|
||||
// 'America/Chicago': 'en_US', // 美国芝加哥 - 英语(美国)
|
||||
// 'America/Denver': 'en_US', // 美国丹佛 - 英语(美国)
|
||||
// 'America/Toronto': 'en_CA', // 加拿大多伦多 - 英语(加拿大)
|
||||
// 'America/Vancouver': 'en_CA', // 加拿大温哥华 - 英语(加拿大)
|
||||
// 'America/Mexico_City': 'es_MX', // 墨西哥墨西哥城 - 西班牙语(墨西哥)
|
||||
// North America
|
||||
// 'America/Chicago': 'en_US', // Chicago, USA - English (US)
|
||||
// 'America/Denver': 'en_US', // Denver, USA - English (US)
|
||||
// 'America/Toronto': 'en_CA', // Toronto, Canada - English (Canada)
|
||||
// 'America/Vancouver': 'en_CA', // Vancouver, Canada - English (Canada)
|
||||
// 'America/Mexico_City': 'es_MX', // Mexico City, Mexico - Spanish (Mexico)
|
||||
|
||||
// // 南美洲
|
||||
// 'America/Sao_Paulo': 'pt_BR', // 巴西圣保罗 - 葡萄牙语(巴西)
|
||||
// 'America/Buenos_Aires': 'es_AR', // 阿根廷布宜诺斯艾利斯 - 西班牙语(阿根廷)
|
||||
// 'America/Santiago': 'es_CL', // 智利圣地亚哥 - 西班牙语(智利)
|
||||
// 'America/Lima': 'es_PE', // 秘鲁利马 - 西班牙语(秘鲁)
|
||||
// 'America/Bogota': 'es_CO', // 哥伦比亚波哥大 - 西班牙语(哥伦比亚)
|
||||
// 'America/Caracas': 'es_VE', // 委内瑞拉加拉加斯 - 西班牙语(委内瑞拉)
|
||||
// South America
|
||||
// 'America/Sao_Paulo': 'pt_BR', // São Paulo, Brazil - Portuguese (Brazil)
|
||||
// 'America/Buenos_Aires': 'es_AR', // Buenos Aires, Argentina - Spanish (Argentina)
|
||||
// 'America/Santiago': 'es_CL', // Santiago, Chile - Spanish (Chile)
|
||||
// 'America/Lima': 'es_PE', // Lima, Peru - Spanish (Peru)
|
||||
// 'America/Bogota': 'es_CO', // Bogotá, Colombia - Spanish (Colombia)
|
||||
// 'America/Caracas': 'es_VE', // Caracas, Venezuela - Spanish (Venezuela)
|
||||
|
||||
// // 大洋洲
|
||||
// 'Australia/Sydney': 'en_AU', // 澳大利亚悉尼 - 英语(澳大利亚)
|
||||
// 'Australia/Melbourne': 'en_AU', // 澳大利亚墨尔本 - 英语(澳大利亚)
|
||||
// 'Australia/Brisbane': 'en_AU', // 澳大利亚布里斯班 - 英语(澳大利亚)
|
||||
// 'Australia/Perth': 'en_AU', // 澳大利亚珀斯 - 英语(澳大利亚)
|
||||
// 'New_Zealand/Auckland': 'en_NZ', // 新西兰奥克兰 - 英语(新西兰)
|
||||
// Oceania
|
||||
// 'Australia/Sydney': 'en_AU', // Sydney, Australia - English (Australia)
|
||||
// 'Australia/Melbourne': 'en_AU', // Melbourne, Australia - English (Australia)
|
||||
// 'Australia/Brisbane': 'en_AU', // Brisbane, Australia - English (Australia)
|
||||
// 'Australia/Perth': 'en_AU', // Perth, Australia - English (Australia)
|
||||
// 'New_Zealand/Auckland': 'en_NZ', // Auckland, New Zealand - English (New Zealand)
|
||||
|
||||
// // 非洲
|
||||
// 'Africa/Cairo': 'ar_EG', // 埃及开罗 - 阿拉伯语(埃及)
|
||||
// 'Africa/Johannesburg': 'en_ZA', // 南非约翰内斯堡 - 英语(南非)
|
||||
// 'Africa/Lagos': 'en_NG', // 尼日利亚拉各斯 - 英语(尼日利亚)
|
||||
// 'Africa/Casablanca': 'fr_MA', // 摩洛哥卡萨布兰卡 - 法语(摩洛哥)
|
||||
// 'Africa/Nairobi': 'en_KE', // 肯尼亚内罗毕 - 英语(肯尼亚)
|
||||
// 'Africa/Addis_Ababa': 'am_ET', // 埃塞俄比亚亚的斯亚贝巴 - 阿姆哈拉语
|
||||
// Africa
|
||||
// 'Africa/Cairo': 'ar_EG', // Cairo, Egypt - Arabic (Egypt)
|
||||
// 'Africa/Johannesburg': 'en_ZA', // Johannesburg, South Africa - English (South Africa)
|
||||
// 'Africa/Lagos': 'en_NG', // Lagos, Nigeria - English (Nigeria)
|
||||
// 'Africa/Casablanca': 'fr_MA', // Casablanca, Morocco - French (Morocco)
|
||||
// 'Africa/Nairobi': 'en_KE', // Nairobi, Kenya - English (Kenya)
|
||||
// 'Africa/Addis_Ababa': 'am_ET', // Addis Ababa, Ethiopia - Amharic
|
||||
|
||||
// // 其他
|
||||
// 'UTC': 'en_US', // 协调世界时 - 默认英语(美国)
|
||||
// Other
|
||||
// 'UTC': 'en_US', // Coordinated Universal Time - Default English (US)
|
||||
};
|
||||
@@ -1,6 +1,24 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:35:32
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:35:32
|
||||
*/
|
||||
/**
|
||||
* YAML Export Utility
|
||||
*
|
||||
* Provides functions to export data as YAML files.
|
||||
*
|
||||
* @module yamlExport
|
||||
*/
|
||||
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
|
||||
/**
|
||||
* Export data to YAML file
|
||||
* @param data - Data to export
|
||||
* @param filename - Output filename (default: 'export.yaml')
|
||||
*/
|
||||
export const exportToYaml = (data: unknown, filename: string = 'export.yaml') => {
|
||||
const yamlStr = yaml.dump(data);
|
||||
const blob = new Blob([yamlStr], { type: 'text/yaml' });
|
||||
|
||||
@@ -12,7 +12,7 @@ import webIcon from '@/assets/images/knowledgeBase/general.png';
|
||||
import tpIcon from '@/assets/images/knowledgeBase/text.png';
|
||||
import type { KnowledgeBaseListItem, CreateModalRef, KnowledgeBaseListResponse, ListQuery } from '@/views/KnowledgeBase/types'
|
||||
import CreateModal from './components/CreateModal'
|
||||
import RbCard from '@/components/RbCard'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import Empty from '@/components/Empty'
|
||||
import { getKnowledgeBaseList, getModelList, getModelTypeList, deleteKnowledgeBase, getKnowledgeBaseTypeList } from '@/api/knowledgeBase'
|
||||
|
||||
@@ -3,15 +3,16 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Row, Col, Space, Select, InputNumber, Slider, App, Form } from 'antd'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import Card from './components/Card'
|
||||
import type { ConfigForm, Variable } from './types'
|
||||
import { getMemoryExtractionConfig, updateMemoryExtractionConfig } from '@/api/memory'
|
||||
import Markdown from '@/components/Markdown'
|
||||
import { getModelList } from '@/api/models';
|
||||
import type { ModelListItem } from '@/views/ModelManagement/types'
|
||||
import { getModelListUrl } from '@/api/models';
|
||||
import { configList } from './constant'
|
||||
import Result from './components/Result'
|
||||
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
|
||||
import CustomSelect from '@/components/CustomSelect'
|
||||
|
||||
const keys = [
|
||||
// 'example',
|
||||
@@ -43,7 +44,6 @@ const MemoryExtractionEngine: FC = () => {
|
||||
const values = Form.useWatch<ConfigForm>([], form)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [iterationPeriodDisabled, setIterationPeriodDisabled] = useState(false)
|
||||
const [modelList, setModelList] = useState<ModelListItem[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (values?.reflexion_range === 'database') {
|
||||
@@ -54,14 +54,6 @@ const MemoryExtractionEngine: FC = () => {
|
||||
}
|
||||
}, [values])
|
||||
|
||||
const getModels = () => {
|
||||
getModelList({ type: 'llm,chat', pagesize: 100, page: 1, is_active: true })
|
||||
.then(res => {
|
||||
const response = res as { items: ModelListItem[] }
|
||||
setModelList(response.items)
|
||||
})
|
||||
}
|
||||
|
||||
const getConfig = () => {
|
||||
if (!id) {
|
||||
return
|
||||
@@ -84,7 +76,6 @@ const MemoryExtractionEngine: FC = () => {
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
getConfig()
|
||||
getModels()
|
||||
}
|
||||
}, [id])
|
||||
|
||||
@@ -123,13 +114,13 @@ const MemoryExtractionEngine: FC = () => {
|
||||
label={t('memoryExtractionEngine.model')}
|
||||
name="llm_id"
|
||||
>
|
||||
<Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
fieldNames={{
|
||||
label: 'name',
|
||||
value: 'id',
|
||||
}}
|
||||
options={modelList}
|
||||
<CustomSelect
|
||||
url={getModelListUrl}
|
||||
params={{ type: 'llm,chat', pagesize: 100, is_active: true }}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
hasAll={false}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -222,7 +213,7 @@ const MemoryExtractionEngine: FC = () => {
|
||||
step={config.step || 0.01}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:text-[#5B6167] rb:leading-5 rb:mt-[-26px]">
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:text-[#5B6167] rb:leading-5 rb:-mt-6.5">
|
||||
{config.min || 0}
|
||||
<span>{t('memoryExtractionEngine.CurrentValue')}: {values?.[config.variableName as keyof ConfigForm]}</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user