Merge branch 'develop' into feature/ui_upgrade_zy

This commit is contained in:
zhaoying
2026-03-20 11:49:00 +08:00
286 changed files with 23406 additions and 5328 deletions

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

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

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

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

View 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)
})
})
}

View File

@@ -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);
// 校验是否为有效的 docxZIP 格式,前两字节为 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>
);
};

View File

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

View File

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

View File

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