feat: Add base project structure with API and web components
This commit is contained in:
48
web/src/components/ButtonCheckbox/index.tsx
Normal file
48
web/src/components/ButtonCheckbox/index.tsx
Normal 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;
|
||||
100
web/src/components/CustomSelect/index.tsx
Normal file
100
web/src/components/CustomSelect/index.tsx
Normal 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;
|
||||
328
web/src/components/DocumentPreview/index.tsx
Normal file
328
web/src/components/DocumentPreview/index.tsx
Normal 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;
|
||||
19
web/src/components/Empty/BodyWrapper.tsx
Normal file
19
web/src/components/Empty/BodyWrapper.tsx
Normal 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
|
||||
15
web/src/components/Empty/Loading.tsx
Normal file
15
web/src/components/Empty/Loading.tsx
Normal 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;
|
||||
32
web/src/components/Empty/index.tsx
Normal file
32
web/src/components/Empty/index.tsx
Normal 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;
|
||||
91
web/src/components/Header/SettingModal.tsx
Normal file
91
web/src/components/Header/SettingModal.tsx
Normal 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;
|
||||
83
web/src/components/Header/UserInfoModal.tsx
Normal file
83
web/src/components/Header/UserInfoModal.tsx
Normal 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;
|
||||
20
web/src/components/Header/index.module.css
Normal file
20
web/src/components/Header/index.module.css
Normal 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;
|
||||
}
|
||||
106
web/src/components/Header/index.tsx
Normal file
106
web/src/components/Header/index.tsx
Normal 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;
|
||||
37
web/src/components/Layout/AuthLayout.tsx
Normal file
37
web/src/components/Layout/AuthLayout.tsx
Normal 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;
|
||||
38
web/src/components/Layout/AuthSpaceLayout.tsx
Normal file
38
web/src/components/Layout/AuthSpaceLayout.tsx
Normal 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;
|
||||
21
web/src/components/Layout/BasicLayout.tsx
Normal file
21
web/src/components/Layout/BasicLayout.tsx
Normal 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;
|
||||
17
web/src/components/Layout/LayoutBg.tsx
Normal file
17
web/src/components/Layout/LayoutBg.tsx
Normal 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;
|
||||
14
web/src/components/Layout/LoginLayout.tsx
Normal file
14
web/src/components/Layout/LoginLayout.tsx
Normal 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;
|
||||
37
web/src/components/Layout/layout.module.css
Normal file
37
web/src/components/Layout/layout.module.css
Normal 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;
|
||||
}
|
||||
22
web/src/components/Markdown/AudioBlock.tsx
Normal file
22
web/src/components/Markdown/AudioBlock.tsx
Normal 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)
|
||||
87
web/src/components/Markdown/Code.tsx
Normal file
87
web/src/components/Markdown/Code.tsx
Normal 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
|
||||
48
web/src/components/Markdown/CodeBlock.tsx
Normal file
48
web/src/components/Markdown/CodeBlock.tsx
Normal 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
|
||||
31
web/src/components/Markdown/CopyBtn.tsx
Normal file
31
web/src/components/Markdown/CopyBtn.tsx
Normal 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
|
||||
14
web/src/components/Markdown/Link.tsx
Normal file
14
web/src/components/Markdown/Link.tsx
Normal 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)
|
||||
46
web/src/components/Markdown/MermaidChart.tsx
Normal file
46
web/src/components/Markdown/MermaidChart.tsx
Normal 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
|
||||
17
web/src/components/Markdown/Paragraph.tsx
Normal file
17
web/src/components/Markdown/Paragraph.tsx
Normal 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)
|
||||
21
web/src/components/Markdown/RbButton.tsx
Normal file
21
web/src/components/Markdown/RbButton.tsx
Normal 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)
|
||||
23
web/src/components/Markdown/Svg.tsx
Normal file
23
web/src/components/Markdown/Svg.tsx
Normal 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;
|
||||
22
web/src/components/Markdown/VideoBlock.tsx
Normal file
22
web/src/components/Markdown/VideoBlock.tsx
Normal 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)
|
||||
147
web/src/components/Markdown/index.tsx
Normal file
147
web/src/components/Markdown/index.tsx
Normal 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, '<').replace(/>/g, '>')
|
||||
return `<span class="html-comment"><!-- ${escaped} --></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
|
||||
128
web/src/components/PageScrollList/index.tsx
Normal file
128
web/src/components/PageScrollList/index.tsx
Normal 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;
|
||||
63
web/src/components/RadioGroupCard/index.tsx
Normal file
63
web/src/components/RadioGroupCard/index.tsx
Normal 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;
|
||||
25
web/src/components/RbAlert/index.tsx
Normal file
25
web/src/components/RbAlert/index.tsx
Normal 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
|
||||
97
web/src/components/RbCard/Card.tsx
Normal file
97
web/src/components/RbCard/Card.tsx
Normal 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
|
||||
73
web/src/components/RbCard/index.tsx
Normal file
73
web/src/components/RbCard/index.tsx
Normal 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
|
||||
71
web/src/components/RbDrawer/index.tsx
Normal file
71
web/src/components/RbDrawer/index.tsx
Normal 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;
|
||||
63
web/src/components/RbModal/Confirm.tsx
Normal file
63
web/src/components/RbModal/Confirm.tsx
Normal 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;
|
||||
28
web/src/components/RbModal/index.tsx
Normal file
28
web/src/components/RbModal/index.tsx
Normal 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
|
||||
42
web/src/components/RbSlider/index.tsx
Normal file
42
web/src/components/RbSlider/index.tsx
Normal 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;
|
||||
93
web/src/components/SearchInput/index.tsx
Normal file
93
web/src/components/SearchInput/index.tsx
Normal 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;
|
||||
34
web/src/components/SiderMenu/index.module.css
Normal file
34
web/src/components/SiderMenu/index.module.css
Normal 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;
|
||||
}
|
||||
234
web/src/components/SiderMenu/index.tsx
Normal file
234
web/src/components/SiderMenu/index.tsx
Normal 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;
|
||||
289
web/src/components/SliderInput/README.md
Normal file
289
web/src/components/SliderInput/README.md
Normal 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: '创造',
|
||||
}}
|
||||
/>
|
||||
```
|
||||
107
web/src/components/SliderInput/index.tsx
Normal file
107
web/src/components/SliderInput/index.tsx
Normal 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;
|
||||
21
web/src/components/SortableList/DragHandle.tsx
Normal file
21
web/src/components/SortableList/DragHandle.tsx
Normal 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;
|
||||
56
web/src/components/SortableList/SortableListItem.tsx
Normal file
56
web/src/components/SortableList/SortableListItem.tsx
Normal 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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createContext } from 'react';
|
||||
import type { SortableListItemContextProps } from './types';
|
||||
|
||||
|
||||
const SortableListItemContext = createContext<SortableListItemContextProps>({});
|
||||
|
||||
export default SortableListItemContext;
|
||||
82
web/src/components/SortableList/index.tsx
Normal file
82
web/src/components/SortableList/index.tsx
Normal 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;
|
||||
8
web/src/components/SortableList/types.ts
Normal file
8
web/src/components/SortableList/types.ts
Normal 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;
|
||||
}
|
||||
29
web/src/components/StatusTag/index.tsx
Normal file
29
web/src/components/StatusTag/index.tsx
Normal 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
|
||||
3
web/src/components/Table/index.module.css
Normal file
3
web/src/components/Table/index.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.row {
|
||||
color: #5B6167;
|
||||
}
|
||||
176
web/src/components/Table/index.tsx
Normal file
176
web/src/components/Table/index.tsx
Normal 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;
|
||||
24
web/src/components/Tag/index.tsx
Normal file
24
web/src/components/Tag/index.tsx
Normal 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
|
||||
307
web/src/components/Upload/UploadFiles.tsx
Normal file
307
web/src/components/Upload/UploadFiles.tsx
Normal 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;
|
||||
222
web/src/components/Upload/UploadImages.tsx
Normal file
222
web/src/components/Upload/UploadImages.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user