feat: Add base project structure with API and web components

This commit is contained in:
Ke Sun
2025-12-02 20:28:01 +08:00
parent f3de6d6cc9
commit c1adc62ec6
817 changed files with 111226 additions and 106 deletions

View File

@@ -0,0 +1,48 @@
import { type FC, type ReactNode, useEffect } from 'react';
import { type RadioGroupProps } from 'antd';
import clsx from 'clsx'
interface ButtonCheckboxProps extends Omit<RadioGroupProps, 'onChange'> {
checked?: boolean;
onValueChange?: (checked: boolean) => void;
onChange?: (checked: boolean) => void;
icon?: string;
checkedIcon?: string;
children?: ReactNode
}
const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
checked = false,
onValueChange,
onChange,
icon,
checkedIcon,
children,
}) => {
// 监听value变化
useEffect(() => {
if (onValueChange) {
onValueChange(checked);
}
}, [checked, onValueChange]);
const handleChange = () => {
if (onChange) {
onChange(!checked);
}
}
return (
<div className={clsx("rb:flex rb:items-center rb:border rb:rounded-[8px] rb:px-[8px] rb:text-[12px] rb:h-[24px] 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}>
{icon && !checked && <img src={icon} className="rb:w-[16px] rb:h-[16px] rb:mr-[4px]" />}
{checkedIcon && checked && <img src={checkedIcon} className="rb:w-[16px] rb:h-[16px] rb:mr-[4px]" />}
{children}
</div>
);
};
export default ButtonCheckbox;

View File

@@ -0,0 +1,100 @@
import { useEffect, useState, useCallback, useRef, 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';
// 定义API响应类型
interface ApiResponse<T> {
items?: T[];
}
interface CustomSelectProps {
url: string;
params?: Record<string, unknown>;
valueKey?: string;
labelKey?: string;
placeholder?: string;
hasAll?: boolean;
allTitle?: string;
format?: (items: OptionType[]) => OptionType[];
showSearch?: boolean;
optionFilterProp?: string;
// 其他SelectProps属性
onChange?: SelectProps<Key, DefaultOptionType>['onChange'];
value?: SelectProps<Key, DefaultOptionType>['value'];
disabled?: boolean;
style?: React.CSSProperties;
className?: string;
filterOption?: (inputValue: string, option: DefaultOptionType) => boolean;
}
interface OptionType {
[key: string]: Key | string | number;
}
const CustomSelect: FC<CustomSelectProps> = ({
onChange,
url,
params,
valueKey = 'value',
labelKey = 'label',
placeholder,
hasAll = true,
allTitle,
format,
showSearch = false,
optionFilterProp = 'label',
filterOption,
...props
}) => {
const { t } = useTranslation();
const [options, setOptions] = useState<OptionType[]>([]);
// 创建防抖定时器引用
const debounceRef = useRef<number>();
// 防抖搜索函数
const handleSearch = useCallback((value?: string) => {
// 清除之前的定时器
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
// 设置新的定时器
debounceRef.current = window.setTimeout(() => {
request.get<ApiResponse<OptionType>>(url, {...params, [optionFilterProp]: value}).then((res) => {
const data = res;
setOptions(Array.isArray(data) ? data || [] : Array.isArray(data?.items) ? data.items || [] : []);
});
}, 300); // 300毫秒防抖延迟
}, [url, params, optionFilterProp]);
// 组件挂载时获取初始数据
useEffect(() => {
handleSearch();
// 组件卸载时清除定时器
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [url, handleSearch]);
return (
<Select
placeholder={placeholder ? placeholder : t('common.select')}
onChange={onChange}
defaultValue={hasAll ? null : undefined}
showSearch={showSearch}
onSearch={handleSearch}
filterOption={filterOption || false} // 禁用本地过滤,使用服务器端过滤
{...props}
>
{hasAll && (<Select.Option>{allTitle || t('common.all')}</Select.Option>)}
{(format ? format(options) : options)?.map(option => (
<Select.Option key={option[valueKey]} value={option[valueKey]}>
{String(option[labelKey])}
</Select.Option>
))}
</Select>
);
}
export default CustomSelect;

View File

@@ -0,0 +1,328 @@
import { useState, useEffect, type FC } from 'react';
import { Spin, Alert, Button } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import RbMarkdown from '../Markdown';
type PreviewMode = 'office' | 'google';
interface DocumentPreviewProps {
fileUrl: string;
fileName?: string;
fileExt?: string; // 文件扩展名(优先使用)
width?: string | number;
height?: string | number;
className?: string;
mode?: PreviewMode; // 预览模式
showModeSwitch?: boolean; // 是否显示模式切换按钮
}
const DocumentPreview: FC<DocumentPreviewProps> = ({
fileUrl,
fileName,
fileExt,
width = '100%',
height = '600px',
className = '',
mode = 'office',
showModeSwitch = true,
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [currentMode, setCurrentMode] = useState<PreviewMode>(mode);
const [textContent, setTextContent] = useState<string>('');
// 支持的文件类型
const supportedTypes = ['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.pdf', '.txt', '.md', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'];
// 获取文件扩展名(优先使用 fileExt prop
const getFileExtension = () => {
if (fileExt) {
return fileExt.toLowerCase().startsWith('.') ? fileExt.toLowerCase() : `.${fileExt.toLowerCase()}`;
}
const name = fileName || fileUrl;
const match = name.match(/\.([^.]+)$/);
return match ? `.${match[1].toLowerCase()}` : '';
};
// 检查是否为文本文件
const isTextFile = () => {
const ext = getFileExtension();
return ext === '.txt';
};
// 检查是否为 Markdown 文件
const isMarkdownFile = () => {
const ext = getFileExtension();
return ext === '.md';
};
// 检查是否为图片文件
const isImageFile = () => {
const ext = getFileExtension();
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'];
return imageExts.includes(ext);
};
// 检查文件类型是否支持
const isSupportedFile = () => {
const ext = getFileExtension();
return ext && supportedTypes.includes(ext);
};
// 检查是否为 PDF 文件
const isPdfFile = () => {
const ext = getFileExtension();
return ext === '.pdf';
};
// 构建预览 URL
const getPreviewUrl = () => {
// 处理文件 URL如果是完整的 URL转换为代理路径
let requestUrl = fileUrl;
// 如果是完整的 https://devapi.mem.redbearai.com 开头的 URL提取路径部分
// 这样可以通过代理访问,避免 CORS 问题
if (fileUrl.includes('devapi.mem.redbearai.com')) {
const url = new URL(fileUrl);
requestUrl = url.pathname; // 只取路径部分,例如 /api/files/xxx
}
// 对于 PDF 文件,直接使用浏览器内置预览
if (isPdfFile()) {
return requestUrl;
}
// 确保 fileUrl 是完整的 URL用于第三方预览服务
let fullUrl = fileUrl;
if (!fileUrl.startsWith('http')) {
fullUrl = `${window.location.origin}${fileUrl.startsWith('/') ? '' : '/'}${fileUrl}`;
}
console.log('预览 URL:', fullUrl);
// 根据模式选择预览服务
if (currentMode === 'google') {
return `https://docs.google.com/viewer?url=${encodeURIComponent(fullUrl)}&embedded=true`;
}
// 默认使用 Microsoft Office Online Viewer
return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fullUrl)}`;
};
const handleLoad = () => {
setLoading(false);
setError(false);
};
const handleError = () => {
setLoading(false);
setError(true);
};
const handleRetry = () => {
setLoading(true);
setError(false);
if (isTextFile() || isMarkdownFile()) {
// 重新加载文本文件
loadTextFile();
} else {
// 强制重新加载 iframe
const iframe = document.querySelector(`iframe[title="${fileName || '文档预览'}"]`) as HTMLIFrameElement;
if (iframe) {
iframe.src = iframe.src;
}
}
};
const handleSwitchMode = () => {
setCurrentMode(prev => prev === 'office' ? 'google' : 'office');
setLoading(true);
setError(false);
};
// 加载文本文件内容
const loadTextFile = async () => {
setLoading(true);
setError(false);
try {
// 处理文件 URL如果是完整的 URL转换为代理路径
let requestUrl = fileUrl;
// 如果是完整的 https://devapi.mem.redbearai.com 开头的 URL提取路径部分
if (fileUrl.includes('devapi.mem.redbearai.com')) {
const url = new URL(fileUrl);
requestUrl = url.pathname; // 只取路径部分,例如 /api/files/xxx
}
const response = await fetch(requestUrl, {
credentials: 'include', // 包含认证信息
headers: {
'Authorization': `Bearer ${localStorage.getItem('token') || ''}`,
},
});
if (!response.ok) {
throw new Error('Failed to load file');
}
// 检查响应的 Content-Type
const contentType = response.headers.get('Content-Type') || '';
console.log('文件 Content-Type:', contentType);
// 如果是图片类型,显示错误提示
if (contentType.startsWith('image/')) {
setError(true);
setTextContent('');
setLoading(false);
console.error('文件实际是图片类型,但被标记为 txt');
return;
}
const text = await response.text();
// 检查是否是二进制数据(如 PNG 文件头)
if (text.startsWith('\x89PNG') || text.startsWith('<27>PNG')) {
setError(true);
setTextContent('');
setLoading(false);
console.error('文件内容是 PNG 图片,但扩展名是 txt');
return;
}
setTextContent(text);
setLoading(false);
} catch (err) {
console.error('加载文本文件失败:', err);
setError(true);
setLoading(false);
}
};
// 当文件是 txt 或 md 时,加载文本内容
useEffect(() => {
if (isTextFile() || isMarkdownFile()) {
loadTextFile();
}
}, [fileUrl]);
if (!isSupportedFile()) {
return (
<Alert
message="不支持的文件类型"
description={`仅支持以下文件类型:${supportedTypes.join(', ')}`}
type="warning"
showIcon
/>
);
}
return (
<div className={`rb:relative ${className}`} style={{ width, height }}>
{loading && (
<div className="rb:absolute rb:inset-0 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:z-10">
<Spin size="large" tip="加载文档预览中..." />
</div>
)}
{error && (
<div className="rb:absolute rb:inset-0 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:z-10">
<Alert
message="预览失败"
description={
<div>
<p></p>
<ul className="rb:list-disc rb:pl-5 rb:mt-2">
<li>访Office 访</li>
<li> URL 访访 URL</li>
<li>Office 10MB</li>
<li></li>
</ul>
<p className="rb:mt-2 rb:text-gray-600"></p>
<div className="rb:mt-4 rb:flex rb:gap-2">
<Button icon={<ReloadOutlined />} onClick={handleRetry}>
</Button>
{showModeSwitch && !isPdfFile() && (
<Button onClick={handleSwitchMode}>
{currentMode === 'office' ? 'Google' : 'Office'}
</Button>
)}
</div>
</div>
}
type="error"
showIcon
/>
</div>
)}
{/* 图片文件预览 */}
{isImageFile() && !error && !loading && (
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-gray-50 rb:flex rb:items-center rb:justify-center">
<img
src={fileUrl}
alt={fileName || '图片预览'}
className="rb:max-w-full rb:max-h-full rb:object-contain"
onError={() => setError(true)}
/>
</div>
)}
{/* Markdown 文件预览 */}
{isMarkdownFile() && !error && !loading && (
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
<RbMarkdown content={textContent} />
</div>
)}
{/* 文本文件预览 */}
{isTextFile() && !error && !loading && (
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
<pre className="rb:whitespace-pre-wrap rb:text-sm rb:text-gray-800 rb:font-mono">
{textContent}
</pre>
</div>
)}
{/* PDF 文件预览(使用浏览器内置预览) */}
{isPdfFile() && !error && !loading && (
<iframe
src={getPreviewUrl()}
width="100%"
height="100%"
title={fileName || 'PDF 预览'}
className="rb:border-0"
style={{ border: 'none' }}
/>
)}
{/* Office 文件预览 */}
{!isTextFile() && !isMarkdownFile() && !isImageFile() && !isPdfFile() && (
<>
{showModeSwitch && !loading && !error && (
<div className="rb:absolute rb:top-2 rb:right-2 rb:z-20">
<Button size="small" onClick={handleSwitchMode}>
{currentMode === 'office' ? 'Google' : 'Office'}
</Button>
</div>
)}
{!error && (
<iframe
src={getPreviewUrl()}
width="100%"
height="100%"
onLoad={handleLoad}
onError={handleError}
title={fileName || '文档预览'}
className="rb:border-0"
style={{ display: loading ? 'none' : 'block', border: 'none' }}
sandbox="allow-scripts allow-same-origin allow-popups"
/>
)}
</>
)}
</div>
);
};
export default DocumentPreview;

View File

@@ -0,0 +1,19 @@
import type { FC, ReactNode } from 'react'
import { Skeleton } from 'antd'
import Empty from './index'
interface BodyWrapperProps {
children: ReactNode
loading?: boolean
empty: boolean
}
const BodyWrapper: FC<BodyWrapperProps> = ({ children, loading = false, empty }) => {
if (loading) {
return <Skeleton active />
}
if (!loading && empty) {
return <Empty />
}
return children
}
export default BodyWrapper

View File

@@ -0,0 +1,15 @@
import { useTranslation } from 'react-i18next'
import LoadingIcon from '@/assets/images/loading.svg'
import Empty from './index'
const Loading = ({ size = 200 }: { size?: number }) => {
const { t } = useTranslation()
return (
<Empty
url={LoadingIcon}
title={t('empty.loadingEmpty')}
subTitle={t('empty.loadingEmptyDesc')}
size={size}
/>
)
}
export default Loading;

View File

@@ -0,0 +1,32 @@
import { type FC } from 'react';
import { useTranslation } from 'react-i18next';
import emptyIcon from '@/assets/images/empty/empty.svg';
interface EmptyProps {
url?: string;
size?: number | number[];
title?: string;
subTitle?: string;
className?: string;
}
const Empty: FC<EmptyProps> = ({
url,
size = 200,
title,
subTitle,
className = '',
}) => {
const { t } = useTranslation();
const width = Array.isArray(size) ? size[0] : size ? size : url ? 200 : 88;
const height = Array.isArray(size) ? size[1] : size ? size : url ? 200 : 88;
subTitle = subTitle || t('empty.tableEmpty');
return (
<div className={`rb:flex rb:items-center rb:justify-center rb:flex-col ${className}`}>
<img src={url || emptyIcon} alt="404" style={{ width: `${width}px`, height: `${height}px` }} />
{title && <div className="rb:mt-[8px] rb:leading-[20px]">{title}</div>}
{subTitle && <div className={`rb:mt-[${url ? 8 : 5}px] rb:leading-[16px] rb:text-[#5B6167]`}>{subTitle}</div>}
</div>
);
}
export default Empty;

View File

@@ -0,0 +1,91 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Select } from 'antd';
import { useTranslation } from 'react-i18next';
import RbModal from '@/components/RbModal'
import { useI18n } from '@/store/locale'
import { timezones } from '@/utils/timezones'
const FormItem = Form.Item;
export interface SettingModalRef {
handleOpen: () => void;
handleClose: () => void;
}
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);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
};
const handleOpen = () => {
form.setFieldsValue({ language, timeZone })
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
const { language: newLanguage, timeZone: newTimeZone } = values
if (newLanguage !== language) {
changeLanguage(newLanguage);
}
changeTimeZone(newTimeZone)
handleClose()
})
.catch((err) => {
console.log('err', err)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t('header.setting')}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
>
<Form
form={form}
layout="vertical"
>
{/* 中英文切换 */}
<FormItem
name="language"
label={t('header.language')}
>
<Select
options={['zh', 'en'].map(key => ({ label: t(`header.${key}`), value: key }))}
/>
</FormItem>
{/* 时区切换 */}
<FormItem
name="timeZone"
label={t('header.timeZone')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Select
options={timezones.map(key => ({ label: t(`timezones.${key}`), value: key }))}
/>
</FormItem>
</Form>
</RbModal>
);
});
export default SettingModal;

View File

@@ -0,0 +1,83 @@
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 RbModal from '@/components/RbModal'
import { formatDateTime } from '@/utils/format';
import ResetPasswordModal from '@/views/UserManagement/components/ResetPasswordModal'
import type { ResetPasswordModalRef } from '@/views/UserManagement/types'
export interface UserInfoModalRef {
handleOpen: () => void;
handleClose: () => void;
}
const UserInfoModal = forwardRef<UserInfoModalRef>((_props, ref) => {
const { t } = useTranslation();
const resetPasswordModalRef = useRef<ResetPasswordModalRef>(null)
const { user } = useUser();
const [visible, setVisible] = useState(false);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
};
const handleOpen = () => {
setVisible(true);
};
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t('header.userInfo')}
open={visible}
onCancel={handleClose}
footer={null}
>
<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]">
<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]">
<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]">
<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]">
<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>
<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]">
<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>
</div>
<Button onClick={() => resetPasswordModalRef.current?.handleOpen(user)}>{t('common.change')}</Button>
</div>
<ResetPasswordModal
ref={resetPasswordModalRef}
source="changePassword"
/>
</RbModal>
);
});
export default UserInfoModal;

View File

@@ -0,0 +1,20 @@
.header {
width: 100%;
display: flex;
justify-content: space-between;
padding: 16px 46px 16px 21px;
height: 64px;
border-bottom: 1px solid #EAECEE;
color: #212332;
z-index: 0;
line-height: 32px;
font-size: 14px;
}
.title {
font-family: PingFangSC, PingFang SC;
font-weight: 500;
font-style: normal;
}
.header :global(.ant-breadcrumb) {
line-height: 31px;
}

View File

@@ -0,0 +1,106 @@
import { type FC, useRef } from 'react';
import { Layout, Dropdown, Space, Breadcrumb } from 'antd';
import type { MenuProps, BreadcrumbProps } from 'antd';
import { UserOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
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;
const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
const { t } = useTranslation();
const settingModalRef = useRef<SettingModalRef>(null)
const userInfoModalRef = useRef<UserInfoModalRef>(null)
const { user, logout } = useUser();
const { allBreadcrumbs } = useMenu();
const breadcrumbs = allBreadcrumbs[source] || [];
// 处理退出登录
const handleLogout = () => {
logout()
};
// 用户下拉菜单配置
const userMenuItems: MenuProps['items'] = [
{
key: '1',
label: (<>
<div>{user.username}</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-[8px]">{user.email}</div>
</>),
},
{
key: '2',
type: 'divider',
},
{
key: '3',
icon: <UserOutlined />,
label: t('header.userInfo'),
onClick: () => {
userInfoModalRef.current?.handleOpen()
},
},
{
key: '4',
icon: <SettingOutlined />,
label: t('header.settings'),
onClick: () => {
settingModalRef.current?.handleOpen()
},
},
{
key: '5',
type: 'divider',
},
{
key: '6',
icon: <LogoutOutlined />,
label: t('header.logout'),
danger: true,
onClick: handleLogout,
},
];
const formatBreadcrumbNames = () => {
return breadcrumbs.map((menu, index) => ({
title: menu.i18nKey ? t(menu.i18nKey) : menu.label,
path: index === breadcrumbs.length - 1 ? undefined : menu.path
}))
}
return (
<Header className={styles.header}>
<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>
<SettingModal
ref={settingModalRef}
/>
<UserInfoModal
ref={userInfoModalRef}
/>
</Header>
);
};
export default AppHeader;

View File

@@ -0,0 +1,37 @@
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';
import Sider from '@/components/SiderMenu'
import { useUser } from '@/store/user';
const { Content } = Layout;
// 认证布局组件使用useRouteGuard hook进行路由鉴权
const AuthLayout: FC = () => {
const { getUserInfo } = useUser();
// 使用路由守卫hook处理认证和权限检查
useRouteGuard('manage');
// 自动更新面包屑导航
useNavigationBreadcrumbs('manage');
useEffect(() => {
getUserInfo()
}, []);
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider />
<Layout style={{maxHeight: '100vh', width: '100vh', overflowY: 'auto' }}>
<AppHeader />
<Content style={{ padding: '16px 17px 24px 16px', zIndex: 0 }}>
<Outlet />
</Content>
</Layout>
</Layout>
)
};
export default AuthLayout;

View File

@@ -0,0 +1,38 @@
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';
import Sider from '@/components/SiderMenu';
import { useUser } from '@/store/user';
const { Content } = Layout;
// 认证布局组件使用useRouteGuard hook进行路由鉴权
const AuthSpaceLayout: FC = () => {
const { getUserInfo, getStorageType } = useUser();
// 使用路由守卫hook处理认证和权限检查
useRouteGuard('space');
// 自动更新面包屑导航
useNavigationBreadcrumbs('space');
useEffect(() => {
getUserInfo()
getStorageType()
}, []);
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider source="space" />
<Layout style={{maxHeight: '100vh', width: '100vh', overflowY: 'auto' }}>
<AppHeader source="space" />
<Content style={{ padding: '16px 17px 24px 16px', zIndex: 0, height: 'calc(100vh - 64px)', overflowY: 'auto' }}>
<Outlet />
</Content>
</Layout>
</Layout>
)
};
export default AuthSpaceLayout;

View File

@@ -0,0 +1,21 @@
import { Outlet } from 'react-router-dom';
import { useEffect, type FC } from 'react';
import { useUser } from '@/store/user';
// 基础布局组件,用于展示内容并保留用户信息获取功能
const BasicLayout: FC = () => {
const { getUserInfo } = useUser();
// 获取用户信息
useEffect(() => {
getUserInfo();
}, [getUserInfo]);
return (
<div className="rb:relative rb:h-full rb:w-full">
<Outlet />
</div>
)
};
export default BasicLayout;

View File

@@ -0,0 +1,17 @@
import { type FC } from 'react';
import clsx from 'clsx';
import styles from './layout.module.css';
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)}>
<div className={clsx(styles.left1)}></div>
<div className={clsx(styles.left2)}></div>
<div className={clsx(styles.right1)}></div>
</div>
</div>
)
};
export default LayoutBg;

View File

@@ -0,0 +1,14 @@
import { Outlet } from 'react-router-dom';
import { type FC } from 'react';
// 基础布局组件,用于展示内容并保留用户信息获取功能
const LoginLayout: FC = () => {
return (
<div className="rb:relative rb:h-full rb:w-full">
<Outlet />
</div>
)
};
export default LoginLayout;

View File

@@ -0,0 +1,37 @@
.bg-top {
width: 100%;
height: 240px;
background: linear-gradient(to bottom, rgba(229, 242, 254, 1), rgba(251, 253, 255, 1));
opacity: 0.8;
}
.left1 {
width: 748px;
height: 354px;
background: radial-gradient(circle, rgba(138, 239, 255, 1) 100%, rgba(232, 244, 255, 0.71) 100%);
opacity: 0.24;
filter: blur(50px);
position: absolute;
left: -374px;
top: -187px;
}
.left2 {
width: 558px;
height: 504px;
background: radial-gradient(circle, #155EEF 0%, rgba(232, 244, 255, 0) 100%);
opacity: 0.22399999999999998;
filter: blur(50px);
position: absolute;
left: -279px;
top: -252px;
}
.right1 {
width: 470px;
height: 474px;
background: radial-gradient(circle, rgba(21, 94, 239, 1) 0%, rgba(232, 244, 255, 0) 100%);
opacity: 0.32;
filter: blur(50px);
position: absolute;
right: -235px;
top: -237px;
}

View File

@@ -0,0 +1,22 @@
import { memo } from 'react'
import type { FC } from 'react'
interface AudioBlockProps {
node: {
children: { properties: { src: string } }[]
}
}
const AudioBlock: FC<AudioBlockProps> = (props) => {
// console.log('AudioBlock', props)
const { children } = props.node;
const srcs = children.map(item => item.properties?.src).filter(item => item)
return (
<>
{srcs.map(src => <audio key={src} src={src} controls />)}
</>
)
}
export default memo(AudioBlock)

View File

@@ -0,0 +1,87 @@
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 Svg from './Svg'
import MermaidChart from './MermaidChart'
type ICodeProps = {
children: string;
className: string;
}
const Code: FC<ICodeProps> = (props) => {
const { children, className } = props;
const language = className?.split('-')[1]
console.log('Code', props)
const charData = useMemo(() => {
if (language !== 'echarts') return null;
try {
return JSON.parse(String(children).replace(/\n$/, ''))
} catch (error) {
console.error('Error parsing JSON for ECharts:', error)
return {"title":{"text":"ECharts error - Wrong JSON format."}}
}
}, [language, children])
if (language === 'echarts') {
return (
<ReactEcharts
option={charData}
style={{
height: '400px',
width: '100%',
}}
/>
)
}
if (language === 'svg') {
return (
<Svg
content={children.replace(/\n/g, '')}
/>
)
}
if (language === 'mermaid') {
return (
<MermaidChart
content={String(children).replace(/\n$/, '')}
/>
)
}
if (className) {
return (
<div className="rb:relative">
<SyntaxHighlighter
style={atelierHeathLight}
customStyle={{
padding: '16px 20px 16px 24px',
backgroundColor: '#F0F3F8',
borderRadius: 8,
}}
language={language}
showLineNumbers={false}
PreTag="div"
>
{children}
</SyntaxHighlighter>
<CopyBtn
value={children}
style={{
position: 'absolute',
top: 20,
right: 20,
}}
/>
</div>
)
}
return <span className="rb:bg-[#F0F3F8] rb:px-1 rb:py-0.5 rb:rounded rb:text-sm rb:font-mono">{children}</span>
}
export default Code

View File

@@ -0,0 +1,48 @@
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';
type ICodeBlockProps = {
value: string;
}
// enum languageType {
// echarts = 'echarts',
// mermaid = 'mermaid',
// svg = 'svg',
// }
const CodeBlock: FC<ICodeBlockProps> = ({
value,
}) => {
return (
<div className="rb:relative">
<SyntaxHighlighter
style={atelierHeathLight}
customStyle={{
padding: '16px 20px 16px 24px',
backgroundColor: '#F0F3F8',
borderRadius: 8,
}}
language="json"
showLineNumbers={false}
PreTag="div"
>
{value}
</SyntaxHighlighter>
<CopyBtn
value={value}
style={{
position: 'absolute',
top: 20,
right: 20,
}}
/>
</div>
)
}
export default CodeBlock

View File

@@ -0,0 +1,31 @@
import { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import copy from 'copy-to-clipboard'
import { Button, App } from 'antd'
type ICopyBtnProps = {
value: string;
className?: string;
style?: React.CSSProperties;
}
const CopyBtn: FC<ICopyBtnProps> = ({
value,
className,
style,
}) => {
const { t } = useTranslation()
const { message } = App.useApp()
const handleCopy = () => {
copy(value)
message.success(t('common.copySuccess'))
}
return (
<Button onClick={handleCopy} className={className} style={style}>{t('common.copy')}</Button>
)
}
export default CopyBtn

View File

@@ -0,0 +1,14 @@
import { memo } from 'react'
import type { FC, ReactNode } from 'react'
interface LinkProps {
href: string;
children: ReactNode;
}
const Link: FC<LinkProps> = (props) => {
// console.log('Link', props)
const { children, href } = props;
return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
}
export default memo(Link)

View File

@@ -0,0 +1,46 @@
import { useRef, useEffect, useState, type FC } from 'react'
import mermaid from 'mermaid'
import CryptoJS from 'crypto-js'
import { Image } from 'antd'
mermaid.initialize({
startOnLoad: true,
theme: 'default',
flowchart: {
htmlLabels: true,
useMaxWidth: true,
},
})
const svgToBase64 = (svgGraph: string) => {
const svgBytes = new TextEncoder().encode(svgGraph)
const blob = new Blob([svgBytes], { type: 'image/svg+xml;charset=utf-8' })
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
const MermaidChart: FC<{ content: string }> = ({ content }) => {
const [chartSvg, setChartSvg] = useState<string>('')
const id = useRef(`mermaidchart_${CryptoJS.MD5(content).toString()}`)
useEffect(() => {
if (!content || content === '') {
return
}
drawDiagram()
}, [content])
const drawDiagram = async function () {
const { svg } = await mermaid.render(id.current, content);
const base64 = await svgToBase64(svg)
setChartSvg(base64 as string)
};
return (
<Image src={chartSvg} />
)
}
export default MermaidChart

View File

@@ -0,0 +1,17 @@
import { memo } from 'react'
import type { FC, ReactNode } from 'react'
interface ParagraphProps {
node: {
children: ReactNode;
};
children: string[]
}
const Paragraph: FC<ParagraphProps> = (props) => {
// console.log('Paragraph', props)
const { children } = props
return <p>{children}</p>
}
export default memo(Paragraph)

View File

@@ -0,0 +1,21 @@
import { memo } from 'react'
import type { FC, ReactNode } from 'react'
import { Button } from 'antd'
interface RbButtonProps {
node: {
children: ReactNode;
};
children: string[]
}
const RbButton: FC<RbButtonProps> = (props) => {
console.log('RbButton', props)
const { children } = props;
return (
<Button>
{children}
</Button>
)
}
export default memo(RbButton)

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
interface SvgProps {
content: string;
}
/**
* 渲染SVG内容的组件
*/
function Svg(props: SvgProps): JSX.Element {
const { content } = props;
// console.log('Svg', props)
return React.createElement(
'div',
{
className: 'svg-container',
dangerouslySetInnerHTML: { __html: content }
}
);
}
export default Svg;

View File

@@ -0,0 +1,22 @@
import { memo } from 'react'
import type { FC } from 'react'
interface VideoBlockProps {
node: {
children: { properties: { src: string } }[]
}
}
const VideoBlock: FC<VideoBlockProps> = (props) => {
// console.log('VideoBlock', props)
const { children } = props.node;
const srcs = children.map(item => item.properties?.src).filter(item => item)
return (
<>
{srcs.map(src => <video key={src} src={src} controls />)}
</>
)
}
export default memo(VideoBlock)

View File

@@ -0,0 +1,147 @@
import { Image, Input, Select, Form, Checkbox, Radio, ColorPicker, DatePicker, TimePicker, InputNumber, Slider } from 'antd'
import ReactMarkdown from 'react-markdown'
import RemarkGfm from 'remark-gfm'
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 Code from './Code'
import VideoBlock from './VideoBlock'
import AudioBlock from './AudioBlock'
import Link from './Link'
import RbButton from './RbButton'
interface RbMarkdownProps {
content: string;
showHtmlComments?: boolean; // 是否显示 HTML 注释,默认为 false隐藏
}
const components = {
h1: ({ children }: { children: string }) => <h1 className="rb:text-2xl rb:font-bold rb:mb-2">{children}</h1>,
h2: ({ children }: { children: string }) => <h2 className="rb:text-xl rb:font-bold rb:mb-2">{children}</h2>,
h3: ({ children }: { children: string }) => <h3 className="rb:text-lg rb:font-bold rb:mb-2">{children}</h3>,
h4: ({ children }: { children: string }) => <h4 className="rb:text-md rb:font-bold rb:mb-2">{children}</h4>,
h5: ({ children }: { children: string }) => <h5 className="rb:text-sm rb:font-bold rb:mb-2">{children}</h5>,
h6: ({ children }: { children: string }) => <h6 className="rb:text-xs rb:font-bold rb:mb-2">{children}</h6>,
ul: ({ children }: { children: string }) => <ul className="rb:list-disc rb:ml-6 rb:mb-2">{children}</ul>,
ol: ({ children }: { children: string }) => <ol className="rb:list-decimal rb:ml-6 rb:mb-2">{children}</ol>,
li: ({ children }: { children: string }) => <li className="rb:mb-1">{children}</li>,
blockquote: ({ children }: { children: string }) => <blockquote className="rb:border-l-4 rb:border-[#D9D9D9] rb:pl-4 rb:mb-2">{children}</blockquote>,
p: ({ children }: { children: string }) => <p className="rb:mb-2">{children}</p>,
strong: ({ children }: { children: string }) => <strong className="rb:font-bold">{children}</strong>,
em: ({ children }: { children: string }) => <em className="rb:italic">{children}</em>,
del: ({ children }: { children: string }) => <del className="rb:line-through">{children}</del>,
span: ({ children, ...props }: any) => {
// 如果是 HTML 注释的 span应用特殊样式
if (props.style?.color === '#999') {
return <span style={{ color: '#999', fontSize: '0.9em' }}>{children}</span>
}
return <span {...props}>{children}</span>
},
code: Code,
img: Image,
video: VideoBlock,
audio: AudioBlock,
a: Link,
button: RbButton,
table: ({ children }: { children: string }) => <table className="rb:border rb:border-[#D9D9D9] rb:mb-2">{children}</table>,
tr: ({ children }: { children: string }) => <tr className="rb:border rb:border-[#D9D9D9]">{children}</tr>,
th: ({ children }: { children: string }) => <th className="rb:border rb:border-[#D9D9D9] rb:px-2 rb:py-1 rb:text-left rb:font-bold">{children}</th>,
td: ({ children }: { children: string }) => <td className="rb:border rb:border-[#D9D9D9] rb:px-2 rb:py-1 rb:text-left">{children}</td>,
input: ({ children, ...props }: { children: string }) => {
switch (props.type) {
case 'color':
return <ColorPicker {...props} />
case 'time':
return <TimePicker {...props} />
case 'date':
return <DatePicker {...props} />
case 'datetime':
case 'datetime-local':
return <DatePicker showTime={true} {...props} />
case 'week':
return <DatePicker picker="week" {...props} />
case 'month':
return <DatePicker picker="month" {...props} />
case 'number':
return <InputNumber {...props} />
case 'search':
return <Input.Search {...props} />
case 'range':
return <Slider {...props} />
case 'submit':
case 'button':
return <RbButton {...props}>{props.value}</RbButton>
case 'checkbox':
return <Checkbox {...props}>{children}</Checkbox>
case 'password':
return <Input.Password {...props} />
case 'radio':
return <Radio {...props}>{children}</Radio>
default:
return <Input value={children} {...props} />
}
},
select: ({ children, ...props }: { children: string }) => <Select style={{width: '100%'}} {...props}>{children}</Select>,
textarea: ({ children, ...props }: { children: string }) => <Input.TextArea {...props}>{children}</Input.TextArea>,
form: ({ children }: { children: string }) => <Form>{children}</Form>,
}
const RbMarkdown: FC<RbMarkdownProps> = ({
content,
showHtmlComments = false,
}) => {
// 根据参数决定是否将 HTML 注释转换为可见文本
// 使用特殊的 markdown 语法来显示注释,避免被 rehype-raw 过滤
const processedContent = showHtmlComments
? content.replace(/<!--([\s\S]*?)-->/g, (_match, commentContent) => {
// 转换为带样式的文本,使用 <span class="html-comment"> 标记
const escaped = commentContent.trim().replace(/</g, '&lt;').replace(/>/g, '&gt;')
return `<span class="html-comment">&lt;!-- ${escaped} --&gt;</span>`
})
: content
return (
<div>
<style>{`
.html-comment {
color: #999;
font-size: 0.9em;
}
`}</style>
<ReactMarkdown
// allowElement={[]}
// allowedElements={[]}
components={components}
disallowedElements={['script', 'iframe', 'head', 'html', 'meta', 'link', 'style', 'body']}
rehypePlugins={[
RehypeKatex,
RehypeRaw,
// The Rehype plug-in is used to remove the ref attribute of an element
// () => {
// return (tree) => {
// const iterate = (node: any) => {
// if (node.type === 'element' && !node.properties?.src && node.properties?.ref && node.properties.ref.startsWith('{') && node.properties.ref.endsWith('}'))
// delete node.properties.ref
// if (node.children)
// node.children.forEach(iterate)
// }
// tree.children.forEach(iterate)
// }
// },
]}
remarkPlugins={[RemarkGfm, RemarkMath, RemarkBreaks]}
remarkRehypeOptions={{
allowDangerousHtml: true,
}}
>
{processedContent}
</ReactMarkdown>
</div>
)
}
export default RbMarkdown

View File

@@ -0,0 +1,128 @@
import React, { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react';
import { List, Skeleton} from 'antd';
import InfiniteScroll from 'react-infinite-scroll-component';
import { request } from '@/utils/request';
import Empty from '@/components/Empty';
const PAGE_SIZE = 20;
interface ApiResponse {
items?: Record<string, unknown>[];
page: {
page: number;
pagesize: number;
total: number;
hasnext: boolean;
};
}
export interface PageScrollListRef {
refresh: () => void;
}
interface PageScrollListProps {
url: string;
renderItem: (item: Record<string, unknown>) => React.ReactNode;
query?: Record<string, unknown>;
column?: number;
className?: string;
}
const PageScrollList = forwardRef<PageScrollListRef, PageScrollListProps>(({
renderItem,
query = {},
url,
column = 4,
className = '',
}, ref) => {
useImperativeHandle(ref, () => ({
refresh,
}));
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const loadMoreData = (flag?: boolean) => {
if (!flag && (loading || !hasMore)) {
return;
}
setLoading(true);
request.get(url, {
page: page,
pagesize: PAGE_SIZE,
...query,
})
.then((res) => {
const response = res as ApiResponse;
const results = Array.isArray(response.items) ? response.items : Array.isArray(response.hosts) ? response.hosts : Array.isArray(response) ? response : [];
if (flag) {
setData(results);
} else {
setData(data.concat(results));
}
setPage(response.page.page + 1);
setHasMore(response.page?.hasnext);
setLoading(false);
console.log(`${results.length} more items loaded!`);
})
.catch(() => {
setLoading(false);
setHasMore(false);
console.error('Failed to load data');
})
.finally(() => {
setLoading(false);
});
};
// 刷新列表数据
const refresh = () => {
setPage(1);
setHasMore(true);
setData([]);
}
useEffect(() => {
refresh()
}, [query]);
useEffect(() => {
if (page === 1 && hasMore && data.length === 0) {
loadMoreData(true);
}
}, [page, hasMore, data])
return (
<>
<div
ref={scrollRef}
id="scrollableDiv"
className={`rb:overflow-y-auto rb:overflow-x-hidden rb:h-[calc(100vh-148px)] ${className}`}
>
<InfiniteScroll
dataLength={data.length}
next={loadMoreData}
hasMore={hasMore}
loader={<Skeleton active />}
// endMessage={<Divider plain>It is all, nothing more 🤐</Divider>}
scrollableTarget="scrollableDiv"
>
{data.length > 0 ? (
<List
grid={{ gutter: 16, column: column }}
dataSource={data}
renderItem={(item) => (
<List.Item>
{renderItem(item)}
</List.Item>
)}
/>
) : !loading ? <Empty /> : null}
</InfiniteScroll>
</div>
</>
);
});
export default PageScrollList;

View File

@@ -0,0 +1,63 @@
import { type FC, type Key, type ReactNode, useEffect } from 'react';
import { type RadioGroupProps } from 'antd';
import clsx from 'clsx'
interface RadioCardOption {
value: string | number | boolean | null | undefined | Key;
label: string;
labelDesc?: string;
icon?: string;
[key: string]: string | number | boolean | undefined | null | Key;
}
interface RadioCardProps extends Omit<RadioGroupProps, 'onChange'> {
options: RadioCardOption[];
onValueChange?: (value: string | null | undefined, option?: RadioCardOption) => void;
onChange?: (value: string | null | undefined, option?: RadioCardOption) => void;
itemRender?: (option: RadioCardOption) => ReactNode;
}
const RadioGroupCard: FC<RadioCardProps> = ({
options,
value,
onValueChange,
onChange,
itemRender
}) => {
// 监听value变化
useEffect(() => {
if (onValueChange) {
onValueChange(value);
}
}, [value, onValueChange]);
const handleChange = (option: RadioCardOption) => {
if (option.disabled) return
if (onChange) {
onChange(value === option.value ? null : String(option.value), value === option.value ? undefined : option);
}
}
return (
<div className={`rb:grid rb:grid-cols-${options.length} rb:gap-[12px]`}>
{options.map(option => (
<div key={String(option.value)} className={clsx("rb:border rb:rounded-[8px] rb:w-full rb:p-[20px_12px] rb:text-center rb:cursor-pointer", {
'rb:bg-[rgba(21,94,239,0.06)] rb:border-[#155EEF]': option.value === value,
'rb:border-[#EBEBEB] rb:bg-[#ffffff]': option.value !== value,
'rb:opacity-[0.75]': option.disabled
})} onClick={() => handleChange(option)}>
{itemRender ? itemRender(option) : (
<>
{option.icon && <img src={option.icon} className="rb:w-[40px] rb:h-[40px] rb:mb-[12px] rb:m-[0_auto]" />}
<div className="rb:text-[14px] rb:font-medium">{option.label}</div>
<div className="rb:mt-[6px] rb:text-[#5B6167] rb:text-[12px] rb:font-regular">{option.labelDesc}</div>
</>
)}
</div>
)
)}
</div>
);
};
export default RadioGroupCard;

View File

@@ -0,0 +1,25 @@
import { type FC, type ReactNode } from 'react'
interface RbAlertProps {
color?: 'blue' | 'green' | 'orange' | 'purple',
children: ReactNode | string;
icon: ReactNode;
className?: string;
}
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)]',
orange: 'rb:text-[rgba(255,93,52,1)] rb:bg-[rgba(255,138,76,0.06)] rb:border-[rgba(255,138,76,0.30)]',
purple: 'rb:text-[rgba(156,111,255,1)] rb:bg-[rgba(156,111,255,0.08)] rb:border-[rgba(156,111,255,0.30)]',
}
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-[16px] rb:border-[1px] rb:rounded-[6px]`}>
{icon && <span className="rb:text-[16px] rb:mr-[9px]">{icon}</span>}
{children}
</div>
)
}
export default RbAlert

View File

@@ -0,0 +1,97 @@
import { type FC, type ReactNode } from 'react'
import { Card } from 'antd';
import clsx from 'clsx';
interface RbCardProps {
headerClassName?: string;
title?: string | ReactNode | (() => ReactNode);
subTitle?: string | ReactNode;
extra?: ReactNode;
children?: ReactNode;
avatar?: ReactNode;
avatarUrl?: string;
bodyPadding?: string;
bodyClassName?: string;
headerType?: 'border' | 'borderless' | 'borderBL' | 'borderL';
bgColor?: string;
height?: string;
className?: string;
onClick?: () => void;
}
const RbCard: FC<RbCardProps> = ({
headerClassName,
title,
subTitle,
extra,
children,
avatar,
avatarUrl,
bodyPadding,
bodyClassName: bodyClassNames,
headerType = 'border',
bgColor = '#FBFDFF',
height = 'auto',
className,
...props
}) => {
const bodyClassName = bodyPadding
? `rb:p-[${bodyPadding}]!`
: headerType === 'borderL'
? 'rb:p-[0_16px_12px_16px]!'
: avatarUrl || avatar
? 'rb:p-[16px_20px_16px_16px]!'
: (headerType === 'borderless')
? 'rb:p-[0_20px_16px_16px]!'
: (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">
{avatarUrl
? <img src={avatarUrl} className="rb:mr-[13px] rb:w-[48px] rb:h-[48px] rb:rounded-[8px]" />
: avatar ? avatar : null
}
<div className={
clsx(
{
'rb:max-w-full': !avatarUrl && !avatar,
'rb:max-w-[calc(100%-60px)]': avatarUrl || avatar,
}
)
}>
<div className="rb:w-full rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{title}</div>
{subTitle && <div className="rb:text-[#5B6167] rb:text-[12px]">{subTitle}</div>}
</div>
</div> : null
}
extra={extra}
classNames={{
header: clsx(
'rb:font-medium',
{
'rb:border-[0]! rb:text-[16px] rb:p-[0_16px]!': headerType === 'borderless',
'rb:border-[0]! rb:text-[16px] rb:p-[16px_16px_0_16px]!': avatarUrl || avatar,
'rb:text-[18px] rb:p-[0]! rb:m-[0_20px]!': headerType === 'border' && !avatarUrl && !avatar,
"rb:m-[0_16px]! rb:p-[0]! rb:relative rb:before:content-[''] rb:before:w-[4px] rb:before:h-[16px] rb:before:bg-[#5B6167] rb:before:absolute rb:before:top-[50%] rb:before:left-[-16px] rb:before:translate-y-[-50%] rb:before:bg-[#5B6167]! rb:before:h-[16px]!": headerType === 'borderBL',
"rb:m-[0_16px]! rb:p-[0]! rb: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,
),
body: bodyClassNames ? bodyClassNames : children ? bodyClassName : 'rb:p-[0]!',
}}
style={{
background: bgColor,
height: height
}}
className={`rb:hover:shadow-[0px_2px_4px_0px_rgba(0,0,0,0.15)] ${className}`}
>
{children}
</Card>
)
}
export default RbCard

View File

@@ -0,0 +1,73 @@
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

@@ -0,0 +1,71 @@
/*
* @Description:
* @Version: 0.0.1
* @Author: yujiangping
* @Date: 2025-11-07 14:16:33
* @LastEditors: yujiangping
* @LastEditTime: 2025-11-27 20:02:46
*/
import { type FC, useState, useEffect } from 'react'
import { Button, Drawer, Space } from 'antd';
import type { DrawerProps } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
const RbDrawer: FC<DrawerProps> =({
children,
size = 'large',
open: externalOpen,
onClose,
...props
}) => {
// 内部状态管理,组件内部完全控制 open 状态
const [internalOpen, setInternalOpen] = useState(false);
// 当外部 open 变化时,同步到内部状态
useEffect(() => {
if (externalOpen !== undefined) {
setInternalOpen(externalOpen);
}
}, [externalOpen]);
// 确保当外部 open 为 true 时,内部状态也同步为 true处理重复打开的情况
useEffect(() => {
if (externalOpen === true && !internalOpen) {
setInternalOpen(true);
}
}, [externalOpen, internalOpen]);
const handleClose = (e: React.MouseEvent | React.KeyboardEvent) => {
// 更新内部状态,关闭抽屉
setInternalOpen(false);
// 如果外部传入了 onClose调用它通知外部
onClose?.(e);
}
const handleButtonClose = (e: React.MouseEvent) => {
handleClose(e);
}
return (
<Drawer
placement="right"
closeIcon={null}
size={size}
width={800}
onClose={handleClose}
open={internalOpen}
extra={
<Space>
<Button type='text' icon={<CloseOutlined />} onClick={handleButtonClose}/>
</Space>
}
{...props}
>
<div className='rb:flex rb:flex-col rb:h-full'>
{children}
</div>
</Drawer>
)
}
export default RbDrawer;

View File

@@ -0,0 +1,63 @@
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

@@ -0,0 +1,28 @@
import { type FC } from 'react'
import { Modal, type ModalProps } from 'antd'
import { useTranslation } from 'react-i18next'
const RbModal: FC<ModalProps> = ({
onOk,
onCancel,
children,
...props
}) => {
const { t } = useTranslation()
return (
<Modal
onCancel={onCancel}
width={480}
cancelText={t('common.cancel')}
onOk={onOk}
destroyOnHidden={true}
{...props}
>
<div className='rb:max-h-[550px] rb:overflow-y-auto rb:overflow-x-hidden'>
{children}
</div>
</Modal>
)
}
export default RbModal

View File

@@ -0,0 +1,42 @@
import { type FC, useEffect } from 'react';
import { Slider, type SliderSingleProps } from 'antd';
interface RbSliderProps extends SliderSingleProps {
onValueChange?: (value: number | null | undefined) => void;
}
const RbSlider: FC<RbSliderProps> = ({
value,
min = 0,
onValueChange,
step = 1,
...rest
}) => {
// 监听value变化包括初始值
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]">
<Slider
style={{
// width: flag1 ? '384px' : '373px',
// margin: flag ? '0 11px 0 0': '0 5px 0 11px'
overflow: 'inherit',
width: '384px'
}}
{...rest}
step={step}
value={value}
/>
<div className="rb:text-[14px] rb:text-[#155EEF] rb:leading-[20px]">{value || min}</div>
</div>
);
};
export default RbSlider;

View File

@@ -0,0 +1,93 @@
import { useState, type FC, useCallback, useRef } from 'react';
import { Input } from 'antd';
import { useTranslation } from 'react-i18next';
import searchIcon from '@/assets/images/search.svg'
interface SearchInputProps {
placeholder?: string;
onSearch?: (value: string) => void;
debounceDelay?: number;
throttleDelay?: number;
defaultValue?: string;
style?: Record<string, string | number>;
className?: string;
}
const SearchInput: FC<SearchInputProps> = ({
placeholder,
onSearch,
debounceDelay = 300,
throttleDelay,
defaultValue = undefined,
className = '',
...props
}) => {
const { t } = useTranslation();
const [value, setValue] = useState(defaultValue);
const timerRef = useRef<number | null>(null);
const throttleRef = useRef<boolean>(false);
const lastCallRef = useRef<number>(0);
// 防抖函数
const debounce = useCallback(<T extends (...args: any[]) => void>(callback: T, delay: number) => {
return (...args: Parameters<T>) => {
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
callback(...args);
}, delay);
};
}, []);
// 节流函数
const throttle = useCallback(<T extends (...args: any[]) => void>(callback: T, delay: number) => {
return (...args: Parameters<T>) => {
const now = Date.now();
if (!throttleRef.current && now - lastCallRef.current >= delay) {
lastCallRef.current = now;
throttleRef.current = true;
callback(...args);
window.setTimeout(() => {
throttleRef.current = false;
}, delay);
}
};
}, []);
// 处理输入变化
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setValue(newValue);
// 根据是否设置了throttleDelay来决定使用防抖还是节流
if (onSearch) {
if (throttleDelay) {
const throttledSearch = throttle(() => {
onSearch(newValue);
}, throttleDelay);
throttledSearch();
} else {
const debouncedSearch = debounce(() => {
onSearch(newValue);
}, debounceDelay);
debouncedSearch();
}
}
};
return (
<Input
allowClear
prefix={<img src={searchIcon} alt="search" className="rb:w-[16px] rb:h-[16px] rb:mr-[4px]" />}
placeholder={placeholder || t('user.searchPlaceholder')}
value={value}
onChange={handleChange}
style={{ width: '300px' }}
className={className}
{...props}
/>
);
};
export default SearchInput;

View File

@@ -0,0 +1,34 @@
.sider {
border-right: 1px solid #EAECEE;
max-height: 100vh;
}
.title {
height: 64px;
padding: 24px 12px 12px 12px;
font-family: Gilroy, Gilroy;
font-weight: 800;
font-size: 16px;
color: #212332;
line-height: 24px;
text-align: left;
font-style: normal;
display: flex;
border-bottom: 1px solid #EAECEE;
justify-content: space-between;
gap: 12px;
}
.title.collapsed {
padding: 18px;
align-items: center;
justify-content: center;
}
.logo {
width: 24px;
height: 24px;
margin-right: 8px;
}
.menuIcon {
cursor: pointer;
width: 28px;
height: 28px;
}

View File

@@ -0,0 +1,234 @@
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 { 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 dashboardIcon from '@/assets/images/menu/dashboard.svg';
import dashboardActiveIcon from '@/assets/images/menu/dashboard_active.svg';
import modelIcon from '@/assets/images/menu/model.svg';
import modelActiveIcon from '@/assets/images/menu/model_active.svg';
import memoryIcon from '@/assets/images/menu/memory.svg';
import memoryActiveIcon from '@/assets/images/menu/memory_active.svg';
import spaceIcon from '@/assets/images/menu/space.svg';
import spaceActiveIcon from '@/assets/images/menu/space_acitve.svg';
import userIcon from '@/assets/images/menu/user.svg';
import userActiveIcon from '@/assets/images/menu/user_active.svg';
import userMemoryIcon from '@/assets/images/menu/userMemory.svg';
import userMemoryActiveIcon from '@/assets/images/menu/userMemory_acitve.svg';
import applicationIcon from '@/assets/images/menu/application.svg';
import applicationActiveIcon from '@/assets/images/menu/application_active.svg';
import knowledgeIcon from '@/assets/images/menu/knowledge.svg';
import knowledgeActiveIcon from '@/assets/images/menu/knowledge_active.svg';
import memoryConversationIcon from '@/assets/images/menu/memoryConversation.svg';
import memoryConversationActiveIcon from '@/assets/images/menu/memoryConversation_active.svg';
import memberIcon from '@/assets/images/menu/member.svg';
import memberActiveIcon from '@/assets/images/menu/member_active.svg';
// 图标路径映射表
const iconPathMap: Record<string, string> = {
'dashboard': dashboardIcon,
'dashboardActive': dashboardActiveIcon,
'model': modelIcon,
'modelActive': modelActiveIcon,
'memory': memoryIcon,
'memoryActive': memoryActiveIcon,
'space': spaceIcon,
'spaceActive': spaceActiveIcon,
'user': userIcon,
'userActive': userActiveIcon,
'userMemory': userMemoryIcon,
'userMemoryActive': userMemoryActiveIcon,
'application': applicationIcon,
'applicationActive': applicationActiveIcon,
'knowledge': knowledgeIcon,
'knowledgeActive': knowledgeActiveIcon,
'memoryConversation': memoryConversationIcon,
'memoryConversationActive': memoryConversationActiveIcon,
'member': memberIcon,
'memberActive': memberActiveIcon,
};
const { Sider } = Layout;
const Menu: FC<{
mode?: 'vertical' | 'horizontal' | 'inline';
source?: 'space' | 'manage';
}> = ({ mode = 'inline', source = 'manage' }) => {
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const { allMenus, collapsed, loadMenus, toggleSider } = useMenu()
const [menus, setMenus] = useState<MenuItem[]>([])
const { user, storageType } = useUser()
useEffect(() => {
if (user.role === 'member' && source === 'space') {
setMenus((allMenus[source] || []).filter(menu => menu.code !== 'member'))
} else if (user) {
setMenus(allMenus[source] || [])
}
}, [source, allMenus, user])
// 处理菜单项点击
const handleMenuClick: MenuProps['onClick'] = (e) => {
const path = e.key;
if (path) {
navigate(path);
setSelectedKeys([path]);
}
};
// 将自定义菜单格式转换为Ant Design Menu的items格式
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);
// 叶子节点
if (!subs || subs.length === 0) {
if (!menu.path) return null;
return {
key: menu.path,
title: menu.i18nKey ? t(menu.i18nKey) : menu.label,
label: menu.i18nKey ? t(menu.i18nKey) : menu.label,
icon: iconSrc ? <img
src={iconSrc}
className="rb:w-[16px] rb:h-[16px] rb:mr-[8px]"
/> : null,
};
}
// 有子菜单的节点
const menuLabel = menu.i18nKey ? t(menu.i18nKey) : menu.label;
return {
key: `submenu-${menu.id}`,
title: menuLabel,
label: menuLabel,
icon: iconSrc ? <img
src={iconSrc}
className="rb:w-[16px] rb:h-[16px] rb:mr-[8px]"
/> : <UserOutlined/>,
children: generateMenuItems(subs),
};
}).filter(Boolean);
};
// 生成菜单项
const menuItems = generateMenuItems(menus);
// 初始加载菜单
useEffect(() => {
loadMenus(source);
}, [])
// 处理当前路径匹配
useEffect(() => {
// 使用location.pathname获取当前路径确保与路由系统保持一致
const currentPath = location.pathname || '/';
// 尝试找到匹配的菜单项和对应的父菜单路径
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;
// 精确匹配或路径前缀匹配(确保是完整路径段匹配)
const isExactMatch = menuPath === currentPath;
const isPrefixMatch = currentPath.startsWith(menuPath + '/') ||
currentPath === menuPath;
if (isExactMatch || isPrefixMatch) {
return { key: menu.path };
}
}
// 递归检查子菜单
if (menu.subs && menu.subs.length > 0) {
const newParentPaths = [...parentPaths, `submenu-${menu.id}`];
const found = findMatchingKey(menu.subs, newParentPaths);
if (found.key) {
return found;
}
}
}
return { key: null };
};
const { key: matchingKey } = findMatchingKey(menus);
if (matchingKey) {
setSelectedKeys([matchingKey]);
} else {
setSelectedKeys([])
}
}, [menus, location.pathname]);
const goToSpace = () => {
navigate('/space')
localStorage.removeItem('user')
}
return (
<Sider
width={240}
collapsedWidth={64}
collapsed={collapsed}
className={styles.sider}
>
<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,
})}>
{!collapsed && source === 'space' && user.current_workspace_name
? <div className="rb:w-[175px] 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">
{t(`space.${storageType}`)}
</span>
</div>
: !collapsed
? <div className="rb:flex">
<img src={logo} className={styles.logo} />
{t('title')}
</div>
: null
}
<img src={collapsed ? menuUnfold : menuFold} className={styles.menuIcon} onClick={toggleSider} />
</div>
<AntMenu
style={{ borderRight: 0 }}
mode={mode}
selectedKeys={selectedKeys}
// openKeys={openKeys}
onClick={handleMenuClick}
items={menuItems}
inlineCollapsed={collapsed}
inlineIndent={13}
className="rb:max-h-[calc(100vh-136px)] rb:overflow-y-auto"
/>
{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"
>
<img src={logout} className="rb:w-[16px] rb:h-[16px] rb:mr-[16px]" />
{collapsed ? null : t('common.returnToSpace')}
</div>
}
</Sider>
);
};
export default Menu;

View File

@@ -0,0 +1,289 @@
# SliderInput 组件
组合了 Slider 和 InputNumber 的组件,支持拖拽和输入两种方式设置数值。
## 功能特性
- 同时支持滑块拖拽和数字输入
- 自动同步两个控件的值
- 支持最小值、最大值、步长设置
- 支持自定义标记点
- 支持禁用状态
- 响应式布局
## 使用方法
### 基础用法
```tsx
import SliderInput from '@/components/SliderInput';
function MyComponent() {
const [blockSize, setBlockSize] = useState(500);
return (
<SliderInput
value={blockSize}
onChange={setBlockSize}
min={100}
max={2000}
step={50}
/>
);
}
```
### 带标签
```tsx
<SliderInput
label="块大小 (Block Size)"
value={blockSize}
onChange={setBlockSize}
min={100}
max={2000}
step={50}
/>
```
### 带标记点
```tsx
<SliderInput
label="块大小"
value={blockSize}
onChange={setBlockSize}
min={100}
max={2000}
step={50}
marks={{
100: '100',
500: '500',
1000: '1000',
2000: '2000',
}}
/>
```
### 自定义标记点样式
```tsx
<SliderInput
label="块大小"
value={blockSize}
onChange={setBlockSize}
min={100}
max={2000}
step={50}
marks={{
100: { style: { color: '#f50' }, label: '最小' },
1000: { style: { color: '#1890ff' }, label: '推荐' },
2000: { style: { color: '#f50' }, label: '最大' },
}}
/>
```
### 带提示信息
```tsx
<SliderInput
label="块大小"
value={blockSize}
onChange={setBlockSize}
min={100}
max={2000}
step={50}
tooltip={{
open: true,
placement: 'top',
formatter: (value) => `${value} 字符`,
}}
/>
```
### 在表单中使用
```tsx
import { Form } from 'antd';
import SliderInput from '@/components/SliderInput';
function FormExample() {
const [form] = Form.useForm();
return (
<Form form={form}>
<Form.Item
name="blockSize"
label="块大小"
initialValue={500}
>
<SliderInput
min={100}
max={2000}
step={50}
/>
</Form.Item>
</Form>
);
}
```
### 在 CreateDataset 中使用
```tsx
import SliderInput from '@/components/SliderInput';
const CreateDataset = () => {
const [blockSize, setBlockSize] = useState(500);
const [overlap, setOverlap] = useState(50);
return (
<div className='rb:flex rb:flex-col rb:gap-6'>
<SliderInput
label={t('knowledgeBase.blockSize') || '块大小'}
value={blockSize}
onChange={setBlockSize}
min={100}
max={2000}
step={50}
marks={{
100: '100',
500: '500',
1000: '1000',
2000: '2000',
}}
/>
<SliderInput
label={t('knowledgeBase.overlap') || '重叠大小'}
value={overlap}
onChange={setOverlap}
min={0}
max={200}
step={10}
marks={{
0: '0',
50: '50',
100: '100',
200: '200',
}}
/>
</div>
);
}
```
## Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| value | number | - | 当前值(受控) |
| onChange | (value: number \| null) => void | - | 值变化时的回调函数 |
| min | number | 0 | 最小值 |
| max | number | 100 | 最大值 |
| step | number | 1 | 步长 |
| defaultValue | number | 0 | 默认值(非受控) |
| disabled | boolean | false | 是否禁用 |
| label | string | - | 标签文本 |
| className | string | '' | 容器自定义样式类名 |
| sliderClassName | string | '' | Slider 自定义样式类名 |
| inputClassName | string | '' | InputNumber 自定义样式类名 |
| marks | Record<number, string \| object> | - | 刻度标记 |
| tooltip | object | - | 提示信息配置 |
### tooltip 配置
| 属性 | 类型 | 说明 |
|------|------|------|
| open | boolean | 是否始终显示提示 |
| placement | 'top' \| 'left' \| 'right' \| 'bottom' | 提示位置 |
| formatter | (value?: number) => ReactNode | 格式化提示内容 |
## 注意事项
1. **值的范围**:输入的值会自动限制在 min 和 max 之间
2. **步长**:拖拽和输入都会遵循 step 设置
3. **受控组件**:建议使用受控模式(传入 value 和 onChange
4. **布局**Slider 占据剩余空间InputNumber 固定宽度 120px
## 样式定制
### 自定义宽度
```tsx
<SliderInput
value={blockSize}
onChange={setBlockSize}
className="rb:max-w-2xl"
/>
```
### 自定义 InputNumber 宽度
```tsx
<SliderInput
value={blockSize}
onChange={setBlockSize}
inputClassName="rb:w-32"
/>
```
## 常见场景
### 场景1文档分块大小设置
```tsx
<SliderInput
label="块大小 (字符数)"
value={chunkSize}
onChange={setChunkSize}
min={100}
max={2000}
step={50}
defaultValue={500}
marks={{
100: '最小',
500: '推荐',
2000: '最大',
}}
tooltip={{
formatter: (value) => `${value} 字符`,
}}
/>
```
### 场景2重叠大小设置
```tsx
<SliderInput
label="重叠大小 (字符数)"
value={overlap}
onChange={setOverlap}
min={0}
max={Math.floor(chunkSize * 0.5)}
step={10}
defaultValue={50}
tooltip={{
formatter: (value) => `${value} 字符 (${((value / chunkSize) * 100).toFixed(0)}%)`,
}}
/>
```
### 场景3温度参数设置
```tsx
<SliderInput
label="Temperature"
value={temperature}
onChange={setTemperature}
min={0}
max={2}
step={0.1}
defaultValue={0.7}
marks={{
0: '精确',
0.7: '平衡',
2: '创造',
}}
/>
```

View File

@@ -0,0 +1,107 @@
import { useState, useEffect, type FC } from 'react';
import { Slider, InputNumber, Row, Col } from 'antd';
interface SliderInputProps {
value?: number;
onChange?: (value: number | null) => void;
min?: number;
max?: number;
step?: number;
defaultValue?: number;
disabled?: boolean;
label?: string;
className?: string;
sliderClassName?: string;
inputClassName?: string;
marks?: Record<number, string | { style: React.CSSProperties; label: string }>;
tooltip?: {
open?: boolean;
placement?: 'top' | 'left' | 'right' | 'bottom';
formatter?: (value?: number) => React.ReactNode;
};
}
const SliderInput: FC<SliderInputProps> = ({
value,
onChange,
min = 0,
max = 100,
step = 1,
defaultValue = 0,
disabled = false,
label,
className = '',
sliderClassName = '',
inputClassName = '',
marks,
tooltip,
}) => {
const [internalValue, setInternalValue] = useState<number>(value ?? defaultValue);
useEffect(() => {
if (value !== undefined && value !== internalValue) {
setInternalValue(value);
}
}, [value]);
const handleSliderChange = (newValue: number) => {
setInternalValue(newValue);
onChange?.(newValue);
};
const handleInputChange = (newValue: number | null) => {
if (newValue === null) {
return;
}
// 确保值在范围内
let validValue = newValue;
if (newValue < min) {
validValue = min;
} else if (newValue > max) {
validValue = max;
}
setInternalValue(validValue);
onChange?.(validValue);
};
return (
<div className={`rb:w-full ${className}`}>
{label && (
<div className="rb:text-sm rb:font-medium rb:text-gray-700">
{label}
</div>
)}
<Row gutter={16} align="middle">
<Col flex="auto">
<Slider
min={min}
max={max}
step={step}
value={internalValue}
onChange={handleSliderChange}
disabled={disabled}
marks={marks}
tooltip={tooltip}
className={sliderClassName}
/>
</Col>
<Col flex="120px">
<InputNumber
min={min}
max={max}
step={step}
value={internalValue}
onChange={handleInputChange}
disabled={disabled}
className={`rb:w-full ${inputClassName}`}
controls
/>
</Col>
</Row>
</div>
);
};
export default SliderInput;

View File

@@ -0,0 +1,21 @@
import React, { useContext } from 'react';
import { HolderOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import SortableListItemContext from './SortableListItemContext';
const DragHandle: React.FC = () => {
const { setActivatorNodeRef, listeners, attributes } = useContext(SortableListItemContext);
return (
<Button
type="text"
size="small"
icon={<HolderOutlined />}
style={{ cursor: 'move' }}
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
/>
);
};
export default DragHandle;

View File

@@ -0,0 +1,56 @@
/*
* @Description:
* @Version: 0.0.1
* @Author: yujiangping
* @Date: 2025-11-11 20:42:28
* @LastEditors: yujiangping
* @LastEditTime: 2025-11-20 14:20:27
*/
import React, { useMemo } from 'react';
import {
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { List } from 'antd';
import type { GetProps } from 'antd';
import type { SortableListItemContextProps } from './types';
import SortableListItemContext from './SortableListItemContext';
const SortableListItem: React.FC<GetProps<typeof List.Item> & { itemKey: number }> = (props) => {
const { itemKey, style, ...rest } = props;
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: itemKey });
const listStyle: React.CSSProperties = {
...style,
transform: CSS.Translate.toString(transform),
transition,
...(isDragging ? { position: 'relative', zIndex: 9999 } : {}),
display: 'flex',
alignItems: 'center',
borderBottom: 'none',
padding: '8px 0',
};
const memoizedValue = useMemo<SortableListItemContextProps>(
() => ({ setActivatorNodeRef, listeners, attributes }),
[setActivatorNodeRef, listeners, attributes],
);
return (
<SortableListItemContext.Provider value={memoizedValue}>
<List.Item {...rest} ref={setNodeRef} style={listStyle} />
</SortableListItemContext.Provider>
);
};
export default SortableListItem;

View File

@@ -0,0 +1,7 @@
import { createContext } from 'react';
import type { SortableListItemContextProps } from './types';
const SortableListItemContext = createContext<SortableListItemContextProps>({});
export default SortableListItemContext;

View File

@@ -0,0 +1,82 @@
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { DragEndEvent } from '@dnd-kit/core';
import { DndContext } from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { List, Input, Button } from 'antd';
import SortableListItem from './SortableListItem';
import DragHandle from './DragHandle';
interface Item {
key: number;
content: string;
type?: 'add';
}
interface SortableListProps {
value?: Item[];
onChange?: (items?: Item[]) => void;
}
const SortableList: React.FC<SortableListProps> = ({
value = [],
onChange,
}) => {
const { t } = useTranslation();
const onDragEnd = ({ active, over }: DragEndEvent) => {
if (!active || !over) {
return;
}
if (active.id !== over.id) {
const activeIndex = value.findIndex((i) => i.key === active.id);
const overIndex = value.findIndex((i) => i.key === over.id);
console.log('onDragEnd', arrayMove([...value], activeIndex, overIndex))
onChange?.(arrayMove([...value], activeIndex, overIndex));
}
};
// 监听value变化包括初始值
useEffect(() => {
if (onChange) {
onChange(value);
}
}, [value, onChange]);
const inputChange = (e: React.ChangeEvent<HTMLInputElement>, index: number) => {
const newItems = [...value];
newItems[index].content = e.target.value;
onChange?.(newItems);
}
const handleAdd = () => {
onChange?.([...value, { key: Date.now(), content: '' }]);
}
return (
<DndContext
modifiers={[restrictToVerticalAxis]}
onDragEnd={onDragEnd}
>
<SortableContext items={value.map((item) => item.key)} strategy={verticalListSortingStrategy}>
<List
dataSource={[...value, { type: 'add', key: Date.now(), content: '' }]}
renderItem={(item: Item, index: number) => {
console.log('renderItem', item, index)
if (item.type === 'add') {
return <Button block onClick={handleAdd}>{t('common.addOption')}</Button>
} else {
return (
<SortableListItem key={item.key} itemKey={item.key}>
<DragHandle /> <Input variant="underlined" value={item.content} onChange={(e) => inputChange(e, index)} />
</SortableListItem>
)
}}}
/>
</SortableContext>
</DndContext>
);
};
export default SortableList;

View File

@@ -0,0 +1,8 @@
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import type { DraggableAttributes } from '@dnd-kit/core';
export interface SortableListItemContextProps {
setActivatorNodeRef?: (element: HTMLElement | null) => void;
listeners?: SyntheticListenerMap;
attributes?: DraggableAttributes;
}

View File

@@ -0,0 +1,29 @@
import { type FC } from 'react'
import { Tag } from 'antd';
import clsx from 'clsx';
interface StatusTagProps {
status: 'success' | 'error' | 'warning',
text: string;
}
const Colors = {
success: 'rb:bg-[#369F21]',
error: 'rb:bg-[#FF5D34]',
warning: 'rb:bg-[#FF8A4C]',
}
const StatusTag: FC<StatusTagProps> = ({
status,
text
}) => {
return (
<Tag style={{ backgroundColor: '#fff', borderColor: '#DFE4ED' }}>
<span className='rb:flex rb:items-center rb:text-[#5B6167] rb:pl-[1px] rb:pr-[1px]'>
<span className={clsx(' rb:w-[5px] rb:h-[5px] rb:rounded-full rb:mr-[4px]', Colors[status])}></span>
{ text }
</span>
</Tag>
)
}
export default StatusTag

View File

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

View File

@@ -0,0 +1,176 @@
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 { useTranslation } from 'react-i18next';
import { request } from '@/utils/request';
import styles from './index.module.css';
import Empty from '@/components/Empty';
interface TableComponentProps extends Omit<TableProps, 'pagination'> {
columns: ColumnsType;
apiUrl?: string;
apiParams?: Record<string, unknown>;
pagination?: boolean | TablePaginationConfig;
rowKey: string;
rowSelection?: TableProps['rowSelection'];
initialData?: Record<string, unknown>[];
emptySize?: number;
isScroll?: boolean;
scrollX?: number | string | true; // 支持自定义横向滚动宽度
scrollY?: number | string; // 支持自定义纵向滚动高度
}
export interface TableRef {
loadData: () => void;
getList: (pageData: TablePaginationConfig) => void;
}
const dealSo = (params: any) => {
let so: any = {}
Object.keys(params).forEach(key => {
if (params[key] === '' || (Array.isArray(params[key]) && params[key].length === 0)) {
return
}
so[key] = params[key]
})
return so
}
const TableComponent = forwardRef<TableRef, TableComponentProps>(({
columns,
apiUrl,
apiParams,
pagination = true,
rowKey,
rowSelection,
initialData,
emptySize = 160,
isScroll = false,
scrollX,
scrollY,
...props
}, ref) => {
const { t } = useTranslation();
const [data, setData] = useState<Record<string, unknown>[]>(initialData || [])
const [loading, setLoading] = useState(false)
const [currentPagination, setCurrentPagination] = useState({
page: 1,
pagesize: 10,
});
const [total, setTotal] = useState(0);
useEffect(() => {
if (initialData && !apiUrl) {
setData(initialData)
}
}, [initialData, apiUrl])
// 数据加载
// 表格初始化
const loadData = () => {
if (apiUrl) {
getList({
...currentPagination,
page: 1
})
}
}
// 获取数据
const getList = (pageData: TablePaginationConfig) => {
if (!apiUrl) {
return
}
let params = dealSo(apiParams || {})
if (pagination) {
setCurrentPagination({
...currentPagination,
...pageData,
})
params = {...params, ...pageData}
}
setLoading(true)
// 构建查询参数并调用API
request.get(apiUrl, params)
.then((res: any) => {
// 支持两种响应格式:直接返回 total 或在 page 对象中返回
const totalCount = res.page?.total ?? res.total ?? 0;
setTotal(totalCount)
setData(Array.isArray(res.items) ? res.items : Array.isArray(res.hosts) ? res.hosts : res || [])
setLoading(false)
})
.catch(err => {
console.log('err', err)
setLoading(false)
})
}
// 初始化和apiParams变化时重新加载数据
useEffect(() => {
loadData()
}, [apiParams])
// 分页相关
// 切换分页
const handlePageChange = (page: number, pagesize: number) => {
getList({
page: page,
pagesize
})
}
// 分页配置
const paginationConfig = pagination ? ({
...(typeof pagination === 'object' ? pagination : {}),
...currentPagination,
total,
onChange: handlePageChange,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (totalCount: number) => t('table.totalRecords', {total: totalCount})
}) : false;
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
loadData,
getList,
}));
// 计算 scroll 配置
const getScrollConfig = () => {
if (!isScroll && !scrollX && !scrollY) return undefined;
const config: { x?: number | string | true; y?: number | string } = {};
if (scrollX !== undefined) {
config.x = scrollX;
} else if (isScroll) {
config.x = 'max-content';
}
if (scrollY !== undefined) {
config.y = scrollY;
} else if (isScroll) {
config.y = 'calc(100vh - 280px)';
}
return Object.keys(config).length > 0 ? config : undefined;
};
return (
<Table
{...props}
rowKey={rowKey}
loading={loading}
columns={columns}
dataSource={data}
pagination={paginationConfig}
rowSelection={rowSelection}
rowClassName={styles.row}
className={styles.table}
locale={{ emptyText: <Empty size={emptySize} /> }}
scroll={getScrollConfig()}
tableLayout="auto"
/>
);
});
export default TableComponent;

View File

@@ -0,0 +1,24 @@
import { type FC, type ReactNode } from 'react'
interface TagProps {
color?: 'processing' | 'error' | 'success' | 'warning' | 'default',
children: ReactNode;
className?: string;
}
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)]',
success: 'rb:text-[#369F21] rb:border-[rgba(54,159,33,0.25)] rb:bg-[rgba(54,159,33,0.06)]',
warning: 'rb:text-[#FF5D34] rb:border-[rgba(255,93,52,0.30)] rb:bg-[rgba(255,93,52,0.08)]',
default: 'rb:text-[#5B6167] rb:border-[rgba(91,97,103,0.30)] rb:bg-[rgba(91,97,103,0.08)]',
}
const Tag: FC<TagProps> = ({ color = 'processing', children, className }) => {
return (
<span className={`rb:inline-block rb:px-[4px] rb:py-[2px] rb:rounded-[4px] rb:text-[12px] rb:font-regular! rb:leading-[16px] rb:border-[1px] ${colors[color]} ${className || ''}`}>
{children}
</span>
)
}
export default Tag

View File

@@ -0,0 +1,307 @@
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { Upload, Button, Modal, Progress, App } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadProps, UploadFile } from 'antd';
// import { request } from '@/utils/request';
import type { UploadProps as RcUploadProps } from 'antd/es/upload/interface';
import CloudUploadOutlined from '@/assets/images/CloudUploadOutlined.png'
import { useTranslation } from 'react-i18next';
const { confirm } = Modal;
const { Dragger } = Upload;
interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
/** 上传接口地址 */
action?: string;
/** 是否支持多选 */
multiple?: boolean;
/** 已上传的文件列表 */
fileList?: UploadFile[];
/** 文件列表变化回调 */
onChange?: (fileList: UploadFile[]) => void;
customRequest?: RcUploadProps['customRequest'];
/** 自定义上传请求配置 */
requestConfig?: {
data?: Record<string, string | number | boolean>;
headers?: Record<string, string>;
};
/** 禁用上传 */
disabled?: boolean;
/** 文件大小限制MB */
fileSize?: number;
/** 文件类型限制 ['doc', 'xls', 'ppt', 'pdf'] */
fileType?: string[];
/** 是否自动上传默认为true */
isAutoUpload?: boolean;
/** 最大上传文件数 */
maxCount?: number;
/** 是否支持拖拽上传默认为false */
isCanDrag?: boolean;
}
const ALL_FILE_TYPE: {
[key: string]: string;
} = {
txt: 'text/plain',
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
csv: 'text/csv',
md: 'text/markdown',
htm: 'text/html',
html: 'text/html',
json: 'application/json',
}
export interface UploadFilesRef {
fileList: UploadFile[];
clearFiles: () => void;
}
/**
* 公共上传组件基于Ant Design Upload组件封装
* 支持单文件/多文件上传、拖拽上传、文件验证、预览等功能
*/
const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
action = '/api/upload',
multiple = false,
fileList: propFileList = [],
onChange,
// requestConfig = {},
disabled = false,
fileSize = 5,
fileType = ['doc', 'xls', 'ppt', 'pdf'],
isAutoUpload = true,
maxCount = 1,
isCanDrag = false,
...props
}, ref) => {
const { t } = useTranslation();
const { message } = App.useApp()
const [fileList, setFileList] = useState<UploadFile[]>(propFileList);
const [accept, setAccept] = useState<string | undefined>();
// 处理文件移除
const handleRemove = (file: UploadFile) => {
confirm({
title: '确定要删除此文件吗?',
okText: '确定',
okType: 'danger',
cancelText: '取消',
onOk: () => {
const newFileList = fileList.filter((item) => item.uid !== file.uid);
setFileList(newFileList);
onChange?.(newFileList);
},
});
return false; // 阻止默认删除行为由confirm控制
};
// 校验文件类型和大小
const beforeUpload: RcUploadProps['beforeUpload'] = (file) => {
// 校验文件大小
if (fileSize) {
const isLtMaxSize = (file.size / 1024 / 1024) < fileSize;
if (!isLtMaxSize) {
message.error(`文件大小不能超过 ${fileSize}MB`);
return Upload.LIST_IGNORE;
}
}
// 校验文件类型
if (fileType && fileType.length > 0) {
// 获取文件扩展名
const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
// 检查扩展名是否在允许的类型列表中
const isValidExtension = fileType.some(type => type.toLowerCase() === fileExtension);
// 如果有 MIME 类型,也检查 MIME 类型(作为备选验证)
const isValidMimeType = file.type && accept ? accept.includes(file.type) : true;
if (!isValidExtension && !isValidMimeType) {
message.error(`不支持的文件类型: ${fileExtension || file.type}`);
return Upload.LIST_IGNORE;
}
}
if (!isAutoUpload) {
const newFileList = [...fileList, file as UploadFile];
setFileList(newFileList);
onChange?.(newFileList);
return Upload.LIST_IGNORE; // 阻止自动上传
}
return isAutoUpload;
};
// 自定义上传方法
/*
const customRequest: RcUploadProps['customRequest'] = ({ file, onSuccess, onError, onProgress }) => {
setLoading(true);
const formData = new FormData();
formData.append('file', file as RcFile);
// 添加额外的请求参数
const requestData = requestConfig.data;
if (requestData) {
Object.keys(requestData).forEach(key => {
const value = requestData[key];
formData.append(key, String(value));
});
}
request.post(action, formData, {
headers: {
'Content-Type': 'multipart/form-data',
...requestConfig.headers,
},
...requestConfig,
})
.then((response) => {
if (onSuccess) onSuccess(response);
})
.catch((error) => {
message.error('上传失败,请重试');
if (onError) onError(error);
// setFileList(fileList.filter((item) => item.uid !== (file as UploadFile).uid));
})
.finally(() => {
setLoading(false);
});
};
*/
// 处理上传状态变化
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList, event }) => {
console.log('event', event)
setFileList(newFileList);
if (onChange) {
onChange(newFileList);
}
};
// 清空已上传文件
const clearFiles = () => {
setFileList([]);
if (onChange) {
onChange([]);
}
}
useEffect(() => {
if (fileType && fileType.length > 0) {
// 同时包含 MIME 类型和文件扩展名
const acceptArray: string[] = [];
fileType.forEach((type: string) => {
const lowerType = type.toLowerCase();
// 添加 MIME 类型(如果存在)
const mimeType = ALL_FILE_TYPE[lowerType];
if (mimeType) {
acceptArray.push(mimeType);
}
// 添加文件扩展名(.md, .html 等)
acceptArray.push(`.${lowerType}`);
});
setAccept(acceptArray.join(','));
} else {
setAccept(undefined);
}
}, [fileType])
// 生成上传组件配置
const uploadProps: UploadProps = {
action,
multiple: multiple && maxCount > 1,
fileList,
beforeUpload,
headers: {
authorization: localStorage.getItem('token') || '',
},
onRemove: handleRemove,
onChange: handleChange,
accept,
disabled,
showUploadList: {
showPreviewIcon: false,
showRemoveIcon: true,
showDownloadIcon: false,
},
itemRender: (_, file, __, actions) => {
return (
<div key={file.uid} className="rb:relative rb:w-full rb:pt-[8px] rb:pl-[10px] rb:pr-[10px] rb-pb-[10px] rb:border-1 rb:border-[#EBEBEB] rb:rounded rb:p-2 rb:mt-2 rb:bg-white">
<div className="rb:text-[12px] rb:flex rb:items-center rb:justify-between rb:mb-[2px]">
{file.name}
<span className="rb:text-[#5B6167]" onClick={() => actions?.remove()}>Cancel</span>
</div>
<Progress percent={file.percent || 0} strokeColor={file.status === 'error' ? '#FF5D34' : '#155EEF'} size="small" showInfo={false} />
</div>
);
},
...props,
};
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
fileList,
clearFiles
}));
const hasProgress = fileList.some((item) => item.percent !== 100);
if (isCanDrag) {
return (
<div className="rb:mb-[24px] rb:w-full">
<Dragger {...uploadProps} style={{ height: '270px' }}>
<div className="rb:flex rb:justify-center rb:flex-col rb:items-center">
<img className="rb:w-[48px] rb:h-[48px]" src={CloudUploadOutlined} />
{!hasProgress && (!fileList || !fileList.length) &&
<>
<div className="rb:text-base rb:text-[14px] rb:font-medium rb:flex rb:items-center rb:mt-[8px] rb:leading-[20px]">
{t('common.dragUploadTip')}<span className="rb:ml-[4px] rb:text-[#155EEF]">{t('common.uploadClickTip')}</span>
</div>
{fileType && <div className="rb:text-[12px] rb:text-[#A8A9AA] rb:leading-[14px] rb:mt-[8px] rb:cursor-pointer">{t('common.supportedFileTypes', { types: fileType.join(',') })}</div>}
{(fileSize || fileType || maxCount > 1) && (
<div className='rb:text-xs rb:mt-2 rb:text-[#A8A9AA]'>
{t('common.uploadFileTipMax', { max: fileSize, maxCount: maxCount })}
</div>
)}
</>
}
{hasProgress && <div className="rb:text-base rb:text-[14px] rb:font-medium rb:flex rb:items-center rb:mt-[8px] rb:mb-[24px] rb:leading-[20px]">{t('common.uploading')}</div>}
</div>
</Dragger>
</div>
);
}
return (
<Upload
{...uploadProps}
>
<div>
<Button
type="default"
icon={<UploadOutlined />}
className="rb:w-full"
disabled={fileList.length >= maxCount}
>
</Button>
{(fileSize || fileType || maxCount > 1) && (
<div>
{fileSize && <> <b style={{color: '#f56c6c'}}>{ fileSize }MB</b></>}
{fileType && <> <b style={{color: '#f56c6c'}}>{ fileType.join('、') }</b></>}
{multiple && maxCount > 1 && <> <b style={{color: '#f56c6c'}}>{ maxCount } </b> </>}
</div>
)}
</div>
</Upload>
);
});
export default UploadFiles;

View File

@@ -0,0 +1,222 @@
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { Upload, Modal, 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'
const { confirm } = Modal;
interface UploadImagesProps extends Omit<UploadProps, 'onChange'> {
/** 上传接口地址 */
action?: string;
/** 是否支持多选 */
multiple?: boolean;
/** 已上传的文件列表 */
fileList?: UploadFile[];
/** 文件列表变化回调 */
onChange?: (fileList: UploadFile[]) => void;
/** 禁用上传 */
disabled?: boolean;
/** 文件大小限制MB */
fileSize?: number;
/** 文件类型限制 */
fileType?: string[];
/** 是否自动上传默认为true */
isAutoUpload?: boolean;
/** 最大上传文件数 */
maxCount?: number;
}
const ALL_FILE_TYPE: {
[key: string]: string;
} = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
bmp: 'image/bmp',
webp: 'image/webp',
svg: 'image/svg+xml',
}
interface UploadImagesRef {
fileList: UploadFile[];
clearFiles: () => void;
}
type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];
const getBase64 = (file: FileType): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
}
/**
* 公共上传组件基于Ant Design Upload组件封装
* 支持单文件/多文件上传、拖拽上传、文件验证、预览等功能
*/
const UploadImages = forwardRef<UploadImagesRef, UploadImagesProps>(({
action = '/api/upload',
multiple = false,
fileList: propFileList = [],
onChange,
disabled = false,
fileSize,
fileType = ['png', 'jpg', 'gif'],
isAutoUpload = true,
maxCount = 1,
...props
}, ref) => {
const { t } = useTranslation();
const { message } = App.useApp()
const [fileList, setFileList] = useState<UploadFile[]>(propFileList);
const [accept, setAccept] = useState<string | undefined>();
// const [loading, setLoading] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState('');
// 处理文件移除
const handleRemove = (file: UploadFile) => {
confirm({
title: '确定要删除此文件吗?',
okText: '确定',
okType: 'danger',
cancelText: '取消',
onOk: () => {
const newFileList = fileList.filter((item) => item.uid !== file.uid);
setFileList(newFileList);
onChange?.(newFileList);
},
});
return false; // 阻止默认删除行为由confirm控制
};
// 校验文件类型和大小
const beforeUpload: RcUploadProps['beforeUpload'] = async (file: UploadFile) => {
// 校验文件大小
if (fileSize && file.size) {
const isLtMaxSize = (file.size / 1024 / 1024) < fileSize;
if (!isLtMaxSize) {
message.error(`文件大小不能超过 ${fileSize}MB`);
return Upload.LIST_IGNORE;
}
}
// 校验文件类型
if (accept && accept.length > 0 && file.type) {
const isAccept = accept.includes(file.type);
if (!isAccept) {
message.error(`不支持的文件类型: ${file.type}`);
return Upload.LIST_IGNORE;
}
}
if (!isAutoUpload) {
if (!file.url && !file.preview) {
file.url = await getBase64(file.originFileObj as FileType);
}
const newFileList = [...fileList, file];
setFileList(newFileList);
onChange?.(newFileList);
return Upload.LIST_IGNORE; // 阻止自动上传
}
return isAutoUpload;
};
// 处理上传状态变化
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => {
setFileList(newFileList);
if (onChange) {
onChange(newFileList);
}
};
// 清空已上传文件
const clearFiles = () => {
setFileList([]);
if (onChange) {
onChange([]);
}
}
const handlePreview = async (file: UploadFile) => {
if (!file.thumbUrl && !file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj as FileType);
}
setPreviewImage(file.thumbUrl || file.url || (file.preview as string));
setPreviewOpen(true);
};
useEffect(() => {
if (fileType && fileType.length > 0) {
const acceptArray = fileType.map((type: string) => ALL_FILE_TYPE[type.toLowerCase()]).filter(Boolean);
setAccept(acceptArray.join(','));
} else {
setAccept(undefined);
}
}, [fileType])
// 生成上传组件配置
const uploadProps: UploadProps = {
action,
multiple: multiple && maxCount > 1,
fileList,
beforeUpload,
headers: {
authorization: localStorage.getItem('token') || '',
},
onPreview: handlePreview,
onRemove: handleRemove,
onChange: handleChange,
accept,
disabled,
listType: 'picture-card',
showUploadList: {
showPreviewIcon: true,
showRemoveIcon: true,
showDownloadIcon: false,
},
...props,
};
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
fileList,
clearFiles
}));
return (
<>
<Upload
{...uploadProps}
style={{
width: '136px',
height: '136px',
}}
>
{fileList.length < maxCount && (
<div className="rb:flex rb:flex-wrap rb:items-center rb:justify-center">
<img src={PlusIcon} className="rb:w-[32px] rb:h-[32px]" />
<div className="rb:mt-[12px] rb:text-[12px] rb:text-[#5B6167] rb:leading-[16px]">{t('common.clickUploadIcon')}</div>
</div>
)}
</Upload>
{previewImage && (
<Image
wrapperStyle={{ display: 'none' }}
preview={{
visible: previewOpen,
onVisibleChange: (visible) => setPreviewOpen(visible),
afterOpenChange: (visible) => !visible && setPreviewImage(''),
}}
src={previewImage}
/>
)}
</>
);
});
export default UploadImages;