docs: add comments to the src/components directory

This commit is contained in:
zhaoying
2026-02-02 16:14:39 +08:00
parent 9a38e8a4a0
commit a191e32f71
55 changed files with 1417 additions and 375 deletions

View File

@@ -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>

View File

@@ -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])}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
);

View File

@@ -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 ")}>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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')}

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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 (

View File

@@ -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>
}

View File

@@ -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,

View File

@@ -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'))

View File

@@ -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>
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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',

View File

@@ -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 (

View File

@@ -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, '&lt;').replace(/>/g, '&gt;')
return `<span class="html-comment">&lt;!-- ${escaped} --&gt;</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>{`

View File

@@ -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 }}

View File

@@ -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,

View File

@@ -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", {

View File

@@ -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`}>

View File

@@ -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,

View File

@@ -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

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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(() => {

View File

@@ -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 Menuitems格式
/** 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>
}

View File

@@ -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}

View File

@@ -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 (

View File

@@ -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],

View File

@@ -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;

View File

@@ -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)} />

View File

@@ -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;
}

View File

@@ -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

View File

@@ -1,3 +0,0 @@
.row {
color: #5B6167;
}

View File

@@ -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;

View File

@@ -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 || ''}`}>

View File

@@ -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

View File

@@ -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'

View File

@@ -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>