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