Refactor: Remove ant design component (#13143)

### What problem does this PR solve?

_Briefly describe what this PR aims to solve. Include background context
that will help reviewers understand the purpose of the PR._

### Type of change

- [x] Refactoring
This commit is contained in:
chanx
2026-02-13 18:40:41 +08:00
committed by GitHub
parent bc9ed24a85
commit f2a1d59c36
51 changed files with 1074 additions and 1484 deletions

View File

@@ -165,3 +165,24 @@ export const useFetchDocx = (filePath: string) => {
return { succeed, containerRef, error };
};
export const useCatchDocumentError = (url: string) => {
const httpHeaders = useMemo(() => {
return {
[Authorization]: getAuthorization(),
};
}, []);
const [error, setError] = useState<string>('');
const fetchDocument = useCallback(async () => {
const { data } = await axios.get(url, { headers: httpHeaders });
if (data.code !== 0) {
setError(data?.message);
}
}, [url, httpHeaders]);
useEffect(() => {
fetchDocument();
}, [fetchDocument]);
return error;
};

View File

@@ -13,7 +13,7 @@ import { Spin } from '@/components/ui/spin';
import { Authorization } from '@/constants/authorization';
import FileError from '@/pages/document-viewer/file-error';
import { getAuthorization } from '@/utils/authorization-util';
import { useCatchDocumentError } from '../pdf-previewer/hooks';
import { useCatchDocumentError } from './hooks';
import styles from './index.module.less';
type PdfLoaderProps = React.ComponentProps<typeof PdfLoader> & {
httpHeaders?: Record<string, string>;

View File

@@ -3,7 +3,7 @@ import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { MessageType, SharedFrom } from '@/constants/chat';
import { useFetchExternalAgentInputs } from '@/hooks/use-agent-request';
import { useFetchExternalChatInfo } from '@/hooks/use-chat-request';
import i18n from '@/locales/config';
import i18n, { changeLanguageAsync } from '@/locales/config';
import { useSendNextSharedMessage } from '@/pages/agent/hooks/use-send-shared-message';
import { MessageCircle, Minimize2, Send, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
@@ -136,7 +136,7 @@ const FloatingChatWidget = () => {
}, 50);
if (locale && i18n.language !== locale) {
i18n.changeLanguage(locale);
changeLanguageAsync(locale);
}
return () => clearTimeout(timer);

View File

@@ -1,5 +1,11 @@
import { PromptIcon } from '@/assets/icon/next-icon';
import CopyToClipboard from '@/components/copy-to-clipboard';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useSetModalState } from '@/hooks/common-hooks';
import { IRemoveMessageById } from '@/hooks/logic-hooks';
import {
@@ -10,7 +16,6 @@ import {
SoundOutlined,
SyncOutlined,
} from '@ant-design/icons';
import { Radio, Tooltip } from 'antd';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import FeedbackDialog from '../feedback-dialog';
@@ -50,34 +55,44 @@ export const AssistantGroupButton = ({
return (
<>
<Radio.Group size="small">
<Radio.Button value="a">
<ToggleGroup
type="single"
size="sm"
variant="outline"
className="space-x-1"
>
<ToggleGroupItem value="a">
<CopyToClipboard text={content}></CopyToClipboard>
</Radio.Button>
</ToggleGroupItem>
{showLoudspeaker && (
<Radio.Button value="b" onClick={handleRead}>
<Tooltip title={t('chat.read')}>
{isPlaying ? <PauseCircleOutlined /> : <SoundOutlined />}
<ToggleGroupItem value="b" onClick={handleRead}>
<Tooltip>
<TooltipTrigger asChild>
<span>
{isPlaying ? <PauseCircleOutlined /> : <SoundOutlined />}
</span>
</TooltipTrigger>
<TooltipContent>{t('chat.read')}</TooltipContent>
</Tooltip>
<audio src="" ref={ref}></audio>
</Radio.Button>
</ToggleGroupItem>
)}
{showLikeButton && (
<>
<Radio.Button value="c" onClick={handleLike}>
<ToggleGroupItem value="c" onClick={handleLike}>
<LikeOutlined />
</Radio.Button>
<Radio.Button value="d" onClick={showModal}>
</ToggleGroupItem>
<ToggleGroupItem value="d" onClick={showModal}>
<DislikeOutlined />
</Radio.Button>
</ToggleGroupItem>
</>
)}
{prompt && (
<Radio.Button value="e" onClick={showPromptModal}>
<ToggleGroupItem value="e" onClick={showPromptModal}>
<PromptIcon style={{ fontSize: '16px' }} />
</Radio.Button>
</ToggleGroupItem>
)}
</Radio.Group>
</ToggleGroup>
{visible && (
<FeedbackDialog
visible={visible}
@@ -118,28 +133,39 @@ export const UserGroupButton = ({
const { t } = useTranslation();
return (
<Radio.Group size="small">
<Radio.Button value="a">
<ToggleGroup
type="single"
size="sm"
variant="outline"
className="space-x-1"
>
<ToggleGroupItem value="a">
<CopyToClipboard text={content}></CopyToClipboard>
</Radio.Button>
</ToggleGroupItem>
{regenerateMessage && (
<Radio.Button
<ToggleGroupItem
value="b"
onClick={regenerateMessage}
disabled={sendLoading}
>
<Tooltip title={t('chat.regenerate')}>
<SyncOutlined spin={sendLoading} />
<Tooltip>
<TooltipTrigger asChild>
<SyncOutlined spin={sendLoading} />
</TooltipTrigger>
<TooltipContent>{t('chat.regenerate')}</TooltipContent>
</Tooltip>
</Radio.Button>
</ToggleGroupItem>
)}
{removeMessageById && (
<Radio.Button value="c" onClick={onRemoveMessage} disabled={loading}>
<Tooltip title={t('common.delete')}>
<DeleteOutlined spin={loading} />
<ToggleGroupItem value="c" onClick={onRemoveMessage} disabled={loading}>
<Tooltip>
<TooltipTrigger asChild>
<DeleteOutlined spin={loading} />
</TooltipTrigger>
<TooltipContent>{t('common.delete')}</TooltipContent>
</Tooltip>
</Radio.Button>
</ToggleGroupItem>
)}
</Radio.Group>
</ToggleGroup>
);
};

View File

@@ -1,5 +1,10 @@
import { PromptIcon } from '@/assets/icon/next-icon';
import CopyToClipboard from '@/components/copy-to-clipboard';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useSetModalState } from '@/hooks/common-hooks';
import { IRemoveMessageById } from '@/hooks/logic-hooks';
import { AgentChatContext } from '@/pages/agent/context';
@@ -13,7 +18,6 @@ import {
SoundOutlined,
SyncOutlined,
} from '@ant-design/icons';
import { Radio, Tooltip } from 'antd';
import { Download, NotebookText } from 'lucide-react';
import { useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
@@ -80,8 +84,13 @@ export const AssistantGroupButton = ({
</ToggleGroupItem>
{showLoudspeaker && (
<ToggleGroupItem value="b" onClick={handleRead}>
<Tooltip title={t('chat.read')}>
{isPlaying ? <PauseCircleOutlined /> : <SoundOutlined />}
<Tooltip>
<TooltipTrigger asChild>
<span>
{isPlaying ? <PauseCircleOutlined /> : <SoundOutlined />}
</span>
</TooltipTrigger>
<TooltipContent>{t('chat.read')}</TooltipContent>
</Tooltip>
<audio src="" ref={ref}></audio>
</ToggleGroupItem>
@@ -97,9 +106,9 @@ export const AssistantGroupButton = ({
</>
)}
{prompt && (
<Radio.Button value="e" onClick={showPromptModal}>
<ToggleGroupItem value="e" onClick={showPromptModal}>
<PromptIcon style={{ fontSize: '16px' }} />
</Radio.Button>
</ToggleGroupItem>
)}
{showLog && (
<ToggleGroupItem value="f" onClick={handleShowLogSheet}>
@@ -168,28 +177,39 @@ export const UserGroupButton = ({
const { t } = useTranslation();
return (
<Radio.Group size="small">
<Radio.Button value="a">
<ToggleGroup
type="single"
size="sm"
variant="outline"
className="space-x-1"
>
<ToggleGroupItem value="a">
<CopyToClipboard text={content}></CopyToClipboard>
</Radio.Button>
</ToggleGroupItem>
{regenerateMessage && (
<Radio.Button
<ToggleGroupItem
value="b"
onClick={regenerateMessage}
disabled={sendLoading}
>
<Tooltip title={t('chat.regenerate')}>
<SyncOutlined spin={sendLoading} />
<Tooltip>
<TooltipTrigger asChild>
<SyncOutlined spin={sendLoading} />
</TooltipTrigger>
<TooltipContent>{t('chat.regenerate')}</TooltipContent>
</Tooltip>
</Radio.Button>
</ToggleGroupItem>
)}
{removeMessageById && (
<Radio.Button value="c" onClick={onRemoveMessage} disabled={loading}>
<Tooltip title={t('common.delete')}>
<DeleteOutlined spin={loading} />
<ToggleGroupItem value="c" onClick={onRemoveMessage} disabled={loading}>
<Tooltip>
<TooltipTrigger asChild>
<DeleteOutlined spin={loading} />
</TooltipTrigger>
<TooltipContent>{t('common.delete')}</TooltipContent>
</Tooltip>
</Radio.Button>
</ToggleGroupItem>
)}
</Radio.Group>
</ToggleGroup>
);
};

View File

@@ -1,217 +0,0 @@
import { DocumentParserType } from '@/constants/knowledge';
import { useTranslate } from '@/hooks/common-hooks';
import { PlusOutlined } from '@ant-design/icons';
import { Button, Flex, Form, Input, InputNumber, Slider, Switch } from 'antd';
import random from 'lodash/random';
export const excludedParseMethods = [
DocumentParserType.Table,
DocumentParserType.Resume,
DocumentParserType.One,
DocumentParserType.Picture,
DocumentParserType.KnowledgeGraph,
DocumentParserType.Qa,
DocumentParserType.Tag,
];
export const showRaptorParseConfiguration = (
parserId: DocumentParserType | undefined,
) => {
return !excludedParseMethods.some((x) => x === parserId);
};
export const excludedTagParseMethods = [
DocumentParserType.Table,
DocumentParserType.KnowledgeGraph,
DocumentParserType.Tag,
];
export const showTagItems = (parserId: DocumentParserType) => {
return !excludedTagParseMethods.includes(parserId);
};
// The three types "table", "resume" and "one" do not display this configuration.
const ParseConfiguration = () => {
const form = Form.useFormInstance();
const { t } = useTranslate('knowledgeConfiguration');
const handleGenerate = () => {
form.setFieldValue(
['parser_config', 'raptor', 'random_seed'],
random(10000),
);
};
return (
<>
<Form.Item
name={['parser_config', 'raptor', 'use_raptor']}
label={t('useRaptor')}
initialValue={false}
valuePropName="checked"
tooltip={t('useRaptorTip')}
>
<Switch />
</Form.Item>
<Form.Item
shouldUpdate={(prevValues, curValues) =>
prevValues.parser_config.raptor.use_raptor !==
curValues.parser_config.raptor.use_raptor
}
>
{({ getFieldValue }) => {
const useRaptor = getFieldValue([
'parser_config',
'raptor',
'use_raptor',
]);
return (
useRaptor && (
<>
<Form.Item
name={['parser_config', 'raptor', 'prompt']}
label={t('prompt')}
initialValue={t('promptText')}
tooltip={t('promptTip')}
rules={[
{
required: true,
message: t('promptMessage'),
},
]}
>
<Input.TextArea rows={8} />
</Form.Item>
<Form.Item label={t('maxToken')} tooltip={t('maxTokenTip')}>
<Flex gap={20} align="center">
<Flex flex={1}>
<Form.Item
name={['parser_config', 'raptor', 'max_token']}
noStyle
initialValue={256}
rules={[
{
required: true,
message: t('maxTokenMessage'),
},
]}
>
<Slider max={2048} style={{ width: '100%' }} />
</Form.Item>
</Flex>
<Form.Item
name={['parser_config', 'raptor', 'max_token']}
noStyle
rules={[
{
required: true,
message: t('maxTokenMessage'),
},
]}
>
<InputNumber max={2048} min={0} />
</Form.Item>
</Flex>
</Form.Item>
<Form.Item label={t('threshold')} tooltip={t('thresholdTip')}>
<Flex gap={20} align="center">
<Flex flex={1}>
<Form.Item
name={['parser_config', 'raptor', 'threshold']}
noStyle
initialValue={0.1}
rules={[
{
required: true,
message: t('thresholdMessage'),
},
]}
>
<Slider
min={0}
max={1}
style={{ width: '100%' }}
step={0.01}
/>
</Form.Item>
</Flex>
<Form.Item
name={['parser_config', 'raptor', 'threshold']}
noStyle
rules={[
{
required: true,
message: t('thresholdMessage'),
},
]}
>
<InputNumber max={1} min={0} step={0.01} />
</Form.Item>
</Flex>
</Form.Item>
<Form.Item label={t('maxCluster')} tooltip={t('maxClusterTip')}>
<Flex gap={20} align="center">
<Flex flex={1}>
<Form.Item
name={['parser_config', 'raptor', 'max_cluster']}
noStyle
initialValue={64}
rules={[
{
required: true,
message: t('maxClusterMessage'),
},
]}
>
<Slider min={1} max={1024} style={{ width: '100%' }} />
</Form.Item>
</Flex>
<Form.Item
name={['parser_config', 'raptor', 'max_cluster']}
noStyle
rules={[
{
required: true,
message: t('maxClusterMessage'),
},
]}
>
<InputNumber max={1024} min={1} />
</Form.Item>
</Flex>
</Form.Item>
<Form.Item label={t('randomSeed')}>
<Flex gap={20} align="center">
<Flex flex={1}>
<Form.Item
name={['parser_config', 'raptor', 'random_seed']}
noStyle
initialValue={0}
rules={[
{
required: true,
message: t('randomSeedMessage'),
},
]}
>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
</Flex>
<Form.Item noStyle>
<Button type="primary" onClick={handleGenerate}>
<PlusOutlined />
</Button>
</Form.Item>
</Flex>
</Form.Item>
</>
)
);
}}
</Form.Item>
</>
);
};
export default ParseConfiguration;

View File

@@ -1,146 +0,0 @@
import { DocumentParserType } from '@/constants/knowledge';
import { useTranslate } from '@/hooks/common-hooks';
import random from 'lodash/random';
import { Plus } from 'lucide-react';
import { useCallback } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { SliderInputFormField } from '../slider-input-form-field';
import { Button } from '../ui/button';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '../ui/form';
import { Input } from '../ui/input';
import { Switch } from '../ui/switch';
import { Textarea } from '../ui/textarea';
export const excludedParseMethods = [
DocumentParserType.Table,
DocumentParserType.Resume,
DocumentParserType.One,
DocumentParserType.Picture,
DocumentParserType.KnowledgeGraph,
DocumentParserType.Qa,
DocumentParserType.Tag,
];
export const showRaptorParseConfiguration = (
parserId: DocumentParserType | undefined,
) => {
return !excludedParseMethods.some((x) => x === parserId);
};
export const excludedTagParseMethods = [
DocumentParserType.Table,
DocumentParserType.KnowledgeGraph,
DocumentParserType.Tag,
];
export const showTagItems = (parserId: DocumentParserType) => {
return !excludedTagParseMethods.includes(parserId);
};
const UseRaptorField = 'parser_config.raptor.use_raptor';
const RandomSeedField = 'parser_config.raptor.random_seed';
// The three types "table", "resume" and "one" do not display this configuration.
const RaptorFormFields = () => {
const form = useFormContext();
const { t } = useTranslate('knowledgeConfiguration');
const useRaptor = useWatch({ name: UseRaptorField });
const handleGenerate = useCallback(() => {
form.setValue(RandomSeedField, random(10000));
}, [form]);
return (
<>
<FormField
control={form.control}
name={UseRaptorField}
render={({ field }) => (
<FormItem defaultChecked={false}>
<FormLabel tooltip={t('useRaptorTip')}>{t('useRaptor')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
></Switch>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{useRaptor && (
<div className="space-y-3">
<FormField
control={form.control}
name={'parser_config.raptor.prompt'}
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('promptTip')}>{t('prompt')}</FormLabel>
<FormControl>
<Textarea {...field} rows={8} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SliderInputFormField
name={'parser_config.raptor.max_token'}
label={t('maxToken')}
tooltip={t('maxTokenTip')}
defaultValue={256}
max={2048}
min={0}
></SliderInputFormField>
<SliderInputFormField
name={'parser_config.raptor.threshold'}
label={t('threshold')}
tooltip={t('thresholdTip')}
defaultValue={0.1}
step={0.01}
max={1}
min={0}
></SliderInputFormField>
<SliderInputFormField
name={'parser_config.raptor.max_cluster'}
label={t('maxCluster')}
tooltip={t('maxClusterTip')}
defaultValue={64}
max={1024}
min={1}
></SliderInputFormField>
<FormField
control={form.control}
name={'parser_config.raptor.random_seed'}
render={({ field }) => (
<FormItem>
<FormLabel>{t('randomSeed')}</FormLabel>
<FormControl defaultValue={0}>
<div className="flex gap-4">
<Input {...field} />
<Button
size={'sm'}
onClick={handleGenerate}
type={'button'}
>
<Plus />
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</>
);
};
export default RaptorFormFields;

View File

@@ -1,25 +0,0 @@
import { Authorization } from '@/constants/authorization';
import { getAuthorization } from '@/utils/authorization-util';
import axios from 'axios';
import { useCallback, useEffect, useMemo, useState } from 'react';
export const useCatchDocumentError = (url: string) => {
const httpHeaders = useMemo(() => {
return {
[Authorization]: getAuthorization(),
};
}, []);
const [error, setError] = useState<string>('');
const fetchDocument = useCallback(async () => {
const { data } = await axios.get(url, { headers: httpHeaders });
if (data.code !== 0) {
setError(data?.message);
}
}, [url, httpHeaders]);
useEffect(() => {
fetchDocument();
}, [fetchDocument]);
return error;
};

View File

@@ -1,12 +0,0 @@
.documentContainer {
width: 100%;
height: 100%;
position: relative;
:global(.PdfHighlighter) {
overflow-x: hidden;
}
:global(.Highlight--scrolledTo .Highlight__part) {
overflow-x: hidden;
background-color: rgba(255, 226, 143, 1);
}
}

View File

@@ -1,135 +0,0 @@
import { IReferenceChunk } from '@/interfaces/database/chat';
import { IChunk } from '@/interfaces/database/knowledge';
import FileError from '@/pages/document-viewer/file-error';
import { Skeleton } from 'antd';
import { useEffect, useRef, useState } from 'react';
import {
AreaHighlight,
Highlight,
IHighlight,
PdfHighlighter,
PdfLoader,
Popup,
} from 'react-pdf-highlighter';
import { useCatchDocumentError } from './hooks';
import {
useGetChunkHighlights,
useGetDocumentUrl,
} from '@/hooks/use-document-request';
import styles from './index.module.less';
interface IProps {
chunk: IChunk | IReferenceChunk;
documentId: string;
visible: boolean;
}
const HighlightPopup = ({
comment,
}: {
comment: { text: string; emoji: string };
}) =>
comment.text ? (
<div className="Highlight__popup">
{comment.emoji} {comment.text}
</div>
) : null;
const DocumentPreviewer = ({ chunk, documentId, visible }: IProps) => {
const getDocumentUrl = useGetDocumentUrl(documentId);
const { highlights: state, setWidthAndHeight } = useGetChunkHighlights(chunk);
const ref = useRef<(highlight: IHighlight) => void>(() => {});
const [loaded, setLoaded] = useState(false);
const url = getDocumentUrl();
const error = useCatchDocumentError(url);
const resetHash = () => {};
useEffect(() => {
setLoaded(visible);
}, [visible]);
useEffect(() => {
if (state.length > 0 && loaded) {
setLoaded(false);
ref.current(state[0]);
}
}, [state, loaded]);
return (
<div className={styles.documentContainer}>
<PdfLoader
url={url}
beforeLoad={<Skeleton active />}
workerSrc="/pdfjs-dist/pdf.worker.min.js"
errorMessage={<FileError>{error}</FileError>}
>
{(pdfDocument) => {
pdfDocument.getPage(1).then((page) => {
const viewport = page.getViewport({ scale: 1 });
const width = viewport.width;
const height = viewport.height;
setWidthAndHeight(width, height);
});
return (
<PdfHighlighter
pdfDocument={pdfDocument}
enableAreaSelection={(event) => event.altKey}
onScrollChange={resetHash}
scrollRef={(scrollTo) => {
ref.current = scrollTo;
setLoaded(true);
}}
onSelectionFinished={() => null}
highlightTransform={(
highlight,
index,
setTip,
hideTip,
viewportToScaled,
screenshot,
isScrolledTo,
) => {
const isTextHighlight = !Boolean(
highlight.content && highlight.content.image,
);
const component = isTextHighlight ? (
<Highlight
isScrolledTo={isScrolledTo}
position={highlight.position}
comment={highlight.comment}
/>
) : (
<AreaHighlight
isScrolledTo={isScrolledTo}
highlight={highlight}
onChange={() => {}}
/>
);
return (
<Popup
popupContent={<HighlightPopup {...highlight} />}
onMouseOver={(popupContent) =>
setTip(highlight, () => popupContent)
}
onMouseOut={hideTip}
key={index}
>
{component}
</Popup>
);
}}
highlights={state}
/>
);
}}
</PdfLoader>
</div>
);
};
export default DocumentPreviewer;

View File

@@ -0,0 +1,470 @@
import { Calendar } from '@/components/originui/calendar';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { Locale } from 'date-fns';
import dayjs from 'dayjs';
import {
Calendar as CalendarIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from 'lucide-react';
import {
ChangeEvent,
forwardRef,
InputHTMLAttributes,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from './button';
import { TimePicker } from './time-picker';
type PickerType = 'date' | 'month' | 'year';
interface DatePickerProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
'value' | 'onChange'
> {
value?: Date | number;
onChange?: (date: Date | undefined) => void;
showTimeSelect?: boolean;
dateFormat?: string;
timeFormat?: string;
showTimeSelectOnly?: boolean;
locale?: Locale;
openChange?: (open: boolean) => void;
picker?: PickerType;
allowInput?: boolean;
minYear?: number;
maxYear?: number;
}
const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(
(
{
className,
value,
onChange,
dateFormat = 'DD/MM/YYYY',
timeFormat = 'HH:mm:ss',
showTimeSelect = false,
showTimeSelectOnly = false,
openChange,
picker = 'date',
allowInput = false,
minYear = 1900,
maxYear = 2100,
placeholder,
...props
},
ref,
) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [currentYear, setCurrentYear] = useState(() => {
const year = value
? value instanceof Date
? value.getFullYear()
: value
: new Date().getFullYear();
return year;
});
const [currentMonth, setCurrentMonth] = useState(() => {
if (value instanceof Date) {
return value.getMonth();
}
return new Date().getMonth();
});
const selectedDate = useMemo(() => {
if (!value) return undefined;
if (value instanceof Date) return value;
return new Date(value, 0, 1);
}, [value]);
const displayFormat = useMemo(() => {
if (picker === 'year') return 'YYYY';
if (picker === 'month') return 'MM/YYYY';
if (showTimeSelect) return `${dateFormat} ${timeFormat}`;
if (showTimeSelectOnly) return timeFormat;
return dateFormat;
}, [picker, dateFormat, timeFormat, showTimeSelect, showTimeSelectOnly]);
const formattedValue = useMemo(() => {
if (selectedDate && !isNaN(selectedDate.getTime())) {
return dayjs(selectedDate).format(displayFormat);
}
return inputValue || '';
}, [selectedDate, displayFormat, inputValue]);
useEffect(() => {
if (selectedDate && !isNaN(selectedDate.getTime())) {
setInputValue(dayjs(selectedDate).format(displayFormat));
}
}, [selectedDate, displayFormat]);
const handleDateSelect = (date: Date | undefined) => {
if (date && selectedDate) {
const valueDate = dayjs(selectedDate);
date.setHours(valueDate.hour());
date.setMinutes(valueDate.minute());
date.setSeconds(valueDate.second());
}
onChange?.(date);
};
const handleTimeSelect = (date: Date | undefined) => {
const valueDate = dayjs(selectedDate);
if (selectedDate) {
date?.setFullYear(valueDate.year());
date?.setMonth(valueDate.month());
date?.setDate(valueDate.date());
}
if (date) {
onChange?.(date);
} else {
valueDate?.hour(0);
valueDate?.minute(0);
valueDate?.second(0);
onChange?.(valueDate.toDate());
}
};
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
if (allowInput) {
const parsed = dayjs(newValue, displayFormat, true);
if (parsed.isValid()) {
onChange?.(parsed.toDate());
}
}
};
const handleOpenChange = (open: boolean) => {
setOpen(open);
openChange?.(open);
};
const handleYearSelect = (year: number) => {
if (picker === 'year') {
onChange?.(new Date(year, 0, 1));
setOpen(false);
} else {
setCurrentYear(year);
}
};
const handleMonthSelect = (month: number) => {
if (picker === 'month') {
onChange?.(new Date(currentYear, month, 1));
setOpen(false);
} else {
setCurrentMonth(month);
}
};
const years = useMemo(() => {
const result: number[] = [];
const startYear = Math.floor(currentYear / 10) * 10 - 1;
for (let i = startYear; i <= startYear + 12; i++) {
result.push(i);
}
return result;
}, [currentYear]);
const months = useMemo(() => {
return [
{ value: 0, label: t('common.january', 'Jan') },
{ value: 1, label: t('common.february', 'Feb') },
{ value: 2, label: t('common.march', 'Mar') },
{ value: 3, label: t('common.april', 'Apr') },
{ value: 4, label: t('common.may', 'May') },
{ value: 5, label: t('common.june', 'Jun') },
{ value: 6, label: t('common.july', 'Jul') },
{ value: 7, label: t('common.august', 'Aug') },
{ value: 8, label: t('common.september', 'Sep') },
{ value: 9, label: t('common.october', 'Oct') },
{ value: 10, label: t('common.november', 'Nov') },
{ value: 11, label: t('common.december', 'Dec') },
];
}, [t]);
const [view, setView] = useState<'date' | 'month' | 'year'>(
picker === 'year' ? 'year' : picker === 'month' ? 'month' : 'date',
);
useEffect(() => {
setView(
picker === 'year' ? 'year' : picker === 'month' ? 'month' : 'date',
);
}, [picker]);
const handlePrev = () => {
if (view === 'year') {
setCurrentYear((prev) => prev - 10);
} else if (view === 'month') {
setCurrentYear((prev) => prev - 1);
} else {
const newMonth = currentMonth === 0 ? 11 : currentMonth - 1;
const newYear = currentMonth === 0 ? currentYear - 1 : currentYear;
setCurrentMonth(newMonth);
setCurrentYear(newYear);
}
};
const handleNext = () => {
if (view === 'year') {
setCurrentYear((prev) => prev + 10);
} else if (view === 'month') {
setCurrentYear((prev) => prev + 1);
} else {
const newMonth = currentMonth === 11 ? 0 : currentMonth + 1;
const newYear = currentMonth === 11 ? currentYear + 1 : currentYear;
setCurrentMonth(newMonth);
setCurrentYear(newYear);
}
};
const renderYearPicker = () => (
<div className="p-0">
<div className="flex items-center justify-between mb-3">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={handlePrev}
>
<ChevronLeftIcon className="size-4" />
</Button>
<span className="text-sm font-medium">
{years[1]} - {years[10]}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={handleNext}
>
<ChevronRightIcon className="size-4" />
</Button>
</div>
<div className="grid grid-cols-3 gap-2">
{years.map((year) => {
const isSelected = year === (selectedDate?.getFullYear() || 0);
const isCurrentDecade = year >= years[1] && year <= years[10];
const isDisabled = year < minYear || year > maxYear;
return (
<Button
key={year}
variant={isSelected ? 'default' : 'ghost'}
size="lg"
disabled={isDisabled}
className={cn(
'text-sm ',
isSelected && 'bg-primary text-primary-foreground',
!isCurrentDecade && 'text-muted-foreground opacity-50',
!isSelected && isCurrentDecade && 'hover:bg-accent',
)}
onClick={() => handleYearSelect(year)}
>
{year}
</Button>
);
})}
</div>
</div>
);
const renderMonthPicker = () => (
<div className="p-3">
<div className="flex items-center justify-between mb-3">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={handlePrev}
>
<ChevronLeftIcon className="size-4" />
</Button>
<span
className="text-sm font-medium cursor-pointer hover:text-primary"
onClick={() => picker !== 'month' && setView('year')}
>
{currentYear}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={handleNext}
>
<ChevronRightIcon className="size-4" />
</Button>
</div>
<div className="grid grid-cols-3 gap-2">
{months.map((month) => {
const isSelected =
month.value === (selectedDate?.getMonth() ?? -1) &&
currentYear === (selectedDate?.getFullYear() ?? 0);
return (
<Button
key={month.value}
variant={isSelected ? 'default' : 'ghost'}
size="lg"
className={cn(
'text-sm',
isSelected && 'bg-primary text-primary-foreground',
)}
onClick={() => handleMonthSelect(month.value)}
>
{month.label}
</Button>
);
})}
</div>
</div>
);
const renderDatePicker = () => (
<>
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
month={new Date(currentYear, currentMonth)}
onMonthChange={(date) => {
setCurrentYear(date.getFullYear());
setCurrentMonth(date.getMonth());
}}
onDayClick={() => {
if (!showTimeSelect) {
setOpen(false);
}
}}
classNames={{
month_caption:
'relative mx-10 mb-1 flex h-9 items-center justify-center z-20 [&>span]:cursor-pointer',
}}
components={{
Chevron: (props) => {
if (props.orientation === 'left') {
return (
<ChevronLeftIcon
size={16}
{...props}
aria-hidden="true"
onClick={(e) => {
e.stopPropagation();
handlePrev();
}}
/>
);
}
return (
<ChevronRightIcon
size={16}
{...props}
aria-hidden="true"
onClick={(e) => {
e.stopPropagation();
handleNext();
}}
/>
);
},
}}
/>
{/* <div
className="text-center py-1 text-sm cursor-pointer hover:text-primary"
onClick={() => setView('month')}
>
{dayjs(new Date(currentYear, currentMonth)).format('MMMM YYYY')}
</div> */}
</>
);
const renderPicker = () => {
if (view === 'year') return renderYearPicker();
if (view === 'month') return renderMonthPicker();
return renderDatePicker();
};
return (
<div className="grid gap-2">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<div className="relative">
<Input
ref={ref}
value={formattedValue}
onChange={handleInputChange}
readOnly={!allowInput}
placeholder={placeholder}
className={cn(
'bg-bg-card hover:text-text-primary border-border-button w-full justify-between px-3 font-normal outline-offset-0 outline-none focus-visible:outline-[3px]',
allowInput ? 'cursor-text' : 'cursor-pointer',
className,
)}
{...props}
/>
<CalendarIcon
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground/80 group-hover:text-foreground shrink-0 transition-colors pointer-events-none"
size={16}
/>
</div>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
{renderPicker()}
{showTimeSelect && picker === 'date' && (
<TimePicker
value={selectedDate}
onChange={(value: Date | undefined) => {
handleTimeSelect(value);
}}
showNow
/>
)}
<div className="w-full flex justify-end mt-2">
<Button
variant="ghost"
type="button"
className="text-sm mr-2"
onClick={() => {
onChange?.(undefined);
setInputValue('');
handleOpenChange(false);
}}
>
{t('common.clear', 'Clear')}
</Button>
<Button
type="button"
className="text-sm text-text-primary-inverse"
onClick={() => {
handleOpenChange(false);
}}
>
{t('common.confirm', 'OK')}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
},
);
DatePicker.displayName = 'DatePicker';
export { DatePicker };

View File

@@ -1,175 +0,0 @@
import { Calendar } from '@/components/originui/calendar';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { Locale } from 'date-fns';
import dayjs from 'dayjs';
import { Calendar as CalendarIcon } from 'lucide-react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from './button';
import { TimePicker } from './time-picker';
// import TimePicker from 'react-time-picker';
interface DateInputProps extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'value' | 'onChange'
> {
value?: Date;
onChange?: (date: Date | undefined) => void;
showTimeSelect?: boolean;
dateFormat?: string;
timeFormat?: string;
showTimeSelectOnly?: boolean;
locale?: Locale; // Support for internationalization
openChange?: (open: boolean) => void;
}
const DateInput = React.forwardRef<HTMLInputElement, DateInputProps>(
(
{
className,
value,
onChange,
dateFormat = 'DD/MM/YYYY',
timeFormat = 'HH:mm:ss',
showTimeSelect = false,
showTimeSelectOnly = false,
openChange,
...props
},
ref,
) => {
const { t } = useTranslation();
const [selectedDate, setSelectedDate] = React.useState<Date | undefined>(
value,
);
const [open, setOpen] = React.useState(false);
const handleDateSelect = (date: Date | undefined) => {
if (selectedDate) {
const valueDate = dayjs(selectedDate);
date?.setHours(valueDate.hour());
date?.setMinutes(valueDate.minute());
date?.setSeconds(valueDate.second());
}
setSelectedDate(date);
// onChange?.(date);
};
const handleTimeSelect = (date: Date | undefined) => {
const valueDate = dayjs(selectedDate);
if (selectedDate) {
date?.setFullYear(valueDate.year());
date?.setMonth(valueDate.month());
date?.setDate(valueDate.date());
}
if (date) {
// onChange?.(date);
setSelectedDate(date);
} else {
valueDate?.hour(0);
valueDate?.minute(0);
valueDate?.second(0);
// onChange?.(valueDate.toDate());
setSelectedDate(valueDate.toDate());
}
};
// Determine display format based on the type of date picker
let displayFormat = dateFormat;
if (showTimeSelect) {
displayFormat = `${dateFormat} ${timeFormat}`;
} else if (showTimeSelectOnly) {
displayFormat = timeFormat;
}
// Format the date according to the specified format
const formattedValue = React.useMemo(() => {
return selectedDate && !isNaN(selectedDate.getTime())
? dayjs(selectedDate).format(displayFormat)
: '';
}, [selectedDate, displayFormat]);
const handleOpenChange = (open: boolean) => {
setOpen(open);
openChange?.(open);
};
return (
<div className="grid gap-2">
<Popover
open={open}
onOpenChange={handleOpenChange}
disableOutsideClick
>
<PopoverTrigger asChild>
<div className="relative">
<Input
ref={ref}
value={formattedValue}
readOnly
className={cn(
'bg-bg-card hover:text-text-primary border-border-button w-full justify-between px-3 font-normal outline-offset-0 outline-none focus-visible:outline-[3px] cursor-pointer',
className,
)}
{...props}
/>
<CalendarIcon
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground/80 group-hover:text-foreground shrink-0 transition-colors"
size={16}
/>
</div>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
/>
{showTimeSelect && (
<TimePicker
value={selectedDate}
onChange={(value: Date | undefined) => {
handleTimeSelect(value);
}}
showNow
/>
// <TimePicker onChange={onChange} value={value} />
)}
<div className="w-full flex justify-end mt-2">
<Button
variant="ghost"
type="button"
className="text-sm mr-2"
onClick={() => {
onChange?.(value);
handleOpenChange(false);
}}
>
{t('common.cancel')}
</Button>
<Button
type="button"
className="text-sm text-text-primary-inverse "
onClick={() => {
onChange?.(selectedDate);
handleOpenChange(false);
}}
>
{t('common.confirm')}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
},
);
DateInput.displayName = 'DateInput';
export { DateInput };

View File

@@ -1,8 +1,9 @@
// src/components/ui/modal.tsx
import { cn } from '@/lib/utils';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Loader, X } from 'lucide-react';
import { AlertCircle, CheckCircle, Info, Loader, X } from 'lucide-react';
import { FC, ReactNode, useCallback, useEffect, useMemo } from 'react';
import { createRoot } from 'react-dom/client';
import { useTranslation } from 'react-i18next';
import { DialogDescription } from '../dialog';
import { createPortalModal } from './modal-manage';
@@ -12,13 +13,15 @@ export interface ModalProps {
onOpenChange?: (open: boolean) => void;
title?: ReactNode;
titleClassName?: string;
children: ReactNode;
children?: ReactNode;
content?: ReactNode;
footer?: ReactNode;
footerClassName?: string;
showfooter?: boolean;
className?: string;
size?: 'small' | 'default' | 'large';
closable?: boolean;
showCancel?: boolean;
closeIcon?: ReactNode;
maskClosable?: boolean;
destroyOnClose?: boolean;
@@ -33,25 +36,42 @@ export interface ModalProps {
disabled?: boolean;
style?: React.CSSProperties;
zIndex?: number;
type?: 'warning' | 'info' | 'success' | 'error' | 'confirm';
}
export interface ModalType extends FC<ModalProps> {
show: typeof modalIns.show;
hide: typeof modalIns.hide;
destroy: typeof modalIns.destroy;
warning: (props: Omit<ModalProps, 'open' | 'type'>) => void;
info: (props: Omit<ModalProps, 'open' | 'type'>) => void;
success: (props: Omit<ModalProps, 'open' | 'type'>) => void;
error: (props: Omit<ModalProps, 'open' | 'type'>) => void;
confirm: (props: Omit<ModalProps, 'open' | 'type'>) => void;
}
const typeIcons = {
warning: <AlertCircle className="w-6 h-6 text-yellow-500" />,
info: <Info className="w-6 h-6 text-blue-500" />,
success: <CheckCircle className="w-6 h-6 text-green-500" />,
error: <AlertCircle className="w-6 h-6 text-red-500" />,
confirm: <AlertCircle className="w-6 h-6 text-yellow-500" />,
};
const Modal: ModalType = ({
open,
onOpenChange,
title,
titleClassName,
children,
content,
footer,
footerClassName,
showfooter = true,
className = '',
size = 'default',
closable = true,
showCancel = true,
closeIcon = <X className="w-4 h-4" />,
maskClosable = true,
destroyOnClose = false,
@@ -66,6 +86,7 @@ const Modal: ModalType = ({
disabled = false,
style,
zIndex = 50,
type,
}) => {
const sizeClasses = {
small: 'max-w-md',
@@ -74,7 +95,6 @@ const Modal: ModalType = ({
};
const { t } = useTranslation();
// Handle ESC key close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && maskClosable) {
@@ -99,7 +119,6 @@ const Modal: ModalType = ({
return;
}
onOpenChange?.(open);
console.log('open', open, onOpenChange);
if (open && !disabled) {
onOk?.();
}
@@ -117,16 +136,18 @@ const Modal: ModalType = ({
} else {
footerTemp = (
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => handleCancel()}
className={cn(
'px-2 py-1 border border-border-button rounded-md hover:bg-bg-card hover:text-text-primary ',
cancelButtonClassName,
)}
>
{cancelText ?? t('modal.cancelText')}
</button>
{showCancel && (
<button
type="button"
onClick={() => handleCancel()}
className={cn(
'px-2 py-1 border border-border-button rounded-md hover:bg-bg-card hover:text-text-primary ',
cancelButtonClassName,
)}
>
{cancelText ?? t('modal.cancelText')}
</button>
)}
<button
type="button"
disabled={confirmLoading || disabled}
@@ -168,7 +189,28 @@ const Modal: ModalType = ({
footerClassName,
okButtonClassName,
cancelButtonClassName,
showCancel,
]);
const contentEl = useMemo(() => {
if (type && typeIcons[type]) {
return (
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">{typeIcons[type]}</div>
<div className="flex-1">
{title && (
<DialogPrimitive.Title className="text-lg font-medium text-foreground mb-2">
{title}
</DialogPrimitive.Title>
)}
<div className="text-text-secondary">{content || children}</div>
</div>
</div>
);
}
return children;
}, [type, title, content, children]);
return (
<DialogPrimitive.Root open={open} onOpenChange={handleChange}>
<DialogPrimitive.Portal>
@@ -180,22 +222,16 @@ const Modal: ModalType = ({
<DialogPrimitive.Content
className={cn(
`relative w-[700px] ${full ? 'max-w-full' : sizeClasses[size]} ${className} bg-bg-base rounded-lg shadow-lg border border-border-default transition-all focus-visible:!outline-none`,
{ 'pt-10': closable && !title },
{ 'pt-10': closable && !title && !type },
)}
style={style}
onClick={(e) => e.stopPropagation()}
>
<DialogDescription></DialogDescription>
{/* title */}
{title && (
{title && !type && (
<div
className={cn(
'flex items-start px-6 py-4 justify-start',
// {
// 'justify-end': closable && !title,
// 'justify-between': closable && title,
// 'justify-start': !closable,
// },
titleClassName,
)}
>
@@ -218,12 +254,10 @@ const Modal: ModalType = ({
</DialogPrimitive.Close>
)}
{/* content */}
<div className="py-2 px-6 overflow-y-auto scrollbar-auto max-h-[calc(100vh-280px)] focus-visible:!outline-none">
{destroyOnClose && !open ? null : children}
{destroyOnClose && !open ? null : contentEl}
</div>
{/* footer */}
{footEl}
</DialogPrimitive.Content>
</DialogPrimitive.Overlay>
@@ -242,4 +276,49 @@ Modal.show = modalIns
Modal.hide = modalIns.hide;
Modal.destroy = modalIns.destroy;
const createStaticModal = (type: ModalProps['type']) => {
return (props: Omit<ModalProps, 'open' | 'type'>) => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const destroy = () => {
root.unmount();
container.remove();
};
const handleOk = () => {
props.onOk?.();
destroy();
};
const handleCancel = () => {
props.onCancel?.();
destroy();
};
root.render(
<Modal
{...props}
open={true}
type={type}
onOk={handleOk}
onCancel={handleCancel}
onOpenChange={(open) => {
if (!open) {
handleCancel();
}
}}
maskClosable={false}
/>,
);
};
};
Modal.warning = createStaticModal('warning');
Modal.info = createStaticModal('info');
Modal.success = createStaticModal('success');
Modal.error = createStaticModal('error');
Modal.confirm = createStaticModal('confirm');
export { Modal };