docs: add comments to the src/components directory
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 FC, type ReactNode, useEffect } from 'react';
|
||||||
import { type RadioGroupProps } from 'antd';
|
import { type RadioGroupProps } from 'antd';
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
// Button checkbox component props
|
||||||
interface ButtonCheckboxProps extends Omit<RadioGroupProps, 'onChange'> {
|
interface ButtonCheckboxProps extends Omit<RadioGroupProps, 'onChange'> {
|
||||||
|
/** Whether the checkbox is checked */
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
|
/** Callback fired when value changes (for side effects) */
|
||||||
onValueChange?: (checked: boolean) => void;
|
onValueChange?: (checked: boolean) => void;
|
||||||
|
/** Callback fired when checkbox state changes */
|
||||||
onChange?: (checked: boolean) => void;
|
onChange?: (checked: boolean) => void;
|
||||||
|
/** Icon path for unchecked state */
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
/** Icon path for checked state */
|
||||||
checkedIcon?: string;
|
checkedIcon?: string;
|
||||||
|
/** Button content */
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,13 +42,14 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
|||||||
checkedIcon,
|
checkedIcon,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
// 监听value变化
|
// Listen to value changes and trigger side effects via onValueChange callback
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onValueChange) {
|
if (onValueChange) {
|
||||||
onValueChange(checked);
|
onValueChange(checked);
|
||||||
}
|
}
|
||||||
}, [checked, onValueChange]);
|
}, [checked, onValueChange]);
|
||||||
|
|
||||||
|
// Toggle checked state when button is clicked
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(!checked);
|
onChange(!checked);
|
||||||
@@ -34,11 +57,18 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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]", {
|
<div
|
||||||
"rb:bg-[rgba(21,94,239,0.06)] rb:border-[#155EEF] rb:text-[#155EEF]": checked,
|
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:border-[#DFE4ED] rb:text-[#212332]": !checked,
|
// Checked state: blue background and border
|
||||||
})} onClick={handleChange}>
|
"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" />}
|
{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" />}
|
{checkedIcon && checked && <img src={checkedIcon} className="rb:w-4 rb:h-4 rb:mr-1" />}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</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 { useEffect, useState, useMemo, type FC, type Key } from 'react';
|
||||||
import { Select } from 'antd';
|
import { Select } from 'antd';
|
||||||
import type { SelectProps, DefaultOptionType } from 'antd/es/select';
|
import type { SelectProps, DefaultOptionType } from 'antd/es/select';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { request } from '@/utils/request';
|
import { request } from '@/utils/request';
|
||||||
|
|
||||||
|
// Generic option type for API response data
|
||||||
interface OptionType {
|
interface OptionType {
|
||||||
[key: string]: Key | string | number;
|
[key: string]: Key | string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API response structure
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
items?: T[];
|
items?: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CustomSelectProps extends Omit<SelectProps, 'filterOption'> {
|
interface CustomSelectProps extends Omit<SelectProps, 'filterOption'> {
|
||||||
|
/** API endpoint URL to fetch options */
|
||||||
url: string;
|
url: string;
|
||||||
|
/** Query parameters for the API request */
|
||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>;
|
||||||
|
/** Key name for option value in response data */
|
||||||
valueKey?: string;
|
valueKey?: string;
|
||||||
|
/** Key name for option label in response data */
|
||||||
labelKey?: string;
|
labelKey?: string;
|
||||||
|
/** Placeholder text for the select */
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** Whether to show "All" option */
|
||||||
hasAll?: boolean;
|
hasAll?: boolean;
|
||||||
|
/** Custom text for "All" option */
|
||||||
allTitle?: string;
|
allTitle?: string;
|
||||||
|
/** Function to format/transform the options data */
|
||||||
format?: (items: OptionType[]) => OptionType[];
|
format?: (items: OptionType[]) => OptionType[];
|
||||||
|
/** Whether to enable search functionality */
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
|
/** Property name to filter options by */
|
||||||
optionFilterProp?: string;
|
optionFilterProp?: string;
|
||||||
|
/** Custom filter function for search */
|
||||||
filterOption?: (inputValue: string, option?: DefaultOptionType) => boolean;
|
filterOption?: (inputValue: string, option?: DefaultOptionType) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default filter function for search - performs case-insensitive substring matching
|
||||||
const defaultFilterOption = (inputValue: string, option?: DefaultOptionType): boolean => {
|
const defaultFilterOption = (inputValue: string, option?: DefaultOptionType): boolean => {
|
||||||
if (!option || !inputValue) return true;
|
if (!option || !inputValue) return true;
|
||||||
const label = String(option.children || option.label || '');
|
const label = String(option.children || option.label || '');
|
||||||
@@ -47,8 +77,10 @@ const CustomSelect: FC<CustomSelectProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [options, setOptions] = useState<OptionType[]>([]);
|
const [options, setOptions] = useState<OptionType[]>([]);
|
||||||
|
// Memoize params to prevent unnecessary re-fetches
|
||||||
const memoizedParams = useMemo(() => params, [JSON.stringify(params)]);
|
const memoizedParams = useMemo(() => params, [JSON.stringify(params)]);
|
||||||
|
|
||||||
|
// Fetch options from API when url or params change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request.get<ApiResponse<OptionType>>(url, memoizedParams).then((res) => {
|
request.get<ApiResponse<OptionType>>(url, memoizedParams).then((res) => {
|
||||||
const data = Array.isArray(res) ? res : res?.items || [];
|
const data = Array.isArray(res) ? res : res?.items || [];
|
||||||
@@ -56,6 +88,7 @@ const CustomSelect: FC<CustomSelectProps> = ({
|
|||||||
});
|
});
|
||||||
}, [url, memoizedParams]);
|
}, [url, memoizedParams]);
|
||||||
|
|
||||||
|
// Apply custom format function if provided
|
||||||
const displayOptions = format ? format(options) : options;
|
const displayOptions = format ? format(options) : options;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,7 +99,9 @@ const CustomSelect: FC<CustomSelectProps> = ({
|
|||||||
filterOption={filterOption || defaultFilterOption}
|
filterOption={filterOption || defaultFilterOption}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
{/* Optional "All" option for selecting all items */}
|
||||||
{hasAll && <Select.Option value={null}>{allTitle || t('common.all')}</Select.Option>}
|
{hasAll && <Select.Option value={null}>{allTitle || t('common.all')}</Select.Option>}
|
||||||
|
{/* Render options from API data */}
|
||||||
{displayOptions.map((option) => (
|
{displayOptions.map((option) => (
|
||||||
<Select.Option key={option[valueKey]} value={option[valueKey]}>
|
<Select.Option key={option[valueKey]} value={option[valueKey]}>
|
||||||
{String(option[labelKey])}
|
{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 type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
import PageEmpty from './PageEmpty'
|
import PageEmpty from './PageEmpty'
|
||||||
import PageLoading from './PageLoading'
|
import PageLoading from './PageLoading'
|
||||||
|
|
||||||
interface BodyWrapperProps {
|
interface BodyWrapperProps {
|
||||||
|
/** Content to render when not loading or empty */
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
/** Whether to show loading state */
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
/** Whether the content is empty */
|
||||||
empty: boolean
|
empty: boolean
|
||||||
}
|
}
|
||||||
const BodyWrapper: FC<BodyWrapperProps> = ({ children, loading = false, empty }) => {
|
const BodyWrapper: FC<BodyWrapperProps> = ({ children, loading = false, empty }) => {
|
||||||
|
// Show loading spinner while data is being fetched
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <PageLoading />
|
return <PageLoading />
|
||||||
}
|
}
|
||||||
|
// Show empty state when no data is available
|
||||||
if (!loading && empty) {
|
if (!loading && empty) {
|
||||||
return <PageEmpty />
|
return <PageEmpty />
|
||||||
}
|
}
|
||||||
|
// Render actual content when data is loaded and available
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
export default BodyWrapper
|
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 { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import LoadingIcon from '@/assets/images/loading.svg'
|
import LoadingIcon from '@/assets/images/loading.svg'
|
||||||
import Empty from './index'
|
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()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<Empty
|
<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 { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import pageEmptyIcon from '@/assets/images/empty/pageEmpty.png'
|
import pageEmptyIcon from '@/assets/images/empty/pageEmpty.png'
|
||||||
import Empty from './index'
|
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()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<Empty
|
<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 { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import LoadingIcon from '@/assets/images/empty/pageLoading.png'
|
import LoadingIcon from '@/assets/images/empty/pageLoading.png'
|
||||||
import Empty from './index'
|
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()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<Empty
|
<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 { type FC } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import emptyIcon from '@/assets/images/empty/empty.svg';
|
import emptyIcon from '@/assets/images/empty/empty.svg';
|
||||||
|
|
||||||
interface EmptyProps {
|
interface EmptyProps {
|
||||||
|
/** Custom icon URL for the empty state */
|
||||||
url?: string;
|
url?: string;
|
||||||
|
/** Icon size - single number or [width, height] array */
|
||||||
size?: number | number[];
|
size?: number | number[];
|
||||||
|
/** Main title text */
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** Whether to show subtitle */
|
||||||
isNeedSubTitle?: boolean;
|
isNeedSubTitle?: boolean;
|
||||||
|
/** Custom subtitle text */
|
||||||
subTitle?: string;
|
subTitle?: string;
|
||||||
|
/** Additional CSS classes */
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
const Empty: FC<EmptyProps> = ({
|
const Empty: FC<EmptyProps> = ({
|
||||||
@@ -19,14 +41,19 @@ const Empty: FC<EmptyProps> = ({
|
|||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
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 width = Array.isArray(size) ? size[0] : size ? size : url ? 200 : 88;
|
||||||
const height = Array.isArray(size) ? size[1] : 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;
|
const curSubTitle = isNeedSubTitle ? (subTitle || t('empty.tableEmpty')) : null;
|
||||||
return (
|
return (
|
||||||
<div className={`rb:flex rb:items-center rb:justify-center rb:flex-col ${className}`}>
|
<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` }} />
|
<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>}
|
{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>}
|
{curSubTitle && <div className={`rb:mt-[${url ? 8 : 5}px] rb:leading-4 rb:text-[12px] rb:text-[#A8A9AA]`}>{curSubTitle}</div>}
|
||||||
</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 clsx from "clsx";
|
||||||
import type { FC, ReactNode } from "react";
|
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}) => {
|
const DescWrapper: FC<{desc: string | ReactNode, className?: string}> = ({desc, className}) => {
|
||||||
return (
|
return (
|
||||||
<div className={clsx(className, "rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4 ")}>
|
<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 clsx from "clsx";
|
||||||
import type { FC, ReactNode } from "react";
|
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}) => {
|
const LabelWrapper: FC<{ title: string | ReactNode, className?: string; children?: ReactNode}> = ({title, className, children}) => {
|
||||||
return (
|
return (
|
||||||
<div className={clsx(className)}>
|
<div className={clsx(className)}>
|
||||||
|
{/* Label title with consistent styling */}
|
||||||
<div className="rb:text-[14px] rb:font-medium rb:leading-5">{title}</div>
|
<div className="rb:text-[14px] rb:font-medium rb:leading-5">{title}</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</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 type { FC, ReactNode } from "react";
|
||||||
import { useContext } from "react";
|
|
||||||
|
|
||||||
import LabelWrapper from './LabelWrapper'
|
import LabelWrapper from './LabelWrapper'
|
||||||
import DescWrapper from './DescWrapper'
|
import DescWrapper from './DescWrapper'
|
||||||
|
|
||||||
interface SwitchFormItemProps {
|
interface SwitchFormItemProps {
|
||||||
|
/** Label text or React node */
|
||||||
title: string | ReactNode;
|
title: string | ReactNode;
|
||||||
|
/** Optional description text or React node */
|
||||||
desc?: string | ReactNode;
|
desc?: string | ReactNode;
|
||||||
|
/** Form field name (string or nested path array) */
|
||||||
name: string | string[];
|
name: string | string[];
|
||||||
|
/** Switch size */
|
||||||
size?: 'small' | 'default'
|
size?: 'small' | 'default'
|
||||||
|
/** Additional CSS classes */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Whether the switch is disabled */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,14 +42,13 @@ const SwitchFormItem: FC<SwitchFormItemProps> = ({
|
|||||||
className,
|
className,
|
||||||
disabled
|
disabled
|
||||||
}) => {
|
}) => {
|
||||||
const componentSize = useSize()
|
|
||||||
console.log('componentSize', componentSize)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${className} rb:flex rb:items-center rb:justify-between`}>
|
<div className={`${className} rb:flex rb:items-center rb:justify-between`}>
|
||||||
|
{/* Label and description section */}
|
||||||
<LabelWrapper title={title}>
|
<LabelWrapper title={title}>
|
||||||
{desc && <DescWrapper desc={desc} className="rb:mt-2" />}
|
{desc && <DescWrapper desc={desc} className="rb:mt-2" />}
|
||||||
</LabelWrapper>
|
</LabelWrapper>
|
||||||
|
{/* Switch control */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={name}
|
name={name}
|
||||||
valuePropName="checked"
|
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 { forwardRef, useImperativeHandle, useState } from 'react';
|
||||||
import { Form, Select } from 'antd';
|
import { Form, Select } from 'antd';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -7,34 +22,39 @@ import { useI18n } from '@/store/locale'
|
|||||||
import { timezones } from '@/utils/timezones'
|
import { timezones } from '@/utils/timezones'
|
||||||
|
|
||||||
const FormItem = Form.Item;
|
const FormItem = Form.Item;
|
||||||
|
|
||||||
|
/** Interface for SettingModal ref methods exposed to parent components */
|
||||||
export interface SettingModalRef {
|
export interface SettingModalRef {
|
||||||
|
/** Open the settings modal */
|
||||||
handleOpen: () => void;
|
handleOpen: () => void;
|
||||||
|
/** Close the settings modal */
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Settings modal component for language and timezone configuration */
|
||||||
const SettingModal = forwardRef<SettingModalRef>((_props, ref) => {
|
const SettingModal = forwardRef<SettingModalRef>((_props, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { changeLanguage, language, timeZone, changeTimeZone } = useI18n()
|
const { changeLanguage, language, timeZone, changeTimeZone } = useI18n()
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
const values = Form.useWatch([], form);
|
/** Close modal and reset form to initial state */
|
||||||
|
|
||||||
// 封装取消方法,添加关闭弹窗逻辑
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Open modal and populate form with current settings */
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
form.setFieldsValue({ language, timeZone })
|
form.setFieldsValue({ language, timeZone })
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
};
|
};
|
||||||
// 封装保存方法,添加提交逻辑
|
|
||||||
|
/** Validate and save settings, update language and timezone if changed */
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
form
|
form
|
||||||
.validateFields()
|
.validateFields()
|
||||||
.then(() => {
|
.then((values) => {
|
||||||
const { language: newLanguage, timeZone: newTimeZone } = values
|
const { language: newLanguage, timeZone: newTimeZone } = values
|
||||||
if (newLanguage !== language) {
|
if (newLanguage !== language) {
|
||||||
changeLanguage(newLanguage);
|
changeLanguage(newLanguage);
|
||||||
@@ -47,11 +67,12 @@ const SettingModal = forwardRef<SettingModalRef>((_props, ref) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暴露给父组件的方法
|
/** Expose handleOpen and handleClose methods to parent component via ref */
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
handleOpen,
|
handleOpen,
|
||||||
handleClose
|
handleClose
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RbModal
|
<RbModal
|
||||||
title={t('header.setting')}
|
title={t('header.setting')}
|
||||||
@@ -64,7 +85,7 @@ const SettingModal = forwardRef<SettingModalRef>((_props, ref) => {
|
|||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
>
|
>
|
||||||
{/* 中英文切换 */}
|
{/* Language selection dropdown */}
|
||||||
<FormItem
|
<FormItem
|
||||||
name="language"
|
name="language"
|
||||||
label={t('header.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 }))}
|
options={['zh', 'en'].map(key => ({ label: t(`header.${key}`), value: key }))}
|
||||||
/>
|
/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
{/* 时区切换 */}
|
{/* Timezone selection dropdown */}
|
||||||
<FormItem
|
<FormItem
|
||||||
name="timeZone"
|
name="timeZone"
|
||||||
label={t('header.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 { forwardRef, useImperativeHandle, useState, useRef } from 'react';
|
||||||
import { Button } from 'antd';
|
import { Button } from 'antd';
|
||||||
import { UnlockOutlined } from '@ant-design/icons';
|
import { UnlockOutlined } from '@ant-design/icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useUser } from '@/store/user';
|
|
||||||
|
|
||||||
|
import { useUser } from '@/store/user';
|
||||||
import RbModal from '@/components/RbModal'
|
import RbModal from '@/components/RbModal'
|
||||||
import { formatDateTime } from '@/utils/format';
|
import { formatDateTime } from '@/utils/format';
|
||||||
import ResetPasswordModal from '@/views/UserManagement/components/ResetPasswordModal'
|
import ResetPasswordModal from '@/views/UserManagement/components/ResetPasswordModal'
|
||||||
import type { ResetPasswordModalRef } from '@/views/UserManagement/types'
|
import type { ResetPasswordModalRef } from '@/views/UserManagement/types'
|
||||||
|
|
||||||
|
/** Interface for UserInfoModal ref methods exposed to parent components */
|
||||||
export interface UserInfoModalRef {
|
export interface UserInfoModalRef {
|
||||||
|
/** Open the user info modal */
|
||||||
handleOpen: () => void;
|
handleOpen: () => void;
|
||||||
|
/** Close the user info modal */
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User information modal component displaying user details and security settings */
|
||||||
const UserInfoModal = forwardRef<UserInfoModalRef>((_props, ref) => {
|
const UserInfoModal = forwardRef<UserInfoModalRef>((_props, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const resetPasswordModalRef = useRef<ResetPasswordModalRef>(null)
|
const resetPasswordModalRef = useRef<ResetPasswordModalRef>(null)
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
// 封装取消方法,添加关闭弹窗逻辑
|
/** Close the modal */
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Open the modal */
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 暴露给父组件的方法
|
/** Expose handleOpen and handleClose methods to parent component via ref */
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
handleOpen,
|
handleOpen,
|
||||||
handleClose
|
handleClose
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RbModal
|
<RbModal
|
||||||
title={t('header.userInfo')}
|
title={t('header.userInfo')}
|
||||||
@@ -41,32 +63,40 @@ const UserInfoModal = forwardRef<UserInfoModalRef>((_props, ref) => {
|
|||||||
onCancel={handleClose}
|
onCancel={handleClose}
|
||||||
footer={null}
|
footer={null}
|
||||||
>
|
>
|
||||||
|
{/* Basic Information Section */}
|
||||||
<div className="rb:text-[#5B6167] rb:font-medium">{t('header.basicInfo')}</div>
|
<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:whitespace-nowrap">{t('user.username')}</span>
|
||||||
<span className="rb:text-[#212332]">{user.username}</span>
|
<span className="rb:text-[#212332]">{user.username}</span>
|
||||||
</div>
|
</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:whitespace-nowrap">{t('user.email')}</span>
|
||||||
<span className="rb:text-[#212332]">{user.email}</span>
|
<span className="rb:text-[#212332]">{user.email}</span>
|
||||||
</div>
|
</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:whitespace-nowrap">{t('user.role')}</span>
|
||||||
<span className="rb:text-[#212332]">{user.is_superuser ? t('user.superuser') : t('user.normalUser')}</span>
|
<span className="rb:text-[#212332]">{user.is_superuser ? t('user.superuser') : t('user.normalUser')}</span>
|
||||||
</div>
|
</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:whitespace-nowrap">{t('user.createdAt')}</span>
|
||||||
<span className="rb:text-[#212332]">{formatDateTime(user.created_at, 'YYYY-MM-DD HH:mm:ss')}</span>
|
<span className="rb:text-[#212332]">{formatDateTime(user.created_at, 'YYYY-MM-DD HH:mm:ss')}</span>
|
||||||
</div>
|
</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]">
|
{/* Password Change Card */}
|
||||||
<div className="rb:flex rb:items-center rb:gap-[12px]">
|
<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]" />
|
<UnlockOutlined className="rb:text-[24px]" />
|
||||||
<div>
|
<div>
|
||||||
<div className="rb:leading-[20px]">{t('header.changePassword')}</div>
|
<div className="rb:leading-5">{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:text-[#5B6167] rb:text-[12px] rb:mt-1 rb:leading-4">{t('header.changePasswordDesc')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => resetPasswordModalRef.current?.handleOpen(user)}>{t('common.change')}</Button>
|
<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 { 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 type { MenuProps, BreadcrumbProps } from 'antd';
|
||||||
import { UserOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons';
|
import { UserOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { useUser } from '@/store/user';
|
import { useUser } from '@/store/user';
|
||||||
import { useMenu } from '@/store/menu';
|
import { useMenu } from '@/store/menu';
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
import SettingModal, { type SettingModalRef } from './SettingModal'
|
import SettingModal, { type SettingModalRef } from './SettingModal'
|
||||||
import UserInfoModal, { type UserInfoModalRef } from './UserInfoModal'
|
import UserInfoModal, { type UserInfoModalRef } from './UserInfoModal'
|
||||||
|
|
||||||
const { Header } = Layout;
|
const { Header } = Layout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param source - Breadcrumb source type ('space' or 'manage'), defaults to 'manage'
|
||||||
|
*/
|
||||||
const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
|
const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -20,21 +40,26 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
|
|||||||
const { user, logout } = useUser();
|
const { user, logout } = useUser();
|
||||||
const { allBreadcrumbs } = useMenu();
|
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 getBreadcrumbSource = () => {
|
||||||
const pathname = location.pathname;
|
const pathname = location.pathname;
|
||||||
|
|
||||||
// 知识库列表页面使用默认的 space 面包屑
|
// Knowledge base list page uses default space breadcrumb
|
||||||
if (pathname === '/knowledge-base') {
|
if (pathname === '/knowledge-base') {
|
||||||
return 'space';
|
return 'space';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 知识库详情相关页面使用独立的面包屑
|
// Knowledge base detail pages use independent breadcrumb
|
||||||
if (pathname.includes('/knowledge-base/') && pathname !== '/knowledge-base') {
|
if (pathname.includes('/knowledge-base/') && pathname !== '/knowledge-base') {
|
||||||
return 'space-detail';
|
return 'space-detail';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他页面使用传入的 source
|
// Other pages use the passed source
|
||||||
return source;
|
return source;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,13 +67,12 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
|
|||||||
const breadcrumbs = allBreadcrumbs[breadcrumbSource] || [];
|
const breadcrumbs = allBreadcrumbs[breadcrumbSource] || [];
|
||||||
|
|
||||||
|
|
||||||
|
/** Handle user logout */
|
||||||
// 处理退出登录
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
};
|
};
|
||||||
|
|
||||||
// 用户下拉菜单配置
|
/** User dropdown menu configuration with profile, settings, and logout options */
|
||||||
const userMenuItems: MenuProps['items'] = [
|
const userMenuItems: MenuProps['items'] = [
|
||||||
{
|
{
|
||||||
key: '1',
|
key: '1',
|
||||||
@@ -89,18 +113,25 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
|
|||||||
onClick: handleLogout,
|
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 = () => {
|
const formatBreadcrumbNames = () => {
|
||||||
return breadcrumbs.map((menu, index) => {
|
return breadcrumbs.map((menu, index) => {
|
||||||
const item: any = {
|
const item: any = {
|
||||||
title: menu.i18nKey ? t(menu.i18nKey) : menu.label,
|
title: menu.i18nKey ? t(menu.i18nKey) : menu.label,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 如果是最后一项,不设置 path
|
// If it's the last item, don't set path
|
||||||
if (index === breadcrumbs.length - 1) {
|
if (index === breadcrumbs.length - 1) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有自定义 onClick,使用 onClick 并设置 href 为 '#' 以显示手型光标
|
// If has custom onClick, use onClick and set href to '#' to show pointer cursor
|
||||||
if ((menu as any).onClick) {
|
if ((menu as any).onClick) {
|
||||||
item.onClick = (e: React.MouseEvent) => {
|
item.onClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -108,35 +139,26 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
|
|||||||
};
|
};
|
||||||
item.href = '#';
|
item.href = '#';
|
||||||
} else if (menu.path && menu.path !== '#') {
|
} else if (menu.path && menu.path !== '#') {
|
||||||
// 只有当 path 不是 '#' 时才设置 path
|
// Only set path when path is not '#'
|
||||||
item.path = menu.path;
|
item.path = menu.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Header className={styles.header}>
|
<Header className={styles.header}>
|
||||||
|
{/* Breadcrumb navigation */}
|
||||||
<Breadcrumb separator=">" items={formatBreadcrumbNames() as BreadcrumbProps['items']} />
|
<Breadcrumb separator=">" items={formatBreadcrumbNames() as BreadcrumbProps['items']} />
|
||||||
{/* 语言切换和主题切换按钮 */}
|
{/* User info dropdown menu */}
|
||||||
<Space>
|
<Dropdown
|
||||||
{/* <Button
|
menu={{
|
||||||
size="small"
|
items: userMenuItems
|
||||||
type="default"
|
}}
|
||||||
onClick={handleLanguageChange}
|
>
|
||||||
>
|
<div className="rb:cursor-pointer">{user.username}</div>
|
||||||
{t(`language.${language === 'en' ? 'zh' : 'en'}`)}
|
</Dropdown>
|
||||||
</Button> */}
|
|
||||||
|
|
||||||
{/* 用户信息下拉菜单 */}
|
|
||||||
<Dropdown
|
|
||||||
menu={{
|
|
||||||
items: userMenuItems
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="rb:cursor-pointer">{user.username}</div>
|
|
||||||
</Dropdown>
|
|
||||||
</Space>
|
|
||||||
<SettingModal
|
<SettingModal
|
||||||
ref={settingModalRef}
|
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 { Outlet } from 'react-router-dom';
|
||||||
import { useEffect, type FC } from 'react';
|
import { useEffect, type FC } from 'react';
|
||||||
import { Layout } from 'antd';
|
import { Layout } from 'antd';
|
||||||
|
|
||||||
import useRouteGuard from '@/hooks/useRouteGuard';
|
import useRouteGuard from '@/hooks/useRouteGuard';
|
||||||
import { useNavigationBreadcrumbs } from '@/hooks/useNavigationBreadcrumbs';
|
import { useNavigationBreadcrumbs } from '@/hooks/useNavigationBreadcrumbs';
|
||||||
import AppHeader from '@/components/Header';
|
import AppHeader from '@/components/Header';
|
||||||
@@ -11,13 +30,20 @@ import { cookieUtils } from '@/utils/request';
|
|||||||
|
|
||||||
const { Content } = Layout;
|
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 AuthLayout: FC = () => {
|
||||||
const { getUserInfo } = useUser();
|
const { getUserInfo } = useUser();
|
||||||
// 使用路由守卫hook处理认证和权限检查
|
|
||||||
|
// Use route guard hook to handle authentication and permission checks
|
||||||
useRouteGuard('manage');
|
useRouteGuard('manage');
|
||||||
// 自动更新面包屑导航
|
|
||||||
|
// Automatically update breadcrumb navigation based on current route
|
||||||
useNavigationBreadcrumbs('manage');
|
useNavigationBreadcrumbs('manage');
|
||||||
|
|
||||||
|
// Check authentication token and fetch user info on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const authToken = cookieUtils.get('authToken')
|
const authToken = cookieUtils.get('authToken')
|
||||||
if (!authToken && !window.location.hash.includes('#/login')) {
|
if (!authToken && !window.location.hash.includes('#/login')) {
|
||||||
@@ -29,9 +55,12 @@ const AuthLayout: FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
|
{/* Sidebar navigation */}
|
||||||
<Sider />
|
<Sider />
|
||||||
<Layout style={{maxHeight: '100vh', width: '100vh', overflowY: 'auto' }}>
|
<Layout style={{maxHeight: '100vh', width: '100vh', overflowY: 'auto' }}>
|
||||||
|
{/* Header with breadcrumbs and user menu */}
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
{/* Main content area - renders child routes */}
|
||||||
<Content style={{ padding: '16px 17px 24px 16px', zIndex: 0 }}>
|
<Content style={{ padding: '16px 17px 24px 16px', zIndex: 0 }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Content>
|
</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 { Outlet } from 'react-router-dom';
|
||||||
import { useEffect, type FC } from 'react';
|
import { useEffect, type FC } from 'react';
|
||||||
import { Layout } from 'antd';
|
import { Layout } from 'antd';
|
||||||
|
|
||||||
import useRouteGuard from '@/hooks/useRouteGuard';
|
import useRouteGuard from '@/hooks/useRouteGuard';
|
||||||
import { useNavigationBreadcrumbs } from '@/hooks/useNavigationBreadcrumbs';
|
import { useNavigationBreadcrumbs } from '@/hooks/useNavigationBreadcrumbs';
|
||||||
import AppHeader from '@/components/Header';
|
import AppHeader from '@/components/Header';
|
||||||
@@ -11,28 +31,38 @@ import { cookieUtils } from '@/utils/request';
|
|||||||
|
|
||||||
const { Content } = Layout;
|
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 AuthSpaceLayout: FC = () => {
|
||||||
const { getUserInfo, getStorageType } = useUser();
|
const { getUserInfo, getStorageType } = useUser();
|
||||||
// 使用路由守卫hook处理认证和权限检查
|
|
||||||
|
// Use route guard hook to handle authentication and permission checks for space context
|
||||||
useRouteGuard('space');
|
useRouteGuard('space');
|
||||||
// 自动更新面包屑导航
|
|
||||||
|
// Automatically update breadcrumb navigation based on current route in space context
|
||||||
useNavigationBreadcrumbs('space');
|
useNavigationBreadcrumbs('space');
|
||||||
|
|
||||||
|
// Check authentication token, fetch user info and storage type on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const authToken = cookieUtils.get('authToken')
|
const authToken = cookieUtils.get('authToken')
|
||||||
if (!authToken && !window.location.hash.includes('#/login')) {
|
if (!authToken && !window.location.hash.includes('#/login')) {
|
||||||
window.location.href = `/#/login`;
|
window.location.href = `/#/login`;
|
||||||
} else {
|
} else {
|
||||||
getUserInfo()
|
getUserInfo()
|
||||||
getStorageType()
|
getStorageType() // Fetch storage type for knowledge base operations
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
|
{/* Sidebar navigation configured for space mode */}
|
||||||
<Sider source="space" />
|
<Sider source="space" />
|
||||||
<Layout style={{maxHeight: '100vh', width: '100vh', overflowY: 'auto' }}>
|
<Layout style={{maxHeight: '100vh', width: '100vh', overflowY: 'auto' }}>
|
||||||
|
{/* Header with breadcrumbs and user menu configured for space mode */}
|
||||||
<AppHeader source="space" />
|
<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' }}>
|
<Content style={{ padding: '16px 17px 24px 16px', zIndex: 0, height: 'calc(100vh - 64px)', overflowY: 'auto' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Content>
|
</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 { Outlet } from 'react-router-dom';
|
||||||
import { useEffect, type FC } from 'react';
|
import { useEffect, type FC } from 'react';
|
||||||
|
|
||||||
import { useUser } from '@/store/user';
|
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 BasicLayout: FC = () => {
|
||||||
const { getUserInfo, getStorageType } = useUser();
|
const { getUserInfo, getStorageType } = useUser();
|
||||||
|
|
||||||
// 获取用户信息
|
// Fetch user information and storage type on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getUserInfo();
|
getUserInfo();
|
||||||
getStorageType()
|
getStorageType()
|
||||||
@@ -14,6 +37,7 @@ const BasicLayout: FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rb:relative rb:h-full rb:w-full">
|
<div className="rb:relative rb:h-full rb:w-full">
|
||||||
|
{/* Render child routes without additional UI */}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</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 { type FC } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import styles from './layout.module.css';
|
import styles from './layout.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background layout component with decorative elements.
|
||||||
|
* Renders a fixed full-screen background with styled shapes.
|
||||||
|
*/
|
||||||
const LayoutBg: FC = () => {
|
const LayoutBg: FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className="rb:fixed rb:top-0 rb:right-0 rb:left-0 rb:bottom-0 rb:bg-[#FBFDFF]">
|
<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>
|
<div className={clsx(styles.left1)}></div>
|
||||||
|
{/* Left decorative element 2 */}
|
||||||
<div className={clsx(styles.left2)}></div>
|
<div className={clsx(styles.left2)}></div>
|
||||||
|
{/* Right decorative element */}
|
||||||
<div className={clsx(styles.right1)}></div>
|
<div className={clsx(styles.right1)}></div>
|
||||||
</div>
|
</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 { Outlet } from 'react-router-dom';
|
||||||
import { type FC } from 'react';
|
import { type FC } from 'react';
|
||||||
|
|
||||||
// 基础布局组件,用于展示内容并保留用户信息获取功能
|
/**
|
||||||
|
* Login layout component for unauthenticated pages.
|
||||||
|
* Renders child routes in a simple full-size container.
|
||||||
|
*/
|
||||||
const LoginLayout: FC = () => {
|
const LoginLayout: FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rb:relative rb:h-full rb:w-full">
|
<div className="rb:relative rb:h-full rb:w-full">
|
||||||
|
{/* Render authentication pages (login, register, etc.) */}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</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 { Outlet } from 'react-router-dom';
|
||||||
import { type FC } from 'react';
|
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 = () => {
|
const NoAuthLayout: FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rb:relative rb:h-full rb:w-full">
|
<div className="rb:relative rb:h-full rb:w-full">
|
||||||
|
{/* Render public pages without authentication */}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</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 {
|
interface AudioBlockProps {
|
||||||
node: {
|
node: {
|
||||||
children: { properties: { src: string } }[]
|
children: { properties: { src: string } }[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Audio block component that renders audio elements from markdown nodes */
|
||||||
const AudioBlock: FC<AudioBlockProps> = (props) => {
|
const AudioBlock: FC<AudioBlockProps> = (props) => {
|
||||||
// console.log('AudioBlock', props)
|
|
||||||
const { children } = props.node;
|
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)
|
const srcs = children.map(item => item.properties?.src).filter(item => item)
|
||||||
|
|
||||||
return (
|
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 { type FC, useMemo } from 'react'
|
||||||
import SyntaxHighlighter from 'react-syntax-highlighter';
|
import SyntaxHighlighter from 'react-syntax-highlighter';
|
||||||
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs';
|
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs';
|
||||||
import CopyBtn from './CopyBtn';
|
|
||||||
import ReactEcharts from 'echarts-for-react';
|
import ReactEcharts from 'echarts-for-react';
|
||||||
|
|
||||||
|
import CopyBtn from './CopyBtn';
|
||||||
import Svg from './Svg'
|
import Svg from './Svg'
|
||||||
import MermaidChart from './MermaidChart'
|
import MermaidChart from './MermaidChart'
|
||||||
|
|
||||||
|
/** Props interface for Code component */
|
||||||
type ICodeProps = {
|
type ICodeProps = {
|
||||||
children: string;
|
children: string;
|
||||||
className: string;
|
className: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Code block component that renders syntax-highlighted code or special visualizations */
|
||||||
const Code: FC<ICodeProps> = (props) => {
|
const Code: FC<ICodeProps> = (props) => {
|
||||||
const { children, className } = props;
|
const { children, className } = props;
|
||||||
|
/** Extract language from className (e.g., 'language-javascript' -> 'javascript') */
|
||||||
const language = className?.split('-')[1]
|
const language = className?.split('-')[1]
|
||||||
console.log('Code', props)
|
console.log('Code', props)
|
||||||
|
|
||||||
|
// Parse ECharts configuration from code content
|
||||||
const charData = useMemo(() => {
|
const charData = useMemo(() => {
|
||||||
if (language !== 'echarts') return null;
|
if (language !== 'echarts') return null;
|
||||||
try {
|
try {
|
||||||
@@ -27,6 +50,7 @@ const Code: FC<ICodeProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}, [language, children])
|
}, [language, children])
|
||||||
|
|
||||||
|
// Render ECharts visualization
|
||||||
if (language === 'echarts') {
|
if (language === 'echarts') {
|
||||||
return (
|
return (
|
||||||
<ReactEcharts
|
<ReactEcharts
|
||||||
@@ -39,6 +63,7 @@ const Code: FC<ICodeProps> = (props) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render SVG content
|
||||||
if (language === 'svg') {
|
if (language === 'svg') {
|
||||||
return (
|
return (
|
||||||
<Svg
|
<Svg
|
||||||
@@ -46,6 +71,7 @@ const Code: FC<ICodeProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Render Mermaid diagram
|
||||||
if (language === 'mermaid') {
|
if (language === 'mermaid') {
|
||||||
return (
|
return (
|
||||||
<MermaidChart
|
<MermaidChart
|
||||||
@@ -54,6 +80,7 @@ const Code: FC<ICodeProps> = (props) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render syntax-highlighted code block with copy button
|
||||||
if (className) {
|
if (className) {
|
||||||
return (
|
return (
|
||||||
<div className="rb:relative">
|
<div className="rb:relative">
|
||||||
@@ -81,6 +108,7 @@ const Code: FC<ICodeProps> = (props) => {
|
|||||||
</div>
|
</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>
|
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 { type FC } from 'react'
|
||||||
import SyntaxHighlighter from 'react-syntax-highlighter';
|
import SyntaxHighlighter from 'react-syntax-highlighter';
|
||||||
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs';
|
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs';
|
||||||
|
|
||||||
import CopyBtn from './CopyBtn';
|
import CopyBtn from './CopyBtn';
|
||||||
|
|
||||||
|
/** Props interface for CodeBlock component */
|
||||||
type ICodeBlockProps = {
|
type ICodeBlockProps = {
|
||||||
value: string;
|
value: string;
|
||||||
needCopy?: boolean;
|
needCopy?: boolean;
|
||||||
@@ -11,12 +29,7 @@ type ICodeBlockProps = {
|
|||||||
showLineNumbers?: boolean;
|
showLineNumbers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// enum languageType {
|
/** Code block component for displaying formatted code with optional copy functionality */
|
||||||
// echarts = 'echarts',
|
|
||||||
// mermaid = 'mermaid',
|
|
||||||
// svg = 'svg',
|
|
||||||
// }
|
|
||||||
|
|
||||||
const CodeBlock: FC<ICodeBlockProps> = ({
|
const CodeBlock: FC<ICodeBlockProps> = ({
|
||||||
value,
|
value,
|
||||||
needCopy = true,
|
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 { type FC } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import copy from 'copy-to-clipboard'
|
import copy from 'copy-to-clipboard'
|
||||||
import { Button, App } from 'antd'
|
import { Button, App } from 'antd'
|
||||||
|
|
||||||
|
/** Props interface for CopyBtn component */
|
||||||
type ICopyBtnProps = {
|
type ICopyBtnProps = {
|
||||||
value: string;
|
value: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Copy button component that copies text to clipboard and shows success message */
|
||||||
const CopyBtn: FC<ICopyBtnProps> = ({
|
const CopyBtn: FC<ICopyBtnProps> = ({
|
||||||
value,
|
value,
|
||||||
className,
|
className,
|
||||||
@@ -18,6 +34,7 @@ const CopyBtn: FC<ICopyBtnProps> = ({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { message } = App.useApp()
|
const { message } = App.useApp()
|
||||||
|
|
||||||
|
/** Copy value to clipboard and show success message */
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
copy(value)
|
copy(value)
|
||||||
message.success(t('common.copySuccess'))
|
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'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
/** Props interface for Link component */
|
||||||
interface LinkProps {
|
interface LinkProps {
|
||||||
href: string;
|
href: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Link component that opens in a new tab with security attributes */
|
||||||
const Link: FC<LinkProps> = (props) => {
|
const Link: FC<LinkProps> = (props) => {
|
||||||
// console.log('Link', props)
|
|
||||||
const { children, href } = props;
|
const { children, href } = props;
|
||||||
return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
|
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 { useRef, useEffect, useState, type FC } from 'react'
|
||||||
import mermaid from 'mermaid'
|
import mermaid from 'mermaid'
|
||||||
import CryptoJS from 'crypto-js'
|
import CryptoJS from 'crypto-js'
|
||||||
import { Image } from 'antd'
|
import { Image } from 'antd'
|
||||||
|
|
||||||
|
/** Initialize Mermaid with default configuration */
|
||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
startOnLoad: true,
|
startOnLoad: true,
|
||||||
theme: 'default',
|
theme: 'default',
|
||||||
@@ -12,6 +30,7 @@ mermaid.initialize({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Convert SVG string to base64 data URL for image display */
|
||||||
const svgToBase64 = (svgGraph: string) => {
|
const svgToBase64 = (svgGraph: string) => {
|
||||||
const svgBytes = new TextEncoder().encode(svgGraph)
|
const svgBytes = new TextEncoder().encode(svgGraph)
|
||||||
const blob = new Blob([svgBytes], { type: 'image/svg+xml;charset=utf-8' })
|
const blob = new Blob([svgBytes], { type: 'image/svg+xml;charset=utf-8' })
|
||||||
@@ -22,8 +41,11 @@ const svgToBase64 = (svgGraph: string) => {
|
|||||||
reader.readAsDataURL(blob)
|
reader.readAsDataURL(blob)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mermaid chart component that renders Mermaid diagrams as images */
|
||||||
const MermaidChart: FC<{ content: string }> = ({ content }) => {
|
const MermaidChart: FC<{ content: string }> = ({ content }) => {
|
||||||
const [chartSvg, setChartSvg] = useState<string>('')
|
const [chartSvg, setChartSvg] = useState<string>('')
|
||||||
|
/** Generate unique ID based on content hash to avoid conflicts */
|
||||||
const id = useRef(`mermaidchart_${CryptoJS.MD5(content).toString()}`)
|
const id = useRef(`mermaidchart_${CryptoJS.MD5(content).toString()}`)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,6 +55,7 @@ const MermaidChart: FC<{ content: string }> = ({ content }) => {
|
|||||||
drawDiagram()
|
drawDiagram()
|
||||||
}, [content])
|
}, [content])
|
||||||
|
|
||||||
|
/** Render Mermaid diagram and convert to base64 image */
|
||||||
const drawDiagram = async function () {
|
const drawDiagram = async function () {
|
||||||
const { svg } = await mermaid.render(id.current, content);
|
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'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
/** Props interface for Paragraph component */
|
||||||
interface ParagraphProps {
|
interface ParagraphProps {
|
||||||
node: {
|
node: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
children: string[]
|
children: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paragraph component for rendering markdown paragraphs */
|
||||||
const Paragraph: FC<ParagraphProps> = (props) => {
|
const Paragraph: FC<ParagraphProps> = (props) => {
|
||||||
// console.log('Paragraph', props)
|
|
||||||
const { children } = props
|
const { children } = props
|
||||||
|
|
||||||
return <p>{children}</p>
|
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 { memo } from 'react'
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
import { Button } from 'antd'
|
import { Button } from 'antd'
|
||||||
|
|
||||||
|
/** Props interface for RbButton component */
|
||||||
interface RbButtonProps {
|
interface RbButtonProps {
|
||||||
node: {
|
node: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
children: string[]
|
children: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Button component for rendering buttons in markdown */
|
||||||
const RbButton: FC<RbButtonProps> = (props) => {
|
const RbButton: FC<RbButtonProps> = (props) => {
|
||||||
console.log('RbButton', props)
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
return (
|
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';
|
import * as React from 'react';
|
||||||
|
|
||||||
|
/** Props interface for Svg component */
|
||||||
interface SvgProps {
|
interface SvgProps {
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Component for rendering SVG content from string */
|
||||||
* 渲染SVG内容的组件
|
|
||||||
*/
|
|
||||||
function Svg(props: SvgProps): JSX.Element {
|
function Svg(props: SvgProps): JSX.Element {
|
||||||
const { content } = props;
|
const { content } = props;
|
||||||
// console.log('Svg', props)
|
|
||||||
|
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
'div',
|
'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'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
/** Props interface for VideoBlock component */
|
||||||
interface VideoBlockProps {
|
interface VideoBlockProps {
|
||||||
node: {
|
node: {
|
||||||
children: { properties: { src: string } }[]
|
children: { properties: { src: string } }[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Video block component that renders video elements from markdown nodes */
|
||||||
const VideoBlock: FC<VideoBlockProps> = (props) => {
|
const VideoBlock: FC<VideoBlockProps> = (props) => {
|
||||||
// console.log('VideoBlock', props)
|
|
||||||
const { children } = props.node;
|
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)
|
const srcs = children.map(item => item.properties?.src).filter(item => item)
|
||||||
|
|
||||||
return (
|
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 { Image, Input, Select, Form, Checkbox, Radio, ColorPicker, DatePicker, TimePicker, InputNumber, Slider } from 'antd'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import RemarkGfm from 'remark-gfm'
|
import RemarkGfm from 'remark-gfm'
|
||||||
@@ -5,8 +30,6 @@ import RemarkMath from 'remark-math'
|
|||||||
import RemarkBreaks from 'remark-breaks'
|
import RemarkBreaks from 'remark-breaks'
|
||||||
import RehypeKatex from 'rehype-katex'
|
import RehypeKatex from 'rehype-katex'
|
||||||
import RehypeRaw from 'rehype-raw'
|
import RehypeRaw from 'rehype-raw'
|
||||||
import type { FC } from 'react'
|
|
||||||
import { useState, useRef, useEffect } from 'react'
|
|
||||||
|
|
||||||
import Code from './Code'
|
import Code from './Code'
|
||||||
import VideoBlock from './VideoBlock'
|
import VideoBlock from './VideoBlock'
|
||||||
@@ -14,14 +37,21 @@ import AudioBlock from './AudioBlock'
|
|||||||
import Link from './Link'
|
import Link from './Link'
|
||||||
import RbButton from './RbButton'
|
import RbButton from './RbButton'
|
||||||
|
|
||||||
|
/** Props interface for RbMarkdown component */
|
||||||
interface RbMarkdownProps {
|
interface RbMarkdownProps {
|
||||||
|
/** Markdown content to render */
|
||||||
content: string;
|
content: string;
|
||||||
showHtmlComments?: boolean; // 是否显示 HTML 注释,默认为 false(隐藏)
|
/** Whether to display HTML comments (default: false) */
|
||||||
editable?: boolean; // 是否可编辑,默认为 false
|
showHtmlComments?: boolean;
|
||||||
onContentChange?: (content: string) => void; // 内容变化回调
|
/** 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;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Custom component mappings for markdown elements */
|
||||||
const components = {
|
const components = {
|
||||||
h1: ({ children, ...props }: any) => <h1 className="rb:text-2xl rb:font-bold rb:mb-2" {...props}>{children}</h1>,
|
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>,
|
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>,
|
em: ({ children, ...props }: any) => <em className="rb:italic" {...props}>{children}</em>,
|
||||||
del: ({ children, ...props }: any) => <del className="rb:line-through" {...props}>{children}</del>,
|
del: ({ children, ...props }: any) => <del className="rb:line-through" {...props}>{children}</del>,
|
||||||
span: ({ children, style, ...restProps }: any) => {
|
span: ({ children, style, ...restProps }: any) => {
|
||||||
// 如果是 HTML 注释的 span,应用特殊样式
|
// Apply special styling for HTML comment spans
|
||||||
if (style?.color === '#999') {
|
if (style?.color === '#999') {
|
||||||
return <span style={{ color: '#999', fontSize: '0.9em' }}>{children}</span>
|
return <span style={{ color: '#999', fontSize: '0.9em' }}>{children}</span>
|
||||||
}
|
}
|
||||||
@@ -104,30 +134,33 @@ const RbMarkdown: FC<RbMarkdownProps> = ({
|
|||||||
const [editContent, setEditContent] = useState(content)
|
const [editContent, setEditContent] = useState(content)
|
||||||
const textareaRef = useRef<any>(null)
|
const textareaRef = useRef<any>(null)
|
||||||
|
|
||||||
// 当外部 content 变化时,同步更新编辑内容
|
/** Sync edit content when external content changes */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditContent(content)
|
setEditContent(content)
|
||||||
}, [content])
|
}, [content])
|
||||||
|
|
||||||
// 处理 textarea 内容变化
|
/** Handle textarea content changes and trigger callback */
|
||||||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const newContent = e.target.value
|
const newContent = e.target.value
|
||||||
setEditContent(newContent)
|
setEditContent(newContent)
|
||||||
// 实时回调内容变化
|
/** Trigger real-time content change callback */
|
||||||
onContentChange?.(newContent)
|
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
|
const processedContent = showHtmlComments
|
||||||
? (editable ? editContent : content).replace(/<!--([\s\S]*?)-->/g, (_match, commentContent) => {
|
? (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, '>')
|
const escaped = commentContent.trim().replace(/</g, '<').replace(/>/g, '>')
|
||||||
return `<span class="html-comment"><!-- ${escaped} --></span>`
|
return `<span class="html-comment"><!-- ${escaped} --></span>`
|
||||||
})
|
})
|
||||||
: (editable ? editContent : content)
|
: (editable ? editContent : content)
|
||||||
|
|
||||||
// 如果是编辑模式,显示 textarea
|
/** Render textarea in edit mode */
|
||||||
if (editable) {
|
if (editable) {
|
||||||
return (
|
return (
|
||||||
<div className="rb:relative">
|
<div className="rb:relative">
|
||||||
@@ -138,21 +171,21 @@ const RbMarkdown: FC<RbMarkdownProps> = ({
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{/* 编辑区域 */}
|
{/* Edit area with textarea */}
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={editContent}
|
value={editContent}
|
||||||
onChange={handleTextareaChange}
|
onChange={handleTextareaChange}
|
||||||
rows={10}
|
rows={10}
|
||||||
className="rb:font-mono rb:text-sm"
|
className="rb:font-mono rb:text-sm"
|
||||||
placeholder="请输入 Markdown 内容..."
|
placeholder="Enter Markdown content..."
|
||||||
style={{ resize: 'vertical' }}
|
style={{ resize: 'vertical' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理键盘快捷键
|
/** Handle keyboard shortcuts (e.g., Ctrl+C for copy) */
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
@@ -162,7 +195,7 @@ const RbMarkdown: FC<RbMarkdownProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预览模式
|
/** Render markdown preview mode */
|
||||||
return (
|
return (
|
||||||
<div className={`rb:relative ${className || ''}`} onKeyDown={handleKeyDown} tabIndex={0}>
|
<div className={`rb:relative ${className || ''}`} onKeyDown={handleKeyDown} tabIndex={0}>
|
||||||
<style>{`
|
<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 React, { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { List } from 'antd';
|
import { List } from 'antd';
|
||||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||||
|
|
||||||
import { request } from '@/utils/request';
|
import { request } from '@/utils/request';
|
||||||
import PageEmpty from '@/components/Empty/PageEmpty'
|
import PageEmpty from '@/components/Empty/PageEmpty'
|
||||||
import PageLoading from '@/components/Empty/PageLoading'
|
import PageLoading from '@/components/Empty/PageLoading'
|
||||||
|
|
||||||
|
/** Default page size for pagination */
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
/** API response structure with pagination metadata */
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
items?: T[];
|
items?: T[];
|
||||||
page: {
|
page: {
|
||||||
@@ -16,18 +37,28 @@ interface ApiResponse<T> {
|
|||||||
hasnext: boolean;
|
hasnext: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ref methods exposed to parent component */
|
||||||
export interface PageScrollListRef {
|
export interface PageScrollListRef {
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Props interface for PageScrollList component */
|
||||||
interface PageScrollListProps<T, Q = Record<string, unknown>> {
|
interface PageScrollListProps<T, Q = Record<string, unknown>> {
|
||||||
|
/** API endpoint URL */
|
||||||
url: string;
|
url: string;
|
||||||
|
/** Function to render each list item */
|
||||||
renderItem: (item: T) => React.ReactNode;
|
renderItem: (item: T) => React.ReactNode;
|
||||||
|
/** Query parameters for API request */
|
||||||
query?: Q;
|
query?: Q;
|
||||||
|
/** Number of columns in grid layout */
|
||||||
column?: number;
|
column?: number;
|
||||||
|
/** Additional CSS classes */
|
||||||
className?: string;
|
className?: string;
|
||||||
needLoading?: boolean;
|
needLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Infinite scroll list component with pagination support */
|
||||||
const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||||
renderItem,
|
renderItem,
|
||||||
query,
|
query,
|
||||||
@@ -36,6 +67,7 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
|||||||
className = '',
|
className = '',
|
||||||
needLoading = true,
|
needLoading = true,
|
||||||
}: PageScrollListProps<T, Q>, ref: React.Ref<PageScrollListRef>) => {
|
}: PageScrollListProps<T, Q>, ref: React.Ref<PageScrollListRef>) => {
|
||||||
|
/** Expose refresh method to parent component */
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
refresh,
|
refresh,
|
||||||
}));
|
}));
|
||||||
@@ -45,6 +77,7 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
|||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
/** Load more data from API with pagination */
|
||||||
const loadMoreData = (flag?: boolean) => {
|
const loadMoreData = (flag?: boolean) => {
|
||||||
if (!flag && (loading || !hasMore)) {
|
if (!flag && (loading || !hasMore)) {
|
||||||
return;
|
return;
|
||||||
@@ -58,6 +91,7 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
const response = res as ApiResponse<T>;
|
const response = res as ApiResponse<T>;
|
||||||
const results = Array.isArray(response.items) ? response.items : Array.isArray(response) ? response as 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) {
|
if (flag) {
|
||||||
setData(results);
|
setData(results);
|
||||||
} else {
|
} else {
|
||||||
@@ -78,17 +112,19 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 刷新列表数据
|
/** Reset list to initial state and reload data */
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
setData([]);
|
setData([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Refresh when query parameters change */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh()
|
refresh()
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
|
/** Load initial data when list is reset */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (page === 1 && hasMore && data.length === 0) {
|
if (page === 1 && hasMore && data.length === 0) {
|
||||||
loadMoreData(true);
|
loadMoreData(true);
|
||||||
@@ -111,6 +147,7 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
|||||||
scrollableTarget="scrollableDiv"
|
scrollableTarget="scrollableDiv"
|
||||||
className='rb:h-full!'
|
className='rb:h-full!'
|
||||||
>
|
>
|
||||||
|
{/* Render grid list or empty state */}
|
||||||
{data.length > 0 ? (
|
{data.length > 0 ? (
|
||||||
<List
|
<List
|
||||||
grid={{ gutter: 16, column: column }}
|
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 { type FC } from 'react';
|
||||||
import { Segmented, type SegmentedProps } from 'antd';
|
import { Segmented, type SegmentedProps } from 'antd';
|
||||||
|
|
||||||
import styles from './index.module.css';
|
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> = ({
|
const PageTabs: FC<SegmentedProps> = ({
|
||||||
value,
|
value,
|
||||||
options,
|
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 FC, type Key, type ReactNode, useEffect } from 'react';
|
||||||
import { type RadioGroupProps } from 'antd';
|
import { type RadioGroupProps } from 'antd';
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
/** Radio card option interface */
|
||||||
interface RadioCardOption {
|
interface RadioCardOption {
|
||||||
|
/** Option value */
|
||||||
value: string | number | boolean | null | undefined | Key;
|
value: string | number | boolean | null | undefined | Key;
|
||||||
|
/** Option label text */
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Optional description text */
|
||||||
labelDesc?: string;
|
labelDesc?: string;
|
||||||
|
/** Optional icon URL */
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
/** Whether the option is disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Additional properties */
|
||||||
[key: string]: string | number | boolean | undefined | null | Key;
|
[key: string]: string | number | boolean | undefined | null | Key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Props interface for RadioGroupCard component */
|
||||||
interface RadioCardProps extends Omit<RadioGroupProps, 'onChange'> {
|
interface RadioCardProps extends Omit<RadioGroupProps, 'onChange'> {
|
||||||
|
/** Array of radio card options */
|
||||||
options: RadioCardOption[];
|
options: RadioCardOption[];
|
||||||
|
/** Callback fired when value changes (for side effects) */
|
||||||
onValueChange?: (value: string | null | undefined, option?: RadioCardOption) => void;
|
onValueChange?: (value: string | null | undefined, option?: RadioCardOption) => void;
|
||||||
|
/** Callback fired when selection changes */
|
||||||
onChange?: (value: string | null | undefined, option?: RadioCardOption) => void;
|
onChange?: (value: string | null | undefined, option?: RadioCardOption) => void;
|
||||||
|
/** Custom render function for each option */
|
||||||
itemRender?: (option: RadioCardOption) => ReactNode;
|
itemRender?: (option: RadioCardOption) => ReactNode;
|
||||||
|
/** Whether clicking selected option clears selection */
|
||||||
allowClear?: boolean;
|
allowClear?: boolean;
|
||||||
|
/** Whether to display cards in block (vertical) layout */
|
||||||
block?: boolean;
|
block?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Radio group card component that displays options as selectable cards */
|
||||||
const RadioGroupCard: FC<RadioCardProps> = ({
|
const RadioGroupCard: FC<RadioCardProps> = ({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
@@ -28,16 +63,19 @@ const RadioGroupCard: FC<RadioCardProps> = ({
|
|||||||
allowClear = true,
|
allowClear = true,
|
||||||
block = false,
|
block = false,
|
||||||
}) => {
|
}) => {
|
||||||
// 监听value变化
|
/** Listen to value changes and trigger side effects via onValueChange callback */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onValueChange) {
|
if (onValueChange) {
|
||||||
onValueChange(value);
|
onValueChange(value);
|
||||||
}
|
}
|
||||||
}, [value, onValueChange]);
|
}, [value, onValueChange]);
|
||||||
|
|
||||||
|
/** Handle option selection with support for clear and disabled states */
|
||||||
const handleChange = (option: RadioCardOption) => {
|
const handleChange = (option: RadioCardOption) => {
|
||||||
|
// Ignore clicks on disabled options
|
||||||
if (option.disabled) return
|
if (option.disabled) return
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
|
// Clear selection if allowClear is true and option is already selected
|
||||||
if (allowClear && value === option.value) {
|
if (allowClear && value === option.value) {
|
||||||
onChange(null, undefined);
|
onChange(null, undefined);
|
||||||
} else {
|
} else {
|
||||||
@@ -51,6 +89,7 @@ const RadioGroupCard: FC<RadioCardProps> = ({
|
|||||||
'rb:gap-3': !block,
|
'rb:gap-3': !block,
|
||||||
'rb:gap-4': block,
|
'rb:gap-4': block,
|
||||||
})}>
|
})}>
|
||||||
|
{/* Render each option as a selectable card */}
|
||||||
{options.map(option => (
|
{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", {
|
<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,
|
'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:opacity-[0.75]': option.disabled,
|
||||||
'rb:flex rb:items-center rb:text-left rb:gap-4': block,
|
'rb:flex rb:items-center rb:text-left rb:gap-4': block,
|
||||||
})} onClick={() => handleChange(option)}>
|
})} onClick={() => handleChange(option)}>
|
||||||
|
{/* Use custom render or default card layout */}
|
||||||
{itemRender ? itemRender(option) : (
|
{itemRender ? itemRender(option) : (
|
||||||
<>
|
<>
|
||||||
{option.icon && <img src={option.icon} className={clsx("rb:w-10 rb:h-10", {
|
{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'
|
import { type FC, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
/** Props interface for RbAlert component */
|
||||||
interface RbAlertProps {
|
interface RbAlertProps {
|
||||||
|
/** Color theme for the alert */
|
||||||
color?: 'blue' | 'green' | 'orange' | 'purple',
|
color?: 'blue' | 'green' | 'orange' | 'purple',
|
||||||
|
/** Alert content */
|
||||||
children: ReactNode | string;
|
children: ReactNode | string;
|
||||||
|
/** Optional icon to display before content */
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
|
/** Additional CSS classes */
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Color theme mappings with text, background, and border colors */
|
||||||
const 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)]',
|
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)]',
|
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)]',
|
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 }) => {
|
const RbAlert: FC<RbAlertProps> = ({ color = 'blue', icon, className, children }) => {
|
||||||
return (
|
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`}>
|
<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 { type FC, type ReactNode } from 'react'
|
||||||
import { Card, Tooltip } from 'antd';
|
import { Card, Tooltip } from 'antd';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
/** Props interface for RbCard component */
|
||||||
interface RbCardProps {
|
interface RbCardProps {
|
||||||
|
/** Additional CSS classes for header */
|
||||||
headerClassName?: string;
|
headerClassName?: string;
|
||||||
|
/** Card title (string, ReactNode, or function) */
|
||||||
title?: string | ReactNode | (() => ReactNode);
|
title?: string | ReactNode | (() => ReactNode);
|
||||||
|
/** Subtitle text displayed below title */
|
||||||
subTitle?: string | ReactNode;
|
subTitle?: string | ReactNode;
|
||||||
|
/** Extra content displayed in header (top-right) */
|
||||||
extra?: ReactNode;
|
extra?: ReactNode;
|
||||||
|
/** Card body content */
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
/** Custom avatar component */
|
||||||
avatar?: ReactNode;
|
avatar?: ReactNode;
|
||||||
|
/** Avatar image URL */
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
|
/** Custom padding for card body */
|
||||||
bodyPadding?: string;
|
bodyPadding?: string;
|
||||||
|
/** Additional CSS classes for body */
|
||||||
bodyClassName?: string;
|
bodyClassName?: string;
|
||||||
|
/** Header style variant */
|
||||||
headerType?: 'border' | 'borderless' | 'borderBL' | 'borderL';
|
headerType?: 'border' | 'borderless' | 'borderBL' | 'borderL';
|
||||||
|
/** Background color */
|
||||||
bgColor?: string;
|
bgColor?: string;
|
||||||
|
/** Card height */
|
||||||
height?: string;
|
height?: string;
|
||||||
|
/** Additional CSS classes */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Click handler */
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Custom card component with flexible styling and header options */
|
||||||
const RbCard: FC<RbCardProps> = ({
|
const RbCard: FC<RbCardProps> = ({
|
||||||
headerClassName,
|
headerClassName,
|
||||||
title,
|
title,
|
||||||
@@ -35,6 +70,7 @@ const RbCard: FC<RbCardProps> = ({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
/** Calculate body padding based on header type and avatar presence */
|
||||||
const bodyClassName = bodyPadding
|
const bodyClassName = bodyPadding
|
||||||
? `rb:p-[${bodyPadding}]!`
|
? `rb:p-[${bodyPadding}]!`
|
||||||
: headerType === 'borderL'
|
: headerType === 'borderL'
|
||||||
@@ -46,11 +82,13 @@ const RbCard: FC<RbCardProps> = ({
|
|||||||
: (headerType === 'border' && !avatarUrl && !avatar) || headerType === 'borderBL'
|
: (headerType === 'border' && !avatarUrl && !avatar) || headerType === 'borderBL'
|
||||||
? 'rb:p-[16px_16px_20px_16px]!'
|
? 'rb:p-[16px_16px_20px_16px]!'
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
{...props}
|
{...props}
|
||||||
title={typeof title === 'function' ? title() : title ?
|
title={typeof title === 'function' ? title() : title ?
|
||||||
<div className="rb:flex rb:items-center rb:gap-2">
|
<div className="rb:flex rb:items-center rb:gap-2">
|
||||||
|
{/* Avatar image or custom avatar component */}
|
||||||
{avatarUrl
|
{avatarUrl
|
||||||
? <img src={avatarUrl} className="rb:mr-3.25 rb:w-12 rb:h-12 rb:rounded-lg" />
|
? <img src={avatarUrl} className="rb:mr-3.25 rb:w-12 rb:h-12 rb:rounded-lg" />
|
||||||
: avatar ? avatar : null
|
: 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>
|
<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>}
|
{subTitle && <div className="rb:text-[#5B6167] rb:text-[12px]">{subTitle}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div> : null
|
</div> : null
|
||||||
@@ -73,10 +113,15 @@ const RbCard: FC<RbCardProps> = ({
|
|||||||
header: clsx(
|
header: clsx(
|
||||||
'rb:font-medium',
|
'rb:font-medium',
|
||||||
{
|
{
|
||||||
|
/** Borderless header style */
|
||||||
'rb:border-[0]! rb:text-[16px] rb:p-[0_16px]!': headerType === 'borderless',
|
'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,
|
'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,
|
'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',
|
"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',
|
"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,
|
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
|
* @Version: 0.0.1
|
||||||
* @Author: yujiangping
|
* @Author: yujiangping
|
||||||
* @Date: 2025-11-07 14:16:33
|
* @Date: 2025-11-07 14:16:33
|
||||||
* @LastEditors: yujiangping
|
* @LastEditors: ZhaoYing
|
||||||
* @LastEditTime: 2025-11-27 20:02:46
|
* @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 { type FC, useState, useEffect } from 'react'
|
||||||
import { Button, Drawer, Space } from 'antd';
|
import { Button, Drawer, Space } from 'antd';
|
||||||
import type { DrawerProps } from 'antd';
|
import type { DrawerProps } from 'antd';
|
||||||
import { CloseOutlined } from '@ant-design/icons';
|
import { CloseOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
/** Custom drawer component with internal state management and custom close button */
|
||||||
const RbDrawer: FC<DrawerProps> =({
|
const RbDrawer: FC<DrawerProps> =({
|
||||||
children,
|
children,
|
||||||
size = 'large',
|
size = 'large',
|
||||||
@@ -18,30 +31,32 @@ const RbDrawer: FC<DrawerProps> =({
|
|||||||
onClose,
|
onClose,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// 内部状态管理,组件内部完全控制 open 状态
|
/** Internal state management - component fully controls open state internally */
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
|
||||||
// 当外部 open 变化时,同步到内部状态
|
/** Sync internal state when external open prop changes */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (externalOpen !== undefined) {
|
if (externalOpen !== undefined) {
|
||||||
setInternalOpen(externalOpen);
|
setInternalOpen(externalOpen);
|
||||||
}
|
}
|
||||||
}, [externalOpen]);
|
}, [externalOpen]);
|
||||||
|
|
||||||
// 确保当外部 open 为 true 时,内部状态也同步为 true(处理重复打开的情况)
|
/** Ensure internal state syncs to true when external open is true (handles repeated opening) */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (externalOpen === true && !internalOpen) {
|
if (externalOpen === true && !internalOpen) {
|
||||||
setInternalOpen(true);
|
setInternalOpen(true);
|
||||||
}
|
}
|
||||||
}, [externalOpen, internalOpen]);
|
}, [externalOpen, internalOpen]);
|
||||||
|
|
||||||
|
/** Handle drawer close - updates internal state and notifies parent */
|
||||||
const handleClose = (e: React.MouseEvent | React.KeyboardEvent) => {
|
const handleClose = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||||
// 更新内部状态,关闭抽屉
|
/** Update internal state to close drawer */
|
||||||
setInternalOpen(false);
|
setInternalOpen(false);
|
||||||
// 如果外部传入了 onClose,调用它通知外部
|
/** If external onClose is provided, call it to notify parent */
|
||||||
onClose?.(e);
|
onClose?.(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handle close button click */
|
||||||
const handleButtonClose = (e: React.MouseEvent) => {
|
const handleButtonClose = (e: React.MouseEvent) => {
|
||||||
handleClose(e);
|
handleClose(e);
|
||||||
}
|
}
|
||||||
@@ -56,11 +71,13 @@ const RbDrawer: FC<DrawerProps> =({
|
|||||||
open={internalOpen}
|
open={internalOpen}
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
|
{/* Custom close button in header */}
|
||||||
<Button type='text' icon={<CloseOutlined />} onClick={handleButtonClose}/>
|
<Button type='text' icon={<CloseOutlined />} onClick={handleButtonClose}/>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
{/* Full-height flex container for content */}
|
||||||
<div className='rb:flex rb:flex-col rb:h-full'>
|
<div className='rb:flex rb:flex-col rb:h-full'>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</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:
|
* @Author: ZhaoYing
|
||||||
* @Version: 0.0.1
|
* @Date: 2026-02-02 15:23:01
|
||||||
* @Author: yujiangping
|
* @Last Modified by: ZhaoYing
|
||||||
* @Date: 2025-12-16 10:19:18
|
* @Last Modified time: 2026-02-02 15:23:01
|
||||||
* @LastEditors: yujiangping
|
|
||||||
* @LastEditTime: 2025-12-22 12:31:31
|
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 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 { type FC } from 'react'
|
||||||
import { Modal, type ModalProps } from 'antd'
|
import { Modal, type ModalProps } from 'antd'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
/** Custom modal component wrapper with default configurations */
|
||||||
const RbModal: FC<ModalProps> = ({
|
const RbModal: FC<ModalProps> = ({
|
||||||
onOk,
|
onOk,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -29,6 +43,7 @@ const RbModal: FC<ModalProps> = ({
|
|||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
{/* Scrollable content container */}
|
||||||
<div className='rb:max-h-137.5 rb:overflow-y-auto rb:overflow-x-hidden'>
|
<div className='rb:max-h-137.5 rb:overflow-y-auto rb:overflow-x-hidden'>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</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 { type FC, useEffect } from 'react';
|
||||||
import { Slider, type SliderSingleProps } from 'antd';
|
import { Slider, type SliderSingleProps } from 'antd';
|
||||||
|
|
||||||
|
/** Props interface for RbSlider component */
|
||||||
interface RbSliderProps extends SliderSingleProps {
|
interface RbSliderProps extends SliderSingleProps {
|
||||||
|
/** Callback fired when value changes (for side effects) */
|
||||||
onValueChange?: (value: number | null | undefined) => void;
|
onValueChange?: (value: number | null | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Custom slider component with value display */
|
||||||
const RbSlider: FC<RbSliderProps> = ({
|
const RbSlider: FC<RbSliderProps> = ({
|
||||||
value,
|
value,
|
||||||
min = 0,
|
min = 0,
|
||||||
@@ -12,21 +32,18 @@ const RbSlider: FC<RbSliderProps> = ({
|
|||||||
step = 1,
|
step = 1,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
// 监听value变化,包括初始值
|
/** Listen to value changes and trigger side effects via onValueChange callback */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onValueChange) {
|
if (onValueChange) {
|
||||||
onValueChange(value);
|
onValueChange(value);
|
||||||
}
|
}
|
||||||
}, [value, onValueChange]);
|
}, [value, onValueChange]);
|
||||||
|
|
||||||
// const flag1 = value && value > (min + step * 1)
|
|
||||||
// const flag = value && value > (min + step * 1)
|
|
||||||
return (
|
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
|
<Slider
|
||||||
style={{
|
style={{
|
||||||
// width: flag1 ? '384px' : '373px',
|
|
||||||
// margin: flag ? '0 11px 0 0': '0 5px 0 11px'
|
|
||||||
overflow: 'inherit',
|
overflow: 'inherit',
|
||||||
width: '384px'
|
width: '384px'
|
||||||
}}
|
}}
|
||||||
@@ -34,7 +51,8 @@ const RbSlider: FC<RbSliderProps> = ({
|
|||||||
step={step}
|
step={step}
|
||||||
value={value}
|
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>
|
</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 { useState, type FC, useCallback, useRef } from 'react';
|
||||||
import { Input, type InputProps } from 'antd';
|
import { Input, type InputProps } from 'antd';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import searchIcon from '@/assets/images/search.svg'
|
import searchIcon from '@/assets/images/search.svg'
|
||||||
|
|
||||||
|
/** Props interface for SearchInput component */
|
||||||
interface SearchInputProps {
|
interface SearchInputProps {
|
||||||
|
/** Placeholder text */
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** Callback fired when search value changes */
|
||||||
onSearch?: (value: string) => void;
|
onSearch?: (value: string) => void;
|
||||||
|
/** Debounce delay in milliseconds (default: 300) */
|
||||||
debounceDelay?: number;
|
debounceDelay?: number;
|
||||||
|
/** Throttle delay in milliseconds (overrides debounce if set) */
|
||||||
throttleDelay?: number;
|
throttleDelay?: number;
|
||||||
|
/** Default input value */
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
|
/** Custom styles */
|
||||||
style?: Record<string, string | number>;
|
style?: Record<string, string | number>;
|
||||||
|
/** Additional CSS classes */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Input size */
|
||||||
size?: InputProps['size']
|
size?: InputProps['size']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Search input component with debounce and throttle support */
|
||||||
const SearchInput: FC<SearchInputProps> = ({
|
const SearchInput: FC<SearchInputProps> = ({
|
||||||
placeholder,
|
placeholder,
|
||||||
onSearch,
|
onSearch,
|
||||||
@@ -29,7 +59,7 @@ const SearchInput: FC<SearchInputProps> = ({
|
|||||||
const throttleRef = useRef<boolean>(false);
|
const throttleRef = useRef<boolean>(false);
|
||||||
const lastCallRef = useRef<number>(0);
|
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) => {
|
const debounce = useCallback(<T extends (...args: any[]) => void>(callback: T, delay: number) => {
|
||||||
return (...args: Parameters<T>) => {
|
return (...args: Parameters<T>) => {
|
||||||
if (timerRef.current) {
|
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) => {
|
const throttle = useCallback(<T extends (...args: any[]) => void>(callback: T, delay: number) => {
|
||||||
return (...args: Parameters<T>) => {
|
return (...args: Parameters<T>) => {
|
||||||
const now = Date.now();
|
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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
|
|
||||||
// 根据是否设置了throttleDelay来决定使用防抖还是节流
|
/** Decide whether to use debounce or throttle based on throttleDelay setting */
|
||||||
if (onSearch) {
|
if (onSearch) {
|
||||||
if (throttleDelay) {
|
if (throttleDelay) {
|
||||||
const throttledSearch = throttle(() => {
|
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 { useState, useEffect, type FC } from 'react';
|
||||||
import { Menu as AntMenu, Layout } from 'antd';
|
import { Menu as AntMenu, Layout } from 'antd';
|
||||||
import { UserOutlined } from '@ant-design/icons';
|
import { UserOutlined } from '@ant-design/icons';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { useMenu, type MenuItem } from '@/store/menu';
|
import { useMenu, type MenuItem } from '@/store/menu';
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
import logo from '@/assets/images/logo.png'
|
import logo from '@/assets/images/logo.png'
|
||||||
import menuFold from '@/assets/images/menuFold.png'
|
import menuFold from '@/assets/images/menuFold.png'
|
||||||
import menuUnfold from '@/assets/images/menuUnfold.png'
|
import menuUnfold from '@/assets/images/menuUnfold.png'
|
||||||
import clsx from 'clsx';
|
|
||||||
import { useUser } from '@/store/user';
|
import { useUser } from '@/store/user';
|
||||||
import logout from '@/assets/images/logout.svg'
|
import logout from '@/assets/images/logout.svg'
|
||||||
|
|
||||||
// 导入SVG文件
|
// Import SVG files
|
||||||
import dashboardIcon from '@/assets/images/menu/dashboard.svg';
|
import dashboardIcon from '@/assets/images/menu/dashboard.svg';
|
||||||
import dashboardActiveIcon from '@/assets/images/menu/dashboard_active.svg';
|
import dashboardActiveIcon from '@/assets/images/menu/dashboard_active.svg';
|
||||||
import modelIcon from '@/assets/images/menu/model.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 promptIcon from '@/assets/images/menu/prompt.svg'
|
||||||
import promptActiveIcon from '@/assets/images/menu/prompt_active.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> = {
|
const iconPathMap: Record<string, string> = {
|
||||||
'dashboard': dashboardIcon,
|
'dashboard': dashboardIcon,
|
||||||
'dashboardActive': dashboardActiveIcon,
|
'dashboardActive': dashboardActiveIcon,
|
||||||
@@ -85,8 +106,11 @@ const iconPathMap: Record<string, string> = {
|
|||||||
|
|
||||||
const { Sider } = Layout;
|
const { Sider } = Layout;
|
||||||
|
|
||||||
|
/** Sidebar menu component with collapsible navigation */
|
||||||
const Menu: FC<{
|
const Menu: FC<{
|
||||||
|
/** Menu display mode */
|
||||||
mode?: 'vertical' | 'horizontal' | 'inline';
|
mode?: 'vertical' | 'horizontal' | 'inline';
|
||||||
|
/** Menu context (space or manage) */
|
||||||
source?: 'space' | 'manage';
|
source?: 'space' | 'manage';
|
||||||
}> = ({ mode = 'inline', source = 'manage' }) => {
|
}> = ({ mode = 'inline', source = 'manage' }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -97,6 +121,7 @@ const Menu: FC<{
|
|||||||
const [menus, setMenus] = useState<MenuItem[]>([])
|
const [menus, setMenus] = useState<MenuItem[]>([])
|
||||||
const { user, storageType } = useUser()
|
const { user, storageType } = useUser()
|
||||||
|
|
||||||
|
/** Filter menus based on user role and source */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user.role === 'member' && source === 'space') {
|
if (user.role === 'member' && source === 'space') {
|
||||||
setMenus((allMenus[source] || []).filter(menu => menu.code !== 'member'))
|
setMenus((allMenus[source] || []).filter(menu => menu.code !== 'member'))
|
||||||
@@ -104,7 +129,8 @@ const Menu: FC<{
|
|||||||
setMenus(allMenus[source] || [])
|
setMenus(allMenus[source] || [])
|
||||||
}
|
}
|
||||||
}, [source, allMenus, user])
|
}, [source, allMenus, user])
|
||||||
// 处理菜单项点击
|
|
||||||
|
/** Handle menu item click and navigate to path */
|
||||||
const handleMenuClick: MenuProps['onClick'] = (e) => {
|
const handleMenuClick: MenuProps['onClick'] = (e) => {
|
||||||
const path = e.key;
|
const path = e.key;
|
||||||
if (path) {
|
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'] => {
|
const generateMenuItems = (menuList: MenuItem[]): MenuProps['items'] => {
|
||||||
|
|
||||||
return menuList.filter(menu => menu.display).map((menu) => {
|
return menuList.filter(menu => menu.display).map((menu) => {
|
||||||
const iconKey = selectedKeys.includes(menu.path || '') ? `${menu.code}Active` : menu.code;
|
const iconKey = selectedKeys.includes(menu.path || '') ? `${menu.code}Active` : menu.code;
|
||||||
const iconSrc = iconPathMap[iconKey as keyof typeof iconPathMap];
|
const iconSrc = iconPathMap[iconKey as keyof typeof iconPathMap];
|
||||||
const subs = (menu.subs || []).filter(sub => sub.display);
|
const subs = (menu.subs || []).filter(sub => sub.display);
|
||||||
// 叶子节点
|
/** Leaf node - menu item without children */
|
||||||
if (!subs || subs.length === 0) {
|
if (!subs || subs.length === 0) {
|
||||||
if (!menu.path) return null;
|
if (!menu.path) return null;
|
||||||
|
|
||||||
@@ -134,13 +160,12 @@ const Menu: FC<{
|
|||||||
),
|
),
|
||||||
icon: iconSrc ? <img
|
icon: iconSrc ? <img
|
||||||
src={iconSrc}
|
src={iconSrc}
|
||||||
className="rb:w-[16px] rb:h-[16px] rb:mr-[8px]"
|
className="rb:w-4 rb:h-4 rb:mr-2"
|
||||||
/> : null,
|
/> : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 有子菜单的节点
|
/** Node with submenu - menu item with children */
|
||||||
|
|
||||||
const menuLabel = menu.i18nKey ? t(menu.i18nKey) : menu.label;
|
const menuLabel = menu.i18nKey ? t(menu.i18nKey) : menu.label;
|
||||||
return {
|
return {
|
||||||
key: `submenu-${menu.id}`,
|
key: `submenu-${menu.id}`,
|
||||||
@@ -148,32 +173,33 @@ const Menu: FC<{
|
|||||||
label: menuLabel,
|
label: menuLabel,
|
||||||
icon: iconSrc ? <img
|
icon: iconSrc ? <img
|
||||||
src={iconSrc}
|
src={iconSrc}
|
||||||
className="rb:w-[16px] rb:h-[16px] rb:mr-[8px]"
|
className="rb:w-4 rb:h-4 rb:mr-2"
|
||||||
/> : <UserOutlined/>,
|
/> : <UserOutlined/>,
|
||||||
children: generateMenuItems(subs),
|
children: generateMenuItems(subs),
|
||||||
};
|
};
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 生成菜单项
|
/** Generate menu items from configuration */
|
||||||
const menuItems = generateMenuItems(menus);
|
const menuItems = generateMenuItems(menus);
|
||||||
// 初始加载菜单
|
|
||||||
|
/** Load menus on component mount */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMenus(source);
|
loadMenus(source);
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 处理当前路径匹配
|
/** Handle current path matching and update selected keys */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 使用location.pathname获取当前路径,确保与路由系统保持一致
|
/** Use location.pathname to get current path, ensuring consistency with routing system */
|
||||||
const currentPath = location.pathname || '/';
|
const currentPath = location.pathname || '/';
|
||||||
|
|
||||||
// 尝试找到匹配的菜单项和对应的父菜单路径
|
/** Try to find matching menu item and corresponding parent menu path */
|
||||||
const findMatchingKey = (menuList: MenuItem[], parentPaths: string[] = []): { key: string | null; } => {
|
const findMatchingKey = (menuList: MenuItem[], parentPaths: string[] = []): { key: string | null; } => {
|
||||||
for (const menu of menuList) {
|
for (const menu of menuList) {
|
||||||
if (menu.path) {
|
if (menu.path) {
|
||||||
const menuPath = menu.path[0] !== '/' ? '/' + menu.path : 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 isExactMatch = menuPath === currentPath;
|
||||||
const isPrefixMatch = currentPath.startsWith(menuPath + '/') ||
|
const isPrefixMatch = currentPath.startsWith(menuPath + '/') ||
|
||||||
currentPath === menuPath;
|
currentPath === menuPath;
|
||||||
@@ -183,7 +209,7 @@ const Menu: FC<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 递归检查子菜单
|
/** Recursively check submenus */
|
||||||
if (menu.subs && menu.subs.length > 0) {
|
if (menu.subs && menu.subs.length > 0) {
|
||||||
const newParentPaths = [...parentPaths, `submenu-${menu.id}`];
|
const newParentPaths = [...parentPaths, `submenu-${menu.id}`];
|
||||||
const found = findMatchingKey(menu.subs, newParentPaths);
|
const found = findMatchingKey(menu.subs, newParentPaths);
|
||||||
@@ -203,6 +229,7 @@ const Menu: FC<{
|
|||||||
}
|
}
|
||||||
}, [menus, location.pathname]);
|
}, [menus, location.pathname]);
|
||||||
|
|
||||||
|
/** Navigate to space list and clear user cache */
|
||||||
const goToSpace = () => {
|
const goToSpace = () => {
|
||||||
navigate('/space')
|
navigate('/space')
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
@@ -215,14 +242,15 @@ const Menu: FC<{
|
|||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
className={styles.sider}
|
className={styles.sider}
|
||||||
>
|
>
|
||||||
|
{/* Sidebar header with logo/workspace name and collapse toggle */}
|
||||||
<div className={clsx(styles.title, {
|
<div className={clsx(styles.title, {
|
||||||
[styles.collapsed]: collapsed,
|
[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
|
{!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>
|
<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}`)}
|
{t(`space.${storageType}`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,6 +263,7 @@ const Menu: FC<{
|
|||||||
}
|
}
|
||||||
<img src={collapsed ? menuUnfold : menuFold} className={styles.menuIcon} onClick={toggleSider} />
|
<img src={collapsed ? menuUnfold : menuFold} className={styles.menuIcon} onClick={toggleSider} />
|
||||||
</div>
|
</div>
|
||||||
|
{/* Main navigation menu */}
|
||||||
<AntMenu
|
<AntMenu
|
||||||
style={{ borderRight: 0 }}
|
style={{ borderRight: 0 }}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
@@ -246,12 +275,13 @@ const Menu: FC<{
|
|||||||
inlineIndent={13}
|
inlineIndent={13}
|
||||||
className="rb:max-h-[calc(100vh-136px)] rb:overflow-y-auto"
|
className="rb:max-h-[calc(100vh-136px)] rb:overflow-y-auto"
|
||||||
/>
|
/>
|
||||||
|
{/* Return to space button for superusers */}
|
||||||
{user?.is_superuser && source === 'space' &&
|
{user?.is_superuser && source === 'space' &&
|
||||||
<div
|
<div
|
||||||
onClick={goToSpace}
|
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')}
|
{collapsed ? null : t('common.returnToSpace')}
|
||||||
</div>
|
</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 { useState, useEffect, type FC } from 'react';
|
||||||
import { Slider, InputNumber, Row, Col } from 'antd';
|
import { Slider, InputNumber, Row, Col } from 'antd';
|
||||||
|
|
||||||
|
/** Props interface for SliderInput component */
|
||||||
interface SliderInputProps {
|
interface SliderInputProps {
|
||||||
|
/** Current value */
|
||||||
value?: number;
|
value?: number;
|
||||||
|
/** Callback fired when value changes */
|
||||||
onChange?: (value: number | null) => void;
|
onChange?: (value: number | null) => void;
|
||||||
|
/** Minimum value */
|
||||||
min?: number;
|
min?: number;
|
||||||
|
/** Maximum value */
|
||||||
max?: number;
|
max?: number;
|
||||||
|
/** Step increment */
|
||||||
step?: number;
|
step?: number;
|
||||||
|
/** Default value */
|
||||||
defaultValue?: number;
|
defaultValue?: number;
|
||||||
|
/** Whether the component is disabled */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Optional label text */
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Additional CSS classes for container */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Additional CSS classes for slider */
|
||||||
sliderClassName?: string;
|
sliderClassName?: string;
|
||||||
|
/** Additional CSS classes for input */
|
||||||
inputClassName?: string;
|
inputClassName?: string;
|
||||||
|
/** Marks to display on slider */
|
||||||
marks?: Record<number, string | { style: React.CSSProperties; label: string }>;
|
marks?: Record<number, string | { style: React.CSSProperties; label: string }>;
|
||||||
|
/** Tooltip configuration */
|
||||||
tooltip?: {
|
tooltip?: {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
placement?: 'top' | 'left' | 'right' | 'bottom';
|
placement?: 'top' | 'left' | 'right' | 'bottom';
|
||||||
@@ -21,6 +54,7 @@ interface SliderInputProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Slider with input number component for precise value control */
|
||||||
const SliderInput: FC<SliderInputProps> = ({
|
const SliderInput: FC<SliderInputProps> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -38,23 +72,26 @@ const SliderInput: FC<SliderInputProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [internalValue, setInternalValue] = useState<number>(value ?? defaultValue);
|
const [internalValue, setInternalValue] = useState<number>(value ?? defaultValue);
|
||||||
|
|
||||||
|
/** Sync internal value when external value changes */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value !== undefined && value !== internalValue) {
|
if (value !== undefined && value !== internalValue) {
|
||||||
setInternalValue(value);
|
setInternalValue(value);
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
/** Handle slider value change */
|
||||||
const handleSliderChange = (newValue: number) => {
|
const handleSliderChange = (newValue: number) => {
|
||||||
setInternalValue(newValue);
|
setInternalValue(newValue);
|
||||||
onChange?.(newValue);
|
onChange?.(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Handle input number value change with range validation */
|
||||||
const handleInputChange = (newValue: number | null) => {
|
const handleInputChange = (newValue: number | null) => {
|
||||||
if (newValue === null) {
|
if (newValue === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保值在范围内
|
/** Ensure value is within min/max range */
|
||||||
let validValue = newValue;
|
let validValue = newValue;
|
||||||
if (newValue < min) {
|
if (newValue < min) {
|
||||||
validValue = min;
|
validValue = min;
|
||||||
@@ -68,12 +105,14 @@ const SliderInput: FC<SliderInputProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rb:w-full ${className}`}>
|
<div className={`rb:w-full ${className}`}>
|
||||||
|
{/* Optional label */}
|
||||||
{label && (
|
{label && (
|
||||||
<div className="rb:text-sm rb:font-medium rb:text-gray-700">
|
<div className="rb:text-sm rb:font-medium rb:text-gray-700">
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Row gutter={16} align="middle">
|
<Row gutter={16} align="middle">
|
||||||
|
{/* Slider component */}
|
||||||
<Col flex="auto">
|
<Col flex="auto">
|
||||||
<Slider
|
<Slider
|
||||||
min={min}
|
min={min}
|
||||||
@@ -87,6 +126,7 @@ const SliderInput: FC<SliderInputProps> = ({
|
|||||||
className={sliderClassName}
|
className={sliderClassName}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
{/* Input number component */}
|
||||||
<Col flex="120px">
|
<Col flex="120px">
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={min}
|
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 React, { useContext } from 'react';
|
||||||
import { HolderOutlined } from '@ant-design/icons';
|
import { HolderOutlined } from '@ant-design/icons';
|
||||||
import { Button } from 'antd';
|
import { Button } from 'antd';
|
||||||
|
|
||||||
import SortableListItemContext from './SortableListItemContext';
|
import SortableListItemContext from './SortableListItemContext';
|
||||||
|
|
||||||
|
/** Drag handle component for sortable list items */
|
||||||
const DragHandle: React.FC = () => {
|
const DragHandle: React.FC = () => {
|
||||||
const { setActivatorNodeRef, listeners, attributes } = useContext(SortableListItemContext);
|
const { setActivatorNodeRef, listeners, attributes } = useContext(SortableListItemContext);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,9 +3,18 @@
|
|||||||
* @Version: 0.0.1
|
* @Version: 0.0.1
|
||||||
* @Author: yujiangping
|
* @Author: yujiangping
|
||||||
* @Date: 2025-11-11 20:42:28
|
* @Date: 2025-11-11 20:42:28
|
||||||
* @LastEditors: yujiangping
|
* @LastEditors: ZhaoYing
|
||||||
* @LastEditTime: 2025-11-20 14:20:27
|
* @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 React, { useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
useSortable,
|
useSortable,
|
||||||
@@ -13,13 +22,15 @@ import {
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { List } from 'antd';
|
import { List } from 'antd';
|
||||||
import type { GetProps } from 'antd';
|
import type { GetProps } from 'antd';
|
||||||
|
|
||||||
import type { SortableListItemContextProps } from './types';
|
import type { SortableListItemContextProps } from './types';
|
||||||
import SortableListItemContext from './SortableListItemContext';
|
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 SortableListItem: React.FC<GetProps<typeof List.Item> & { itemKey: number }> = (props) => {
|
||||||
const { itemKey, style, ...rest } = props;
|
const { itemKey, style, ...rest } = props;
|
||||||
|
|
||||||
|
/** Get sortable hooks and properties from @dnd-kit */
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -30,6 +41,7 @@ const SortableListItem: React.FC<GetProps<typeof List.Item> & { itemKey: number
|
|||||||
isDragging,
|
isDragging,
|
||||||
} = useSortable({ id: itemKey });
|
} = useSortable({ id: itemKey });
|
||||||
|
|
||||||
|
/** Apply drag transform and transition styles */
|
||||||
const listStyle: React.CSSProperties = {
|
const listStyle: React.CSSProperties = {
|
||||||
...style,
|
...style,
|
||||||
transform: CSS.Translate.toString(transform),
|
transform: CSS.Translate.toString(transform),
|
||||||
@@ -41,6 +53,7 @@ const SortableListItem: React.FC<GetProps<typeof List.Item> & { itemKey: number
|
|||||||
padding: '8px 0',
|
padding: '8px 0',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Memoize context value to avoid unnecessary re-renders */
|
||||||
const memoizedValue = useMemo<SortableListItemContextProps>(
|
const memoizedValue = useMemo<SortableListItemContextProps>(
|
||||||
() => ({ setActivatorNodeRef, listeners, attributes }),
|
() => ({ setActivatorNodeRef, listeners, attributes }),
|
||||||
[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 { createContext } from 'react';
|
||||||
|
|
||||||
import type { SortableListItemContextProps } from './types';
|
import type { SortableListItemContextProps } from './types';
|
||||||
|
|
||||||
|
/** Context for sharing sortable item properties with child components (e.g., DragHandle) */
|
||||||
const SortableListItemContext = createContext<SortableListItemContextProps>({});
|
const SortableListItemContext = createContext<SortableListItemContextProps>({});
|
||||||
|
|
||||||
export default SortableListItemContext;
|
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 React, { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { DragEndEvent } from '@dnd-kit/core';
|
import type { DragEndEvent } from '@dnd-kit/core';
|
||||||
@@ -9,24 +27,36 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { List, Input, Button } from 'antd';
|
import { List, Input, Button } from 'antd';
|
||||||
|
|
||||||
import SortableListItem from './SortableListItem';
|
import SortableListItem from './SortableListItem';
|
||||||
import DragHandle from './DragHandle';
|
import DragHandle from './DragHandle';
|
||||||
|
|
||||||
|
/** Item interface for sortable list */
|
||||||
interface Item {
|
interface Item {
|
||||||
|
/** Unique key for the item */
|
||||||
key: number;
|
key: number;
|
||||||
|
/** Text content of the item */
|
||||||
content: string;
|
content: string;
|
||||||
|
/** Special type for add button */
|
||||||
type?: 'add';
|
type?: 'add';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Props interface for SortableList component */
|
||||||
interface SortableListProps {
|
interface SortableListProps {
|
||||||
|
/** Array of list items */
|
||||||
value?: Item[];
|
value?: Item[];
|
||||||
|
/** Callback fired when items change */
|
||||||
onChange?: (items?: Item[]) => void;
|
onChange?: (items?: Item[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sortable list component with drag-and-drop functionality */
|
||||||
const SortableList: React.FC<SortableListProps> = ({
|
const SortableList: React.FC<SortableListProps> = ({
|
||||||
value = [],
|
value = [],
|
||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
/** Handle drag end event to reorder items */
|
||||||
const onDragEnd = ({ active, over }: DragEndEvent) => {
|
const onDragEnd = ({ active, over }: DragEndEvent) => {
|
||||||
if (!active || !over) {
|
if (!active || !over) {
|
||||||
return;
|
return;
|
||||||
@@ -38,18 +68,22 @@ const SortableList: React.FC<SortableListProps> = ({
|
|||||||
onChange?.(arrayMove([...value], activeIndex, overIndex));
|
onChange?.(arrayMove([...value], activeIndex, overIndex));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 监听value变化,包括初始值
|
|
||||||
|
/** Listen to value changes and trigger callback */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(value);
|
onChange(value);
|
||||||
}
|
}
|
||||||
}, [value, onChange]);
|
}, [value, onChange]);
|
||||||
|
|
||||||
|
/** Handle input content change for a specific item */
|
||||||
const inputChange = (e: React.ChangeEvent<HTMLInputElement>, index: number) => {
|
const inputChange = (e: React.ChangeEvent<HTMLInputElement>, index: number) => {
|
||||||
const newItems = [...value];
|
const newItems = [...value];
|
||||||
newItems[index].content = e.target.value;
|
newItems[index].content = e.target.value;
|
||||||
onChange?.(newItems);
|
onChange?.(newItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Add new item to the list */
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
onChange?.([...value, { key: Date.now(), content: '' }]);
|
onChange?.([...value, { key: Date.now(), content: '' }]);
|
||||||
}
|
}
|
||||||
@@ -64,9 +98,11 @@ const SortableList: React.FC<SortableListProps> = ({
|
|||||||
dataSource={[...value, { type: 'add', key: Date.now(), content: '' }]}
|
dataSource={[...value, { type: 'add', key: Date.now(), content: '' }]}
|
||||||
renderItem={(item: Item, index: number) => {
|
renderItem={(item: Item, index: number) => {
|
||||||
console.log('renderItem', item, index)
|
console.log('renderItem', item, index)
|
||||||
|
/** Render add button for special 'add' type */
|
||||||
if (item.type === 'add') {
|
if (item.type === 'add') {
|
||||||
return <Button block onClick={handleAdd}>{t('common.addOption')}</Button>
|
return <Button block onClick={handleAdd}>{t('common.addOption')}</Button>
|
||||||
} else {
|
} else {
|
||||||
|
/** Render sortable item with drag handle and input */
|
||||||
return (
|
return (
|
||||||
<SortableListItem key={item.key} itemKey={item.key}>
|
<SortableListItem key={item.key} itemKey={item.key}>
|
||||||
<DragHandle /> <Input variant="underlined" value={item.content} onChange={(e) => inputChange(e, index)} />
|
<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 { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
||||||
import type { DraggableAttributes } from '@dnd-kit/core';
|
import type { DraggableAttributes } from '@dnd-kit/core';
|
||||||
|
|
||||||
|
/** Props interface for SortableListItem context */
|
||||||
export interface SortableListItemContextProps {
|
export interface SortableListItemContextProps {
|
||||||
|
/** Function to set the activator node ref for drag handle */
|
||||||
setActivatorNodeRef?: (element: HTMLElement | null) => void;
|
setActivatorNodeRef?: (element: HTMLElement | null) => void;
|
||||||
|
/** Event listeners for drag interactions */
|
||||||
listeners?: SyntheticListenerMap;
|
listeners?: SyntheticListenerMap;
|
||||||
|
/** Accessibility attributes for draggable elements */
|
||||||
attributes?: DraggableAttributes;
|
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 { type FC } from 'react'
|
||||||
import { Tag } from 'antd';
|
import { Tag } from 'antd';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
/** Props interface for StatusTag component */
|
||||||
interface StatusTagProps {
|
interface StatusTagProps {
|
||||||
|
/** Status type determining the indicator color */
|
||||||
status: 'success' | 'error' | 'warning' | 'default' | 'lightBlue' | 'purple',
|
status: 'success' | 'error' | 'warning' | 'default' | 'lightBlue' | 'purple',
|
||||||
|
/** Text to display in the tag */
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Color mappings for different status types */
|
||||||
const Colors = {
|
const Colors = {
|
||||||
success: 'rb:bg-[#369F21]',
|
success: 'rb:bg-[#369F21]',
|
||||||
error: 'rb:bg-[#FF5D34]',
|
error: 'rb:bg-[#FF5D34]',
|
||||||
@@ -15,6 +35,7 @@ const Colors = {
|
|||||||
purple: 'rb:bg-[#9C6FFF]'
|
purple: 'rb:bg-[#9C6FFF]'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Status tag component with colored indicator dot */
|
||||||
const StatusTag: FC<StatusTagProps> = ({
|
const StatusTag: FC<StatusTagProps> = ({
|
||||||
status,
|
status,
|
||||||
text
|
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 { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { Table } from 'antd';
|
import { Table } from 'antd';
|
||||||
import type { TableProps } 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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { request } from '@/utils/request';
|
import { request } from '@/utils/request';
|
||||||
import styles from './index.module.css';
|
|
||||||
import Empty from '@/components/Empty';
|
import Empty from '@/components/Empty';
|
||||||
|
|
||||||
|
interface TablePaginationConfig { pagesize: number; page: number; }
|
||||||
|
|
||||||
|
/** Props interface for Table component */
|
||||||
interface TableComponentProps extends Omit<TableProps, 'pagination'> {
|
interface TableComponentProps extends Omit<TableProps, 'pagination'> {
|
||||||
|
/** Table column definitions */
|
||||||
columns: ColumnsType;
|
columns: ColumnsType;
|
||||||
|
/** API endpoint URL for data fetching */
|
||||||
apiUrl?: string;
|
apiUrl?: string;
|
||||||
|
/** Query parameters for API request */
|
||||||
apiParams?: Record<string, unknown>;
|
apiParams?: Record<string, unknown>;
|
||||||
|
/** Pagination configuration or boolean to enable/disable */
|
||||||
pagination?: boolean | TablePaginationConfig;
|
pagination?: boolean | TablePaginationConfig;
|
||||||
|
/** Key to use for row identification */
|
||||||
rowKey: string;
|
rowKey: string;
|
||||||
|
/** Row selection configuration */
|
||||||
rowSelection?: TableProps['rowSelection'];
|
rowSelection?: TableProps['rowSelection'];
|
||||||
|
/** Initial data to display (used when no API) */
|
||||||
initialData?: Record<string, unknown>[];
|
initialData?: Record<string, unknown>[];
|
||||||
|
/** Size of empty state icon */
|
||||||
emptySize?: number;
|
emptySize?: number;
|
||||||
|
/** Custom empty state text */
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
|
/** Whether to enable scroll */
|
||||||
isScroll?: boolean;
|
isScroll?: boolean;
|
||||||
scrollX?: number | string | true; // 支持自定义横向滚动宽度
|
/** Custom horizontal scroll width */
|
||||||
scrollY?: number | string; // 支持自定义纵向滚动高度
|
scrollX?: number | string | true;
|
||||||
|
/** Custom vertical scroll height */
|
||||||
|
scrollY?: number | string;
|
||||||
|
/** Key name for current page in API params */
|
||||||
currentPageKey?: string;
|
currentPageKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ref methods exposed to parent component */
|
||||||
export interface TableRef {
|
export interface TableRef {
|
||||||
|
/** Reload data from first page */
|
||||||
loadData: () => void;
|
loadData: () => void;
|
||||||
|
/** Fetch data with specific pagination */
|
||||||
getList: (pageData: TablePaginationConfig) => void;
|
getList: (pageData: TablePaginationConfig) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Filter out empty or invalid parameters from API request */
|
||||||
const dealSo = (params: any) => {
|
const dealSo = (params: any) => {
|
||||||
let so: any = {}
|
let so: any = {}
|
||||||
Object.keys(params).forEach(key => {
|
Object.keys(params).forEach(key => {
|
||||||
@@ -38,7 +79,9 @@ const dealSo = (params: any) => {
|
|||||||
|
|
||||||
return so
|
return so
|
||||||
}
|
}
|
||||||
const TableComponent = forwardRef<TableRef, TableComponentProps>(({
|
|
||||||
|
/** Table component with pagination and API integration */
|
||||||
|
const RbTable = forwardRef<TableRef, TableComponentProps>(({
|
||||||
columns,
|
columns,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
apiParams,
|
apiParams,
|
||||||
@@ -63,14 +106,14 @@ const TableComponent = forwardRef<TableRef, TableComponentProps>(({
|
|||||||
});
|
});
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
/** Sync initial data when provided without API */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialData && !apiUrl) {
|
if (initialData && !apiUrl) {
|
||||||
setData(initialData)
|
setData(initialData)
|
||||||
}
|
}
|
||||||
}, [initialData, apiUrl])
|
}, [initialData, apiUrl])
|
||||||
|
|
||||||
// 数据加载
|
/** Initialize table and load data from first page */
|
||||||
// 表格初始化
|
|
||||||
const loadData = () => {
|
const loadData = () => {
|
||||||
if (apiUrl) {
|
if (apiUrl) {
|
||||||
getList({
|
getList({
|
||||||
@@ -79,7 +122,8 @@ const TableComponent = forwardRef<TableRef, TableComponentProps>(({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 获取数据
|
|
||||||
|
/** Fetch data from API with pagination */
|
||||||
const getList = (pageData: TablePaginationConfig) => {
|
const getList = (pageData: TablePaginationConfig) => {
|
||||||
if (!apiUrl) {
|
if (!apiUrl) {
|
||||||
return
|
return
|
||||||
@@ -93,10 +137,10 @@ const TableComponent = forwardRef<TableRef, TableComponentProps>(({
|
|||||||
params = { ...params, ...pageData, [currentPageKey]: pageData.page}
|
params = { ...params, ...pageData, [currentPageKey]: pageData.page}
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
// 构建查询参数并调用API
|
/** Build query parameters and call API */
|
||||||
request.get(apiUrl, params)
|
request.get(apiUrl, params)
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
// 支持两种响应格式:直接返回 total 或在 page 对象中返回
|
/** Support two response formats: direct total or total in page object */
|
||||||
const totalCount = res.page?.total ?? res.total ?? 0;
|
const totalCount = res.page?.total ?? res.total ?? 0;
|
||||||
setTotal(totalCount)
|
setTotal(totalCount)
|
||||||
setData(Array.isArray(res.items) ? res.items : Array.isArray(res.hosts) ? res.hosts : Array.isArray(res.list) ? res.list : res || [])
|
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)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 初始化和apiParams变化时重新加载数据
|
|
||||||
|
/** Reload data when initialized or apiParams changes */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [apiParams])
|
}, [apiParams])
|
||||||
|
|
||||||
// 分页相关
|
/** Handle page change event */
|
||||||
// 切换分页
|
|
||||||
const handlePageChange = (page: number, pagesize: number) => {
|
const handlePageChange = (page: number, pagesize: number) => {
|
||||||
getList({
|
getList({
|
||||||
page: page,
|
page: page,
|
||||||
pagesize
|
pagesize
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 分页配置
|
|
||||||
|
/** Pagination configuration with i18n support */
|
||||||
const paginationConfig = pagination ? ({
|
const paginationConfig = pagination ? ({
|
||||||
...(typeof pagination === 'object' ? pagination : {}),
|
...(typeof pagination === 'object' ? pagination : {}),
|
||||||
...currentPagination,
|
...currentPagination,
|
||||||
@@ -132,19 +177,19 @@ const TableComponent = forwardRef<TableRef, TableComponentProps>(({
|
|||||||
}) : false;
|
}) : false;
|
||||||
|
|
||||||
|
|
||||||
// 暴露给父组件的方法
|
/** Expose loadData and getList methods to parent component via ref */
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
loadData,
|
loadData,
|
||||||
getList,
|
getList,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 计算 scroll 配置
|
/** Calculate scroll configuration based on props */
|
||||||
const getScrollConfig = () => {
|
const getScrollConfig = () => {
|
||||||
if (!isScroll && !scrollX && !scrollY) return undefined;
|
if (!isScroll && !scrollX && !scrollY) return undefined;
|
||||||
|
|
||||||
const config: { x?: number | string | true; y?: number | string } = {};
|
const config: { x?: number | string | true; y?: number | string } = {};
|
||||||
|
|
||||||
// 只有在有数据时才应用横向滚动
|
/** Only apply horizontal scroll when there is data */
|
||||||
if (scrollX !== undefined && data.length > 0) {
|
if (scrollX !== undefined && data.length > 0) {
|
||||||
config.x = scrollX;
|
config.x = scrollX;
|
||||||
} else if (isScroll) {
|
} else if (isScroll) {
|
||||||
@@ -169,8 +214,7 @@ const TableComponent = forwardRef<TableRef, TableComponentProps>(({
|
|||||||
dataSource={data}
|
dataSource={data}
|
||||||
pagination={paginationConfig}
|
pagination={paginationConfig}
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
rowClassName={styles.row}
|
rowClassName="rb:text-[#5B6167]"
|
||||||
className={styles.table}
|
|
||||||
locale={{ emptyText: <Empty size={emptySize} subTitle={emptyText} /> }}
|
locale={{ emptyText: <Empty size={emptySize} subTitle={emptyText} /> }}
|
||||||
scroll={getScrollConfig()}
|
scroll={getScrollConfig()}
|
||||||
tableLayout="auto"
|
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'
|
import { type FC, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
/** Props interface for Tag component */
|
||||||
export interface TagProps {
|
export interface TagProps {
|
||||||
|
/** Color theme for the tag */
|
||||||
color?: 'processing' | 'error' | 'success' | 'warning' | 'default',
|
color?: 'processing' | 'error' | 'success' | 'warning' | 'default',
|
||||||
|
/** Tag content */
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
/** Additional CSS classes */
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Color theme mappings with text, border, and background colors */
|
||||||
const colors = {
|
const colors = {
|
||||||
processing: 'rb:text-[#155EEF] rb:border-[rgba(21,94,239,0.25)] rb:bg-[rgba(21,94,239,0.06)]',
|
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)]',
|
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)]',
|
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 }) => {
|
const Tag: FC<TagProps> = ({ color = 'processing', children, className }) => {
|
||||||
return (
|
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 || ''}`}>
|
<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 { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { Upload, Image, App } from 'antd';
|
import { Upload, Image, App } from 'antd';
|
||||||
import type { GetProp, UploadFile, UploadProps } 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 type { UploadProps as RcUploadProps } from 'antd/es/upload/interface';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import PlusIcon from '@/assets/images/plus.svg'
|
import PlusIcon from '@/assets/images/plus.svg'
|
||||||
import { cookieUtils } from '@/utils/request'
|
import { cookieUtils } from '@/utils/request'
|
||||||
import { fileUploadUrl } from '@/api/fileStorage'
|
import { fileUploadUrl } from '@/api/fileStorage'
|
||||||
import styles from './index.module.less'
|
import styles from './index.module.less'
|
||||||
|
|
||||||
|
/** Props interface for UploadImages component */
|
||||||
interface UploadImagesProps extends Omit<UploadProps, 'onChange' | 'fileList'> {
|
interface UploadImagesProps extends Omit<UploadProps, 'onChange' | 'fileList'> {
|
||||||
/** 上传接口地址 */
|
/** Upload API URL */
|
||||||
action?: string;
|
action?: string;
|
||||||
/** 是否支持多选 */
|
/** Support multiple file selection */
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
/** 已上传的文件列表 */
|
/** Uploaded file list */
|
||||||
fileList?: UploadFile[] | UploadFile;
|
fileList?: UploadFile[] | UploadFile;
|
||||||
/** 文件列表变化回调 */
|
/** File list change callback */
|
||||||
onChange?: (fileList?: UploadFile[] | UploadFile) => void;
|
onChange?: (fileList?: UploadFile[] | UploadFile) => void;
|
||||||
/** 禁用上传 */
|
/** Disable upload */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** 文件大小限制(MB) */
|
/** File size limit (MB) */
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
/** 文件类型限制 */
|
/** File type restrictions */
|
||||||
fileType?: string[];
|
fileType?: string[];
|
||||||
/** 是否自动上传,默认为true */
|
/** Auto upload, default is true */
|
||||||
isAutoUpload?: boolean;
|
isAutoUpload?: boolean;
|
||||||
/** 最大上传文件数 */
|
/** Maximum upload file count */
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Supported file type mappings (extension to MIME type) */
|
||||||
const ALL_FILE_TYPE: {
|
const ALL_FILE_TYPE: {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
} = {
|
} = {
|
||||||
@@ -41,11 +64,15 @@ const ALL_FILE_TYPE: {
|
|||||||
webp: 'image/webp',
|
webp: 'image/webp',
|
||||||
svg: 'image/svg+xml',
|
svg: 'image/svg+xml',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ref methods exposed to parent component */
|
||||||
interface UploadImagesRef {
|
interface UploadImagesRef {
|
||||||
fileList: UploadFile[];
|
fileList: UploadFile[];
|
||||||
clearFiles: () => void;
|
clearFiles: () => void;
|
||||||
}
|
}
|
||||||
type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];
|
type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];
|
||||||
|
|
||||||
|
/** Convert file to base64 string for preview */
|
||||||
const getBase64 = (file: FileType): Promise<string> => {
|
const getBase64 = (file: FileType): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
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>(({
|
const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
||||||
action = fileUploadUrl,
|
action = fileUploadUrl,
|
||||||
@@ -86,6 +113,7 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
|||||||
}
|
}
|
||||||
}, [propFileList])
|
}, [propFileList])
|
||||||
|
|
||||||
|
/** Update value based on maxCount (single or multiple) */
|
||||||
const updateValue = (list: UploadFile[]) => {
|
const updateValue = (list: UploadFile[]) => {
|
||||||
if (maxCount === 1) {
|
if (maxCount === 1) {
|
||||||
onChange?.(list[0])
|
onChange?.(list[0])
|
||||||
@@ -94,7 +122,7 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理文件移除
|
/** Handle file removal with confirmation dialog */
|
||||||
const handleRemove = (file: UploadFile) => {
|
const handleRemove = (file: UploadFile) => {
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: t('common.confirmRemoveFile'),
|
title: t('common.confirmRemoveFile'),
|
||||||
@@ -107,12 +135,12 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
|||||||
updateValue(newFileList)
|
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) => {
|
const beforeUpload: RcUploadProps['beforeUpload'] = async (file: UploadFile) => {
|
||||||
// 校验文件大小
|
// Validate file size
|
||||||
if (fileSize && file.size) {
|
if (fileSize && file.size) {
|
||||||
const isLtMaxSize = (file.size / 1024 / 1024) < fileSize;
|
const isLtMaxSize = (file.size / 1024 / 1024) < fileSize;
|
||||||
if (!isLtMaxSize) {
|
if (!isLtMaxSize) {
|
||||||
@@ -120,7 +148,7 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
|||||||
return Upload.LIST_IGNORE;
|
return Upload.LIST_IGNORE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 校验文件类型
|
// Validate file type
|
||||||
if (accept && accept.length > 0 && file.type) {
|
if (accept && accept.length > 0 && file.type) {
|
||||||
const isAccept = accept.includes(file.type);
|
const isAccept = accept.includes(file.type);
|
||||||
if (!isAccept) {
|
if (!isAccept) {
|
||||||
@@ -136,24 +164,25 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
|||||||
const newFileList = [...fileList, file];
|
const newFileList = [...fileList, file];
|
||||||
setFileList(newFileList);
|
setFileList(newFileList);
|
||||||
updateValue(newFileList);
|
updateValue(newFileList);
|
||||||
return Upload.LIST_IGNORE; // 阻止自动上传
|
return Upload.LIST_IGNORE; // Prevent auto upload
|
||||||
}
|
}
|
||||||
|
|
||||||
return isAutoUpload;
|
return isAutoUpload;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理上传状态变化
|
/** Handle upload status change */
|
||||||
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => {
|
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => {
|
||||||
setFileList(newFileList);
|
setFileList(newFileList);
|
||||||
updateValue(newFileList);
|
updateValue(newFileList);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 清空已上传文件
|
/** Clear all uploaded files */
|
||||||
const clearFiles = () => {
|
const clearFiles = () => {
|
||||||
setFileList([]);
|
setFileList([]);
|
||||||
updateValue([]);
|
updateValue([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handle image preview */
|
||||||
const handlePreview = async (file: UploadFile) => {
|
const handlePreview = async (file: UploadFile) => {
|
||||||
if (!file.thumbUrl && !file.url && !file.preview) {
|
if (!file.thumbUrl && !file.url && !file.preview) {
|
||||||
file.preview = await getBase64(file.originFileObj as FileType);
|
file.preview = await getBase64(file.originFileObj as FileType);
|
||||||
@@ -163,6 +192,7 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
|||||||
setPreviewOpen(true);
|
setPreviewOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Build accept string from fileType array */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fileType && fileType.length > 0) {
|
if (fileType && fileType.length > 0) {
|
||||||
const acceptArray = fileType.map((type: string) => ALL_FILE_TYPE[type.toLowerCase()]).filter(Boolean);
|
const acceptArray = fileType.map((type: string) => ALL_FILE_TYPE[type.toLowerCase()]).filter(Boolean);
|
||||||
@@ -172,7 +202,7 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
|||||||
}
|
}
|
||||||
}, [fileType])
|
}, [fileType])
|
||||||
|
|
||||||
// 生成上传组件配置
|
/** Generate upload component configuration */
|
||||||
const uploadProps: UploadProps = {
|
const uploadProps: UploadProps = {
|
||||||
action,
|
action,
|
||||||
multiple: multiple && maxCount > 1,
|
multiple: multiple && maxCount > 1,
|
||||||
@@ -196,7 +226,7 @@ const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
|
|||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 暴露给父组件的方法
|
/** Expose methods to parent component via ref */
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
fileList,
|
fileList,
|
||||||
clearFiles
|
clearFiles
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import webIcon from '@/assets/images/knowledgeBase/general.png';
|
|||||||
import tpIcon from '@/assets/images/knowledgeBase/text.png';
|
import tpIcon from '@/assets/images/knowledgeBase/text.png';
|
||||||
import type { KnowledgeBaseListItem, CreateModalRef, KnowledgeBaseListResponse, ListQuery } from '@/views/KnowledgeBase/types'
|
import type { KnowledgeBaseListItem, CreateModalRef, KnowledgeBaseListResponse, ListQuery } from '@/views/KnowledgeBase/types'
|
||||||
import CreateModal from './components/CreateModal'
|
import CreateModal from './components/CreateModal'
|
||||||
import RbCard from '@/components/RbCard'
|
import RbCard from '@/components/RbCard/Card'
|
||||||
import SearchInput from '@/components/SearchInput'
|
import SearchInput from '@/components/SearchInput'
|
||||||
import Empty from '@/components/Empty'
|
import Empty from '@/components/Empty'
|
||||||
import { getKnowledgeBaseList, getModelList, getModelTypeList, deleteKnowledgeBase, getKnowledgeBaseTypeList } from '@/api/knowledgeBase'
|
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 { useParams } from 'react-router-dom'
|
||||||
import { Row, Col, Space, Select, InputNumber, Slider, App, Form } from 'antd'
|
import { Row, Col, Space, Select, InputNumber, Slider, App, Form } from 'antd'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import Card from './components/Card'
|
import Card from './components/Card'
|
||||||
import type { ConfigForm, Variable } from './types'
|
import type { ConfigForm, Variable } from './types'
|
||||||
import { getMemoryExtractionConfig, updateMemoryExtractionConfig } from '@/api/memory'
|
import { getMemoryExtractionConfig, updateMemoryExtractionConfig } from '@/api/memory'
|
||||||
import Markdown from '@/components/Markdown'
|
import Markdown from '@/components/Markdown'
|
||||||
import { getModelList } from '@/api/models';
|
import { getModelListUrl } from '@/api/models';
|
||||||
import type { ModelListItem } from '@/views/ModelManagement/types'
|
|
||||||
import { configList } from './constant'
|
import { configList } from './constant'
|
||||||
import Result from './components/Result'
|
import Result from './components/Result'
|
||||||
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
|
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
|
||||||
|
import CustomSelect from '@/components/CustomSelect'
|
||||||
|
|
||||||
const keys = [
|
const keys = [
|
||||||
// 'example',
|
// 'example',
|
||||||
@@ -43,7 +44,6 @@ const MemoryExtractionEngine: FC = () => {
|
|||||||
const values = Form.useWatch<ConfigForm>([], form)
|
const values = Form.useWatch<ConfigForm>([], form)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [iterationPeriodDisabled, setIterationPeriodDisabled] = useState(false)
|
const [iterationPeriodDisabled, setIterationPeriodDisabled] = useState(false)
|
||||||
const [modelList, setModelList] = useState<ModelListItem[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (values?.reflexion_range === 'database') {
|
if (values?.reflexion_range === 'database') {
|
||||||
@@ -54,14 +54,6 @@ const MemoryExtractionEngine: FC = () => {
|
|||||||
}
|
}
|
||||||
}, [values])
|
}, [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 = () => {
|
const getConfig = () => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return
|
return
|
||||||
@@ -84,7 +76,6 @@ const MemoryExtractionEngine: FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
getConfig()
|
getConfig()
|
||||||
getModels()
|
|
||||||
}
|
}
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
@@ -123,13 +114,13 @@ const MemoryExtractionEngine: FC = () => {
|
|||||||
label={t('memoryExtractionEngine.model')}
|
label={t('memoryExtractionEngine.model')}
|
||||||
name="llm_id"
|
name="llm_id"
|
||||||
>
|
>
|
||||||
<Select
|
<CustomSelect
|
||||||
placeholder={t('common.pleaseSelect')}
|
url={getModelListUrl}
|
||||||
fieldNames={{
|
params={{ type: 'llm,chat', pagesize: 100, is_active: true }}
|
||||||
label: 'name',
|
valueKey="id"
|
||||||
value: 'id',
|
labelKey="name"
|
||||||
}}
|
hasAll={false}
|
||||||
options={modelList}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -222,7 +213,7 @@ const MemoryExtractionEngine: FC = () => {
|
|||||||
step={config.step || 0.01}
|
step={config.step || 0.01}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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}
|
{config.min || 0}
|
||||||
<span>{t('memoryExtractionEngine.CurrentValue')}: {values?.[config.variableName as keyof ConfigForm]}</span>
|
<span>{t('memoryExtractionEngine.CurrentValue')}: {values?.[config.variableName as keyof ConfigForm]}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user