Merge branch 'develop' into feature/ui_upgrade_zy
This commit is contained in:
@@ -1,26 +1,48 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:11:51
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-17 18:39:09
|
||||
*/
|
||||
import { type FC, useRef, useState } from 'react'
|
||||
import RecordRTC from 'recordrtc'
|
||||
import { App } from 'antd'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
/** Props for the AudioRecorder component */
|
||||
interface AudioRecorderProps {
|
||||
/** Callback fired when recording is complete, receives uploaded file info and raw blob */
|
||||
onRecordingComplete?: (file: { file_id: string; file_key: string; url: string; type?: string; }, blob?: Blob) => void
|
||||
className?: string;
|
||||
/** Upload endpoint URL, defaults to fileUploadUrlWithoutApiPrefix */
|
||||
action?: string;
|
||||
/** Additional config passed to the upload request */
|
||||
requestConfig?: Record<string, any>;
|
||||
disabled?: boolean;
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
onRecordingComplete,
|
||||
className = '',
|
||||
action = fileUploadUrlWithoutApiPrefix,
|
||||
requestConfig = {}
|
||||
requestConfig = {},
|
||||
disabled = false,
|
||||
maxSize,
|
||||
}) => {
|
||||
const { message } = App.useApp()
|
||||
const { t } = useTranslation();
|
||||
// Whether the recorder is currently capturing audio
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
// Holds the RecordRTC instance across renders
|
||||
const recorderRef = useRef<RecordRTC | null>(null)
|
||||
|
||||
/** Request microphone access and start recording */
|
||||
const startRecording = async () => {
|
||||
if (disabled) return
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
recorderRef.current = new RecordRTC(stream, {
|
||||
@@ -34,11 +56,19 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop recording, upload the audio blob, then invoke the completion callback */
|
||||
const stopRecording = () => {
|
||||
if (disabled) return
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.stopRecording(() => {
|
||||
const blob = recorderRef.current!.getBlob()
|
||||
const url = recorderRef.current!.toURL()
|
||||
|
||||
if (maxSize && blob.size > maxSize * 1024 * 1024) {
|
||||
message.error(t('common.fileSizeTip', { size: maxSize }));
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, `recording_${Date.now()}.webm`)
|
||||
request
|
||||
@@ -49,6 +79,7 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
type: blob.type,
|
||||
url
|
||||
}, blob)
|
||||
// Release recorder resources after upload
|
||||
recorderRef.current?.destroy()
|
||||
recorderRef.current = null
|
||||
})
|
||||
@@ -57,12 +88,14 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle between recording/idle states on click;
|
||||
// swap background image to reflect current state
|
||||
return (
|
||||
<div
|
||||
className={`rb:size-5.5 rb:cursor-pointer rb:bg-cover ${className} ${
|
||||
className={`rb:size-5.5 rb:bg-cover ${disabled ? 'rb:opacity-65 rb:cursor-not-allowed' : 'rb:cursor-pointer'} ${className} ${
|
||||
isRecording
|
||||
? `rb:bg-[url('@/assets/images/conversation/audio_ing.gif')]`
|
||||
: `rb:bg-[url('@/assets/images/conversation/audio.svg')] rb:hover:bg-[url('@/assets/images/conversation/audio_hover.svg')]`
|
||||
: `rb:bg-[url('@/assets/images/conversation/audio.svg')]`
|
||||
}`}
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:01:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-11 10:53:27
|
||||
* @Last Modified time: 2026-03-19 20:45:13
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -32,6 +32,7 @@ interface ButtonCheckboxProps extends Omit<RadioGroupProps, 'onChange'> {
|
||||
checkedIcon?: string;
|
||||
/** Button content */
|
||||
children?: ReactNode
|
||||
cicle?: boolean;
|
||||
}
|
||||
|
||||
const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
||||
@@ -41,6 +42,8 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
||||
icon,
|
||||
checkedIcon,
|
||||
children,
|
||||
cicle = false,
|
||||
disabled,
|
||||
}) => {
|
||||
// Listen to value changes and trigger side effects via onValueChange callback
|
||||
useEffect(() => {
|
||||
@@ -57,18 +60,21 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
<Flex
|
||||
align="center"
|
||||
justify={cicle ? 'center' : 'start'}
|
||||
gap={4}
|
||||
className={clsx("rb:border rb:rounded-lg rb:px-2! rb:text-[12px] rb:h-6 rb:cursor-pointer", {
|
||||
// Checked state: blue background and border
|
||||
"rb:bg-[#FAFAFA] rb:border-[#171719]": checked,
|
||||
// Unchecked state: gray border and dark text
|
||||
"rb:border-[#EBEBEB] rb:text-[#212332] rb:hover:bg-[#F0F3F8]": !checked,
|
||||
"rb:opacity-65 rb:cursor-not-allowed!": disabled
|
||||
})}
|
||||
onClick={handleChange}
|
||||
>
|
||||
{/* Display unchecked icon when not checked */}
|
||||
{icon && !checked && <img src={icon} className="rb:w-4 rb:h-4 rb:mr-1" />}
|
||||
{icon && !checked && <img src={icon} className="rb:size-4" />}
|
||||
{/* Display checked icon when checked */}
|
||||
{checkedIcon && checked && <img src={checkedIcon} className="rb:w-4 rb:h-4 rb:mr-1" />}
|
||||
{children}
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:17
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-25 19:04:55
|
||||
* @Last Modified time: 2026-03-19 20:45:39
|
||||
*/
|
||||
import { type FC, useRef, useEffect } from 'react'
|
||||
import { type FC, useRef, useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import Markdown from '@/components/Markdown'
|
||||
import type { ChatContentProps } from './types'
|
||||
import { Spin } from 'antd'
|
||||
import { Spin, Divider, Space, Image, Flex } from 'antd'
|
||||
import { SoundOutlined } from '@ant-design/icons'
|
||||
|
||||
|
||||
const getFileUrl = (file: any) => {
|
||||
return file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat Content Display Component
|
||||
@@ -28,15 +34,33 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
// Scroll container reference for controlling auto-scroll to bottom
|
||||
const scrollContainerRef = useRef<(HTMLDivElement | null)>(null)
|
||||
const prevDataLengthRef = useRef(data.length);
|
||||
const isScrolledToBottomRef = useRef(true); // Track if user is scrolled to bottom
|
||||
const isScrolledToBottomRef = useRef(true);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [playingIndex, setPlayingIndex] = useState<number | null>(null)
|
||||
|
||||
const handlePlay = (index: number, audio_url: string) => {
|
||||
if (playingIndex === index) {
|
||||
audioRef.current?.pause()
|
||||
setPlayingIndex(null)
|
||||
return
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
const audio = new Audio(audio_url)
|
||||
audioRef.current = audio
|
||||
audio.play()
|
||||
setPlayingIndex(index)
|
||||
audio.onended = () => setPlayingIndex(null)
|
||||
}
|
||||
|
||||
// Track scroll position to determine if user is at bottom
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
||||
// Consider user is at bottom if within 20px of the bottom
|
||||
isScrolledToBottomRef.current = scrollHeight - scrollTop - clientHeight < 20;
|
||||
// Consider user is at bottom if within 100px of the bottom
|
||||
isScrolledToBottomRef.current = scrollHeight - scrollTop - clientHeight < 100;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,11 +88,16 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
// Auto-scroll if data length changed OR user is currently at bottom
|
||||
if (data.length !== prevDataLengthRef.current || isScrolledToBottomRef.current) {
|
||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||
isScrolledToBottomRef.current = true;
|
||||
}
|
||||
prevDataLengthRef.current = data.length;
|
||||
}
|
||||
}, 0);
|
||||
}, [data])
|
||||
|
||||
const handleDownload = (file: any) => {
|
||||
window.open(getFileUrl(file), '_blank')
|
||||
}
|
||||
return (
|
||||
<div ref={scrollContainerRef} className={clsx("rb:relative rb:overflow-y-auto", classNames)}>
|
||||
{data.length === 0
|
||||
@@ -89,6 +118,44 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
{labelFormat(item)}
|
||||
</div>
|
||||
}
|
||||
{item.meta_data?.files && item.meta_data?.files.length > 0 && <Flex gap={8} vertical align="end">
|
||||
{item.meta_data?.files?.map((file) => {
|
||||
if (file.type.includes('image')) {
|
||||
return (
|
||||
<div key={file.url || file.uid} className={`rb:inline-block rb:group rb:relative rb:rounded-lg ${contentClassNames}`}>
|
||||
<Image src={getFileUrl(file)} alt={file.name} className="rb:w-full rb:max-w-80 rb:rounded-lg rb:object-cover rb:cursor-pointer" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (file.type.includes('video')) {
|
||||
return (
|
||||
<div key={file.url || file.uid} className="rb:inline-block rb:group rb:relative rb:rounded-lg">
|
||||
<video src={getFileUrl(file)} controls className="rb:max-w-80 rb:rounded-lg rb:object-cover rb:cursor-pointer" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (file.type.includes('audio')) {
|
||||
return (
|
||||
<div key={file.url || file.uid} className="rb:inline-flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2 rb:px-2.5 rb:gap-2">
|
||||
<audio src={getFileUrl(file)} controls className="rb:max-w-80" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={file.url || file.uid} className="rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:p-1! rb:cursor-pointer" onClick={() => handleDownload(file)}>
|
||||
{(file.type.includes('doc') || file.type.includes('docx') || file.type.includes('word') || file.type.includes('wordprocessingml.document')) && <div
|
||||
className="rb:size-10 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/word.svg')]"
|
||||
></div>}
|
||||
{(file.type.includes('pdf')) && <div
|
||||
className="rb:size-10 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf.svg')]"
|
||||
></div>}
|
||||
{(file.type.includes('excel') || file.type.includes('spreadsheetml.sheet') || file.type.includes('csv')) && <div
|
||||
className="rb:size-10 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/excel.svg')]"
|
||||
></div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Flex>}
|
||||
{/* Message bubble */}
|
||||
<div className={clsx('rb:text-left rb:rounded-lg rb:leading-5 rb:p-[10px_12px_2px_12px] rb:inline-block rb:max-w-130 rb:wrap-break-word rb:relative', contentClassNames, {
|
||||
// Error message style (content is null and not assistant message)
|
||||
@@ -104,6 +171,19 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
{item.subContent && renderRuntime && renderRuntime(item, index)}
|
||||
{/* Render message content using Markdown component */}
|
||||
<Markdown content={renderRuntime ? item.content ?? '' : item.content ?? errorDesc ?? ''} />
|
||||
|
||||
{item.meta_data?.audio_url && <>
|
||||
<Divider className="rb:my-3!" />
|
||||
<Space size={12} className="rb:pb-2 rb:pl-1">
|
||||
{playingIndex !== index
|
||||
? <SoundOutlined className="rb:cursor-pointer rb:hover:text-[#155EEF]! rb:size-5.5" onClick={() => handlePlay(index, item.meta_data?.audio_url!)} />
|
||||
: <div
|
||||
className="rb:size-5.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/audio_ing.gif')]"
|
||||
onClick={() => handlePlay(index, item.meta_data?.audio_url!)}
|
||||
/>
|
||||
}
|
||||
</Space>
|
||||
</>}
|
||||
</div>
|
||||
{/* Bottom label (such as timestamp, username, etc.) */}
|
||||
{labelPosition === 'bottom' &&
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 17:35:14
|
||||
* @Last Modified time: 2026-03-19 20:46:45
|
||||
*/
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { Flex, Input, Form } from 'antd'
|
||||
@@ -122,15 +122,20 @@ const ChatInput: FC<ChatInputProps> = ({
|
||||
gap={10}
|
||||
className="rb:w-45 rb:text-[12px] rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2! rb:px-2.5!"
|
||||
>
|
||||
{(file.type.includes('doc') || file.type.includes('docx') || file.type.includes('word') || file.type.includes('wordprocessingml.document')) && <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/word_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/word.svg')]"
|
||||
></div>}
|
||||
{(file.type.includes('pdf')) && <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/pdf.svg')]"
|
||||
></div>}
|
||||
{(file.type.includes('excel') || file.type.includes('spreadsheetml.sheet') || file.type.includes('csv')) && <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/excel_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/excel.svg')]"
|
||||
></div>}
|
||||
{file.type.includes('pdf')
|
||||
? <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/pdf.svg')]"
|
||||
></div>
|
||||
: (file.type.includes('excel') || file.type.includes('spreadsheetml.sheet') || file.type.includes('csv'))
|
||||
? <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/excel_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/excel.svg')]"
|
||||
></div>
|
||||
: (file.type.includes('doc') || file.type.includes('docx') || file.type.includes('word') || file.type.includes('wordprocessingml.document'))
|
||||
? <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/word_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/word.svg')]"
|
||||
></div>
|
||||
: null
|
||||
}
|
||||
<div className="rb:flex-1 rb:w-32.5">
|
||||
<div className="rb:leading-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.name}</div>
|
||||
<div className="rb:leading-3.5 rb:mt-0.5 rb:text-[#5B6167] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.type} · {file.size}</div>
|
||||
|
||||
204
web/src/components/Chat/ChatToolbar.tsx
Normal file
204
web/src/components/Chat/ChatToolbar.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-17 14:22:25
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 15:55:13
|
||||
*/
|
||||
// Toolbar component for chat input area, supporting file upload, audio recording, and variable configuration
|
||||
import { useRef, forwardRef, useImperativeHandle, type ReactNode, useEffect } from 'react'
|
||||
import { Flex, Dropdown, Divider, App, Form, type MenuProps } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||
import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
|
||||
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||
import type { VariableConfigModalRef } from '@/views/Workflow/types'
|
||||
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
|
||||
|
||||
// Exposed methods via ref for parent components to access/set form state
|
||||
export interface ChatToolbarRef {
|
||||
getFiles: () => any[]
|
||||
getVariables: () => Variable[]
|
||||
setFiles: (files: any[]) => void
|
||||
setVariables: (variables: Variable[]) => void
|
||||
}
|
||||
|
||||
// Props for configuring toolbar features, upload settings, and event callbacks
|
||||
export interface ChatToolbarProps {
|
||||
features: FeaturesConfigForm
|
||||
extra?: ReactNode
|
||||
uploadAction?: string
|
||||
uploadRequestConfig?: {
|
||||
data?: Record<string, string | number | boolean>
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
onFilesChange?: (files: any[]) => void
|
||||
onVariablesChange?: (variables: Variable[]) => void
|
||||
onRecordingComplete?: (file: any) => void;
|
||||
defaultValue?: { memory: boolean }
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
files: any[]
|
||||
variables: Variable[];
|
||||
memory?: boolean;
|
||||
}
|
||||
|
||||
const ChatToolbar = forwardRef<ChatToolbarRef, ChatToolbarProps>(({
|
||||
features,
|
||||
extra,
|
||||
uploadAction,
|
||||
uploadRequestConfig,
|
||||
onFilesChange,
|
||||
onVariablesChange,
|
||||
onRecordingComplete,
|
||||
defaultValue,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
|
||||
const [form] = Form.useForm<FormValues>()
|
||||
const queryValues = Form.useWatch([], form)
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultValue) return
|
||||
form.setFieldsValue(defaultValue)
|
||||
}, [defaultValue])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getFiles: () => form.getFieldValue('files') || [],
|
||||
getVariables: () => form.getFieldValue('variables') || [],
|
||||
setFiles: (files) => form.setFieldValue('files', files),
|
||||
setVariables: (variables) => {
|
||||
console.log('variables', variables)
|
||||
form.setFieldValue('variables', variables)
|
||||
},
|
||||
}))
|
||||
|
||||
const { file_upload } = features || {}
|
||||
|
||||
// Append newly uploaded file to the file list when upload is complete
|
||||
const fileChange = (file?: any) => {
|
||||
if (file?.status !== 'done') return
|
||||
const files = [...(queryValues?.files || []), file]
|
||||
form.setFieldValue('files', files)
|
||||
onFilesChange?.(files)
|
||||
}
|
||||
|
||||
// Append recorded audio file to the file list and notify parent
|
||||
const handleRecordingComplete = (file: any) => {
|
||||
const files = [...(queryValues?.files || []), file]
|
||||
form.setFieldValue('files', files)
|
||||
onFilesChange?.(files)
|
||||
onRecordingComplete?.(file)
|
||||
}
|
||||
|
||||
// Merge a batch of files (e.g. from remote URL modal) into the file list
|
||||
const addFileList = (list?: any[]) => {
|
||||
if (!list?.length) return
|
||||
const files = [...(queryValues?.files || []), ...list]
|
||||
form.setFieldValue('files', files)
|
||||
onFilesChange?.(files)
|
||||
}
|
||||
|
||||
// Persist variable values from the config modal and notify parent
|
||||
const handleVariablesSave = (values: Variable[]) => {
|
||||
form.setFieldValue('variables', values)
|
||||
onVariablesChange?.(values)
|
||||
}
|
||||
|
||||
// True when any required variable is missing a value, used to highlight the config button
|
||||
const isNeedVariableConfig = queryValues?.variables?.some(
|
||||
vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === '')
|
||||
)
|
||||
|
||||
// Build dropdown menu items based on allowed transfer methods
|
||||
const fileMenus: MenuProps['items'] = []
|
||||
const enabledTypes = ['image', 'document', 'video', 'audio'].filter(
|
||||
type => file_upload?.[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]
|
||||
)
|
||||
if (file_upload?.allowed_transfer_methods?.includes('remote_url') && enabledTypes.length > 0) {
|
||||
fileMenus.push({
|
||||
key: 'url',
|
||||
label: t('memoryConversation.addRemoteFile'),
|
||||
onClick: () => {
|
||||
if ((queryValues?.files?.length || 0) >= file_upload.max_file_count) {
|
||||
messageApi.warning(t('common.fileNumTip', { num: file_upload.max_file_count }))
|
||||
return
|
||||
}
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
}
|
||||
})
|
||||
}
|
||||
if (file_upload?.allowed_transfer_methods?.includes('local_file') && enabledTypes.length > 0) {
|
||||
fileMenus.push({
|
||||
key: 'upload',
|
||||
label: (
|
||||
<UploadFiles
|
||||
action={uploadAction}
|
||||
onChange={fileChange}
|
||||
requestConfig={uploadRequestConfig}
|
||||
featureConfig={file_upload}
|
||||
disabled={(queryValues?.files?.length || 0) >= file_upload.max_file_count}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form form={form} initialValues={{ files: [], variables: [] }}>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Form.Item name="files" noStyle hidden={!file_upload?.enabled || fileMenus.length === 0}>
|
||||
<Dropdown menu={{ items: fileMenus }}>
|
||||
<div className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]" />
|
||||
</Dropdown>
|
||||
</Form.Item>
|
||||
{extra}
|
||||
<Form.Item name="variables" className="rb:mb-0!" hidden={queryValues?.variables?.length < 1}>
|
||||
<div
|
||||
className={clsx('rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]', {
|
||||
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
|
||||
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
|
||||
})}
|
||||
onClick={() => variableConfigModalRef.current?.handleOpen(queryValues.variables)}
|
||||
>
|
||||
<SettingOutlined className="rb:mr-1" />
|
||||
{t('memoryConversation.variableConfig')}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
{file_upload?.audio_enabled && file_upload?.allowed_transfer_methods?.includes('local_file') && (
|
||||
<Flex align="center">
|
||||
<AudioRecorder
|
||||
disabled={(queryValues?.files?.length || 0) >= file_upload.max_file_count}
|
||||
action={uploadAction}
|
||||
requestConfig={uploadRequestConfig}
|
||||
onRecordingComplete={handleRecordingComplete}
|
||||
maxSize={file_upload?.audio_max_size_mb}
|
||||
/>
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
featureConfig={file_upload}
|
||||
/>
|
||||
<VariableConfigModal
|
||||
ref={variableConfigModalRef}
|
||||
refresh={handleVariablesSave}
|
||||
/>
|
||||
</Form>
|
||||
)
|
||||
})
|
||||
|
||||
export default ChatToolbar
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:09
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 14:57:56
|
||||
* @Last Modified time: 2026-03-19 20:47:27
|
||||
*/
|
||||
import { type FC } from 'react'
|
||||
import ChatInput from './ChatInput'
|
||||
@@ -26,7 +26,8 @@ const Chat: FC<ChatProps> = ({
|
||||
errorDesc,
|
||||
fileList,
|
||||
fileChange,
|
||||
className
|
||||
className,
|
||||
renderRuntime
|
||||
}) => {
|
||||
return (
|
||||
<div className={`rb:h-full rb:relative rb:pt-2 ${className}`}>
|
||||
@@ -38,6 +39,7 @@ const Chat: FC<ChatProps> = ({
|
||||
empty={empty}
|
||||
labelFormat={labelFormat}
|
||||
errorDesc={errorDesc}
|
||||
renderRuntime={renderRuntime}
|
||||
/>
|
||||
|
||||
{/* Chat input area */}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:45:54
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:05:09
|
||||
* @Last Modified time: 2026-03-19 20:47:12
|
||||
*/
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
@@ -22,8 +22,11 @@ export interface ChatItem {
|
||||
created_at?: number | string;
|
||||
status?: string;
|
||||
subContent?: Record<string, any>[];
|
||||
files?: any[];
|
||||
error?: string;
|
||||
meta_data?: {
|
||||
audio_url?: string;
|
||||
files?: any[];
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +57,7 @@ export interface ChatProps {
|
||||
/** Attachment update */
|
||||
fileChange?: (fileList: any[]) => void;
|
||||
className?: string;
|
||||
renderRuntime?: (item: ChatItem, index: number) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
67
web/src/components/D3Graph/CommunityGraph.tsx
Normal file
67
web/src/components/D3Graph/CommunityGraph.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useState, useRef, useMemo, useEffect, type FC } from 'react'
|
||||
import Empty from '@/components/Empty'
|
||||
import { GRAPH_COLORS, initCommunityGraph } from './utils'
|
||||
import { useD3Graph } from './hooks'
|
||||
import type { CommunityD3Node, D3Link, CommunityGraphProps } from './types'
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
// Renders a D3-powered community graph with optional tooltip and legend.
|
||||
|
||||
const CommunityGraph: FC<CommunityGraphProps> = ({
|
||||
data,
|
||||
empty: emptyProp,
|
||||
colors = GRAPH_COLORS,
|
||||
renderTooltip,
|
||||
showLegend = true,
|
||||
onCommunityClick,
|
||||
onNodeClick,
|
||||
defaultZoom = 1,
|
||||
}) => {
|
||||
// Tooltip position and hovered node state
|
||||
const [tooltip, setTooltip] = useState<{ x: number; y: number; node: CommunityD3Node } | null>(null)
|
||||
|
||||
// Keep callback refs stable to avoid re-initializing the graph on every render
|
||||
const onCommunityClickRef = useRef(onCommunityClick)
|
||||
const onNodeClickRef = useRef(onNodeClick)
|
||||
const renderTooltipRef = useRef(renderTooltip)
|
||||
useEffect(() => { onCommunityClickRef.current = onCommunityClick }, [onCommunityClick])
|
||||
useEffect(() => { onNodeClickRef.current = onNodeClick }, [onNodeClick])
|
||||
useEffect(() => { renderTooltipRef.current = renderTooltip }, [renderTooltip])
|
||||
|
||||
const graphState = useMemo(() => data, [data])
|
||||
// Show empty state when explicitly flagged or when there are no nodes
|
||||
const isEmpty = emptyProp ?? !data?.nodes.length
|
||||
|
||||
// Initialize (or re-initialize) the D3 graph whenever relevant state changes
|
||||
const containerRef = useD3Graph((container) => {
|
||||
if (!graphState) return
|
||||
return initCommunityGraph(
|
||||
container,
|
||||
graphState.nodes,
|
||||
graphState.links as D3Link[],
|
||||
graphState.communityMap,
|
||||
graphState.communityCaption,
|
||||
graphState.communityNodeMap,
|
||||
{ colors, showLegend, defaultZoom, setTooltip: renderTooltip ? setTooltip : () => {}, onCommunityClickRef, onNodeClickRef }
|
||||
)
|
||||
}, [graphState, showLegend, defaultZoom])
|
||||
|
||||
// Resolve tooltip content: use custom renderer if provided, otherwise fall back to DefaultTooltip
|
||||
const tooltipNode = tooltip && renderTooltipRef.current
|
||||
? renderTooltipRef.current(tooltip.node)
|
||||
: null
|
||||
|
||||
if (isEmpty) return <Empty className="rb:h-full" />
|
||||
return (
|
||||
<div className="rb:w-full rb:h-full rb:relative">
|
||||
<div ref={containerRef} className="rb:w-full rb:h-full" />
|
||||
{tooltipNode ? (
|
||||
<div style={{ position: 'absolute', left: tooltip!.x + 14, top: tooltip!.y - 10, pointerEvents: 'none', zIndex: 20 }}>
|
||||
{tooltipNode}
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(CommunityGraph)
|
||||
24
web/src/components/D3Graph/hooks.ts
Normal file
24
web/src/components/D3Graph/hooks.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
/**
|
||||
* Generic hook that mounts a D3 graph inside a div container.
|
||||
* Clears any existing SVG before calling initFn, and runs cleanup on unmount or dep change.
|
||||
*/
|
||||
export function useD3Graph<T>(
|
||||
initFn: (container: HTMLDivElement) => (() => void) | void,
|
||||
deps: T[]
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
d3.select(container).selectAll('svg').remove()
|
||||
const cleanup = initFn(container)
|
||||
return () => {
|
||||
cleanup?.()
|
||||
d3.select(container).selectAll('svg').remove()
|
||||
}
|
||||
}, deps)
|
||||
return containerRef
|
||||
}
|
||||
102
web/src/components/D3Graph/types.ts
Normal file
102
web/src/components/D3Graph/types.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type * as d3 from 'd3'
|
||||
|
||||
// ─── Raw input types (mirror of API response, no external dependency) ─────────
|
||||
// These interfaces map 1-to-1 with the graph API response shape.
|
||||
|
||||
export interface RawCommunityNode {
|
||||
id: string
|
||||
label: 'Community'
|
||||
properties: {
|
||||
name: string
|
||||
summary: string
|
||||
member_entity_ids: string[]
|
||||
member_count: number
|
||||
core_entities: string[]
|
||||
community_id: string
|
||||
end_user_id?: string
|
||||
updated_at?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface RawEntityNode {
|
||||
id: string
|
||||
label: 'ExtractedEntity'
|
||||
properties: {
|
||||
name: string
|
||||
description: string
|
||||
entity_type: string
|
||||
community_name?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface RawEdge {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
export interface RawCommunityGraphData {
|
||||
nodes: (RawCommunityNode | RawEntityNode)[]
|
||||
edges: RawEdge[]
|
||||
}
|
||||
|
||||
// ─── D3 graph types ───────────────────────────────────────────────────────────
|
||||
// Runtime node shape used by D3 simulations; extends SimulationNodeDatum for x/y/vx/vy.
|
||||
|
||||
export interface CommunityD3Node extends d3.SimulationNodeDatum {
|
||||
id: string
|
||||
name: string
|
||||
community: string
|
||||
label: string
|
||||
symbolSize: number
|
||||
color: string
|
||||
properties?: RawEntityNode['properties']
|
||||
}
|
||||
|
||||
export interface D3Link extends d3.SimulationLinkDatum<CommunityD3Node> {
|
||||
isCross: boolean
|
||||
}
|
||||
|
||||
// Convex-hull shape rendered behind each community cluster.
|
||||
export interface HullDatum {
|
||||
id: string
|
||||
path: string
|
||||
color: string
|
||||
labelX: number
|
||||
labelY: number
|
||||
dashed: boolean
|
||||
caption: string
|
||||
}
|
||||
|
||||
// Fully transformed graph data ready to be passed into initCommunityGraph.
|
||||
export interface CommunityGraphData {
|
||||
nodes: CommunityD3Node[]
|
||||
links: Array<{ source: string; target: string; isCross: boolean }>
|
||||
communityMap: Map<string, string[]>
|
||||
communityCaption: Map<string, string>
|
||||
communityNodeMap: Map<string, RawCommunityNode>
|
||||
}
|
||||
|
||||
// Props accepted by the CommunityGraph React component.
|
||||
export interface CommunityGraphProps {
|
||||
data: CommunityGraphData | null
|
||||
empty?: boolean
|
||||
colors?: string[]
|
||||
renderTooltip?: (node: CommunityD3Node) => ReactNode
|
||||
showLegend?: boolean
|
||||
onCommunityClick?: (node: RawCommunityNode) => void
|
||||
onNodeClick?: (node: CommunityD3Node) => void
|
||||
defaultZoom?: number
|
||||
}
|
||||
|
||||
// Options forwarded from the React component into the D3 initializer.
|
||||
export interface InitOptions {
|
||||
colors: string[]
|
||||
showLegend: boolean
|
||||
defaultZoom: number
|
||||
setTooltip: (s: { x: number; y: number; node: CommunityD3Node } | null) => void
|
||||
onCommunityClickRef: RefObject<((node: RawCommunityNode) => void) | undefined>
|
||||
onNodeClickRef: RefObject<((node: CommunityD3Node) => void) | undefined>
|
||||
}
|
||||
547
web/src/components/D3Graph/utils.ts
Normal file
547
web/src/components/D3Graph/utils.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
import * as d3 from 'd3'
|
||||
import type { CommunityD3Node, D3Link, HullDatum, CommunityGraphData, RawCommunityGraphData, RawCommunityNode, RawEntityNode, InitOptions } from './types'
|
||||
|
||||
// ─── Colors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GRAPH_COLORS = ['#155EEF', '#369F21', '#4DA8FF', '#FF5D34', '#9C6FFF', '#FF8A4C', '#8BAEF7', '#FFB048']
|
||||
export const colorAt = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length]
|
||||
|
||||
export function connectionToRadius(connections: number): number {
|
||||
if (connections <= 1) return 5
|
||||
if (connections <= 10) return 8
|
||||
if (connections <= 15) return 11
|
||||
if (connections <= 20) return 16
|
||||
return 22
|
||||
}
|
||||
|
||||
// ─── Arrow markers ────────────────────────────────────────────────────────────
|
||||
|
||||
export function addArrowMarkers(
|
||||
defs: d3.Selection<SVGDefsElement, unknown, null, undefined>,
|
||||
markers: { id: string; color: string }[]
|
||||
) {
|
||||
markers.forEach(({ id, color }) => {
|
||||
defs.append('marker')
|
||||
.attr('id', id)
|
||||
.attr('viewBox', '0 -4 8 8')
|
||||
.attr('refX', 8).attr('refY', 0)
|
||||
.attr('markerWidth', 6).attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('path').attr('d', 'M0,-4L8,0L0,4').attr('fill', color)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Zoom ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function addZoom(
|
||||
svg: d3.Selection<SVGSVGElement, unknown, null, undefined>,
|
||||
g: d3.Selection<SVGGElement, unknown, null, undefined>
|
||||
) {
|
||||
svg.call(
|
||||
d3.zoom<SVGSVGElement, unknown>().scaleExtent([0.2, 4])
|
||||
.on('zoom', e => g.attr('transform', e.transform))
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Node drag ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function makeNodeDrag<N extends d3.SimulationNodeDatum>(
|
||||
simulation: d3.Simulation<N, d3.SimulationLinkDatum<N>>
|
||||
) {
|
||||
return d3.drag<SVGGElement, N>()
|
||||
.on('start', (e, d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
|
||||
.on('drag', (e, d) => { d.fx = e.x; d.fy = e.y })
|
||||
.on('end', (e, d) => { if (!e.active) simulation.alphaTarget(0); d.fx = e.x; d.fy = e.y })
|
||||
}
|
||||
|
||||
// ─── Cluster force ────────────────────────────────────────────────────────────
|
||||
// Works for both string and number group keys.
|
||||
|
||||
export function makeClusterForce<N extends d3.SimulationNodeDatum & { x?: number; y?: number; vx?: number; vy?: number }>(
|
||||
nodes: N[],
|
||||
getGroup: (d: N) => string | number,
|
||||
centers: Record<string | number, { x: number; y: number }>,
|
||||
width: number,
|
||||
height: number,
|
||||
opts: { pullStrength?: number; minSepRatio?: number; pushStrength?: number } = {}
|
||||
) {
|
||||
const { pullStrength = 0.45, minSepRatio = 0.68, pushStrength = 1.0 } = opts
|
||||
return (alpha: number) => {
|
||||
// pre-group nodes by key to avoid repeated filter() in hot path
|
||||
const groups = new Map<string, N[]>()
|
||||
nodes.forEach(d => {
|
||||
const k = String(getGroup(d))
|
||||
if (!groups.has(k)) groups.set(k, [])
|
||||
groups.get(k)!.push(d)
|
||||
})
|
||||
// pull toward group center
|
||||
nodes.forEach(d => {
|
||||
const c = centers[getGroup(d)]
|
||||
if (!c) return
|
||||
d.vx = (d.vx ?? 0) + (c.x - (d.x ?? 0)) * pullStrength * alpha
|
||||
d.vy = (d.vy ?? 0) + (c.y - (d.y ?? 0)) * pullStrength * alpha
|
||||
})
|
||||
// live centroids
|
||||
const centroids: Record<string, { x: number; y: number; n: number }> = {}
|
||||
nodes.forEach(d => {
|
||||
const g = String(getGroup(d))
|
||||
if (!centroids[g]) centroids[g] = { x: 0, y: 0, n: 0 }
|
||||
centroids[g].x += d.x ?? 0
|
||||
centroids[g].y += d.y ?? 0
|
||||
centroids[g].n++
|
||||
})
|
||||
Object.values(centroids).forEach(c => { c.x /= c.n; c.y /= c.n })
|
||||
// push groups apart
|
||||
const keys = Object.keys(centroids)
|
||||
const minSep = Math.min(width, height) * minSepRatio
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
for (let j = i + 1; j < keys.length; j++) {
|
||||
const ci = centroids[keys[i]], cj = centroids[keys[j]]
|
||||
const dx = cj.x - ci.x, dy = cj.y - ci.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
if (dist >= minSep) continue
|
||||
const push = ((minSep - dist) / dist) * pushStrength * alpha
|
||||
const fx = dx * push, fy = dy * push
|
||||
groups.get(keys[i])?.forEach(d => { d.vx = (d.vx ?? 0) - fx; d.vy = (d.vy ?? 0) - fy })
|
||||
groups.get(keys[j])?.forEach(d => { d.vx = (d.vx ?? 0) + fx; d.vy = (d.vy ?? 0) + fy })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Group centers ────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildGroupCenters(
|
||||
keys: (string | number)[],
|
||||
width: number,
|
||||
height: number,
|
||||
radiusRatio = 0.4
|
||||
): Record<string | number, { x: number; y: number }> {
|
||||
const centers: Record<string | number, { x: number; y: number }> = {}
|
||||
const r = Math.min(width, height) * radiusRatio
|
||||
keys.forEach((key, i) => {
|
||||
const angle = (i / keys.length) * 2 * Math.PI - Math.PI / 2
|
||||
centers[key] = { x: width / 2 + r * Math.cos(angle), y: height / 2 + r * Math.sin(angle) }
|
||||
})
|
||||
return centers
|
||||
}
|
||||
|
||||
// ─── Community graph data transform ─────────────────────────────────────────
|
||||
|
||||
export function buildCommunityGraphData(raw: RawCommunityGraphData, colors: string[] = GRAPH_COLORS): CommunityGraphData | null {
|
||||
const getColor = (i: number) => colors[i % colors.length]
|
||||
|
||||
const communityNodes = raw.nodes.filter(n => n.label === 'Community') as RawCommunityNode[]
|
||||
const communityCaption = new Map<string, string>()
|
||||
const communityMap = new Map<string, string[]>()
|
||||
|
||||
communityNodes.forEach(n => {
|
||||
communityCaption.set(n.id, n.properties.name)
|
||||
communityMap.set(n.id, n.properties.member_entity_ids)
|
||||
})
|
||||
|
||||
const entityToCommunity = new Map<string, string>()
|
||||
communityMap.forEach((members, commId) => members.forEach(eid => entityToCommunity.set(eid, commId)))
|
||||
|
||||
const commKeys = Array.from(communityMap.keys())
|
||||
const commIndex = new Map(commKeys.map((k, i) => [k, i]))
|
||||
|
||||
const entityNodes = raw.nodes.filter(n => n.label === 'ExtractedEntity') as RawEntityNode[]
|
||||
const entityNodeSet = new Set(entityNodes.map(n => n.id))
|
||||
|
||||
const connectionCount: Record<string, number> = {}
|
||||
raw.edges.forEach(e => {
|
||||
if (entityNodeSet.has(e.source)) connectionCount[e.source] = (connectionCount[e.source] || 0) + 1
|
||||
if (entityNodeSet.has(e.target)) connectionCount[e.target] = (connectionCount[e.target] || 0) + 1
|
||||
})
|
||||
|
||||
const nodes: CommunityD3Node[] = entityNodes.map(n => {
|
||||
const commId = entityToCommunity.get(n.id) ?? commKeys[0]
|
||||
return {
|
||||
id: n.id,
|
||||
name: n.properties.name,
|
||||
community: commId,
|
||||
label: n.label,
|
||||
symbolSize: connectionToRadius(connectionCount[n.id] || 0),
|
||||
color: getColor(commIndex.get(commId) ?? 0),
|
||||
properties: n.properties,
|
||||
}
|
||||
})
|
||||
|
||||
if (!nodes.length) return null
|
||||
|
||||
const links = raw.edges
|
||||
.filter(e => entityNodeSet.has(e.source) && entityNodeSet.has(e.target))
|
||||
.map(e => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
isCross: entityToCommunity.get(e.source) !== entityToCommunity.get(e.target),
|
||||
}))
|
||||
|
||||
const communityNodeMap = new Map<string, RawCommunityNode>(
|
||||
communityNodes.map(n => [n.id, n])
|
||||
)
|
||||
return { nodes, links, communityMap, communityCaption, communityNodeMap }
|
||||
}
|
||||
|
||||
// ─── Hull helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const smoothLine = d3.line<[number, number]>()
|
||||
.x(d => d[0]).y(d => d[1])
|
||||
.curve(d3.curveCatmullRomClosed.alpha(0.5))
|
||||
|
||||
function expandPoints(pts: [number, number][], pad: number): [number, number][] {
|
||||
const cx = pts.reduce((s, p) => s + p[0], 0) / pts.length
|
||||
const cy = pts.reduce((s, p) => s + p[1], 0) / pts.length
|
||||
return pts.map(([x, y]) => {
|
||||
const dx = x - cx, dy = y - cy
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
return [x + (dx / len) * pad, y + (dy / len) * pad]
|
||||
})
|
||||
}
|
||||
|
||||
function toHullPoints(pts: [number, number][]): [number, number][] {
|
||||
if (pts.length === 1) {
|
||||
const [x, y] = pts[0]
|
||||
return [[x - 1, y - 1], [x + 1, y - 1], [x, y + 1]]
|
||||
}
|
||||
if (pts.length === 2) {
|
||||
const [[x1, y1], [x2, y2]] = pts
|
||||
return [[x1, y1], [x2, y2], [(x1 + x2) / 2, (y1 + y2) / 2 - 1]]
|
||||
}
|
||||
return d3.polygonHull(pts) ?? pts
|
||||
}
|
||||
|
||||
const CIRCLE_THRESHOLD = 4 // 节点数 < 此值时使用圆形
|
||||
const CIRCLE_SEGMENTS = 32
|
||||
|
||||
function circlePoints(cx: number, cy: number, r: number): [number, number][] {
|
||||
return Array.from({ length: CIRCLE_SEGMENTS }, (_, i) => {
|
||||
const a = (i / CIRCLE_SEGMENTS) * 2 * Math.PI
|
||||
return [cx + r * Math.cos(a), cy + r * Math.sin(a)] as [number, number]
|
||||
})
|
||||
}
|
||||
|
||||
export function buildHullData(
|
||||
nodes: CommunityD3Node[],
|
||||
communityMap: Map<string, string[]>,
|
||||
communityCaption: Map<string, string>,
|
||||
colors: string[]
|
||||
): HullDatum[] {
|
||||
const getColor = (i: number) => colors[i % colors.length]
|
||||
const byComm = new Map<string, [number, number][]>()
|
||||
communityMap.forEach((_, id) => byComm.set(id, []))
|
||||
nodes.forEach(d => {
|
||||
if (d.x != null && d.y != null) byComm.get(d.community)?.push([d.x, d.y])
|
||||
})
|
||||
|
||||
const hulls: HullDatum[] = []
|
||||
let ci = 0
|
||||
byComm.forEach((pts, id) => {
|
||||
const color = getColor(ci++)
|
||||
if (!pts.length) return
|
||||
let pathPoints: [number, number][]
|
||||
if (pts.length < CIRCLE_THRESHOLD) {
|
||||
const cx = pts.reduce((s, p) => s + p[0], 0) / pts.length
|
||||
const cy = pts.reduce((s, p) => s + p[1], 0) / pts.length
|
||||
pathPoints = circlePoints(cx, cy, 60)
|
||||
} else {
|
||||
pathPoints = expandPoints(toHullPoints(pts), 60) as [number, number][]
|
||||
}
|
||||
const path = smoothLine(pathPoints)
|
||||
if (!path) return
|
||||
hulls.push({
|
||||
id, path, color,
|
||||
labelX: pathPoints.reduce((s, p) => s + p[0], 0) / pathPoints.length,
|
||||
labelY: Math.min(...pathPoints.map(p => p[1])) - 10,
|
||||
dashed: pts.length <= 2,
|
||||
caption: communityCaption.get(id) ?? id,
|
||||
})
|
||||
})
|
||||
return hulls
|
||||
}
|
||||
|
||||
// ─── Hull render ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function renderHulls(
|
||||
hullG: d3.Selection<SVGGElement, unknown, null, undefined>,
|
||||
hulls: HullDatum[],
|
||||
hiddenCommunities: Set<string>,
|
||||
nodes: CommunityD3Node[],
|
||||
simulation: d3.Simulation<CommunityD3Node, D3Link>,
|
||||
onCommunityClick?: (node: RawCommunityNode) => void,
|
||||
communityNodeMap?: Map<string, RawCommunityNode>
|
||||
) {
|
||||
let dragNodes: CommunityD3Node[] = []
|
||||
let dragStart = { x: 0, y: 0 }
|
||||
const communityDrag = d3.drag<SVGPathElement, HullDatum>()
|
||||
.on('start', (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart()
|
||||
dragNodes = nodes.filter(n => n.community === d.id)
|
||||
dragStart = { x: event.x, y: event.y }
|
||||
dragNodes.forEach(n => { n.fx = n.x; n.fy = n.y })
|
||||
})
|
||||
.on('drag', (event) => {
|
||||
const dx = event.x - dragStart.x, dy = event.y - dragStart.y
|
||||
dragStart = { x: event.x, y: event.y }
|
||||
dragNodes.forEach(n => { n.fx = (n.fx ?? n.x ?? 0) + dx; n.fy = (n.fy ?? n.y ?? 0) + dy })
|
||||
})
|
||||
.on('end', (event) => { if (!event.active) simulation.alphaTarget(0) })
|
||||
|
||||
const pathSel = hullG.selectAll<SVGPathElement, HullDatum>('path.hull').data(hulls, d => d.id)
|
||||
pathSel.enter().append('path').attr('class', 'hull').style('cursor', 'grab')
|
||||
.merge(pathSel)
|
||||
.call(communityDrag)
|
||||
.attr('d', d => d.path)
|
||||
.attr('fill', d => d.color).attr('fill-opacity', 0.08)
|
||||
.attr('stroke', d => d.color).attr('stroke-opacity', 0.5).attr('stroke-width', 1.5)
|
||||
.attr('stroke-dasharray', 'none')
|
||||
.style('display', d => hiddenCommunities.has(d.id) ? 'none' : null)
|
||||
.on('click', (event, d) => {
|
||||
if ((event as MouseEvent).defaultPrevented) return
|
||||
const node = communityNodeMap?.get(d.id)
|
||||
if (node) onCommunityClick?.(node)
|
||||
})
|
||||
pathSel.exit().remove()
|
||||
|
||||
const labelSel = hullG.selectAll<SVGTextElement, HullDatum>('text.hull-label').data(hulls, d => d.id)
|
||||
labelSel.enter().append('text').attr('class', 'hull-label')
|
||||
.attr('text-anchor', 'middle').attr('font-size', '12px').attr('font-weight', '500')
|
||||
.style('pointer-events', 'none')
|
||||
.merge(labelSel)
|
||||
.attr('x', d => d.labelX).attr('y', d => d.labelY)
|
||||
.attr('fill', d => d.color)
|
||||
.style('display', d => hiddenCommunities.has(d.id) ? 'none' : null)
|
||||
.text(d => d.caption)
|
||||
labelSel.exit().remove()
|
||||
}
|
||||
|
||||
// ─── Community graph init ─────────────────────────────────────────────────────
|
||||
|
||||
export function initCommunityGraph(
|
||||
container: HTMLDivElement,
|
||||
nodes: CommunityD3Node[],
|
||||
links: D3Link[],
|
||||
communityMap: Map<string, string[]>,
|
||||
communityCaption: Map<string, string>,
|
||||
communityNodeMap: Map<string, RawCommunityNode>,
|
||||
opts: InitOptions
|
||||
) {
|
||||
const { colors, showLegend, defaultZoom, setTooltip, onCommunityClickRef, onNodeClickRef } = opts
|
||||
const getColor = (i: number) => colors[i % colors.length]
|
||||
|
||||
const width = container.clientWidth || 600
|
||||
const height = container.clientHeight || 518
|
||||
|
||||
const svg = d3.select(container).append('svg')
|
||||
.attr('width', width).attr('height', height)
|
||||
.style('width', '100%').style('height', '100%')
|
||||
.style('background', '#F6F8FC')
|
||||
|
||||
const g = svg.append('g')
|
||||
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.2, 4])
|
||||
.on('zoom', e => g.attr('transform', e.transform))
|
||||
svg.call(zoom)
|
||||
if (defaultZoom !== 1) {
|
||||
svg.call(zoom.transform, d3.zoomIdentity
|
||||
.translate(width / 2 * (1 - defaultZoom), height / 2 * (1 - defaultZoom))
|
||||
.scale(defaultZoom)
|
||||
)
|
||||
}
|
||||
|
||||
const defs = svg.append('defs')
|
||||
addArrowMarkers(defs, [{ id: 'arrow', color: 'rgba(91, 97, 103, 0.7)' }])
|
||||
|
||||
const commKeys = Array.from(communityMap.keys())
|
||||
const centers = buildGroupCenters(commKeys, width, height, 0.45)
|
||||
const linkedIds = new Set(links.flatMap(l => [l.source as string, l.target as string]))
|
||||
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink<CommunityD3Node, D3Link>(links).id(d => d.id).distance(60))
|
||||
.force('charge', d3.forceManyBody().strength(-120))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2).strength(0.02))
|
||||
.force('collision', d3.forceCollide<CommunityD3Node>(d => d.symbolSize + 16))
|
||||
.force('cluster', makeClusterForce(nodes, d => d.community, centers, width, height, {
|
||||
pullStrength: 0.45, minSepRatio: 0.68, pushStrength: 1.0,
|
||||
}))
|
||||
.force('isolatedPull', (alpha: number) => {
|
||||
nodes.forEach(d => {
|
||||
if (linkedIds.has(d.id)) return
|
||||
const c = centers[d.community]
|
||||
if (!c) return
|
||||
d.vx = (d.vx ?? 0) + (c.x - (d.x ?? 0)) * 0.4 * alpha
|
||||
d.vy = (d.vy ?? 0) + (c.y - (d.y ?? 0)) * 0.4 * alpha
|
||||
})
|
||||
})
|
||||
|
||||
const hullG = g.append('g').attr('class', 'hulls')
|
||||
const hiddenCommunities = new Set<string>()
|
||||
|
||||
const linkSel = g.append('g').selectAll<SVGLineElement, D3Link>('line')
|
||||
.data(links).enter().append('line')
|
||||
.attr('stroke', '#5B6167')
|
||||
.attr('stroke-opacity', d => d.isCross ? 0.3 : 0.5)
|
||||
.attr('stroke-width', d => d.isCross ? 1 : 1.2)
|
||||
.attr('marker-end', 'url(#arrow)')
|
||||
|
||||
const nodeSel = g.append('g').selectAll<SVGGElement, CommunityD3Node>('g')
|
||||
.data(nodes).enter().append('g')
|
||||
.call(makeNodeDrag(simulation))
|
||||
|
||||
nodeSel.append('circle')
|
||||
.attr('r', d => d.symbolSize)
|
||||
.attr('fill', d => d.color).attr('fill-opacity', 0.85)
|
||||
.attr('stroke', '#fff').attr('stroke-width', 1.5)
|
||||
.style('cursor', 'pointer')
|
||||
.on('mouseenter', (event: MouseEvent, d: CommunityD3Node) => {
|
||||
const { left, top } = container.getBoundingClientRect()
|
||||
setTooltip({ x: event.clientX - left, y: event.clientY - top, node: d })
|
||||
})
|
||||
.on('mousemove', (event: MouseEvent) => {
|
||||
const { left, top } = container.getBoundingClientRect()
|
||||
const nd = d3.select<SVGCircleElement, CommunityD3Node>(event.target as SVGCircleElement).datum()
|
||||
setTooltip({ x: event.clientX - left, y: event.clientY - top, node: nd })
|
||||
})
|
||||
.on('mouseleave', () => setTooltip(null))
|
||||
.on('click', (_event: MouseEvent, d: CommunityD3Node) => onNodeClickRef.current?.(d))
|
||||
|
||||
nodeSel.append('text')
|
||||
.text(d => d.name)
|
||||
.attr('x', 0).attr('dy', d => -(d.symbolSize + 5))
|
||||
.attr('text-anchor', 'middle').attr('font-size', '11px').attr('fill', '#444')
|
||||
.style('pointer-events', 'none')
|
||||
|
||||
if (showLegend) {
|
||||
renderLegend(
|
||||
svg,
|
||||
commKeys.map((cid, i) => ({ key: cid, label: communityCaption.get(cid) ?? cid, color: getColor(i) })),
|
||||
width, height,
|
||||
(key, hidden) => {
|
||||
const cid = key as string
|
||||
if (hidden) hiddenCommunities.add(cid)
|
||||
else hiddenCommunities.delete(cid)
|
||||
nodeSel.style('display', d => hiddenCommunities.has(d.community) ? 'none' : null)
|
||||
linkSel.style('display', d => {
|
||||
const s = d.source as CommunityD3Node, t = d.target as CommunityD3Node
|
||||
return hiddenCommunities.has(s.community) || hiddenCommunities.has(t.community) ? 'none' : null
|
||||
})
|
||||
hullG.selectAll<SVGPathElement, HullDatum>('path.hull').style('display', d => hiddenCommunities.has(d.id) ? 'none' : null)
|
||||
hullG.selectAll<SVGTextElement, HullDatum>('text.hull-label').style('display', d => hiddenCommunities.has(d.id) ? 'none' : null)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
simulation.on('tick', () => {
|
||||
linkSel
|
||||
.attr('x1', d => (d.source as CommunityD3Node).x ?? 0)
|
||||
.attr('y1', d => (d.source as CommunityD3Node).y ?? 0)
|
||||
.attr('x2', d => {
|
||||
const s = d.source as CommunityD3Node, t = d.target as CommunityD3Node
|
||||
const dx = (t.x ?? 0) - (s.x ?? 0), dy = (t.y ?? 0) - (s.y ?? 0)
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
return (t.x ?? 0) - (dx / dist) * (t.symbolSize + 2)
|
||||
})
|
||||
.attr('y2', d => {
|
||||
const s = d.source as CommunityD3Node, t = d.target as CommunityD3Node
|
||||
const dx = (t.x ?? 0) - (s.x ?? 0), dy = (t.y ?? 0) - (s.y ?? 0)
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
return (t.y ?? 0) - (dy / dist) * (t.symbolSize + 2)
|
||||
})
|
||||
nodeSel.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`)
|
||||
renderHulls(hullG, buildHullData(nodes, communityMap, communityCaption, colors), hiddenCommunities, nodes, simulation, (n) => onCommunityClickRef.current?.(n), communityNodeMap)
|
||||
})
|
||||
|
||||
return () => { simulation.stop(); d3.select(container).selectAll('svg').remove() }
|
||||
}
|
||||
|
||||
// ─── Legend ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LegendItem {
|
||||
key: string | number
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const LEGEND_GAP = 12
|
||||
const LEGEND_RECT_W = 20
|
||||
const LEGEND_RECT_H = 10
|
||||
const LEGEND_TEXT_OFFSET = 24
|
||||
const LEGEND_FONT_SIZE = 11
|
||||
const LEGEND_ROW_H = 24
|
||||
const LEGEND_BOTTOM_PAD = 8
|
||||
|
||||
// Approximate text width using canvas measureText if available, else char-based estimate
|
||||
function measureText(text: string, fontSize: number): number {
|
||||
try {
|
||||
const ctx = document.createElement('canvas').getContext('2d')
|
||||
if (ctx) { ctx.font = `${fontSize}px sans-serif`; return ctx.measureText(text).width }
|
||||
} catch { /* noop */ }
|
||||
return text.length * fontSize * 0.6
|
||||
}
|
||||
|
||||
export function renderLegend(
|
||||
svg: d3.Selection<SVGSVGElement, unknown, null, undefined>,
|
||||
items: LegendItem[],
|
||||
width: number,
|
||||
height: number,
|
||||
onToggle: (key: string | number, hidden: boolean) => void
|
||||
) {
|
||||
// Compute per-item width: rect + text-offset + textW
|
||||
const itemWidths = items.map(item =>
|
||||
LEGEND_RECT_W + LEGEND_TEXT_OFFSET + measureText(item.label, LEGEND_FONT_SIZE)
|
||||
)
|
||||
|
||||
// Layout items into rows
|
||||
const rows: { item: LegendItem; w: number; x: number; row: number }[] = []
|
||||
let rowIdx = 0, curX = 0
|
||||
itemWidths.forEach((w, i) => {
|
||||
const slotW = w + LEGEND_GAP
|
||||
if (curX > 0 && curX + w > width - LEGEND_GAP * 2) { rowIdx++; curX = 0 }
|
||||
rows.push({ item: items[i], w, x: curX, row: rowIdx })
|
||||
curX += slotW
|
||||
})
|
||||
|
||||
const totalRows = rowIdx + 1
|
||||
const totalH = totalRows * LEGEND_ROW_H
|
||||
const baseY = height - totalH - LEGEND_BOTTOM_PAD
|
||||
|
||||
// Center each row
|
||||
const rowWidths: number[] = Array(totalRows).fill(0)
|
||||
rows.forEach(({ w, row }, i) => {
|
||||
rowWidths[row] += w + (i > 0 && rows[i - 1].row === row ? LEGEND_GAP : 0)
|
||||
})
|
||||
// Recalculate row widths properly
|
||||
const rowTotals: number[] = Array(totalRows).fill(0)
|
||||
const rowCounts: number[] = Array(totalRows).fill(0)
|
||||
rows.forEach(r => { rowCounts[r.row]++; rowTotals[r.row] += r.w })
|
||||
rowTotals.forEach((_, ri) => { rowTotals[ri] += Math.max(0, rowCounts[ri] - 1) * LEGEND_GAP })
|
||||
|
||||
const legendG = svg.append('g')
|
||||
|
||||
rows.forEach(({ item, x, row }) => {
|
||||
const rowOffsetX = (width - rowTotals[row]) / 2
|
||||
const g = legendG.append('g')
|
||||
.attr('transform', `translate(${rowOffsetX + x},${baseY + row * LEGEND_ROW_H + LEGEND_ROW_H / 2})`)
|
||||
.style('cursor', 'pointer')
|
||||
|
||||
const rect = g.append('rect')
|
||||
.attr('x', 0).attr('y', -LEGEND_RECT_H / 2)
|
||||
.attr('width', LEGEND_RECT_W).attr('height', LEGEND_RECT_H).attr('rx', 2)
|
||||
.attr('fill', item.color)
|
||||
|
||||
const text = g.append('text')
|
||||
.text(item.label)
|
||||
.attr('x', LEGEND_TEXT_OFFSET).attr('dy', '0.35em')
|
||||
.attr('font-size', `${LEGEND_FONT_SIZE}px`).attr('fill', '#5B6167')
|
||||
|
||||
let hidden = false
|
||||
g.on('click', () => {
|
||||
hidden = !hidden
|
||||
rect.attr('fill', hidden ? '#ccc' : item.color)
|
||||
text.attr('fill', hidden ? '#bbb' : '#5B6167')
|
||||
onToggle(item.key, hidden)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,20 +1,37 @@
|
||||
import { useState, useEffect, type FC } from 'react';
|
||||
import { Spin, Alert, Button } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
/*
|
||||
* @Description:
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2026-03-16 19:01:12
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2026-03-18 18:35:53
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback, type FC } from 'react';
|
||||
import { Spin, Alert, Button, Table, InputNumber, Image } from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
DownloadOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import RbMarkdown from '../Markdown';
|
||||
import { cookieUtils } from '@/utils/request'
|
||||
import { cookieUtils } from '@/utils/request';
|
||||
import mammoth from 'mammoth';
|
||||
import * as XLSX from 'xlsx';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
type PreviewMode = 'office' | 'google';
|
||||
// 设置 pdf.js worker - 使用 CDN 避免 Vite 打包动态 import 问题
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.10.38/pdf.worker.min.mjs';
|
||||
|
||||
interface DocumentPreviewProps {
|
||||
fileUrl: string;
|
||||
fileName?: string;
|
||||
fileExt?: string; // 文件扩展名(优先使用)
|
||||
fileExt?: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
className?: string;
|
||||
mode?: PreviewMode; // 预览模式
|
||||
showModeSwitch?: boolean; // 是否显示模式切换按钮
|
||||
}
|
||||
|
||||
const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
@@ -24,18 +41,38 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
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 [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [textContent, setTextContent] = useState<string>('');
|
||||
const [htmlContent, setHtmlContent] = useState<string>('');
|
||||
const [excelData, setExcelData] = useState<{ sheetName: string; data: any[][] }[]>([]);
|
||||
|
||||
// PDF 状态
|
||||
const [pdfDoc, setPdfDoc] = useState<pdfjsLib.PDFDocumentProxy | null>(null);
|
||||
const [pdfCurrentPage, setPdfCurrentPage] = useState(1);
|
||||
const [pdfTotalPages, setPdfTotalPages] = useState(0);
|
||||
const [pdfScale, setPdfScale] = useState(1.5);
|
||||
const pdfCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pdfRenderingRef = useRef(false);
|
||||
|
||||
// PPT 状态
|
||||
const [pptSlides, setPptSlides] = useState<string[]>([]);
|
||||
const [pptCurrentPage, setPptCurrentPage] = useState(1);
|
||||
const [pptTotalPages, setPptTotalPages] = useState(0);
|
||||
|
||||
// 图片状态
|
||||
const [imageBlobUrl, setImageBlobUrl] = useState<string>('');
|
||||
|
||||
// 支持预览的文件类型
|
||||
const previewableTypes = [
|
||||
'.pdf', '.txt', '.md', '.csv',
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp',
|
||||
'.doc', '.docx', '.xls', '.xlsx',
|
||||
'.ppt', '.pptx',
|
||||
];
|
||||
|
||||
// 支持的文件类型
|
||||
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()}`;
|
||||
@@ -44,172 +81,356 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
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 isTextFile = () => getFileExtension() === '.txt';
|
||||
const isMarkdownFile = () => getFileExtension() === '.md';
|
||||
const isImageFile = () => {
|
||||
const ext = getFileExtension();
|
||||
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'];
|
||||
return imageExts.includes(ext);
|
||||
return imageExts.includes(getFileExtension());
|
||||
};
|
||||
|
||||
// 检查文件类型是否支持
|
||||
const isSupportedFile = () => {
|
||||
const ext = getFileExtension();
|
||||
return ext && supportedTypes.includes(ext);
|
||||
const isPdfFile = () => getFileExtension() === '.pdf';
|
||||
const isWordFile = () => ['.doc', '.docx'].includes(getFileExtension());
|
||||
const isExcelFile = () => ['.xls', '.xlsx', '.csv'].includes(getFileExtension());
|
||||
const isPptFile = () => ['.ppt', '.pptx'].includes(getFileExtension());
|
||||
const isPreviewable = () => previewableTypes.includes(getFileExtension());
|
||||
|
||||
const getRequestUrl = (url: string) => {
|
||||
if (url.includes('devapi.mem.redbearai.com')) {
|
||||
const parsed = new URL(url);
|
||||
return parsed.pathname;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
// 检查是否为 PDF 文件
|
||||
const isPdfFile = () => {
|
||||
const ext = getFileExtension();
|
||||
return ext === '.pdf';
|
||||
const fetchFileBuffer = async (url: string): Promise<ArrayBuffer> => {
|
||||
const requestUrl = getRequestUrl(url);
|
||||
const response = await fetch(requestUrl, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
};
|
||||
|
||||
// 构建预览 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 handleDownload = () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = fileUrl;
|
||||
link.download = fileName || 'document';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleLoad = () => {
|
||||
setLoading(false);
|
||||
setError(false);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
const handleError = (msg?: string) => {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
if (msg) setErrorMessage(msg);
|
||||
};
|
||||
|
||||
// ========== PDF 渲染逻辑 ==========
|
||||
const renderPdfPage = useCallback(async (doc: pdfjsLib.PDFDocumentProxy, pageNum: number, scale: number) => {
|
||||
if (pdfRenderingRef.current || !pdfCanvasRef.current) return;
|
||||
pdfRenderingRef.current = true;
|
||||
try {
|
||||
const page = await doc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = pdfCanvasRef.current;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = viewport.width * dpr;
|
||||
canvas.height = viewport.height * dpr;
|
||||
canvas.style.width = `${viewport.width}px`;
|
||||
canvas.style.height = `${viewport.height}px`;
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
} finally {
|
||||
pdfRenderingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadPdfFile = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
setPdfDoc(doc);
|
||||
setPdfTotalPages(doc.numPages);
|
||||
setPdfCurrentPage(1);
|
||||
await renderPdfPage(doc, 1, pdfScale);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('加载 PDF 文件失败:', err);
|
||||
handleError(err.message || '加载 PDF 文件失败');
|
||||
}
|
||||
}, [fileUrl, pdfScale, renderPdfPage]);
|
||||
|
||||
const handlePdfPageChange = async (page: number) => {
|
||||
if (!pdfDoc || page < 1 || page > pdfTotalPages) return;
|
||||
setPdfCurrentPage(page);
|
||||
await renderPdfPage(pdfDoc, page, pdfScale);
|
||||
};
|
||||
|
||||
const handlePdfZoom = async (delta: number) => {
|
||||
const newScale = Math.max(0.5, Math.min(3, pdfScale + delta));
|
||||
setPdfScale(newScale);
|
||||
if (pdfDoc) {
|
||||
await renderPdfPage(pdfDoc, pdfCurrentPage, newScale);
|
||||
}
|
||||
};
|
||||
|
||||
// ========== PPT/PPTX 预览逻辑(转 PDF 后用 pdfjs 渲染每页为图片) ==========
|
||||
const loadPptFile = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
// 尝试用 pdfjs 直接加载(某些服务端会返回转换后的 PDF)
|
||||
// 如果失败,则使用 Office Online Viewer 作为 fallback
|
||||
try {
|
||||
const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
// 成功解析为 PDF,逐页渲染为图片
|
||||
const slides: string[] = [];
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
const page = await doc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2 });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) continue;
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
slides.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
setPptSlides(slides);
|
||||
setPptTotalPages(slides.length);
|
||||
setPptCurrentPage(1);
|
||||
setLoading(false);
|
||||
} catch {
|
||||
// 不是 PDF 格式,使用 Office Online Viewer
|
||||
setPptSlides([]);
|
||||
setPptTotalPages(0);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('加载 PPT 文件失败:', err);
|
||||
handleError(err.message || '加载 PPT 文件失败');
|
||||
}
|
||||
}, [fileUrl]);
|
||||
|
||||
// ========== 图片加载逻辑 ==========
|
||||
const loadImageFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
const ext = getFileExtension().replace('.', '');
|
||||
const mimeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
||||
gif: 'image/gif', bmp: 'image/bmp', webp: 'image/webp', svg: 'image/svg+xml',
|
||||
};
|
||||
const blob = new Blob([arrayBuffer], { type: mimeMap[ext] || 'image/png' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setImageBlobUrl(url);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('加载图片文件失败:', err);
|
||||
handleError(err.message || '图片加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 文本/Word/Excel 加载逻辑 ==========
|
||||
const loadTextFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const requestUrl = getRequestUrl(fileUrl);
|
||||
const response = await fetch(requestUrl, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
if (contentType.startsWith('image/')) {
|
||||
handleError('文件实际是图片类型,但被标记为文本文件');
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (text.startsWith('\x89PNG') || text.startsWith('<27>PNG')) {
|
||||
handleError('文件内容是图片,但扩展名是文本');
|
||||
return;
|
||||
}
|
||||
setTextContent(text);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('加载文本文件失败:', err);
|
||||
handleError(err.message || '加载文本文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadWordFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
// .doc 旧格式 mammoth 不支持,使用 Office Online Viewer
|
||||
if (getFileExtension() === '.doc') {
|
||||
setHtmlContent('');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
// 校验是否为有效的 docx(ZIP 格式,前两字节为 PK)
|
||||
const header = new Uint8Array(arrayBuffer.slice(0, 4));
|
||||
if (header[0] !== 0x50 || header[1] !== 0x4B) {
|
||||
// 不是 ZIP/docx 格式,可能是 HTML 错误页或 JSON 响应
|
||||
const text = new TextDecoder().decode(arrayBuffer.slice(0, 200));
|
||||
throw new Error(`文件内容不是有效的 docx 格式: ${text.substring(0, 100)}`);
|
||||
}
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer });
|
||||
setHtmlContent(result.value);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('加载 Word 文件失败:', err);
|
||||
handleError(err.message || '加载 Word 文件失败,文件可能已损坏');
|
||||
}
|
||||
};
|
||||
|
||||
const isCsvFile = () => getFileExtension() === '.csv';
|
||||
|
||||
const loadExcelFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
|
||||
// CSV 文件需要处理编码问题(可能是 GBK/GB2312)
|
||||
if (isCsvFile()) {
|
||||
let csvText: string;
|
||||
// 先尝试 UTF-8 解码
|
||||
const utf8Text = new TextDecoder('utf-8').decode(arrayBuffer);
|
||||
// 检测是否有乱码特征(常见的 GBK 被错误解析为 UTF-8 的替换字符)
|
||||
if (utf8Text.includes('\uFFFD') || /[\x80-\xff]/.test(utf8Text.slice(0, 200))) {
|
||||
// 尝试 GBK 解码
|
||||
try {
|
||||
csvText = new TextDecoder('gbk').decode(arrayBuffer);
|
||||
} catch {
|
||||
csvText = utf8Text;
|
||||
}
|
||||
} else {
|
||||
csvText = utf8Text;
|
||||
}
|
||||
const workbook = XLSX.read(csvText, { type: 'string' });
|
||||
const sheets = workbook.SheetNames.map(sheetName => {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
|
||||
return { sheetName, data };
|
||||
});
|
||||
setExcelData(sheets);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
const sheets = workbook.SheetNames.map(sheetName => {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
|
||||
return { sheetName, data };
|
||||
});
|
||||
setExcelData(sheets);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('加载 Excel 文件失败:', err);
|
||||
handleError(err.message || '加载 Excel 文件失败,文件可能已损坏');
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
setErrorMessage('');
|
||||
if (isTextFile() || isMarkdownFile()) loadTextFile();
|
||||
else if (isWordFile()) loadWordFile();
|
||||
else if (isExcelFile()) loadExcelFile();
|
||||
else if (isPdfFile()) loadPdfFile();
|
||||
else if (isPptFile()) loadPptFile();
|
||||
};
|
||||
|
||||
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 ${cookieUtils.get('authToken') || ''}`,
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
if (isTextFile() || isMarkdownFile()) loadTextFile();
|
||||
else if (isWordFile()) loadWordFile();
|
||||
else if (isExcelFile()) loadExcelFile();
|
||||
else if (isPdfFile()) loadPdfFile();
|
||||
else if (isPptFile()) loadPptFile();
|
||||
else if (isImageFile()) loadImageFile();
|
||||
}, [fileUrl]);
|
||||
|
||||
if (!isSupportedFile()) {
|
||||
// PDF 翻页/缩放后重新渲染
|
||||
useEffect(() => {
|
||||
if (pdfDoc && isPdfFile()) {
|
||||
renderPdfPage(pdfDoc, pdfCurrentPage, pdfScale);
|
||||
}
|
||||
}, [pdfCurrentPage, pdfScale, pdfDoc]);
|
||||
|
||||
// ========== 分页控制栏组件 ==========
|
||||
const PaginationBar = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
extraControls,
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
extraControls?: React.ReactNode;
|
||||
}) => (
|
||||
<div className="rb:flex rb:items-center rb:justify-center rb:gap-3 rb:py-2 rb:px-4 rb:bg-white rb:border-t rb:border-gray-200 rb:select-none">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LeftOutlined />}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
/>
|
||||
<span className="rb:text-sm rb:text-gray-600 rb:flex rb:items-center rb:gap-1">
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={currentPage}
|
||||
onChange={(val) => val && onPageChange(val)}
|
||||
style={{ width: 56 }}
|
||||
/>
|
||||
<span>/ {totalPages}</span>
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<RightOutlined />}
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
/>
|
||||
{extraControls}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isPreviewable()) {
|
||||
return (
|
||||
<Alert
|
||||
message="不支持的文件类型"
|
||||
description={`仅支持以下文件类型:${supportedTypes.join(', ')}`}
|
||||
description={`仅支持预览:${previewableTypes.join(', ')}`}
|
||||
type="warning"
|
||||
showIcon
|
||||
/>
|
||||
@@ -217,36 +438,33 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rb:relative ${className}`} style={{ width, height }}>
|
||||
<div className={`rb:relative rb:flex rb:flex-col ${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>
|
||||
<p className="rb:mb-2">无法加载文档预览</p>
|
||||
{errorMessage && (
|
||||
<p className="rb:text-sm rb:text-red-600 rb:mb-3">错误详情:{errorMessage}</p>
|
||||
)}
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:mb-3">可能的原因:</p>
|
||||
<ul className="rb:list-disc rb:pl-5 rb:text-sm rb:text-gray-600 rb:mb-3">
|
||||
<li>文件 URL 无法访问(401/403/404)</li>
|
||||
<li>认证 token 已过期</li>
|
||||
<li>文件格式损坏或不匹配</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>
|
||||
)}
|
||||
<Button icon={<ReloadOutlined />} onClick={handleRetry}>重试</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleDownload}>下载文件</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -255,73 +473,160 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
/>
|
||||
</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 className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-gray-50 rb:flex rb:items-center rb:justify-center">
|
||||
<Image
|
||||
src={imageBlobUrl}
|
||||
alt={fileName || '图片预览'}
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
||||
onError={() => handleError('图片渲染失败')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Markdown 文件预览 */}
|
||||
{/* 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">
|
||||
<div className="rb:w-full rb:flex-1 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">
|
||||
<div className="rb:w-full rb:flex-1 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' }}
|
||||
/>
|
||||
{/* Word 预览 */}
|
||||
{isWordFile() && !error && !loading && (
|
||||
getFileExtension() === '.doc' ? (
|
||||
/* .doc 旧格式前端无法解析,提示下载 */
|
||||
<div className="rb:w-full rb:flex-1 rb:flex rb:items-center rb:justify-center rb:bg-gray-50">
|
||||
<div className="rb:text-center">
|
||||
<p className="rb:text-gray-600 rb:mb-4">.doc 格式暂不支持在线预览,请下载后查看</p>
|
||||
<Button icon={<DownloadOutlined />} type="primary" onClick={handleDownload}>下载文件</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
|
||||
<div
|
||||
className="rb:prose rb:max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{/* Excel 预览 */}
|
||||
{isExcelFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
|
||||
{excelData.map((sheet, index) => (
|
||||
<div key={index} className="rb:mb-6">
|
||||
<h3 className="rb:text-lg rb:font-semibold rb:mb-3">{sheet.sheetName}</h3>
|
||||
{sheet.data.length > 0 && (
|
||||
<Table
|
||||
dataSource={sheet.data.slice(1).map((row, idx) => ({ key: idx, ...row }))}
|
||||
columns={sheet.data[0]?.map((header: any, colIdx: number) => ({
|
||||
title: header || `列 ${colIdx + 1}`,
|
||||
dataIndex: colIdx,
|
||||
key: colIdx,
|
||||
width: 150,
|
||||
})) || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* PDF 预览 - 带分页和缩放 */}
|
||||
{isPdfFile() && !error && !loading && (
|
||||
<>
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-gray-100 rb:flex rb:justify-center rb:p-4">
|
||||
<canvas ref={pdfCanvasRef} className="rb:shadow-lg" />
|
||||
</div>
|
||||
{pdfTotalPages > 0 && (
|
||||
<PaginationBar
|
||||
currentPage={pdfCurrentPage}
|
||||
totalPages={pdfTotalPages}
|
||||
onPageChange={handlePdfPageChange}
|
||||
extraControls={
|
||||
<div className="rb:flex rb:items-center rb:gap-1 rb:ml-4">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ZoomOutOutlined />}
|
||||
disabled={pdfScale <= 0.5}
|
||||
onClick={() => handlePdfZoom(-0.25)}
|
||||
/>
|
||||
<span className="rb:text-sm rb:text-gray-600 rb:min-w-[48px] rb:text-center">
|
||||
{Math.round(pdfScale * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ZoomInOutlined />}
|
||||
disabled={pdfScale >= 3}
|
||||
onClick={() => handlePdfZoom(0.25)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* PPT/PPTX 预览 */}
|
||||
{isPptFile() && !error && !loading && (
|
||||
<>
|
||||
{pptSlides.length > 0 ? (
|
||||
/* 本地渲染模式(服务端返回了可解析的格式) */
|
||||
<>
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-gray-100 rb:flex rb:justify-center rb:items-center rb:p-4">
|
||||
<img
|
||||
src={pptSlides[pptCurrentPage - 1]}
|
||||
alt={`Slide ${pptCurrentPage}`}
|
||||
className="rb:max-w-full rb:max-h-full rb:object-contain rb:shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
<PaginationBar
|
||||
currentPage={pptCurrentPage}
|
||||
totalPages={pptTotalPages}
|
||||
onPageChange={(page) => {
|
||||
if (page >= 1 && page <= pptTotalPages) setPptCurrentPage(page);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
/* Office Online Viewer fallback */
|
||||
<div className="rb:w-full rb:flex-1 rb:flex rb:flex-col">
|
||||
<iframe
|
||||
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fileUrl)}`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
title={fileName || 'PPT 预览'}
|
||||
className="rb:border-0 rb:flex-1"
|
||||
style={{ border: 'none' }}
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={() => handleError('PPT 在线预览加载失败')}
|
||||
/>
|
||||
<div className="rb:flex rb:items-center rb:justify-center rb:gap-3 rb:py-2 rb:px-4 rb:bg-white rb:border-t rb:border-gray-200">
|
||||
<span className="rb:text-sm rb:text-gray-500">使用 Office Online 预览</span>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={handleDownload}>
|
||||
下载文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-10 11:08:27
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-24 15:25:14
|
||||
* @Last Modified time: 2026-03-20 11:47:43
|
||||
*/
|
||||
/*
|
||||
* PageHeader Component
|
||||
@@ -43,7 +43,7 @@ const PageHeader: FC<ConfigHeaderProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
// Main header container: full width, 64px height, flex layout with space between
|
||||
<Header className="rb:w-full rb:h-16 rb:flex rb:items-center rb:justify-between rb:gap-6 rb:px-4! rb:bg-[#FFFFFF]!">
|
||||
<Header className={`"rb:w-full rb:h-16 rb:grid rb:grid-cols-${extra && centerContent ? '3' : ((extra && !centerContent) || (!extra && centerContent)) ? '2': 1} rb:gap-6 rb:px-4! rb:bg-[#FFFFFF]!"`}>
|
||||
<Flex align="center" gap={8}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} alt={avatarUrl} className="rb:size-8 rb:rounded-lg rb:mr-2" />
|
||||
@@ -58,9 +58,11 @@ const PageHeader: FC<ConfigHeaderProps> = ({
|
||||
{operation}
|
||||
</Flex>
|
||||
|
||||
{centerContent}
|
||||
{centerContent && <Flex align="center">
|
||||
{centerContent}
|
||||
</Flex>}
|
||||
{/* Right section: Extra content (buttons, filters, etc.) */}
|
||||
<Flex align="center" gap={12}>
|
||||
<Flex align="center" justify="end" gap={12}>
|
||||
{extra}
|
||||
</Flex>
|
||||
</Header>
|
||||
|
||||
@@ -136,7 +136,7 @@ const RbMarkdown: FC<RbMarkdownProps> = ({
|
||||
|
||||
/** Sync edit content when external content changes */
|
||||
useEffect(() => {
|
||||
setEditContent(content)
|
||||
setEditContent(prev => prev !== content ? content : prev)
|
||||
}, [content])
|
||||
|
||||
/** Handle textarea content changes and trigger callback */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:18:19
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-09 13:51:01
|
||||
* @Last Modified time: 2026-03-19 20:47:34
|
||||
*/
|
||||
/**
|
||||
* PageScrollList Component
|
||||
@@ -62,8 +62,8 @@ const heightClass = 'rb:h-[calc(100vh-124px)]!';
|
||||
|
||||
/** Infinite scroll list component with pagination support */
|
||||
const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
renderItem,
|
||||
query,
|
||||
renderItem,
|
||||
query,
|
||||
url,
|
||||
column = 4,
|
||||
className = '',
|
||||
@@ -71,68 +71,70 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
}: PageScrollListProps<T, Q>, ref: React.Ref<PageScrollListRef>) => {
|
||||
/** Expose refresh method to parent component */
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh,
|
||||
refresh: () => {
|
||||
pageRef.current = 1;
|
||||
loadingRef.current = false;
|
||||
setHasMore(true);
|
||||
setData([]);
|
||||
loadMoreData(true);
|
||||
},
|
||||
}));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<T[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const pageRef = useRef(1);
|
||||
const loadingRef = useRef(false);
|
||||
const hasMoreRef = useRef(true);
|
||||
|
||||
/** Load more data from API with pagination */
|
||||
const loadMoreData = (flag?: boolean) => {
|
||||
if (!flag && (loading || !hasMore)) {
|
||||
return;
|
||||
}
|
||||
const loadMoreData = (reset?: boolean) => {
|
||||
if (loadingRef.current || (!reset && !hasMoreRef.current)) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
const currentPage = reset ? 1 : pageRef.current;
|
||||
request.get(url, {
|
||||
page: page,
|
||||
page: currentPage,
|
||||
pagesize: PAGE_SIZE,
|
||||
...(query||{}),
|
||||
...(query || {}),
|
||||
})
|
||||
.then((res) => {
|
||||
const response = res as ApiResponse<T>;
|
||||
const results = Array.isArray(response.items) ? response.items : Array.isArray(response) ? response as T[] : [];
|
||||
// Replace data if flag is true, otherwise append
|
||||
if (flag) {
|
||||
setData(results);
|
||||
} else {
|
||||
setData(data.concat(results));
|
||||
}
|
||||
setPage(response.page.page + 1);
|
||||
pageRef.current = response.page.page + 1;
|
||||
setData(prev => reset ? results : [...prev, ...results]);
|
||||
hasMoreRef.current = response.page?.hasnext;
|
||||
setHasMore(response.page?.hasnext);
|
||||
setLoading(false);
|
||||
console.log(`${results.length} more items loaded!`);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
hasMoreRef.current = false;
|
||||
setHasMore(false);
|
||||
console.error('Failed to load data');
|
||||
})
|
||||
.finally(() => {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
// 内容不足以填满容器时,主动继续加载
|
||||
setTimeout(() => {
|
||||
const el = scrollRef.current;
|
||||
console.log(el, el?.scrollHeight, el?.clientHeight, hasMoreRef.current)
|
||||
if (el && hasMoreRef.current && el.scrollHeight <= el.clientHeight) {
|
||||
loadMoreData();
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
};
|
||||
|
||||
/** Reset list to initial state and reload data */
|
||||
const refresh = () => {
|
||||
setPage(1);
|
||||
/** Reset and reload when query parameters change */
|
||||
const queryKey = JSON.stringify(query);
|
||||
useEffect(() => {
|
||||
pageRef.current = 1;
|
||||
loadingRef.current = false;
|
||||
hasMoreRef.current = true;
|
||||
setHasMore(true);
|
||||
setData([]);
|
||||
}
|
||||
loadMoreData(true);
|
||||
}, [queryKey]);
|
||||
|
||||
/** Refresh when query parameters change */
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [query]);
|
||||
|
||||
/** Load initial data when list is reset */
|
||||
useEffect(() => {
|
||||
if (page === 1 && hasMore && data.length === 0) {
|
||||
loadMoreData(true);
|
||||
}
|
||||
}, [page, hasMore, data])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -142,7 +144,7 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
>
|
||||
<InfiniteScroll
|
||||
dataLength={data.length}
|
||||
next={loadMoreData}
|
||||
next={() => loadMoreData()}
|
||||
hasMore={hasMore}
|
||||
loader={loading && needLoading ? <PageLoading className={heightClass} /> : false}
|
||||
// endMessage={<Divider plain>It is all, nothing more 🤐</Divider>}
|
||||
|
||||
Reference in New Issue
Block a user