mirror of
https://github.com/langgenius/dify.git
synced 2026-04-27 19:27:23 +08:00
click canvas icon display the active comment detail
This commit is contained in:
parent
89bed479e4
commit
4d3adec738
@ -1,3 +1,4 @@
|
|||||||
export { CommentCursor } from './cursor'
|
export { CommentCursor } from './cursor'
|
||||||
export { CommentInput } from './input'
|
export { CommentInput } from './input'
|
||||||
export { CommentIcon } from './icon'
|
export { CommentIcon } from './icon'
|
||||||
|
export { CommentThread } from './thread'
|
||||||
|
|||||||
116
web/app/components/workflow/comment/thread.tsx
Normal file
116
web/app/components/workflow/comment/thread.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import { memo, useMemo } from 'react'
|
||||||
|
import { useReactFlow, useViewport } from 'reactflow'
|
||||||
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
import Avatar from '@/app/components/base/avatar'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import { useFormatTimeFromNow } from '@/app/components/workflow/hooks'
|
||||||
|
import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/service/workflow-comment'
|
||||||
|
|
||||||
|
type CommentThreadProps = {
|
||||||
|
comment: WorkflowCommentDetail
|
||||||
|
loading?: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThreadMessage: FC<{
|
||||||
|
authorName: string
|
||||||
|
avatarUrl?: string | null
|
||||||
|
createdAt: number
|
||||||
|
content: string
|
||||||
|
isReply?: boolean
|
||||||
|
}> = ({ authorName, avatarUrl, createdAt, content, isReply }) => {
|
||||||
|
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex gap-3', isReply && 'pl-9')}>
|
||||||
|
<div className='shrink-0'>
|
||||||
|
<Avatar
|
||||||
|
name={authorName}
|
||||||
|
avatar={avatarUrl || null}
|
||||||
|
size={32}
|
||||||
|
className='h-8 w-8'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='min-w-0 flex-1 pb-4 text-text-primary last:pb-0'>
|
||||||
|
<div className='flex flex-wrap items-center gap-x-2 gap-y-1'>
|
||||||
|
<span className='system-sm-medium text-text-primary'>{authorName}</span>
|
||||||
|
<span className='system-2xs-regular text-text-tertiary'>{formatTimeFromNow(createdAt * 1000)}</span>
|
||||||
|
</div>
|
||||||
|
<div className='system-sm-regular mt-1 whitespace-pre-wrap break-words text-text-secondary'>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderReply = (reply: WorkflowCommentDetailReply) => (
|
||||||
|
<ThreadMessage
|
||||||
|
key={reply.id}
|
||||||
|
authorName={reply.created_by_account?.name || 'User'}
|
||||||
|
avatarUrl={reply.created_by_account?.avatar_url || null}
|
||||||
|
createdAt={reply.created_at}
|
||||||
|
content={reply.content}
|
||||||
|
isReply
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const CommentThread: FC<CommentThreadProps> = memo(({ comment, loading = false, onClose }) => {
|
||||||
|
const { flowToScreenPosition } = useReactFlow()
|
||||||
|
const viewport = useViewport()
|
||||||
|
|
||||||
|
const screenPosition = useMemo(() => {
|
||||||
|
return flowToScreenPosition({
|
||||||
|
x: comment.position_x,
|
||||||
|
y: comment.position_y,
|
||||||
|
})
|
||||||
|
}, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='absolute z-50 w-[360px] max-w-[360px]'
|
||||||
|
style={{
|
||||||
|
left: screenPosition.x,
|
||||||
|
top: screenPosition.y,
|
||||||
|
transform: 'translate(-50%, -100%) translateY(-24px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='relative rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl'>
|
||||||
|
<div className='flex items-center justify-between rounded-t-2xl px-4 py-3'>
|
||||||
|
<div className='system-2xs-semibold uppercase tracking-[0.08em] text-text-tertiary'>Comment</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='flex h-6 w-6 items-center justify-center rounded-full text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label='Close comment'
|
||||||
|
>
|
||||||
|
<RiCloseLine className='h-4 w-4' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className='relative px-4 pb-4'>
|
||||||
|
<ThreadMessage
|
||||||
|
authorName={comment.created_by_account?.name || 'User'}
|
||||||
|
avatarUrl={comment.created_by_account?.avatar_url || null}
|
||||||
|
createdAt={comment.created_at}
|
||||||
|
content={comment.content}
|
||||||
|
/>
|
||||||
|
{comment.replies?.length > 0 && (
|
||||||
|
<div className='mt-2 flex flex-col gap-2'>
|
||||||
|
{comment.replies.map(renderReply)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loading && (
|
||||||
|
<div className='bg-components-panel-bg/70 absolute inset-0 flex items-center justify-center rounded-2xl text-sm text-text-tertiary'>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
CommentThread.displayName = 'CommentThread'
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { useReactFlow } from 'reactflow'
|
import { useReactFlow } from 'reactflow'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import { ControlMode } from '../types'
|
import { ControlMode } from '../types'
|
||||||
import type { WorkflowCommentList } from '@/service/workflow-comment'
|
import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
|
||||||
import { createWorkflowComment, fetchWorkflowComments } from '@/service/workflow-comment'
|
import { createWorkflowComment, fetchWorkflowComment, fetchWorkflowComments } from '@/service/workflow-comment'
|
||||||
|
|
||||||
export const useWorkflowComment = () => {
|
export const useWorkflowComment = () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@ -14,8 +14,17 @@ export const useWorkflowComment = () => {
|
|||||||
const setControlMode = useStore(s => s.setControlMode)
|
const setControlMode = useStore(s => s.setControlMode)
|
||||||
const pendingComment = useStore(s => s.pendingComment)
|
const pendingComment = useStore(s => s.pendingComment)
|
||||||
const setPendingComment = useStore(s => s.setPendingComment)
|
const setPendingComment = useStore(s => s.setPendingComment)
|
||||||
|
const setActiveCommentId = useStore(s => s.setActiveCommentId)
|
||||||
|
const activeCommentId = useStore(s => s.activeCommentId)
|
||||||
const [comments, setComments] = useState<WorkflowCommentList[]>([])
|
const [comments, setComments] = useState<WorkflowCommentList[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [activeComment, setActiveComment] = useState<WorkflowCommentDetail | null>(null)
|
||||||
|
const [activeCommentLoading, setActiveCommentLoading] = useState(false)
|
||||||
|
const commentDetailCacheRef = useRef<Record<string, WorkflowCommentDetail>>({})
|
||||||
|
const activeCommentIdRef = useRef<string | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
activeCommentIdRef.current = activeCommentId ?? null
|
||||||
|
}, [activeCommentId])
|
||||||
|
|
||||||
const loadComments = useCallback(async () => {
|
const loadComments = useCallback(async () => {
|
||||||
if (!appId) return
|
if (!appId) return
|
||||||
@ -72,17 +81,50 @@ export const useWorkflowComment = () => {
|
|||||||
setControlMode(ControlMode.Pointer)
|
setControlMode(ControlMode.Pointer)
|
||||||
}, [setControlMode, setPendingComment])
|
}, [setControlMode, setPendingComment])
|
||||||
|
|
||||||
const handleCommentIconClick = useCallback((comment: WorkflowCommentList) => {
|
const handleCommentIconClick = useCallback(async (comment: WorkflowCommentList) => {
|
||||||
|
setPendingComment(null)
|
||||||
|
|
||||||
|
activeCommentIdRef.current = comment.id
|
||||||
|
setControlMode(ControlMode.Comment)
|
||||||
|
setActiveCommentId(comment.id)
|
||||||
|
|
||||||
|
const cachedDetail = commentDetailCacheRef.current[comment.id]
|
||||||
|
setActiveComment(cachedDetail || comment)
|
||||||
|
|
||||||
|
reactflow.setCenter(comment.position_x, comment.position_y, { zoom: 1, duration: 600 })
|
||||||
|
|
||||||
|
if (!appId) return
|
||||||
|
|
||||||
|
if (!cachedDetail)
|
||||||
|
setActiveCommentLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const store = useStore.getState()
|
const detailResponse = await fetchWorkflowComment(appId, comment.id)
|
||||||
store.setControlMode(ControlMode.Comment)
|
const detail = (detailResponse as any)?.data ?? detailResponse
|
||||||
store.setActiveCommentId(comment.id)
|
|
||||||
reactflow.setCenter(comment.position_x, comment.position_y, { zoom: 1, duration: 600 })
|
commentDetailCacheRef.current = {
|
||||||
|
...commentDetailCacheRef.current,
|
||||||
|
[comment.id]: detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeCommentIdRef.current === comment.id)
|
||||||
|
setActiveComment(detail)
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
console.error('Failed to open comments panel:', e)
|
console.warn('Failed to load workflow comment detail', e)
|
||||||
}
|
}
|
||||||
}, [reactflow])
|
finally {
|
||||||
|
setActiveCommentLoading(false)
|
||||||
|
}
|
||||||
|
}, [appId, reactflow, setPendingComment])
|
||||||
|
|
||||||
|
const handleActiveCommentClose = useCallback(() => {
|
||||||
|
setActiveComment(null)
|
||||||
|
setActiveCommentLoading(false)
|
||||||
|
setActiveCommentId(null)
|
||||||
|
setControlMode(ControlMode.Pointer)
|
||||||
|
activeCommentIdRef.current = null
|
||||||
|
}, [setActiveCommentId, setControlMode])
|
||||||
|
|
||||||
const handleCreateComment = useCallback((mousePosition: { pageX: number; pageY: number }) => {
|
const handleCreateComment = useCallback((mousePosition: { pageX: number; pageY: number }) => {
|
||||||
if (controlMode === ControlMode.Comment) {
|
if (controlMode === ControlMode.Comment) {
|
||||||
@ -101,9 +143,12 @@ export const useWorkflowComment = () => {
|
|||||||
comments,
|
comments,
|
||||||
loading,
|
loading,
|
||||||
pendingComment,
|
pendingComment,
|
||||||
|
activeComment,
|
||||||
|
activeCommentLoading,
|
||||||
handleCommentSubmit,
|
handleCommentSubmit,
|
||||||
handleCommentCancel,
|
handleCommentCancel,
|
||||||
handleCommentIconClick,
|
handleCommentIconClick,
|
||||||
|
handleActiveCommentClose,
|
||||||
handleCreateComment,
|
handleCreateComment,
|
||||||
loadComments,
|
loadComments,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,7 +69,7 @@ import PanelContextmenu from './panel-contextmenu'
|
|||||||
import NodeContextmenu from './node-contextmenu'
|
import NodeContextmenu from './node-contextmenu'
|
||||||
import SyncingDataModal from './syncing-data-modal'
|
import SyncingDataModal from './syncing-data-modal'
|
||||||
import LimitTips from './limit-tips'
|
import LimitTips from './limit-tips'
|
||||||
import { CommentCursor, CommentIcon, CommentInput } from './comment'
|
import { CommentCursor, CommentIcon, CommentInput, CommentThread } from './comment'
|
||||||
import { useWorkflowComment } from './hooks/use-workflow-comment'
|
import { useWorkflowComment } from './hooks/use-workflow-comment'
|
||||||
import {
|
import {
|
||||||
useStore,
|
useStore,
|
||||||
@ -164,9 +164,12 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||||||
const {
|
const {
|
||||||
comments,
|
comments,
|
||||||
pendingComment,
|
pendingComment,
|
||||||
|
activeComment,
|
||||||
|
activeCommentLoading,
|
||||||
handleCommentSubmit,
|
handleCommentSubmit,
|
||||||
handleCommentCancel,
|
handleCommentCancel,
|
||||||
handleCommentIconClick,
|
handleCommentIconClick,
|
||||||
|
handleActiveCommentClose,
|
||||||
} = useWorkflowComment()
|
} = useWorkflowComment()
|
||||||
const mousePosition = useStore(s => s.mousePosition)
|
const mousePosition = useStore(s => s.mousePosition)
|
||||||
|
|
||||||
@ -351,13 +354,28 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||||||
onCancel={handleCommentCancel}
|
onCancel={handleCommentCancel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{comments.map(comment => (
|
{comments.map((comment) => {
|
||||||
<CommentIcon
|
const isActive = activeComment?.id === comment.id
|
||||||
key={comment.id}
|
|
||||||
comment={comment}
|
if (isActive && activeComment) {
|
||||||
onClick={() => handleCommentIconClick(comment)}
|
return (
|
||||||
/>
|
<CommentThread
|
||||||
))}
|
key={comment.id}
|
||||||
|
comment={activeComment}
|
||||||
|
loading={activeCommentLoading}
|
||||||
|
onClose={handleActiveCommentClose}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommentIcon
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
onClick={() => handleCommentIconClick(comment)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
{children}
|
{children}
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
|||||||
@ -26,6 +26,20 @@ export type WorkflowCommentList = {
|
|||||||
participants: UserProfile[]
|
participants: UserProfile[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkflowCommentDetailMention = {
|
||||||
|
mentioned_user_id: string
|
||||||
|
mentioned_user_account?: UserProfile | null
|
||||||
|
reply_id: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkflowCommentDetailReply = {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
created_by: string
|
||||||
|
created_by_account?: UserProfile | null
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkflowCommentDetail = {
|
export type WorkflowCommentDetail = {
|
||||||
id: string
|
id: string
|
||||||
position_x: number
|
position_x: number
|
||||||
@ -33,14 +47,14 @@ export type WorkflowCommentDetail = {
|
|||||||
content: string
|
content: string
|
||||||
created_by: string
|
created_by: string
|
||||||
created_by_account: UserProfile
|
created_by_account: UserProfile
|
||||||
created_at: string
|
created_at: number
|
||||||
updated_at: string
|
updated_at: number
|
||||||
resolved: boolean
|
resolved: boolean
|
||||||
resolved_by?: string
|
resolved_by?: string
|
||||||
resolved_by_account?: UserProfile
|
resolved_by_account?: UserProfile
|
||||||
resolved_at?: string
|
resolved_at?: number
|
||||||
replies: []
|
replies: WorkflowCommentDetailReply[]
|
||||||
mentions: []
|
mentions: WorkflowCommentDetailMention[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkflowCommentCreateRes = {
|
export type WorkflowCommentCreateRes = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user